react-native-nitro-markdown 0.6.0 → 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.
Files changed (80) hide show
  1. package/.watchmanconfig +6 -0
  2. package/README.md +56 -2
  3. package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +21 -3
  4. package/ios/HybridMarkdownSession.swift +62 -9
  5. package/lib/commonjs/MarkdownContext.js.map +1 -1
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/markdown-stream.js +40 -15
  8. package/lib/commonjs/markdown-stream.js.map +1 -1
  9. package/lib/commonjs/markdown.js +4 -2
  10. package/lib/commonjs/markdown.js.map +1 -1
  11. package/lib/commonjs/renderers/heading.js +1 -0
  12. package/lib/commonjs/renderers/heading.js.map +1 -1
  13. package/lib/commonjs/renderers/image.js +22 -7
  14. package/lib/commonjs/renderers/image.js.map +1 -1
  15. package/lib/commonjs/renderers/link.js +1 -0
  16. package/lib/commonjs/renderers/link.js.map +1 -1
  17. package/lib/commonjs/renderers/list.js +4 -0
  18. package/lib/commonjs/renderers/list.js.map +1 -1
  19. package/lib/commonjs/use-markdown-stream.js +9 -1
  20. package/lib/commonjs/use-markdown-stream.js.map +1 -1
  21. package/lib/commonjs/utils/link-security.js +42 -5
  22. package/lib/commonjs/utils/link-security.js.map +1 -1
  23. package/lib/module/MarkdownContext.js.map +1 -1
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/markdown-stream.js +40 -15
  26. package/lib/module/markdown-stream.js.map +1 -1
  27. package/lib/module/markdown.js +4 -2
  28. package/lib/module/markdown.js.map +1 -1
  29. package/lib/module/renderers/heading.js +1 -0
  30. package/lib/module/renderers/heading.js.map +1 -1
  31. package/lib/module/renderers/image.js +22 -7
  32. package/lib/module/renderers/image.js.map +1 -1
  33. package/lib/module/renderers/link.js +1 -0
  34. package/lib/module/renderers/link.js.map +1 -1
  35. package/lib/module/renderers/list.js +4 -0
  36. package/lib/module/renderers/list.js.map +1 -1
  37. package/lib/module/use-markdown-stream.js +9 -1
  38. package/lib/module/use-markdown-stream.js.map +1 -1
  39. package/lib/module/utils/link-security.js +40 -4
  40. package/lib/module/utils/link-security.js.map +1 -1
  41. package/lib/typescript/commonjs/MarkdownContext.d.ts +44 -7
  42. package/lib/typescript/commonjs/MarkdownContext.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/index.d.ts +3 -2
  44. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/markdown.d.ts +12 -11
  47. package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/renderers/heading.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/renderers/image.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/renderers/link.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/renderers/list.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/use-markdown-stream.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/utils/link-security.d.ts +5 -0
  54. package/lib/typescript/commonjs/utils/link-security.d.ts.map +1 -1
  55. package/lib/typescript/module/MarkdownContext.d.ts +44 -7
  56. package/lib/typescript/module/MarkdownContext.d.ts.map +1 -1
  57. package/lib/typescript/module/index.d.ts +3 -2
  58. package/lib/typescript/module/index.d.ts.map +1 -1
  59. package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
  60. package/lib/typescript/module/markdown.d.ts +12 -11
  61. package/lib/typescript/module/markdown.d.ts.map +1 -1
  62. package/lib/typescript/module/renderers/heading.d.ts.map +1 -1
  63. package/lib/typescript/module/renderers/image.d.ts.map +1 -1
  64. package/lib/typescript/module/renderers/link.d.ts.map +1 -1
  65. package/lib/typescript/module/renderers/list.d.ts.map +1 -1
  66. package/lib/typescript/module/use-markdown-stream.d.ts.map +1 -1
  67. package/lib/typescript/module/utils/link-security.d.ts +5 -0
  68. package/lib/typescript/module/utils/link-security.d.ts.map +1 -1
  69. package/package.json +2 -1
  70. package/react-native-nitro-markdown.podspec +1 -1
  71. package/src/MarkdownContext.ts +51 -11
  72. package/src/index.ts +6 -0
  73. package/src/markdown-stream.tsx +49 -17
  74. package/src/markdown.tsx +20 -12
  75. package/src/renderers/heading.tsx +5 -1
  76. package/src/renderers/image.tsx +31 -8
  77. package/src/renderers/link.tsx +5 -1
  78. package/src/renderers/list.tsx +5 -1
  79. package/src/use-markdown-stream.ts +9 -1
  80. package/src/utils/link-security.ts +68 -4
@@ -12,6 +12,7 @@ import {
12
12
  type StylingStrategy,
13
13
  } from "./theme";
14
14
  import type { CodeHighlighter } from "./utils/code-highlight";
