react-native-nitro-markdown 0.6.1 → 0.6.2
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/README.md +55 -1
- package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +21 -3
- package/ios/HybridMarkdownSession.swift +53 -3
- package/lib/commonjs/MarkdownContext.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/markdown-stream.js +40 -15
- package/lib/commonjs/markdown-stream.js.map +1 -1
- package/lib/commonjs/markdown.js +4 -2
- package/lib/commonjs/markdown.js.map +1 -1
- package/lib/commonjs/renderers/heading.js +1 -0
- package/lib/commonjs/renderers/heading.js.map +1 -1
- package/lib/commonjs/renderers/image.js +22 -7
- package/lib/commonjs/renderers/image.js.map +1 -1
- package/lib/commonjs/renderers/link.js +1 -0
- package/lib/commonjs/renderers/link.js.map +1 -1
- package/lib/commonjs/renderers/list.js +4 -0
- package/lib/commonjs/renderers/list.js.map +1 -1
- package/lib/commonjs/use-markdown-stream.js +9 -1
- package/lib/commonjs/use-markdown-stream.js.map +1 -1
- package/lib/commonjs/utils/link-security.js +42 -5
- package/lib/commonjs/utils/link-security.js.map +1 -1
- package/lib/module/MarkdownContext.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/markdown-stream.js +40 -15
- package/lib/module/markdown-stream.js.map +1 -1
- package/lib/module/markdown.js +4 -2
- package/lib/module/markdown.js.map +1 -1
- package/lib/module/renderers/heading.js +1 -0
- package/lib/module/renderers/heading.js.map +1 -1
- package/lib/module/renderers/image.js +22 -7
- package/lib/module/renderers/image.js.map +1 -1
- package/lib/module/renderers/link.js +1 -0
- package/lib/module/renderers/link.js.map +1 -1
- package/lib/module/renderers/list.js +4 -0
- package/lib/module/renderers/list.js.map +1 -1
- package/lib/module/use-markdown-stream.js +9 -1
- package/lib/module/use-markdown-stream.js.map +1 -1
- package/lib/module/utils/link-security.js +40 -4
- package/lib/module/utils/link-security.js.map +1 -1
- package/lib/typescript/commonjs/MarkdownContext.d.ts +44 -7
- package/lib/typescript/commonjs/MarkdownContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +3 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
- package/lib/typescript/commonjs/markdown.d.ts +12 -11
- package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
- package/lib/typescript/commonjs/renderers/heading.d.ts.map +1 -1
- package/lib/typescript/commonjs/renderers/image.d.ts.map +1 -1
- package/lib/typescript/commonjs/renderers/link.d.ts.map +1 -1
- package/lib/typescript/commonjs/renderers/list.d.ts.map +1 -1
- package/lib/typescript/commonjs/use-markdown-stream.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/link-security.d.ts +5 -0
- package/lib/typescript/commonjs/utils/link-security.d.ts.map +1 -1
- package/lib/typescript/module/MarkdownContext.d.ts +44 -7
- package/lib/typescript/module/MarkdownContext.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +3 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
- package/lib/typescript/module/markdown.d.ts +12 -11
- package/lib/typescript/module/markdown.d.ts.map +1 -1
- package/lib/typescript/module/renderers/heading.d.ts.map +1 -1
- package/lib/typescript/module/renderers/image.d.ts.map +1 -1
- package/lib/typescript/module/renderers/link.d.ts.map +1 -1
- package/lib/typescript/module/renderers/list.d.ts.map +1 -1
- package/lib/typescript/module/use-markdown-stream.d.ts.map +1 -1
- package/lib/typescript/module/utils/link-security.d.ts +5 -0
- package/lib/typescript/module/utils/link-security.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/MarkdownContext.ts +51 -11
- package/src/index.ts +6 -0
- package/src/markdown-stream.tsx +49 -17
- package/src/markdown.tsx +20 -12
- package/src/renderers/heading.tsx +5 -1
- package/src/renderers/image.tsx +31 -8
- package/src/renderers/link.tsx +5 -1
- package/src/renderers/list.tsx +5 -1
- package/src/use-markdown-stream.ts +9 -1
- package/src/utils/link-security.ts +68 -4
package/src/markdown.tsx
CHANGED
|
@@ -31,9 +31,11 @@ import type { ParserOptions } from "./Markdown.nitro";
|
|
|
31
31
|
import {
|
|
32
32
|
MarkdownContext,
|
|
33
33
|
useMarkdownContext,
|
|
34
|
+
type CustomRenderer,
|
|
34
35
|
type CustomRenderers,
|
|
35
36
|
type LinkPressHandler,
|
|
36
37
|
type NodeRendererProps,
|
|
38
|
+
type TableOptions,
|
|
37
39
|
} from "./MarkdownContext";
|
|
38
40
|
import { Blockquote } from "./renderers/blockquote";
|
|
39
41
|
import { CodeBlock, InlineCode } from "./renderers/code";
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
type StylingStrategy,
|
|
56
58
|
} from "./theme";
|
|
57
59
|
import type { CodeHighlighter } from "./utils/code-highlight";
|
|
60
|
+
import type { UrlSafetyOptions } from "./utils/link-security";
|
|
58
61
|
|
|
59
62
|
function hashString(str: string): number {
|
|
60
63
|
let hash = 0;
|
|
@@ -144,6 +147,14 @@ export type MarkdownPlugin = {
|
|
|
144
147
|
afterParse?: AstTransform;
|
|
145
148
|
};
|
|
146
149
|
|
|
150
|
+
export type MarkdownErrorPhase = "parse" | "before-plugin" | "after-plugin";
|
|
151
|
+
|
|
152
|
+
export type MarkdownParseCompleteResult = {
|
|
153
|
+
raw: string;
|
|
154
|
+
ast: MarkdownNode;
|
|
155
|
+
text: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
147
158
|
const isMarkdownNode = (value: unknown): value is MarkdownNode => {
|
|
148
159
|
if (typeof value !== "object" || value === null) return false;
|
|
149
160
|
return typeof Reflect.get(value, "type") === "string";
|
|
@@ -355,11 +366,7 @@ export type MarkdownProps = {
|
|
|
355
366
|
/**
|
|
356
367
|
* Callback fired when parsing completes.
|
|
357
368
|
*/
|
|
358
|
-
onParseComplete?: (result:
|
|
359
|
-
raw: string;
|
|
360
|
-
ast: MarkdownNode;
|
|
361
|
-
text: string;
|
|
362
|
-
}) => void;
|
|
369
|
+
onParseComplete?: (result: MarkdownParseCompleteResult) => void;
|
|
363
370
|
/**
|
|
364
371
|
* Called when a parse error or plugin error occurs.
|
|
365
372
|
* @param error - The thrown error.
|
|
@@ -368,7 +375,7 @@ export type MarkdownProps = {
|
|
|
368
375
|
*/
|
|
369
376
|
onError?: (
|
|
370
377
|
error: Error,
|
|
371
|
-
phase:
|
|
378
|
+
phase: MarkdownErrorPhase,
|
|
372
379
|
pluginName?: string,
|
|
373
380
|
) => void;
|
|
374
381
|
/**
|
|
@@ -426,10 +433,8 @@ export type MarkdownProps = {
|
|
|
426
433
|
/**
|
|
427
434
|
* Optional configuration for the table renderer.
|
|
428
435
|
*/
|
|
429
|
-
tableOptions?:
|
|
430
|
-
|
|
431
|
-
measurementStabilizeMs?: number;
|
|
432
|
-
};
|
|
436
|
+
tableOptions?: TableOptions;
|
|
437
|
+
imageOptions?: UrlSafetyOptions;
|
|
433
438
|
/**
|
|
434
439
|
* Enable built-in syntax highlighting for code blocks.
|
|
435
440
|
* Pass `true` to use the built-in tokenizer, or a custom highlighter function.
|
|
@@ -457,6 +462,7 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
457
462
|
virtualizationMinBlocks = 40,
|
|
458
463
|
virtualization,
|
|
459
464
|
tableOptions,
|
|
465
|
+
imageOptions,
|
|
460
466
|
highlightCode,
|
|
461
467
|
}) => {
|
|
462
468
|
const parserOptionGfm = options?.gfm;
|
|
@@ -563,6 +569,7 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
563
569
|
stylingStrategy,
|
|
564
570
|
onLinkPress,
|
|
565
571
|
tableOptions,
|
|
572
|
+
imageOptions,
|
|
566
573
|
highlightCode,
|
|
567
574
|
}),
|
|
568
575
|
[
|
|
@@ -572,6 +579,7 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
572
579
|
stylingStrategy,
|
|
573
580
|
onLinkPress,
|
|
574
581
|
tableOptions,
|
|
582
|
+
imageOptions,
|
|
575
583
|
highlightCode,
|
|
576
584
|
],
|
|
577
585
|
);
|
|
@@ -625,7 +633,7 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
625
633
|
virtualization?.updateCellsBatchingPeriod ?? 16
|
|
626
634
|
}
|
|
627
635
|
removeClippedSubviews={
|
|
628
|
-
virtualization?.removeClippedSubviews ??
|
|
636
|
+
virtualization?.removeClippedSubviews ?? Platform.OS === "android"
|
|
629
637
|
}
|
|
630
638
|
bounces={false}
|
|
631
639
|
alwaysBounceVertical={false}
|
|
@@ -747,7 +755,7 @@ const NodeRendererComponent: FC<NodeRendererProps> = ({
|
|
|
747
755
|
return elements;
|
|
748
756
|
};
|
|
749
757
|
|
|
750
|
-
const customRenderer = renderers[node.type];
|
|
758
|
+
const customRenderer = renderers[node.type] as CustomRenderer | undefined;
|
|
751
759
|
if (customRenderer) {
|
|
752
760
|
const childrenRendered = renderChildren(
|
|
753
761
|
node.children,
|
|
@@ -34,7 +34,11 @@ export const Heading: FC<HeadingProps> = ({ level, children, style }) => {
|
|
|
34
34
|
level === 6 && styles.h6,
|
|
35
35
|
];
|
|
36
36
|
|
|
37
|
-
return
|
|
37
|
+
return (
|
|
38
|
+
<Text style={[...headingStyles, style]} accessibilityRole="header">
|
|
39
|
+
{children}
|
|
40
|
+
</Text>
|
|
41
|
+
);
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
type HeadingStyles = ReturnType<typeof createStyles>;
|
package/src/renderers/image.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "react-native";
|
|
17
17
|
import { parseMarkdownWithOptions, type MarkdownNode } from "../headless";
|
|
18
18
|
import { useMarkdownContext } from "../MarkdownContext";
|
|
19
|
+
import { getAllowedImageHref } from "../utils/link-security";
|
|
19
20
|
import type { NodeRendererProps } from "../MarkdownContext";
|
|
20
21
|
|
|
21
22
|
const renderInlineContent = (
|
|
@@ -46,7 +47,11 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
|
|
|
46
47
|
const [loading, setLoading] = useState(true);
|
|
47
48
|
const [error, setError] = useState(false);
|
|
48
49
|
const [aspectRatio, setAspectRatio] = useState<number | undefined>(undefined);
|
|
49
|
-
const { theme } = useMarkdownContext();
|
|
50
|
+
const { theme, imageOptions } = useMarkdownContext();
|
|
51
|
+
const allowedImageHref = useMemo(
|
|
52
|
+
() => getAllowedImageHref(url, imageOptions),
|
|
53
|
+
[imageOptions, url],
|
|
54
|
+
);
|
|
50
55
|
|
|
51
56
|
const styles = useMemo(
|
|
52
57
|
() =>
|
|
@@ -113,10 +118,18 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
|
|
|
113
118
|
useEffect(() => {
|
|
114
119
|
let isMounted = true;
|
|
115
120
|
setLoading(true);
|
|
116
|
-
setError(
|
|
121
|
+
setError(!allowedImageHref);
|
|
117
122
|
setAspectRatio(undefined);
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
if (!allowedImageHref) {
|
|
125
|
+
return () => {
|
|
126
|
+
isMounted = false;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const picsumMatch = allowedImageHref.match(
|
|
131
|
+
/picsum\.photos\/.*\/(\d+)\/(\d+)/,
|
|
132
|
+
);
|
|
120
133
|
if (picsumMatch) {
|
|
121
134
|
const w = parseInt(picsumMatch[1], 10);
|
|
122
135
|
const h = parseInt(picsumMatch[2], 10);
|
|
@@ -129,7 +142,7 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
|
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
RNImage.getSize(
|
|
132
|
-
|
|
145
|
+
allowedImageHref,
|
|
133
146
|
(width, height) => {
|
|
134
147
|
if (isMounted && width > 0 && height > 0) {
|
|
135
148
|
setAspectRatio(width / height);
|
|
@@ -149,7 +162,7 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
|
|
|
149
162
|
return () => {
|
|
150
163
|
isMounted = false;
|
|
151
164
|
};
|
|
152
|
-
}, [
|
|
165
|
+
}, [allowedImageHref]);
|
|
153
166
|
|
|
154
167
|
const altContent = useMemo(() => {
|
|
155
168
|
if (!alt || !Renderer) return null;
|
|
@@ -180,9 +193,16 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
|
|
|
180
193
|
return <Text style={styles.imageErrorText}>{alt}</Text>;
|
|
181
194
|
}, [alt, Renderer, styles.imageErrorText]);
|
|
182
195
|
|
|
183
|
-
|
|
196
|
+
const accessibilityLabel = alt || title;
|
|
197
|
+
|
|
198
|
+
if (error || !allowedImageHref) {
|
|
184
199
|
return (
|
|
185
|
-
<View
|
|
200
|
+
<View
|
|
201
|
+
style={[styles.imageError, style]}
|
|
202
|
+
accessible={Boolean(accessibilityLabel)}
|
|
203
|
+
accessibilityRole={accessibilityLabel ? "image" : undefined}
|
|
204
|
+
accessibilityLabel={accessibilityLabel}
|
|
205
|
+
>
|
|
186
206
|
<View
|
|
187
207
|
style={{
|
|
188
208
|
flexDirection: "row",
|
|
@@ -210,9 +230,12 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
|
|
|
210
230
|
</View>
|
|
211
231
|
) : null}
|
|
212
232
|
<RNImage
|
|
213
|
-
source={{ uri:
|
|
233
|
+
source={{ uri: allowedImageHref ?? "" }}
|
|
214
234
|
style={[styles.image, loading && !aspectRatio && styles.imageHidden]}
|
|
215
235
|
resizeMode="contain"
|
|
236
|
+
accessible={Boolean(accessibilityLabel)}
|
|
237
|
+
accessibilityRole={accessibilityLabel ? "image" : undefined}
|
|
238
|
+
accessibilityLabel={accessibilityLabel}
|
|
216
239
|
onLoad={() => {
|
|
217
240
|
setLoading(false);
|
|
218
241
|
}}
|
package/src/renderers/link.tsx
CHANGED
|
@@ -59,7 +59,11 @@ export const Link: FC<LinkProps> = ({ href, children, style }) => {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
return (
|
|
62
|
-
<Text
|
|
62
|
+
<Text
|
|
63
|
+
style={[styles.link, style]}
|
|
64
|
+
onPress={handlePress}
|
|
65
|
+
accessibilityRole="link"
|
|
66
|
+
>
|
|
63
67
|
{children}
|
|
64
68
|
</Text>
|
|
65
69
|
);
|
package/src/renderers/list.tsx
CHANGED
|
@@ -71,7 +71,11 @@ export const TaskListItem: FC<TaskListItemProps> = ({
|
|
|
71
71
|
createTaskListItemStyles,
|
|
72
72
|
);
|
|
73
73
|
return (
|
|
74
|
-
<View
|
|
74
|
+
<View
|
|
75
|
+
style={[styles.taskListItem, style]}
|
|
76
|
+
accessibilityRole="checkbox"
|
|
77
|
+
accessibilityState={{ checked }}
|
|
78
|
+
>
|
|
75
79
|
<View
|
|
76
80
|
style={[styles.taskCheckbox, checked && styles.taskCheckboxChecked]}
|
|
77
81
|
>
|
|
@@ -19,7 +19,15 @@ export function useMarkdownSession() {
|
|
|
19
19
|
useEffect(() => {
|
|
20
20
|
const session = sessionRef.current!;
|
|
21
21
|
return () => {
|
|
22
|
-
|
|
22
|
+
try {
|
|
23
|
+
session.clear();
|
|
24
|
+
} finally {
|
|
25
|
+
try {
|
|
26
|
+
session.dispose();
|
|
27
|
+
} finally {
|
|
28
|
+
sessionRef.current = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
23
31
|
};
|
|
24
32
|
}, []);
|
|
25
33
|
|
|
@@ -6,17 +6,81 @@ const ALLOWED_LINK_PROTOCOLS = new Set([
|
|
|
6
6
|
"sms:",
|
|
7
7
|
]);
|
|
8
8
|
|
|
9
|
+
const DEFAULT_IMAGE_PROTOCOLS = ["http:", "https:"] as const;
|
|
10
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F]/;
|
|
11
|
+
|
|
12
|
+
export type UrlSafetyOptions = {
|
|
13
|
+
allowedProtocols?: readonly string[];
|
|
14
|
+
allowedHosts?: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
9
17
|
export const normalizeLinkHref = (href: string): string | null => {
|
|
10
18
|
const normalizedHref = href.trim();
|
|
19
|
+
if (CONTROL_CHARACTER_PATTERN.test(normalizedHref)) return null;
|
|
11
20
|
return normalizedHref.length > 0 ? normalizedHref : null;
|
|
12
21
|
};
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
const parseAbsoluteHref = (
|
|
24
|
+
href: string,
|
|
25
|
+
): { protocol: string; hostname: string } | null => {
|
|
15
26
|
const protocolMatch = href.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
16
27
|
if (!protocolMatch) return null;
|
|
17
28
|
|
|
18
|
-
const protocol =
|
|
19
|
-
|
|
29
|
+
const protocol = normalizeProtocol(protocolMatch[1]);
|
|
30
|
+
const rest = href.slice(protocolMatch[0].length);
|
|
31
|
+
const authorityMatch = rest.match(/^\/\/([^/?#]*)/);
|
|
32
|
+
const rawHost = authorityMatch?.[1] ?? "";
|
|
33
|
+
const hostname = rawHost
|
|
34
|
+
.replace(/^[^@]*@/, "")
|
|
35
|
+
.replace(/^\[|\]$/g, "")
|
|
36
|
+
.split(":")[0]
|
|
37
|
+
.toLowerCase();
|
|
38
|
+
|
|
39
|
+
return { protocol, hostname };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const normalizeProtocol = (protocol: string): string => {
|
|
43
|
+
const normalized = protocol.trim().toLowerCase();
|
|
44
|
+
return normalized.endsWith(":") ? normalized : `${normalized}:`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const getAllowedExternalHref = (href: string): string | null => {
|
|
48
|
+
const normalizedHref = normalizeLinkHref(href);
|
|
49
|
+
if (!normalizedHref) return null;
|
|
50
|
+
|
|
51
|
+
const parsed = parseAbsoluteHref(normalizedHref);
|
|
52
|
+
if (!parsed) return null;
|
|
53
|
+
|
|
54
|
+
if (!ALLOWED_LINK_PROTOCOLS.has(parsed.protocol)) return null;
|
|
55
|
+
|
|
56
|
+
return normalizedHref;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const getAllowedImageHref = (
|
|
60
|
+
href: string,
|
|
61
|
+
options?: UrlSafetyOptions,
|
|
62
|
+
): string | null => {
|
|
63
|
+
const normalizedHref = normalizeLinkHref(href);
|
|
64
|
+
if (!normalizedHref) return null;
|
|
65
|
+
|
|
66
|
+
const parsed = parseAbsoluteHref(normalizedHref);
|
|
67
|
+
if (!parsed) return null;
|
|
68
|
+
|
|
69
|
+
const allowedProtocols = new Set(
|
|
70
|
+
(options?.allowedProtocols ?? DEFAULT_IMAGE_PROTOCOLS).map(
|
|
71
|
+
normalizeProtocol,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
if (!allowedProtocols.has(parsed.protocol)) return null;
|
|
75
|
+
|
|
76
|
+
const allowedHosts = options?.allowedHosts;
|
|
77
|
+
if (allowedHosts && allowedHosts.length > 0) {
|
|
78
|
+
const normalizedHost = parsed.hostname.toLowerCase();
|
|
79
|
+
const allowedHostSet = new Set(
|
|
80
|
+
allowedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean),
|
|
81
|
+
);
|
|
82
|
+
if (!allowedHostSet.has(normalizedHost)) return null;
|
|
83
|
+
}
|
|
20
84
|
|
|
21
|
-
return
|
|
85
|
+
return normalizedHref;
|
|
22
86
|
};
|