@neeleshyadav/react-native-html-renderer 1.1.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.
Files changed (155) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +407 -0
  3. package/lib/module/HtmlRenderer.js +183 -0
  4. package/lib/module/HtmlRenderer.js.map +1 -0
  5. package/lib/module/context/index.js +32 -0
  6. package/lib/module/context/index.js.map +1 -0
  7. package/lib/module/hooks/index.js +6 -0
  8. package/lib/module/hooks/index.js.map +1 -0
  9. package/lib/module/hooks/useContentWidth.js +12 -0
  10. package/lib/module/hooks/useContentWidth.js.map +1 -0
  11. package/lib/module/hooks/useHtmlParser.js +16 -0
  12. package/lib/module/hooks/useHtmlParser.js.map +1 -0
  13. package/lib/module/hooks/useTagStyle.js +26 -0
  14. package/lib/module/hooks/useTagStyle.js.map +1 -0
  15. package/lib/module/index.js +23 -0
  16. package/lib/module/index.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/module/parser/index.js +62 -0
  19. package/lib/module/parser/index.js.map +1 -0
  20. package/lib/module/renderer/ErrorBoundary.js +66 -0
  21. package/lib/module/renderer/ErrorBoundary.js.map +1 -0
  22. package/lib/module/renderer/NodeRenderer.js +279 -0
  23. package/lib/module/renderer/NodeRenderer.js.map +1 -0
  24. package/lib/module/renderer/index.js +5 -0
  25. package/lib/module/renderer/index.js.map +1 -0
  26. package/lib/module/renderer/tags/BlockTags.js +28 -0
  27. package/lib/module/renderer/tags/BlockTags.js.map +1 -0
  28. package/lib/module/renderer/tags/FormTags.js +129 -0
  29. package/lib/module/renderer/tags/FormTags.js.map +1 -0
  30. package/lib/module/renderer/tags/ImageTag.js +163 -0
  31. package/lib/module/renderer/tags/ImageTag.js.map +1 -0
  32. package/lib/module/renderer/tags/LinkTag.js +50 -0
  33. package/lib/module/renderer/tags/LinkTag.js.map +1 -0
  34. package/lib/module/renderer/tags/ListTags.js +96 -0
  35. package/lib/module/renderer/tags/ListTags.js.map +1 -0
  36. package/lib/module/renderer/tags/MediaTags.js +69 -0
  37. package/lib/module/renderer/tags/MediaTags.js.map +1 -0
  38. package/lib/module/renderer/tags/TableTags.js +48 -0
  39. package/lib/module/renderer/tags/TableTags.js.map +1 -0
  40. package/lib/module/renderer/tags/TextTags.js +87 -0
  41. package/lib/module/renderer/tags/TextTags.js.map +1 -0
  42. package/lib/module/renderer/tags/index.js +11 -0
  43. package/lib/module/renderer/tags/index.js.map +1 -0
  44. package/lib/module/styles/cssToRn.js +34 -0
  45. package/lib/module/styles/cssToRn.js.map +1 -0
  46. package/lib/module/styles/darkModeStyles.js +81 -0
  47. package/lib/module/styles/darkModeStyles.js.map +1 -0
  48. package/lib/module/styles/defaultStyles.js +218 -0
  49. package/lib/module/styles/defaultStyles.js.map +1 -0
  50. package/lib/module/styles/index.js +7 -0
  51. package/lib/module/styles/index.js.map +1 -0
  52. package/lib/module/styles/mergeStyles.js +47 -0
  53. package/lib/module/styles/mergeStyles.js.map +1 -0
  54. package/lib/module/types/index.js +4 -0
  55. package/lib/module/types/index.js.map +1 -0
  56. package/lib/module/utils/accessibility.js +108 -0
  57. package/lib/module/utils/accessibility.js.map +1 -0
  58. package/lib/module/utils/cache.js +69 -0
  59. package/lib/module/utils/cache.js.map +1 -0
  60. package/lib/module/utils/index.js +95 -0
  61. package/lib/module/utils/index.js.map +1 -0
  62. package/lib/module/utils/sanitize.js +102 -0
  63. package/lib/module/utils/sanitize.js.map +1 -0
  64. package/lib/typescript/package.json +1 -0
  65. package/lib/typescript/src/HtmlRenderer.d.ts +15 -0
  66. package/lib/typescript/src/HtmlRenderer.d.ts.map +1 -0
  67. package/lib/typescript/src/context/index.d.ts +5 -0
  68. package/lib/typescript/src/context/index.d.ts.map +1 -0
  69. package/lib/typescript/src/hooks/index.d.ts +4 -0
  70. package/lib/typescript/src/hooks/index.d.ts.map +1 -0
  71. package/lib/typescript/src/hooks/useContentWidth.d.ts +6 -0
  72. package/lib/typescript/src/hooks/useContentWidth.d.ts.map +1 -0
  73. package/lib/typescript/src/hooks/useHtmlParser.d.ts +11 -0
  74. package/lib/typescript/src/hooks/useHtmlParser.d.ts.map +1 -0
  75. package/lib/typescript/src/hooks/useTagStyle.d.ts +11 -0
  76. package/lib/typescript/src/hooks/useTagStyle.d.ts.map +1 -0
  77. package/lib/typescript/src/index.d.ts +9 -0
  78. package/lib/typescript/src/index.d.ts.map +1 -0
  79. package/lib/typescript/src/parser/index.d.ts +10 -0
  80. package/lib/typescript/src/parser/index.d.ts.map +1 -0
  81. package/lib/typescript/src/renderer/ErrorBoundary.d.ts +22 -0
  82. package/lib/typescript/src/renderer/ErrorBoundary.d.ts.map +1 -0
  83. package/lib/typescript/src/renderer/NodeRenderer.d.ts +7 -0
  84. package/lib/typescript/src/renderer/NodeRenderer.d.ts.map +1 -0
  85. package/lib/typescript/src/renderer/index.d.ts +3 -0
  86. package/lib/typescript/src/renderer/index.d.ts.map +1 -0
  87. package/lib/typescript/src/renderer/tags/BlockTags.d.ts +18 -0
  88. package/lib/typescript/src/renderer/tags/BlockTags.d.ts.map +1 -0
  89. package/lib/typescript/src/renderer/tags/FormTags.d.ts +16 -0
  90. package/lib/typescript/src/renderer/tags/FormTags.d.ts.map +1 -0
  91. package/lib/typescript/src/renderer/tags/ImageTag.d.ts +18 -0
  92. package/lib/typescript/src/renderer/tags/ImageTag.d.ts.map +1 -0
  93. package/lib/typescript/src/renderer/tags/LinkTag.d.ts +19 -0
  94. package/lib/typescript/src/renderer/tags/LinkTag.d.ts.map +1 -0
  95. package/lib/typescript/src/renderer/tags/ListTags.d.ts +15 -0
  96. package/lib/typescript/src/renderer/tags/ListTags.d.ts.map +1 -0
  97. package/lib/typescript/src/renderer/tags/MediaTags.d.ts +14 -0
  98. package/lib/typescript/src/renderer/tags/MediaTags.d.ts.map +1 -0
  99. package/lib/typescript/src/renderer/tags/TableTags.d.ts +15 -0
  100. package/lib/typescript/src/renderer/tags/TableTags.d.ts.map +1 -0
  101. package/lib/typescript/src/renderer/tags/TextTags.d.ts +22 -0
  102. package/lib/typescript/src/renderer/tags/TextTags.d.ts.map +1 -0
  103. package/lib/typescript/src/renderer/tags/index.d.ts +9 -0
  104. package/lib/typescript/src/renderer/tags/index.d.ts.map +1 -0
  105. package/lib/typescript/src/styles/cssToRn.d.ts +11 -0
  106. package/lib/typescript/src/styles/cssToRn.d.ts.map +1 -0
  107. package/lib/typescript/src/styles/darkModeStyles.d.ts +7 -0
  108. package/lib/typescript/src/styles/darkModeStyles.d.ts.map +1 -0
  109. package/lib/typescript/src/styles/defaultStyles.d.ts +8 -0
  110. package/lib/typescript/src/styles/defaultStyles.d.ts.map +1 -0
  111. package/lib/typescript/src/styles/index.d.ts +5 -0
  112. package/lib/typescript/src/styles/index.d.ts.map +1 -0
  113. package/lib/typescript/src/styles/mergeStyles.d.ts +10 -0
  114. package/lib/typescript/src/styles/mergeStyles.d.ts.map +1 -0
  115. package/lib/typescript/src/types/index.d.ts +158 -0
  116. package/lib/typescript/src/types/index.d.ts.map +1 -0
  117. package/lib/typescript/src/utils/accessibility.d.ts +32 -0
  118. package/lib/typescript/src/utils/accessibility.d.ts.map +1 -0
  119. package/lib/typescript/src/utils/cache.d.ts +24 -0
  120. package/lib/typescript/src/utils/cache.d.ts.map +1 -0
  121. package/lib/typescript/src/utils/index.d.ts +33 -0
  122. package/lib/typescript/src/utils/index.d.ts.map +1 -0
  123. package/lib/typescript/src/utils/sanitize.d.ts +11 -0
  124. package/lib/typescript/src/utils/sanitize.d.ts.map +1 -0
  125. package/package.json +171 -0
  126. package/src/HtmlRenderer.tsx +216 -0
  127. package/src/context/index.tsx +30 -0
  128. package/src/hooks/index.ts +3 -0
  129. package/src/hooks/useContentWidth.ts +9 -0
  130. package/src/hooks/useHtmlParser.ts +18 -0
  131. package/src/hooks/useTagStyle.ts +23 -0
  132. package/src/index.tsx +39 -0
  133. package/src/parser/index.ts +80 -0
  134. package/src/renderer/ErrorBoundary.tsx +80 -0
  135. package/src/renderer/NodeRenderer.tsx +345 -0
  136. package/src/renderer/index.tsx +2 -0
  137. package/src/renderer/tags/BlockTags.tsx +49 -0
  138. package/src/renderer/tags/FormTags.tsx +169 -0
  139. package/src/renderer/tags/ImageTag.tsx +215 -0
  140. package/src/renderer/tags/LinkTag.tsx +76 -0
  141. package/src/renderer/tags/ListTags.tsx +148 -0
  142. package/src/renderer/tags/MediaTags.tsx +81 -0
  143. package/src/renderer/tags/TableTags.tsx +94 -0
  144. package/src/renderer/tags/TextTags.tsx +139 -0
  145. package/src/renderer/tags/index.ts +8 -0
  146. package/src/styles/cssToRn.ts +45 -0
  147. package/src/styles/darkModeStyles.ts +80 -0
  148. package/src/styles/defaultStyles.ts +176 -0
  149. package/src/styles/index.ts +4 -0
  150. package/src/styles/mergeStyles.ts +59 -0
  151. package/src/types/index.ts +229 -0
  152. package/src/utils/accessibility.ts +132 -0
  153. package/src/utils/cache.ts +83 -0
  154. package/src/utils/index.ts +151 -0
  155. package/src/utils/sanitize.ts +149 -0
