react-native-nitro-markdown 0.6.1 → 0.7.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 (96) hide show
  1. package/README.md +77 -8
  2. package/android/gradle.properties +2 -2
  3. package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +21 -3
  4. package/ios/HybridMarkdownSession.swift +53 -3
  5. package/lib/commonjs/MarkdownContext.js.map +1 -1
  6. package/lib/commonjs/headless.js +0 -4
  7. package/lib/commonjs/headless.js.map +1 -1
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/markdown-stream.js +40 -15
  10. package/lib/commonjs/markdown-stream.js.map +1 -1
  11. package/lib/commonjs/markdown.js +4 -3
  12. package/lib/commonjs/markdown.js.map +1 -1
  13. package/lib/commonjs/renderers/heading.js +1 -0
  14. package/lib/commonjs/renderers/heading.js.map +1 -1
  15. package/lib/commonjs/renderers/image.js +22 -8
  16. package/lib/commonjs/renderers/image.js.map +1 -1
  17. package/lib/commonjs/renderers/link.js +1 -1
  18. package/lib/commonjs/renderers/link.js.map +1 -1
  19. package/lib/commonjs/renderers/list.js +4 -0
  20. package/lib/commonjs/renderers/list.js.map +1 -1
  21. package/lib/commonjs/renderers/math.js +0 -1
  22. package/lib/commonjs/renderers/math.js.map +1 -1
  23. package/lib/commonjs/use-markdown-stream.js +9 -1
  24. package/lib/commonjs/use-markdown-stream.js.map +1 -1
  25. package/lib/commonjs/utils/link-security.js +42 -5
  26. package/lib/commonjs/utils/link-security.js.map +1 -1
  27. package/lib/module/MarkdownContext.js.map +1 -1
  28. package/lib/module/headless.js +0 -4
  29. package/lib/module/headless.js.map +1 -1
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/markdown-stream.js +40 -15
  32. package/lib/module/markdown-stream.js.map +1 -1
  33. package/lib/module/markdown.js +4 -3
  34. package/lib/module/markdown.js.map +1 -1
  35. package/lib/module/renderers/heading.js +1 -0
  36. package/lib/module/renderers/heading.js.map +1 -1
  37. package/lib/module/renderers/image.js +22 -8
  38. package/lib/module/renderers/image.js.map +1 -1
  39. package/lib/module/renderers/link.js +1 -1
  40. package/lib/module/renderers/link.js.map +1 -1
  41. package/lib/module/renderers/list.js +4 -0
  42. package/lib/module/renderers/list.js.map +1 -1
  43. package/lib/module/renderers/math.js +0 -1
  44. package/lib/module/renderers/math.js.map +1 -1
  45. package/lib/module/use-markdown-stream.js +9 -1
  46. package/lib/module/use-markdown-stream.js.map +1 -1
  47. package/lib/module/utils/link-security.js +40 -4
  48. package/lib/module/utils/link-security.js.map +1 -1
  49. package/lib/typescript/commonjs/MarkdownContext.d.ts +44 -7
  50. package/lib/typescript/commonjs/MarkdownContext.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/headless.d.ts +6 -3
  52. package/lib/typescript/commonjs/headless.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/index.d.ts +4 -3
  54. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/markdown.d.ts +12 -11
  57. package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/renderers/heading.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/renderers/image.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/renderers/link.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/renderers/list.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/renderers/math.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/use-markdown-stream.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/utils/link-security.d.ts +5 -0
  65. package/lib/typescript/commonjs/utils/link-security.d.ts.map +1 -1
  66. package/lib/typescript/module/MarkdownContext.d.ts +44 -7
  67. package/lib/typescript/module/MarkdownContext.d.ts.map +1 -1
  68. package/lib/typescript/module/headless.d.ts +6 -3
  69. package/lib/typescript/module/headless.d.ts.map +1 -1
  70. package/lib/typescript/module/index.d.ts +4 -3
  71. package/lib/typescript/module/index.d.ts.map +1 -1
  72. package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
  73. package/lib/typescript/module/markdown.d.ts +12 -11
  74. package/lib/typescript/module/markdown.d.ts.map +1 -1
  75. package/lib/typescript/module/renderers/heading.d.ts.map +1 -1
  76. package/lib/typescript/module/renderers/image.d.ts.map +1 -1
  77. package/lib/typescript/module/renderers/link.d.ts.map +1 -1
  78. package/lib/typescript/module/renderers/list.d.ts.map +1 -1
  79. package/lib/typescript/module/renderers/math.d.ts.map +1 -1
  80. package/lib/typescript/module/use-markdown-stream.d.ts.map +1 -1
  81. package/lib/typescript/module/utils/link-security.d.ts +5 -0
  82. package/lib/typescript/module/utils/link-security.d.ts.map +1 -1
  83. package/package.json +5 -5
  84. package/react-native-nitro-markdown.podspec +1 -1
  85. package/src/MarkdownContext.ts +51 -11
  86. package/src/headless.ts +35 -34
  87. package/src/index.ts +14 -1
  88. package/src/markdown-stream.tsx +49 -17
  89. package/src/markdown.tsx +20 -13
  90. package/src/renderers/heading.tsx +5 -1
  91. package/src/renderers/image.tsx +31 -9
  92. package/src/renderers/link.tsx +5 -2
  93. package/src/renderers/list.tsx +5 -1
  94. package/src/renderers/math.tsx +0 -1
  95. package/src/use-markdown-stream.ts +9 -1
  96. 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;
