@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.
- package/LICENSE +202 -0
- package/README.md +263 -0
- package/dist/index.d.mts +63 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +1093 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1068 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
- package/src/HTMLValidator.spec.ts +157 -0
- package/src/HTMLValidator.ts +35 -0
- package/src/RenderHTML.spec.tsx +435 -0
- package/src/RenderHTML.tsx +47 -0
- package/src/__snapshots__/RenderHTML.spec.tsx.snap +2795 -0
- package/src/assets.ts +5 -0
- package/src/context/AlignedWidthItem.tsx +25 -0
- package/src/context/AlignedWidthProvider.tsx +52 -0
- package/src/context/HtmlProvider.tsx +564 -0
- package/src/index.ts +4 -0
- package/src/renderers/ATagRenderer.tsx +60 -0
- package/src/renderers/ImgTagRenderer.tsx +64 -0
- package/src/renderers/LiTagRenderer.tsx +73 -0
- package/src/renderers/PTagRenderer.tsx +31 -0
- package/src/renderers/_DefaultBlockRenderer.tsx +82 -0
- package/src/renderers/_DefaultTextRenderer.tsx +26 -0
- package/src/renderers/_NodeRenderer.tsx +119 -0
- package/src/renderers/_NodesRenderer.tsx +12 -0
- package/src/types.ts +162 -0
- package/src/utils.ts +43 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Element } from 'domhandler';
|
|
2
|
+
import { type FunctionComponent, useEffect, useState } from 'react';
|
|
3
|
+
import { Image, type ImageStyle, View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { useHtmlContext } from '../context/HtmlProvider';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
node: Element;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ImgTagRenderer: FunctionComponent<Props> = ({ node }) => {
|
|
12
|
+
const { getStyle, renderImage } = useHtmlContext();
|
|
13
|
+
const [size, setSize] = useState<{
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
} | null>(null);
|
|
17
|
+
const [containerWidth, setContainerWidth] = useState<number>(0);
|
|
18
|
+
|
|
19
|
+
const { src, alt } = node.attribs || {};
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!src) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
Image.getSize(
|
|
26
|
+
src,
|
|
27
|
+
(width, height) => {
|
|
28
|
+
setSize({ w: width, h: height });
|
|
29
|
+
},
|
|
30
|
+
() => {
|
|
31
|
+
// ignore errors — image simply won't render
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
}, [src]);
|
|
35
|
+
|
|
36
|
+
if (!size) return null;
|
|
37
|
+
|
|
38
|
+
const { w, h } = size;
|
|
39
|
+
|
|
40
|
+
const finalWidth = Math.min(w, containerWidth);
|
|
41
|
+
const ratio = h / w;
|
|
42
|
+
|
|
43
|
+
const imageStyle = {
|
|
44
|
+
...(getStyle(node).block as ImageStyle),
|
|
45
|
+
width: finalWidth,
|
|
46
|
+
height: finalWidth * ratio,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<View
|
|
51
|
+
onLayout={(e) => {
|
|
52
|
+
const width = e.nativeEvent.layout.width;
|
|
53
|
+
setContainerWidth(width);
|
|
54
|
+
}}
|
|
55
|
+
style={{ width: '100%' }}
|
|
56
|
+
>
|
|
57
|
+
{renderImage({
|
|
58
|
+
source: { uri: src },
|
|
59
|
+
style: imageStyle,
|
|
60
|
+
alt,
|
|
61
|
+
})}
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Element } from 'domhandler';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { UL_MARKER_URI } from '../assets';
|
|
6
|
+
import { useHtmlContext } from '../context/HtmlProvider';
|
|
7
|
+
import { concatTextNodes, isList } from '../utils';
|
|
8
|
+
import { NodesRenderer } from './_NodesRenderer';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
node: Element;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const LiTagRenderer: FunctionComponent<Props> = ({ node }) => {
|
|
15
|
+
const { getStyle, markerColor, renderImage, renderText } = useHtmlContext();
|
|
16
|
+
|
|
17
|
+
const isParentOl = node.parent?.type === 'tag' && node.parent?.tagName === 'ol';
|
|
18
|
+
const parentLiTags = node.parent?.children || [];
|
|
19
|
+
const currentIndex = parentLiTags.indexOf(node) + 1;
|
|
20
|
+
const items = node.children;
|
|
21
|
+
const style = getStyle(node);
|
|
22
|
+
const { lineHeight = 20, fontSize = 16 } = style.text;
|
|
23
|
+
const markerHeight = 5;
|
|
24
|
+
const markerColorResult = markerColor ?? style.text.color ?? 'pink';
|
|
25
|
+
const renderMarker = () => {
|
|
26
|
+
if (isParentOl) {
|
|
27
|
+
return renderText({ style: [style.text], children: `${currentIndex}.` });
|
|
28
|
+
} else {
|
|
29
|
+
return renderImage({
|
|
30
|
+
source: { uri: UL_MARKER_URI },
|
|
31
|
+
style: {
|
|
32
|
+
width: markerHeight,
|
|
33
|
+
height: lineHeight,
|
|
34
|
+
tintColor: markerColorResult,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Separate nested lists and main content
|
|
41
|
+
const nestedLists = items.filter((child) => isList(child));
|
|
42
|
+
const mainContent = items.filter((child) => !isList(child));
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View>
|
|
46
|
+
<View
|
|
47
|
+
style={{
|
|
48
|
+
flexDirection: 'row',
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<View
|
|
52
|
+
style={{
|
|
53
|
+
marginRight: 8,
|
|
54
|
+
marginBottom: lineHeight - fontSize,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{renderMarker()}
|
|
58
|
+
</View>
|
|
59
|
+
{renderText({
|
|
60
|
+
accessible: true,
|
|
61
|
+
accessibilityLabel: `List item ${currentIndex} of ${parentLiTags.length}: ${concatTextNodes(mainContent)}`,
|
|
62
|
+
style: [style.text, { flex: 1 }],
|
|
63
|
+
children: <NodesRenderer nodes={mainContent} />,
|
|
64
|
+
})}
|
|
65
|
+
</View>
|
|
66
|
+
{nestedLists.length > 0 && (
|
|
67
|
+
<View style={{ width: '100%' }}>
|
|
68
|
+
<NodesRenderer nodes={nestedLists} />
|
|
69
|
+
</View>
|
|
70
|
+
)}
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Element } from 'domhandler';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
|
|
4
|
+
import { useHtmlContext } from '../context/HtmlProvider';
|
|
5
|
+
import { DefaultBlockRenderer } from './_DefaultBlockRenderer';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
node: Element;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const PTagRenderer: FunctionComponent<Props> = ({ node }) => {
|
|
12
|
+
const { getStyle } = useHtmlContext();
|
|
13
|
+
|
|
14
|
+
const isFirstChild = !node.previousSibling;
|
|
15
|
+
const isLastChild = !node.nextSibling;
|
|
16
|
+
const style = getStyle(node);
|
|
17
|
+
// collapse top margin for first child and bottom margin for last child
|
|
18
|
+
const marginTop = isFirstChild ? 0 : style.block.marginTop;
|
|
19
|
+
const marginBottom = isLastChild ? 0 : style.block.marginBottom;
|
|
20
|
+
return (
|
|
21
|
+
<DefaultBlockRenderer
|
|
22
|
+
node={node}
|
|
23
|
+
viewProps={{
|
|
24
|
+
style: {
|
|
25
|
+
marginTop,
|
|
26
|
+
marginBottom,
|
|
27
|
+
},
|
|
28
|
+
}}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ChildNode, Element } from 'domhandler';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
import { Text, type TextProps, View, type ViewProps } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { useHtmlContext } from '../context/HtmlProvider';
|
|
6
|
+
import { type HTMLTag, type RendererType, rendererTypeMap } from '../types';
|
|
7
|
+
import { NodesRenderer } from './_NodesRenderer';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
node: Element;
|
|
11
|
+
viewProps?: ViewProps;
|
|
12
|
+
textProps?: TextProps;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DefaultBlockRenderer: FunctionComponent<Props> = ({
|
|
16
|
+
node,
|
|
17
|
+
viewProps = {},
|
|
18
|
+
textProps = {},
|
|
19
|
+
}) => {
|
|
20
|
+
const { getStyle } = useHtmlContext();
|
|
21
|
+
const { style: viewStyle, ...restViewProps } = viewProps;
|
|
22
|
+
const { style: textStyle, ...restTextProps } = textProps;
|
|
23
|
+
|
|
24
|
+
const groupedChildren = groupByInlineBlock(node.children);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View {...restViewProps} style={[getStyle(node).block, viewStyle]}>
|
|
28
|
+
{groupedChildren.map((childrenGroup, index) => {
|
|
29
|
+
if (childrenGroup.type === 'text') {
|
|
30
|
+
return (
|
|
31
|
+
<Text
|
|
32
|
+
{...restTextProps}
|
|
33
|
+
key={`${node.type}-${index}`}
|
|
34
|
+
style={[getStyle(node).text, textStyle]}
|
|
35
|
+
>
|
|
36
|
+
<NodesRenderer nodes={childrenGroup.nodes} />
|
|
37
|
+
</Text>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return <NodesRenderer key={`${node.type}-${index}`} nodes={childrenGroup.nodes} />;
|
|
41
|
+
})}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function groupByInlineBlock(arr: ChildNode[]): {
|
|
47
|
+
type: RendererType;
|
|
48
|
+
nodes: ChildNode[];
|
|
49
|
+
}[] {
|
|
50
|
+
const result: {
|
|
51
|
+
type: RendererType;
|
|
52
|
+
nodes: ChildNode[];
|
|
53
|
+
}[] = [];
|
|
54
|
+
let currentGroup: ChildNode[] = [];
|
|
55
|
+
|
|
56
|
+
for (const item of arr) {
|
|
57
|
+
const isTextNode = item.type === 'text';
|
|
58
|
+
const isTextTag = item.type === 'tag' && rendererTypeMap[item.name as HTMLTag] === 'text';
|
|
59
|
+
if (isTextNode || isTextTag) {
|
|
60
|
+
currentGroup.push(item);
|
|
61
|
+
} else {
|
|
62
|
+
if (currentGroup.length) {
|
|
63
|
+
result.push({
|
|
64
|
+
type: 'text',
|
|
65
|
+
nodes: currentGroup,
|
|
66
|
+
});
|
|
67
|
+
currentGroup = [];
|
|
68
|
+
}
|
|
69
|
+
result.push({
|
|
70
|
+
type: 'block',
|
|
71
|
+
nodes: [item],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (currentGroup.length) {
|
|
76
|
+
result.push({
|
|
77
|
+
type: 'text',
|
|
78
|
+
nodes: currentGroup,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Element } from 'domhandler';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
import { type TextProps } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { useHtmlContext } from '../context/HtmlProvider';
|
|
6
|
+
import { NodesRenderer } from './_NodesRenderer';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
node: Element;
|
|
10
|
+
textProps?: TextProps;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DefaultTextRenderer: FunctionComponent<Props> = ({ node, textProps = {} }) => {
|
|
14
|
+
const { style, ...restProps } = textProps;
|
|
15
|
+
const { getStyle, renderText } = useHtmlContext();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
{renderText({
|
|
20
|
+
...restProps,
|
|
21
|
+
style: [getStyle(node).text, style],
|
|
22
|
+
children: <NodesRenderer nodes={node.children} />,
|
|
23
|
+
})}
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { AnyNode, ChildNode } from 'domhandler';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
import { Text, View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { AlignedWidthItem } from '../context/AlignedWidthItem';
|
|
6
|
+
import { AlignedWidthProvider } from '../context/AlignedWidthProvider';
|
|
7
|
+
import { useHtmlContext } from '../context/HtmlProvider';
|
|
8
|
+
import { isTextRenderer } from '../utils';
|
|
9
|
+
import { DefaultBlockRenderer } from './_DefaultBlockRenderer';
|
|
10
|
+
import { DefaultTextRenderer } from './_DefaultTextRenderer';
|
|
11
|
+
import { ATagRenderer } from './ATagRenderer';
|
|
12
|
+
import { ImgTagRenderer } from './ImgTagRenderer';
|
|
13
|
+
import { LiTagRenderer } from './LiTagRenderer';
|
|
14
|
+
import { PTagRenderer } from './PTagRenderer';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
node: AnyNode | ChildNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const NodeRenderer: FunctionComponent<Props> = ({ node }) => {
|
|
21
|
+
const { getStyle } = useHtmlContext();
|
|
22
|
+
|
|
23
|
+
if (node.type === 'tag') {
|
|
24
|
+
switch (node.tagName) {
|
|
25
|
+
case 'thead':
|
|
26
|
+
case 'tbody':
|
|
27
|
+
case 'tfoot':
|
|
28
|
+
case 'blockquote':
|
|
29
|
+
case 'ul':
|
|
30
|
+
case 'ol':
|
|
31
|
+
case 'dl':
|
|
32
|
+
case 'dt':
|
|
33
|
+
case 'dd':
|
|
34
|
+
case 'div':
|
|
35
|
+
case 'main':
|
|
36
|
+
case 'section':
|
|
37
|
+
case 'article':
|
|
38
|
+
case 'aside':
|
|
39
|
+
case 'nav':
|
|
40
|
+
case 'header':
|
|
41
|
+
case 'footer':
|
|
42
|
+
case 'tr':
|
|
43
|
+
return <DefaultBlockRenderer node={node} />;
|
|
44
|
+
case 'h1':
|
|
45
|
+
case 'h2':
|
|
46
|
+
case 'h3':
|
|
47
|
+
case 'h4':
|
|
48
|
+
case 'h5':
|
|
49
|
+
case 'h6':
|
|
50
|
+
return (
|
|
51
|
+
<DefaultBlockRenderer
|
|
52
|
+
node={node}
|
|
53
|
+
textProps={{
|
|
54
|
+
accessible: true,
|
|
55
|
+
accessibilityRole: 'header',
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
case 'p':
|
|
60
|
+
return <PTagRenderer node={node} />;
|
|
61
|
+
case 'li':
|
|
62
|
+
return <LiTagRenderer node={node} />;
|
|
63
|
+
case 'hr':
|
|
64
|
+
return <View style={[getStyle(node).block]} />;
|
|
65
|
+
case 'br':
|
|
66
|
+
if (isTextRenderer(node.prev)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return <View style={[getStyle(node).block]} />;
|
|
70
|
+
case 'table':
|
|
71
|
+
return (
|
|
72
|
+
<AlignedWidthProvider>
|
|
73
|
+
<DefaultBlockRenderer node={node} />
|
|
74
|
+
</AlignedWidthProvider>
|
|
75
|
+
);
|
|
76
|
+
case 'img':
|
|
77
|
+
return <ImgTagRenderer node={node} />;
|
|
78
|
+
// text renderers
|
|
79
|
+
case 'b':
|
|
80
|
+
case 'strong':
|
|
81
|
+
case 'i':
|
|
82
|
+
case 'em':
|
|
83
|
+
case 'u':
|
|
84
|
+
case 'mark':
|
|
85
|
+
case 'small':
|
|
86
|
+
case 's':
|
|
87
|
+
case 'del':
|
|
88
|
+
case 'sup':
|
|
89
|
+
case 'sub':
|
|
90
|
+
case 'span':
|
|
91
|
+
case 'pre':
|
|
92
|
+
case 'code':
|
|
93
|
+
return <DefaultTextRenderer node={node} />;
|
|
94
|
+
case 'a':
|
|
95
|
+
return <ATagRenderer node={node} />;
|
|
96
|
+
case 'th':
|
|
97
|
+
case 'td': {
|
|
98
|
+
const currentIndex = node.parent?.children.indexOf(node) || 0;
|
|
99
|
+
return (
|
|
100
|
+
<AlignedWidthItem index={currentIndex}>
|
|
101
|
+
<DefaultTextRenderer node={node} />
|
|
102
|
+
</AlignedWidthItem>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
// Ignore unsupported tags (script, style, iframe, etc.)
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (node.type === 'text') {
|
|
111
|
+
const text = node.data
|
|
112
|
+
// crush multiple spaces into one
|
|
113
|
+
.replace(/\s+/g, ' ')
|
|
114
|
+
.replace(/ /g, ' ');
|
|
115
|
+
|
|
116
|
+
return <Text>{text}</Text>;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AnyNode, ChildNode } from 'domhandler';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
|
|
4
|
+
import { NodeRenderer } from './_NodeRenderer';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
nodes: (AnyNode | ChildNode)[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const NodesRenderer: FunctionComponent<Props> = ({ nodes }) => {
|
|
11
|
+
return nodes.map((node, index) => <NodeRenderer key={`${node.type}-${index}`} node={node} />);
|
|
12
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { ColorValue, ImageProps, TextProps, TextStyle, ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { OnHTMLLinkPress } from './context/HtmlProvider';
|
|
5
|
+
|
|
6
|
+
export type HTMLTag =
|
|
7
|
+
| 'h1'
|
|
8
|
+
| 'h2'
|
|
9
|
+
| 'h3'
|
|
10
|
+
| 'h4'
|
|
11
|
+
| 'h5'
|
|
12
|
+
| 'h6'
|
|
13
|
+
| 'p'
|
|
14
|
+
| 'a'
|
|
15
|
+
| 'ul'
|
|
16
|
+
| 'ol'
|
|
17
|
+
| 'li'
|
|
18
|
+
| 'img'
|
|
19
|
+
| 'table'
|
|
20
|
+
| 'tr'
|
|
21
|
+
| 'th'
|
|
22
|
+
| 'td'
|
|
23
|
+
| 'pre'
|
|
24
|
+
| 'code'
|
|
25
|
+
| 'blockquote'
|
|
26
|
+
| 'hr'
|
|
27
|
+
| 'br'
|
|
28
|
+
| 'div'
|
|
29
|
+
| 'b'
|
|
30
|
+
| 'strong'
|
|
31
|
+
| 'i'
|
|
32
|
+
| 'em'
|
|
33
|
+
| 'u'
|
|
34
|
+
| 'mark'
|
|
35
|
+
| 'small'
|
|
36
|
+
| 's'
|
|
37
|
+
| 'del'
|
|
38
|
+
| 'sup'
|
|
39
|
+
| 'sub'
|
|
40
|
+
| 'span'
|
|
41
|
+
| 'dt'
|
|
42
|
+
| 'dd'
|
|
43
|
+
| 'thead'
|
|
44
|
+
| 'tbody'
|
|
45
|
+
| 'tfoot'
|
|
46
|
+
| 'dl'
|
|
47
|
+
| 'main'
|
|
48
|
+
| 'section'
|
|
49
|
+
| 'article'
|
|
50
|
+
| 'aside'
|
|
51
|
+
| 'nav'
|
|
52
|
+
| 'header'
|
|
53
|
+
| 'footer';
|
|
54
|
+
|
|
55
|
+
export type RendererType = 'text' | 'block';
|
|
56
|
+
|
|
57
|
+
export const rendererTypeMap = {
|
|
58
|
+
// Elements rendered with a View (block)
|
|
59
|
+
thead: 'block',
|
|
60
|
+
tbody: 'block',
|
|
61
|
+
tfoot: 'block',
|
|
62
|
+
blockquote: 'block',
|
|
63
|
+
ul: 'block',
|
|
64
|
+
ol: 'block',
|
|
65
|
+
dl: 'block',
|
|
66
|
+
li: 'block',
|
|
67
|
+
div: 'block',
|
|
68
|
+
hr: 'block',
|
|
69
|
+
br: 'block',
|
|
70
|
+
pre: 'block',
|
|
71
|
+
code: 'block',
|
|
72
|
+
img: 'block',
|
|
73
|
+
table: 'block',
|
|
74
|
+
tr: 'block',
|
|
75
|
+
dt: 'block',
|
|
76
|
+
dd: 'block',
|
|
77
|
+
p: 'block',
|
|
78
|
+
h1: 'block',
|
|
79
|
+
h2: 'block',
|
|
80
|
+
h3: 'block',
|
|
81
|
+
h4: 'block',
|
|
82
|
+
h5: 'block',
|
|
83
|
+
h6: 'block',
|
|
84
|
+
main: 'block',
|
|
85
|
+
section: 'block',
|
|
86
|
+
article: 'block',
|
|
87
|
+
aside: 'block',
|
|
88
|
+
nav: 'block',
|
|
89
|
+
header: 'block',
|
|
90
|
+
footer: 'block',
|
|
91
|
+
|
|
92
|
+
// All other elements are text
|
|
93
|
+
b: 'text',
|
|
94
|
+
strong: 'text',
|
|
95
|
+
i: 'text',
|
|
96
|
+
em: 'text',
|
|
97
|
+
u: 'text',
|
|
98
|
+
mark: 'text',
|
|
99
|
+
small: 'text',
|
|
100
|
+
s: 'text',
|
|
101
|
+
del: 'text',
|
|
102
|
+
sup: 'text',
|
|
103
|
+
sub: 'text',
|
|
104
|
+
span: 'text',
|
|
105
|
+
a: 'text',
|
|
106
|
+
th: 'text',
|
|
107
|
+
td: 'text',
|
|
108
|
+
} satisfies Record<HTMLTag, RendererType>;
|
|
109
|
+
|
|
110
|
+
export type HtmlStyle = {
|
|
111
|
+
text?: TextStyle;
|
|
112
|
+
block?: ViewStyle;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type TagStyles = {
|
|
116
|
+
[key in HTMLTag]: HtmlStyle;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export interface CommonProps {
|
|
120
|
+
/**
|
|
121
|
+
* Render prop for all images rendered by the HTML renderer
|
|
122
|
+
* (<img> tags, bullet markers, and external link icons).
|
|
123
|
+
* Keeps the package free of any specific image library dependency.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* renderImage={(props) => <Image {...props} />}
|
|
127
|
+
*/
|
|
128
|
+
renderImage?: (props: ImageProps) => ReactNode;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Custom component to use instead of the built-in React Native `Text`.
|
|
132
|
+
* Useful for applying a global font or integrating a design-system text component.
|
|
133
|
+
* Must accept the same props as `Text` (i.e. `TextProps`).
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* TextComponent={MyAppText}
|
|
137
|
+
*/
|
|
138
|
+
TextComponent?: ComponentType<TextProps & { children?: ReactNode }>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Render prop for all text nodes rendered by the HTML renderer.
|
|
142
|
+
* Receives the full `TextProps` (including `style`, `children`, accessibility props, etc.)
|
|
143
|
+
* and must return a renderable node. Takes priority over `TextComponent` when both are set.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* renderTextComponent={(props) => <MyText {...props} />}
|
|
147
|
+
*/
|
|
148
|
+
renderTextComponent?: (props: TextProps) => ReactNode;
|
|
149
|
+
|
|
150
|
+
// style props
|
|
151
|
+
baseStyle?: TextStyle;
|
|
152
|
+
tagStyles?: Partial<TagStyles>;
|
|
153
|
+
classesStyles?: {
|
|
154
|
+
[className: string]: HtmlStyle;
|
|
155
|
+
};
|
|
156
|
+
listGap?: number;
|
|
157
|
+
|
|
158
|
+
// interaction/functional props
|
|
159
|
+
onLinkPress?: (options: OnHTMLLinkPress) => void;
|
|
160
|
+
markerColor?: string;
|
|
161
|
+
overrideExternalLinkTintColor?: ColorValue;
|
|
162
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AnyNode, ChildNode, Element } from 'domhandler';
|
|
2
|
+
|
|
3
|
+
import { type HTMLTag, rendererTypeMap } from './types';
|
|
4
|
+
|
|
5
|
+
export const isTextRenderer = (node: AnyNode | ChildNode | null): boolean => {
|
|
6
|
+
if (!node) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
if (node.type === 'tag') {
|
|
10
|
+
return rendererTypeMap[node.tagName as HTMLTag] === 'text';
|
|
11
|
+
}
|
|
12
|
+
return node.type === 'text';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Concatenate all text nodes from children
|
|
16
|
+
export const concatTextNodes = (children: (AnyNode | ChildNode)[]): string => {
|
|
17
|
+
let result = '';
|
|
18
|
+
for (const child of children) {
|
|
19
|
+
if (child.type === 'text') {
|
|
20
|
+
result += child.data;
|
|
21
|
+
}
|
|
22
|
+
// Recursively check for text nodes in element children
|
|
23
|
+
if (child.type === 'tag' && Array.isArray(child.children)) {
|
|
24
|
+
result += concatTextNodes(child.children);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const isList = (child: ChildNode) => {
|
|
31
|
+
return child.type === 'tag' && (child.tagName === 'ol' || child.tagName === 'ul');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const getTextFromNode = (node: Element): string => {
|
|
35
|
+
return node.children.map((c) => (c.type === 'text' ? c.data : '')).join('');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const isExternalURL = (url?: string): boolean => {
|
|
39
|
+
if (!url) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return url.startsWith('https://') || url.startsWith('http://');
|
|
43
|
+
};
|