@@ -0,0 +1,215 @@
1
+ import { memo, useState, useEffect, useCallback } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ Image,
6
+ TouchableOpacity,
7
+ ActivityIndicator,
8
+ type ViewStyle,
9
+ type TextStyle,
10
+ type ImageStyle,
11
+ } from 'react-native';
12
+ import type {
13
+ DOMElement,
14
+ HtmlRendererContextValue,
15
+ RNStyle,
16
+ } from '../../types';
17
+ import {
18
+ getCachedImageDimensions,
19
+ setCachedImageDimensions,
20
+ } from '../../utils/cache';
21
+ import { getImageA11yLabel } from '../../utils/accessibility';
22
+
23
+ interface ImageTagProps {
24
+ node: DOMElement;
25
+ style: RNStyle;
26
+ nodeKey: string;
27
+ ctx: HtmlRendererContextValue;
28
+ }
29
+
30
+ interface ImageDimensions {
31
+ width: number;
32
+ height: number;
33
+ }
34
+
35
+ /**
36
+ * Renders an `<img>` tag with:
37
+ * - Cached dimension fetching via `Image.getSize()`
38
+ * - Proportional scaling to fit `contentWidth` / `maxImagesWidth`
39
+ * - Loading placeholder and error fallback
40
+ * - Optional `onImagePress` callback
41
+ * - Accessibility labels from `alt`, `aria-label`, `title`
42
+ */
43
+ export const ImageTag = memo(function ImageTag({
44
+ node,
45
+ style,
46
+ nodeKey,
47
+ ctx,
48
+ }: ImageTagProps) {
49
+ const src = node.attributes.src;
50
+ if (!src) return null;
51
+
52
+ // aria-hidden check
53
+ if (node.attributes['aria-hidden'] === 'true') return null;
54
+
55
+ return (
56
+ <ImageInner
57
+ src={src}
58
+ node={node}
59
+ style={style}
60
+ nodeKey={nodeKey}
61
+ ctx={ctx}
62
+ />
63
+ );
64
+ });
65
+
66
+ const ImageInner = memo(function ImageInner({
67
+ src,
68
+ node,
69
+ style,
70
+ nodeKey,
71
+ ctx,
72
+ }: ImageTagProps & { src: string }) {
73
+ const attrWidth = parseInt(node.attributes.width ?? '0', 10) || 0;
74
+ const attrHeight = parseInt(node.attributes.height ?? '0', 10) || 0;
75
+
76
+ // Check cache first
77
+ const cached = getCachedImageDimensions(src);
78
+ const initialDims =
79
+ attrWidth && attrHeight
80
+ ? { width: attrWidth, height: attrHeight }
81
+ : (cached ?? null);
82
+
83
+ const [dimensions, setDimensions] = useState<ImageDimensions | null>(
84
+ initialDims
85
+ );
86
+ const [loading, setLoading] = useState(!initialDims);
87
+ const [errored, setErrored] = useState(false);
88
+
89
+ useEffect(() => {
90
+ if (dimensions) return;
91
+
92
+ let cancelled = false;
93
+
94
+ Image.getSize(
95
+ src,
96
+ (w, h) => {
97
+ if (!cancelled) {
98
+ const dims = { width: w, height: h };
99
+ setDimensions(dims);
100
+ setCachedImageDimensions(src, dims);
101
+ setLoading(false);
102
+ }
103
+ },
104
+ () => {
105
+ if (!cancelled) {
106
+ setErrored(true);
107
+ setLoading(false);
108
+ }
109
+ }
110
+ );
111
+
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [src, dimensions]);
116
+
117
+ const handlePress = useCallback(() => {
118
+ ctx.onImagePress?.(src, node.attributes);
119
+ }, [ctx, src, node.attributes]);
120
+
121
+ // Calculate scaled dimensions
122
+ const maxWidth = Math.min(
123
+ ctx.contentWidth,
124
+ ctx.maxImagesWidth ?? ctx.contentWidth
125
+ );
126
+ const placeholder = ctx.imagesInitialDimensions;
127
+
128
+ let displayWidth: number;
129
+ let displayHeight: number;
130
+
131
+ if (dimensions) {
132
+ const ratio = dimensions.height / dimensions.width;
133
+ displayWidth = Math.min(dimensions.width, maxWidth);
134
+ displayHeight = displayWidth * ratio;
135
+ } else {
136
+ displayWidth = Math.min(placeholder.width, maxWidth);
137
+ displayHeight = placeholder.height;
138
+ }
139
+
140
+ const a11yLabel = getImageA11yLabel(node);
141
+
142
+ if (errored) {
143
+ return (
144
+ <View
145
+ key={nodeKey}
146
+ style={[errorContainer, { width: displayWidth, height: displayHeight }]}
147
+ accessibilityRole="image"
148
+ accessibilityLabel={a11yLabel ?? 'Image failed to load'}
149
+ >
150
+ <Text style={errorText}>Image failed to load</Text>
151
+ </View>
152
+ );
153
+ }
154
+
155
+ const imageElement = (
156
+ <View key={nodeKey}>
157
+ {loading && (
158
+ <View
159
+ style={[
160
+ loadingContainer,
161
+ { width: displayWidth, height: displayHeight },
162
+ ]}
163
+ >
164
+ <ActivityIndicator size="small" />
165
+ </View>
166
+ )}
167
+ <Image
168
+ source={{ uri: src }}
169
+ style={[
170
+ { width: displayWidth, height: displayHeight } as ImageStyle,
171
+ style as ImageStyle,
172
+ ]}
173
+ accessibilityRole="image"
174
+ accessibilityLabel={a11yLabel}
175
+ resizeMode="contain"
176
+ onLoad={() => setLoading(false)}
177
+ onError={() => {
178
+ setErrored(true);
179
+ setLoading(false);
180
+ }}
181
+ />
182
+ </View>
183
+ );
184
+
185
+ if (ctx.onImagePress) {
186
+ return (
187
+ <TouchableOpacity key={nodeKey} onPress={handlePress} activeOpacity={0.8}>
188
+ {imageElement}
189
+ </TouchableOpacity>
190
+ );
191
+ }
192
+
193
+ return imageElement;
194
+ });
195
+
196
+ const loadingContainer: ViewStyle = {
197
+ justifyContent: 'center',
198
+ alignItems: 'center',
199
+ backgroundColor: '#f5f5f5',
200
+ borderRadius: 4,
201
+ };
202
+
203
+ const errorContainer: ViewStyle = {
204
+ justifyContent: 'center',
205
+ alignItems: 'center',
206
+ backgroundColor: '#f5f5f5',
207
+ borderRadius: 4,
208
+ borderWidth: 1,
209
+ borderColor: '#ddd',
210
+ };
211
+
212
+ const errorText: TextStyle = {
213
+ color: '#999',
214
+ fontSize: 12,
215
+ };
@@ -0,0 +1,76 @@
1
+ import { memo, useCallback, type ReactNode } from 'react';
2
+ import { Text, TouchableOpacity, Linking, type TextStyle } from 'react-native';
3
+ import type { DOMNode, RNStyle, HtmlRendererContextValue } from '../../types';
4
+ import { isInlineContent } from '../../utils';
5
+ import { getLinkA11yLabel } from '../../utils/accessibility';
6
+ import { renderInlineNodes } from './TextTags';
7
+
8
+ /** Only allow these URL schemes in the default Linking.openURL handler. */
9
+ const SAFE_LINK_SCHEMES = /^(https?:|mailto:|tel:|sms:)/i;
10
+
11
+ interface LinkTagProps {
12
+ node: {
13
+ attributes: Record<string, string>;
14
+ children: DOMNode[];
15
+ };
16
+ style: RNStyle;
17
+ nodeKey: string;
18
+ ctx: HtmlRendererContextValue;
19
+ renderNodes: (
20
+ nodes: DOMNode[],
21
+ ctx: HtmlRendererContextValue,
22
+ keyPrefix: string
23
+ ) => ReactNode[];
24
+ }
25
+
26
+ /**
27
+ * Renders a block-level `<a>` tag as a TouchableOpacity wrapping a Text.
28
+ * Includes accessibility role, label, and hint.
29
+ */
30
+ export const LinkTag = memo(function LinkTag({
31
+ node,
32
+ style,
33
+ nodeKey,
34
+ ctx,
35
+ renderNodes,
36
+ }: LinkTagProps) {
37
+ const href = node.attributes.href ?? '';
38
+
39
+ const handlePress = useCallback(() => {
40
+ if (ctx.onLinkPress) {
41
+ ctx.onLinkPress(href, node.attributes);
42
+ } else if (SAFE_LINK_SCHEMES.test(href)) {
43
+ Linking.openURL(href).catch(() => {
44
+ // silently ignore if URL can't be opened
45
+ });
46
+ }
47
+ }, [ctx, href, node.attributes]);
48
+
49
+ const children = isInlineContent(node.children)
50
+ ? renderInlineNodes(node.children, ctx, nodeKey, renderNodes)
51
+ : renderNodes(node.children, ctx, nodeKey);
52
+
53
+ const a11yLabel =
54
+ node.attributes['aria-label'] ??
55
+ getLinkA11yLabel(node as Parameters<typeof getLinkA11yLabel>[0]);
56
+
57
+ return (
58
+ <TouchableOpacity
59
+ key={nodeKey}
60
+ onPress={handlePress}
61
+ accessibilityRole="link"
62
+ accessibilityLabel={a11yLabel}
63
+ accessibilityHint={href ? `Opens ${href}` : undefined}
64
+ activeOpacity={0.7}
65
+ >
66
+ <Text
67
+ style={style as TextStyle}
68
+ allowFontScaling={ctx.allowFontScaling}
69
+ maxFontSizeMultiplier={ctx.maxFontSizeMultiplier}
70
+ {...ctx.defaultTextProps}
71
+ >
72
+ {children}
73
+ </Text>
74
+ </TouchableOpacity>
75
+ );
76
+ });
@@ -0,0 +1,148 @@
1
+ import { memo, type ReactNode } from 'react';
2
+ import { View, Text, type ViewStyle, type TextStyle } from 'react-native';
3
+ import type {
4
+ DOMNode,
5
+ DOMElement,
6
+ RNStyle,
7
+ HtmlRendererContextValue,
8
+ } from '../../types';
9
+ import { isInlineContent, isDOMElement } from '../../utils';
10
+ import { mergeStylesForElement } from '../../styles';
11
+ import { getDefaultTagStyles } from '../../styles/defaultStyles';
12
+ import { getAccessibilityProps } from '../../utils/accessibility';
13
+ import { renderInlineNodes } from './TextTags';
14
+
15
+ interface ListTagProps {
16
+ node: DOMElement;
17
+ style: RNStyle;
18
+ nodeKey: string;
19
+ ctx: HtmlRendererContextValue;
20
+ renderNodes: (
21
+ nodes: DOMNode[],
22
+ ctx: HtmlRendererContextValue,
23
+ keyPrefix: string
24
+ ) => ReactNode[];
25
+ }
26
+
27
+ /**
28
+ * Renders `<ul>` and `<ol>` lists with proper bullets/numbers and nesting.
29
+ */
30
+ export const ListTag = memo(function ListTag({
31
+ node,
32
+ style,
33
+ nodeKey,
34
+ ctx,
35
+ renderNodes,
36
+ }: ListTagProps) {
37
+ const isOrdered = node.tag === 'ol';
38
+ const defaults = getDefaultTagStyles(ctx.emSize);
39
+ const nestedCtx = { ...ctx, nestLevel: ctx.nestLevel + 1 };
40
+ const a11y = getAccessibilityProps(node);
41
+
42
+ const listItems = node.children.filter(
43
+ (child): child is DOMElement => isDOMElement(child) && child.tag === 'li'
44
+ );
45
+
46
+ return (
47
+ <View
48
+ key={nodeKey}
49
+ style={style as ViewStyle}
50
+ accessibilityRole="list"
51
+ {...a11y}
52
+ {...ctx.defaultViewProps}
53
+ >
54
+ {listItems.map((li, liIndex) => {
55
+ const liKey = `${nodeKey}_li_${liIndex}`;
56
+ const liStyle = mergeStylesForElement(
57
+ li,
58
+ defaults,
59
+ ctx.tagsStyles,
60
+ ctx.classesStyles,
61
+ ctx.idsStyles,
62
+ ctx.ignoredStyles.size > 0 ? ctx.ignoredStyles : undefined,
63
+ ctx.allowedStyles
64
+ );
65
+
66
+ const prefixRenderer = isOrdered
67
+ ? ctx.listsPrefixesRenderers?.ol
68
+ : ctx.listsPrefixesRenderers?.ul;
69
+
70
+ const prefix = prefixRenderer ? (
71
+ prefixRenderer({ index: liIndex, nestLevel: ctx.nestLevel })
72
+ ) : (
73
+ <Text style={bulletStyle}>
74
+ {isOrdered ? `${liIndex + 1}. ` : '\u2022 '}
75
+ </Text>
76
+ );
77
+
78
+ const hasNestedList = li.children.some(
79
+ (c) => isDOMElement(c) && (c.tag === 'ul' || c.tag === 'ol')
80
+ );
81
+
82
+ if (hasNestedList) {
83
+ const inlineChildren: DOMNode[] = [];
84
+ const blockChildren: DOMNode[] = [];
85
+
86
+ for (const child of li.children) {
87
+ if (
88
+ isDOMElement(child) &&
89
+ (child.tag === 'ul' || child.tag === 'ol')
90
+ ) {
91
+ blockChildren.push(child);
92
+ } else {
93
+ inlineChildren.push(child);
94
+ }
95
+ }
96
+
97
+ return (
98
+ <View key={liKey}>
99
+ <View style={liRowStyle}>
100
+ {prefix}
101
+ <Text
102
+ style={[liStyle as TextStyle, { flex: 1 }]}
103
+ allowFontScaling={ctx.allowFontScaling}
104
+ maxFontSizeMultiplier={ctx.maxFontSizeMultiplier}
105
+ {...ctx.defaultTextProps}
106
+ >
107
+ {isInlineContent(inlineChildren)
108
+ ? renderInlineNodes(inlineChildren, ctx, liKey, renderNodes)
109
+ : renderNodes(inlineChildren, ctx, liKey)}
110
+ </Text>
111
+ </View>
112
+ {renderNodes(blockChildren, nestedCtx, `${liKey}_nested`)}
113
+ </View>
114
+ );
115
+ }
116
+
117
+ const liChildren = isInlineContent(li.children)
118
+ ? renderInlineNodes(li.children, ctx, liKey, renderNodes)
119
+ : renderNodes(li.children, ctx, liKey);
120
+
121
+ return (
122
+ <View key={liKey} style={liRowStyle}>
123
+ {prefix}
124
+ <Text
125
+ style={[liStyle as TextStyle, { flex: 1 }]}
126
+ allowFontScaling={ctx.allowFontScaling}
127
+ maxFontSizeMultiplier={ctx.maxFontSizeMultiplier}
128
+ {...ctx.defaultTextProps}
129
+ >
130
+ {liChildren}
131
+ </Text>
132
+ </View>
133
+ );
134
+ })}
135
+ </View>
136
+ );
137
+ });
138
+
139
+ const liRowStyle: ViewStyle = {
140
+ flexDirection: 'row',
141
+ alignItems: 'flex-start',
142
+ };
143
+
144
+ const bulletStyle: TextStyle = {
145
+ width: 20,
146
+ textAlign: 'right',
147
+ marginRight: 4,
148
+ };
@@ -0,0 +1,81 @@
1
+ import { memo } from 'react';
2
+ import { View, Text, type ViewStyle, type TextStyle } from 'react-native';
3
+ import type {
4
+ DOMElement,
5
+ RNStyle,
6
+ HtmlRendererContextValue,
7
+ } from '../../types';
8
+
9
+ interface MediaTagProps {
10
+ node: DOMElement;
11
+ style: RNStyle;
12
+ nodeKey: string;
13
+ ctx: HtmlRendererContextValue;
14
+ }
15
+
16
+ /**
17
+ * Placeholder renderers for `<video>` and `<audio>` tags.
18
+ */
19
+ export const VideoTag = memo(function VideoTag({
20
+ node,
21
+ style,
22
+ nodeKey,
23
+ ctx,
24
+ }: MediaTagProps) {
25
+ const width = Math.min(ctx.contentWidth, 320);
26
+ const label = node.attributes['aria-label'] ?? 'Video content';
27
+
28
+ return (
29
+ <View
30
+ key={nodeKey}
31
+ style={[
32
+ placeholderStyle,
33
+ style as ViewStyle,
34
+ { width, height: width * 0.5625 },
35
+ ]}
36
+ accessibilityLabel={label}
37
+ >
38
+ <Text style={iconStyle}>{'\u25B6'}</Text>
39
+ <Text style={labelStyle}>Video</Text>
40
+ </View>
41
+ );
42
+ });
43
+
44
+ export const AudioTag = memo(function AudioTag({
45
+ node,
46
+ style,
47
+ nodeKey,
48
+ }: MediaTagProps) {
49
+ const label = node.attributes['aria-label'] ?? 'Audio content';
50
+
51
+ return (
52
+ <View
53
+ key={nodeKey}
54
+ style={[placeholderStyle, style as ViewStyle, { height: 48 }]}
55
+ accessibilityLabel={label}
56
+ >
57
+ <Text style={iconStyle}>{'\u266B'}</Text>
58
+ <Text style={labelStyle}>Audio</Text>
59
+ </View>
60
+ );
61
+ });
62
+
63
+ const placeholderStyle: ViewStyle = {
64
+ backgroundColor: '#f0f0f0',
65
+ borderRadius: 6,
66
+ justifyContent: 'center',
67
+ alignItems: 'center',
68
+ marginVertical: 4,
69
+ flexDirection: 'row',
70
+ gap: 6,
71
+ };
72
+
73
+ const iconStyle: TextStyle = {
74
+ fontSize: 20,
75
+ color: '#999',
76
+ };
77
+
78
+ const labelStyle: TextStyle = {
79
+ fontSize: 13,
80
+ color: '#999',
81
+ };
@@ -0,0 +1,94 @@
1
+ import { memo, type ReactNode } from 'react';
2
+ import { View, ScrollView, type ViewStyle } from 'react-native';
3
+ import type {
4
+ DOMNode,
5
+ DOMElement,
6
+ RNStyle,
7
+ HtmlRendererContextValue,
8
+ } from '../../types';
9
+ import { isDOMElement } from '../../utils';
10
+
11
+ interface TableTagProps {
12
+ node: DOMElement;
13
+ style: RNStyle;
14
+ nodeKey: string;
15
+ ctx: HtmlRendererContextValue;
16
+ renderNodes: (
17
+ nodes: DOMNode[],
18
+ ctx: HtmlRendererContextValue,
19
+ keyPrefix: string
20
+ ) => ReactNode[];
21
+ }
22
+
23
+ /**
24
+ * Renders a `<table>` wrapped in a horizontal ScrollView for overflow.
25
+ */
26
+ export const TableTag = memo(function TableTag({
27
+ node,
28
+ style,
29
+ nodeKey,
30
+ ctx,
31
+ renderNodes,
32
+ }: TableTagProps) {
33
+ return (
34
+ <ScrollView
35
+ key={nodeKey}
36
+ horizontal
37
+ showsHorizontalScrollIndicator={false}
38
+ style={style as ViewStyle}
39
+ >
40
+ <View>
41
+ {renderTableContent(
42
+ node.children,
43
+ ctx,
44
+ keyPrefix(nodeKey),
45
+ renderNodes
46
+ )}
47
+ </View>
48
+ </ScrollView>
49
+ );
50
+ });
51
+
52
+ function keyPrefix(k: string): string {
53
+ return `${k}_tbl`;
54
+ }
55
+
56
+ function renderTableContent(
57
+ nodes: DOMNode[],
58
+ ctx: HtmlRendererContextValue,
59
+ prefix: string,
60
+ renderNodes: (
61
+ nodes: DOMNode[],
62
+ ctx: HtmlRendererContextValue,
63
+ keyPrefix: string
64
+ ) => ReactNode[]
65
+ ): ReactNode[] {
66
+ const results: ReactNode[] = [];
67
+
68
+ for (const [i, node] of nodes.entries()) {
69
+ if (!isDOMElement(node)) continue;
70
+ const key = `${prefix}_${i}`;
71
+
72
+ if (
73
+ node.tag === 'thead' ||
74
+ node.tag === 'tbody' ||
75
+ node.tag === 'tfoot' ||
76
+ node.tag === 'colgroup' ||
77
+ node.tag === 'caption'
78
+ ) {
79
+ results.push(...renderTableContent(node.children, ctx, key, renderNodes));
80
+ } else if (node.tag === 'tr') {
81
+ results.push(
82
+ <View key={key} style={trStyle}>
83
+ {renderNodes(node.children, ctx, key)}
84
+ </View>
85
+ );
86
+ }
87
+ }
88
+
89
+ return results;
90
+ }
91
+
92
+ const trStyle: ViewStyle = {
93
+ flexDirection: 'row',
94
+ };