15
+ import type { UrlSafetyOptions } from "./utils/link-security";
15
16
 
16
17
  export type NodeRendererProps = {
17
18
  node: MarkdownNode;
@@ -72,19 +73,60 @@ export type TaskListItemRendererProps = {
72
73
  checked: boolean;
73
74
  } & BaseCustomRendererProps;
74
75
 
75
- export type CustomRendererProps = {} & EnhancedRendererProps;
76
+ export type MathRendererProps = {
77
+ content: string;
78
+ } & BaseCustomRendererProps;
79
+
80
+ export type CustomRendererProps = EnhancedRendererProps;
76
81
 
77
82
  export type LinkPressHandler = (
78
83
  href: string,
79
84
  ) => void | boolean | Promise<void | boolean>;
80
85
 
81
- export type CustomRenderer = (
82
- props: EnhancedRendererProps,
83
- ) => ReactNode | undefined;
86
+ export type CustomRenderer<
87
+ Props extends EnhancedRendererProps = EnhancedRendererProps,
88
+ > = (props: Props) => ReactNode | undefined;
89
+
90
+ export type CustomRendererPropsByNode = {
91
+ document: CustomRendererProps;
92
+ heading: HeadingRendererProps;
93
+ paragraph: CustomRendererProps;
94
+ text: CustomRendererProps;
95
+ bold: CustomRendererProps;
96
+ italic: CustomRendererProps;
97
+ strikethrough: CustomRendererProps;
98
+ link: LinkRendererProps;
99
+ image: ImageRendererProps;
100
+ code_inline: InlineCodeRendererProps;
101
+ code_block: CodeBlockRendererProps;
102
+ blockquote: CustomRendererProps;
103
+ horizontal_rule: CustomRendererProps;
104
+ line_break: CustomRendererProps;
105
+ soft_break: CustomRendererProps;
106
+ table: CustomRendererProps;
107
+ table_head: CustomRendererProps;
108
+ table_body: CustomRendererProps;
109
+ table_row: CustomRendererProps;
110
+ table_cell: CustomRendererProps;
111
+ list: ListRendererProps;
112
+ list_item: CustomRendererProps;
113
+ task_list_item: TaskListItemRendererProps;
114
+ math_inline: MathRendererProps;
115
+ math_block: MathRendererProps;
116
+ html_block: CustomRendererProps;
117
+ html_inline: CustomRendererProps;
118
+ };
119
+
120
+ export type CustomRenderers = Partial<{
121
+ [Type in MarkdownNode["type"]]: CustomRenderer<
122
+ CustomRendererPropsByNode[Type]
123
+ >;
124
+ }>;
84
125
 
85
- export type CustomRenderers = Partial<
86
- Record<MarkdownNode["type"], CustomRenderer>
87
- >;
126
+ export type TableOptions = {
127
+ minColumnWidth?: number;
128
+ measurementStabilizeMs?: number;
129
+ };
88
130
 
89
131
  export type MarkdownContextValue = {
90
132
  renderers: CustomRenderers;
@@ -93,10 +135,8 @@ export type MarkdownContextValue = {
93
135
  stylingStrategy: StylingStrategy;
94
136
  onLinkPress?: LinkPressHandler;
95
137
  highlightCode?: boolean | CodeHighlighter;
96
- tableOptions?: {
97
- minColumnWidth?: number;
98
- measurementStabilizeMs?: number;
99
- };
138
+ tableOptions?: TableOptions;
139
+ imageOptions?: UrlSafetyOptions;
100
140
  };
101
141
 
102
142
  export const MarkdownContext = createContext<MarkdownContextValue>({
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ export type {
15
15
  MarkdownProps,
16
16
  AstTransform,
17
17
  MarkdownPlugin,
18
+ MarkdownErrorPhase,
19
+ MarkdownParseCompleteResult,
18
20
  MarkdownVirtualizationOptions,
19
21
  } from "./markdown";
20
22
  export { MarkdownStream } from "./markdown-stream";
@@ -35,8 +37,11 @@ export type {
35
37
  InlineCodeRendererProps,
36
38
  ListRendererProps,
37
39
  TaskListItemRendererProps,
40
+ MathRendererProps,
38
41
  LinkPressHandler,
39
42
  MarkdownContextValue,
43
+ CustomRendererPropsByNode,
44
+ TableOptions,
40
45
  } from "./MarkdownContext";
41
46
 
42
47
  export {
@@ -72,3 +77,4 @@ export type {
72
77
  CodeHighlighter,
73
78
  } from "./utils/code-highlight";
74
79
  export { defaultHighlighter } from "./utils/code-highlight";
80
+ export type { UrlSafetyOptions } from "./utils/link-security";
@@ -58,6 +58,15 @@ const resolveStreamText = ({
58
58
  return session.getAllText();
59
59
  };
60
60
 
61
+ function warnStreamError(message: string, error: unknown): void {
62
+ if (!__DEV__) return;
63
+
64
+ const warn = Reflect.get(console, "warn");
65
+ if (typeof warn === "function") {
66
+ warn.call(console, message, error);
67
+ }
68
+ }
69
+
61
70
  export type MarkdownStreamProps = {
62
71
  /**
63
72
  * The active MarkdownSession to stream content from.
@@ -124,12 +133,20 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
124
133
  const forceFullSyncRef = useRef(false);
125
134
  const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
126
135
  const rafRef = useRef<number | null>(null);
136
+ const mountedRef = useRef(true);
127
137
  const allowIncremental = incrementalParsing && !hasBeforeParsePlugins;
128
138
 
129
139
  useEffect(() => {
130
140
  renderStateRef.current = renderState;
131
141
  }, [renderState]);
132
142
 
143
+ useEffect(() => {
144
+ mountedRef.current = true;
145
+ return () => {
146
+ mountedRef.current = false;
147
+ };
148
+ }, []);
149
+
133
150
  useEffect(() => {
134
151
  const initialText = session.getAllText();
135
152
  const initialState = {
@@ -185,9 +202,11 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
185
202
  ast: nextAst,
186
203
  };
187
204
  renderStateRef.current = nextState;
205
+ if (!mountedRef.current) return;
188
206
 
189
207
  if (useTransitionUpdates) {
190
208
  startTransition(() => {
209
+ if (!mountedRef.current) return;
191
210
  setRenderState(nextState);
192
211
  });
193
212
  } else {
@@ -208,28 +227,41 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
208
227
  }
209
228
  };
210
229
 
211
- const unsubscribe = session.addListener((from, to) => {
212
- const nextFrom = normalizeOffset(from);
213
- const nextTo = normalizeOffset(to);
230
+ let unsubscribe: (() => void) | null = null;
214
231
 
215
- if (nextFrom === null || nextTo === null || nextTo < nextFrom) {
216
- forceFullSyncRef.current = true;
217
- } else {
218
- const currentFrom = pendingFromRef.current;
219
- const currentTo = pendingToRef.current;
232
+ try {
233
+ unsubscribe = session.addListener((from, to) => {
234
+ const nextFrom = normalizeOffset(from);
235
+ const nextTo = normalizeOffset(to);
220
236
 
221
- pendingFromRef.current =
222
- currentFrom === null ? nextFrom : Math.min(currentFrom, nextFrom);
223
- pendingToRef.current =
224
- currentTo === null ? nextTo : Math.max(currentTo, nextTo);
225
- }
237
+ if (nextFrom === null || nextTo === null || nextTo < nextFrom) {
238
+ forceFullSyncRef.current = true;
239
+ } else {
240
+ const currentFrom = pendingFromRef.current;
241
+ const currentTo = pendingToRef.current;
226
242
 
227
- pendingUpdateRef.current = true;
228
- scheduleFlush();
229
- });
243
+ pendingFromRef.current =
244
+ currentFrom === null ? nextFrom : Math.min(currentFrom, nextFrom);
245
+ pendingToRef.current =
246
+ currentTo === null ? nextTo : Math.max(currentTo, nextTo);
247
+ }
248
+
249
+ pendingUpdateRef.current = true;
250
+ scheduleFlush();
251
+ });
252
+ } catch (error) {
253
+ warnStreamError("[NitroMarkdown] Failed to subscribe to stream:", error);
254
+ }
230
255
 
231
256
  return () => {
232
- unsubscribe();
257
+ try {
258
+ unsubscribe?.();
259
+ } catch (error) {
260
+ warnStreamError(
261
+ "[NitroMarkdown] Failed to unsubscribe from stream:",
262
+ error,
263
+ );
264
+ }
233
265
  if (updateTimerRef.current) {
234
266
  clearTimeout(updateTimerRef.current);
235
267
  updateTimerRef.current = null;
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: "parse" | "before-plugin" | "after-plugin",
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
- minColumnWidth?: number;
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 ?? true
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 <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);
@@ -149,7 +162,7 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
149
162
  return () => {
150
163
  isMounted = false;
151
164
  };
152
- }, [url]);
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
- if (error) {
196
+ const accessibilityLabel = alt || title;
197
+
198
+ if (error || !allowedImageHref) {
184
199
  return (
185
- <View style={[styles.imageError, style]}>
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: url }}
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
  }}
@@ -59,7 +59,11 @@ export const Link: FC<LinkProps> = ({ href, children, style }) => {
59
59
  };
60
60
 
61
61
  return (
62
- <Text style={[styles.link, style]} onPress={handlePress}>
62
+ <Text
63
+ style={[styles.link, style]}
64
+ onPress={handlePress}
65
+ accessibilityRole="link"
66
+ >
63
67
  {children}
64
68
  </Text>
65
69
  );
@@ -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
  >
@@ -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
  };