@@ -90,7 +93,6 @@ function safeOnError<P extends string>(
90
93
  );
91
94
  } catch (callbackError) {
92
95
  if (__DEV__) {
93
- // eslint-disable-next-line no-console
94
96
  console.warn(
95
97
  "[NitroMarkdown] onError callback threw an exception:",
96
98
  callbackError,
@@ -144,6 +146,14 @@ export type MarkdownPlugin = {
144
146
  afterParse?: AstTransform;
145
147
  };
146
148
 
149
+ export type MarkdownErrorPhase = "parse" | "before-plugin" | "after-plugin";
150
+
151
+ export type MarkdownParseCompleteResult = {
152
+ raw: string;
153
+ ast: MarkdownNode;
154
+ text: string;
155
+ };
156
+
147
157
  const isMarkdownNode = (value: unknown): value is MarkdownNode => {
148
158
  if (typeof value !== "object" || value === null) return false;
149
159
  return typeof Reflect.get(value, "type") === "string";
@@ -355,11 +365,7 @@ export type MarkdownProps = {
355
365
  /**
356
366
  * Callback fired when parsing completes.
357
367
  */
358
- onParseComplete?: (result: {
359
- raw: string;
360
- ast: MarkdownNode;
361
- text: string;
362
- }) => void;
368
+ onParseComplete?: (result: MarkdownParseCompleteResult) => void;
363
369
  /**
364
370
  * Called when a parse error or plugin error occurs.
365
371
  * @param error - The thrown error.
@@ -368,7 +374,7 @@ export type MarkdownProps = {
368
374
  */
369
375
  onError?: (
370
376
  error: Error,
371
- phase: "parse" | "before-plugin" | "after-plugin",
377
+ phase: MarkdownErrorPhase,
372
378
  pluginName?: string,
373
379
  ) => void;
374
380
  /**
@@ -426,10 +432,8 @@ export type MarkdownProps = {
426
432
  /**
427
433
  * Optional configuration for the table renderer.
428
434
  */
429
- tableOptions?: {
430
- minColumnWidth?: number;
431
- measurementStabilizeMs?: number;
432
- };
435
+ tableOptions?: TableOptions;
436
+ imageOptions?: UrlSafetyOptions;
433
437
  /**
434
438
  * Enable built-in syntax highlighting for code blocks.
435
439
  * Pass `true` to use the built-in tokenizer, or a custom highlighter function.
@@ -457,6 +461,7 @@ export const Markdown: FC<MarkdownProps> = ({
457
461
  virtualizationMinBlocks = 40,
458
462
  virtualization,
459
463
  tableOptions,
464
+ imageOptions,
460
465
  highlightCode,
461
466
  }) => {
462
467
  const parserOptionGfm = options?.gfm;
@@ -563,6 +568,7 @@ export const Markdown: FC<MarkdownProps> = ({
563
568
  stylingStrategy,
564
569
  onLinkPress,
565
570
  tableOptions,
571
+ imageOptions,
566
572
  highlightCode,
567
573
  }),
568
574
  [
@@ -572,6 +578,7 @@ export const Markdown: FC<MarkdownProps> = ({
572
578
  stylingStrategy,
573
579
  onLinkPress,
574
580
  tableOptions,
581
+ imageOptions,
575
582
  highlightCode,
576
583
  ],
577
584
  );
@@ -625,7 +632,7 @@ export const Markdown: FC<MarkdownProps> = ({
625
632
  virtualization?.updateCellsBatchingPeriod ?? 16
626
633
  }
627
634
  removeClippedSubviews={
628
- virtualization?.removeClippedSubviews ?? true
635
+ virtualization?.removeClippedSubviews ?? Platform.OS === "android"
629
636
  }
630
637
  bounces={false}
631
638
  alwaysBounceVertical={false}
@@ -747,7 +754,7 @@ const NodeRendererComponent: FC<NodeRendererProps> = ({
747
754
  return elements;
748
755
  };
749
756
 
750
- const customRenderer = renderers[node.type];
757
+ const customRenderer = renderers[node.type] as CustomRenderer | undefined;
751
758
  if (customRenderer) {
752
759
  const childrenRendered = renderChildren(
753
760
  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 <Text style={[...headingStyles, style]}>{children}</Text>;
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>;
@@ -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(false);
121
+ setError(!allowedImageHref);
117
122
  setAspectRatio(undefined);
118
123
 
119
- const picsumMatch = url.match(/picsum\.photos\/.*\/(\d+)\/(\d+)/);
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
- url,
145
+ allowedImageHref,
133
146
  (width, height) => {
134
147
  if (isMounted && width > 0 && height > 0) {
135
148
  setAspectRatio(width / height);
@@ -137,7 +150,6 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
137
150
  },
138
151
  (error) => {
139
152
  if (__DEV__) {
140
- // eslint-disable-next-line no-console
141
153
  console.warn(
142
154
  "[NitroMarkdown] Failed to get image dimensions:",
143
155
  error,
@@ -149,7 +161,7 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
149
161
  return () => {
150
162
  isMounted = false;
151
163
  };
152
- }, [url]);
164
+ }, [allowedImageHref]);
153
165
 
154
166
  const altContent = useMemo(() => {
155
167
  if (!alt || !Renderer) return null;
@@ -180,9 +192,16 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
180
192
  return <Text style={styles.imageErrorText}>{alt}</Text>;
181
193
  }, [alt, Renderer, styles.imageErrorText]);
182
194
 
183
- if (error) {
195
+ const accessibilityLabel = alt || title;
196
+
197
+ if (error || !allowedImageHref) {
184
198
  return (
185
- <View style={[styles.imageError, style]}>
199
+ <View
200
+ style={[styles.imageError, style]}
201
+ accessible={Boolean(accessibilityLabel)}
202
+ accessibilityRole={accessibilityLabel ? "image" : undefined}
203
+ accessibilityLabel={accessibilityLabel}
204
+ >
186
205
  <View
187
206
  style={{
188
207
  flexDirection: "row",
@@ -210,9 +229,12 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
210
229
  </View>
211
230
  ) : null}
212
231
  <RNImage
213
- source={{ uri: url }}
232
+ source={{ uri: allowedImageHref ?? "" }}
214
233
  style={[styles.image, loading && !aspectRatio && styles.imageHidden]}
215
234
  resizeMode="contain"
235
+ accessible={Boolean(accessibilityLabel)}
236
+ accessibilityRole={accessibilityLabel ? "image" : undefined}
237
+ accessibilityLabel={accessibilityLabel}
216
238
  onLoad={() => {
217
239
  setLoading(false);
218
240
  }}
@@ -52,14 +52,17 @@ export const Link: FC<LinkProps> = ({ href, children, style }) => {
52
52
  await Linking.openURL(allowedExternalHref);
53
53
  } catch (error) {
54
54
  if (__DEV__) {
55
- // eslint-disable-next-line no-console
56
55
  console.warn("[NitroMarkdown] Link press handler failed:", error);
57
56
  }
58
57
  }
59
58
  };
60
59
 
61
60
  return (
62
- <Text style={[styles.link, style]} onPress={handlePress}>
61
+ <Text
62
+ style={[styles.link, style]}
63
+ onPress={handlePress}
64
+ accessibilityRole="link"
65
+ >
63
66
  {children}
64
67
  </Text>
65
68
  );
@@ -71,7 +71,11 @@ export const TaskListItem: FC<TaskListItemProps> = ({
71
71
  createTaskListItemStyles,
72
72
  );
73
73
  return (
74
- <View style={[styles.taskListItem, style]}>
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
  >
@@ -29,7 +29,6 @@ try {
29
29
  RaTeXViewComponent = ratexModule.RaTeXView ?? null;
30
30
  } catch {
31
31
  if (__DEV__) {
32
- // eslint-disable-next-line no-console
33
32
  console.warn(
34
33
  "[NitroMarkdown] ratex-react-native not found — math will render as plain text.",
35
34
  );
@@ -19,7 +19,15 @@ export function useMarkdownSession() {
19
19
  useEffect(() => {
20
20
  const session = sessionRef.current!;
21
21
  return () => {
22
- session.clear();
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
- export const getAllowedExternalHref = (href: string): string | null => {
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 = `${protocolMatch[1].toLowerCase()}:`;
19
- if (!ALLOWED_LINK_PROTOCOLS.has(protocol)) return null;
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 href;
85
+ return normalizedHref;
22
86
  };