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.
- package/.watchmanconfig +6 -0
- package/README.md +56 -2
- package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +21 -3
- package/ios/HybridMarkdownSession.swift +62 -9
- 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 +2 -1
- package/react-native-nitro-markdown.podspec +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/MarkdownContext.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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";
|
package/src/markdown-stream.tsx
CHANGED
|
@@ -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
|
-
|
|
212
|
-
const nextFrom = normalizeOffset(from);
|
|
213
|
-
const nextTo = normalizeOffset(to);
|
|
230
|
+
let unsubscribe: (() => void) | null = null;
|
|
214
231
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
};
|