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.
- package/README.md +77 -8
- package/android/gradle.properties +2 -2
- 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/headless.js +0 -4
- package/lib/commonjs/headless.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 -3
- 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 -8
- package/lib/commonjs/renderers/image.js.map +1 -1
- package/lib/commonjs/renderers/link.js +1 -1
- 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/renderers/math.js +0 -1
- package/lib/commonjs/renderers/math.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/headless.js +0 -4
- package/lib/module/headless.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 -3
- 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 -8
- package/lib/module/renderers/image.js.map +1 -1
- package/lib/module/renderers/link.js +1 -1
- 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/renderers/math.js +0 -1
- package/lib/module/renderers/math.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/headless.d.ts +6 -3
- package/lib/typescript/commonjs/headless.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -3
- 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/renderers/math.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/headless.d.ts +6 -3
- package/lib/typescript/module/headless.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -3
- 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/renderers/math.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 +5 -5
- package/react-native-nitro-markdown.podspec +1 -1
- package/src/MarkdownContext.ts +51 -11
- package/src/headless.ts +35 -34
- package/src/index.ts +14 -1
- package/src/markdown-stream.tsx +49 -17
- package/src/markdown.tsx +20 -13
- package/src/renderers/heading.tsx +5 -1
- package/src/renderers/image.tsx +31 -9
- package/src/renderers/link.tsx +5 -2
- package/src/renderers/list.tsx +5 -1
- package/src/renderers/math.tsx +0 -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;
|
|
@@ -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:
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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);
|
|
@@ -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
|
-
}, [
|
|
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
|
-
|
|
195
|
+
const accessibilityLabel = alt || title;
|
|
196
|
+
|
|
197
|
+
if (error || !allowedImageHref) {
|
|
184
198
|
return (
|
|
185
|
-
<View
|
|
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:
|
|
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
|
}}
|
package/src/renderers/link.tsx
CHANGED
|
@@ -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
|
|
61
|
+
<Text
|
|
62
|
+
style={[styles.link, style]}
|
|
63
|
+
onPress={handlePress}
|
|
64
|
+
accessibilityRole="link"
|
|
65
|
+
>
|
|
63
66
|
{children}
|
|
64
67
|
</Text>
|
|
65
68
|
);
|
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
|
>
|
package/src/renderers/math.tsx
CHANGED
|
@@ -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
|
};
|