@riducms/plugin-richtext 0.2.3 → 0.3.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 (43) hide show
  1. package/README.md +7 -1
  2. package/package.json +7 -5
  3. package/src/block/rich-text-block-card.scss +139 -0
  4. package/src/block/rich-text-block-card.svelte +42 -21
  5. package/src/block/rich-text-block-clipboard.ts +1 -1
  6. package/src/block/rich-text-block-node.ts +1 -1
  7. package/src/block/rich-text-blocks-plugin.svelte +5 -2
  8. package/src/document-validation.ts +7 -5
  9. package/src/field/reference-node-placement.ts +49 -0
  10. package/src/field/rich-text-blocks.ts +1 -5
  11. package/src/field/rich-text-config.ts +1 -4
  12. package/src/field/rich-text-document.ts +1 -4
  13. package/src/field/rich-text-editor.svelte +105 -57
  14. package/src/field/rich-text-footer.svelte +7 -12
  15. package/src/field/rich-text-markdown.ts +62 -0
  16. package/src/link/link-url.ts +30 -0
  17. package/src/link/rich-text-link-plugin.svelte +528 -0
  18. package/src/link/rich-text-link.scss +144 -0
  19. package/src/link/safe-link-transform.ts +20 -0
  20. package/src/link-url-policy.ts +14 -0
  21. package/src/menu/rich-text-block-toolbar.svelte +403 -70
  22. package/src/menu/rich-text-commands.ts +10 -1
  23. package/src/menu/rich-text-menu.scss +92 -0
  24. package/src/menu/rich-text-menu.svelte +62 -69
  25. package/src/menu/rich-text-option-filter.ts +30 -0
  26. package/src/menu/rich-text-options.ts +81 -84
  27. package/src/menu/rich-text-slash-menu.svelte +15 -11
  28. package/src/messages.ts +72 -27
  29. package/src/relationship/rich-text-relationship-node.scss +98 -0
  30. package/src/relationship/rich-text-relationship-node.svelte +38 -32
  31. package/src/relationship/rich-text-relationship-plugin.svelte +24 -40
  32. package/src/render/rich-text.svelte +10 -5
  33. package/src/render.ts +3 -8
  34. package/src/styles/integration.scss +384 -0
  35. package/src/toolbar/PAYLOAD-LICENSE.md +22 -0
  36. package/src/toolbar/rich-text-floating-toolbar.svelte +331 -169
  37. package/src/toolbar/rich-text-toolbar.scss +80 -0
  38. package/src/toolbar/toolbar-icon.svelte +200 -30
  39. package/src/upload/rich-text-upload-node.scss +308 -0
  40. package/src/upload/rich-text-upload-node.svelte +96 -125
  41. package/src/upload/rich-text-upload-node.ts +1 -0
  42. package/src/upload/rich-text-upload-plugin.svelte +10 -33
  43. package/src/styles/integration.css +0 -83
@@ -6,24 +6,40 @@
6
6
  import { CodeNode } from "@lexical/code";
7
7
  import { HistoryExtension } from "@lexical/history";
8
8
  import { LinkNode } from "@lexical/link";
9
- import { ListExtension } from "@lexical/list";
9
+ import { CheckListExtension, ListExtension } from "@lexical/list";
10
+ import { registerMarkdownShortcuts } from "@lexical/markdown";
10
11
  import { RichTextExtension } from "@lexical/rich-text";
12
+ import { $insertNodeToNearestRoot } from "@lexical/utils";
11
13
  import {
12
14
  ContentEditable,
15
+ DEFAULT_TRANSFORMERS,
16
+ $createHorizontalRuleNode,
13
17
  HorizontalRuleNode,
14
- HorizontalRulePlugin,
18
+ INSERT_HORIZONTAL_RULE_COMMAND,
15
19
  LexicalExtensionComposer,
16
20
  LinkPlugin,
17
21
  OnChangePlugin,
18
22
  } from "@hvniel/lexical-svelte";
19
- import { $createParagraphNode, $getRoot, defineExtension, type EditorState } from "lexical";
23
+ import {
24
+ $createParagraphNode,
25
+ $getRoot,
26
+ $getSelection,
27
+ $isRangeSelection,
28
+ COMMAND_PRIORITY_EDITOR,
29
+ defineExtension,
30
+ mergeRegister,
31
+ type EditorState,
32
+ } from "lexical";
20
33
  import { Button, FieldFrame, fieldControlARIA } from "@riducms/ui";
21
34
 
22
- import "@plugin-richtext/styles/integration.css";
35
+ import "@plugin-richtext/styles/integration.scss";
23
36
  import RichTextBlockToolbar from "@plugin-richtext/menu/rich-text-block-toolbar.svelte";
24
37
  import RichTextEditabilityPlugin from "@plugin-richtext/field/rich-text-editability-plugin.svelte";
25
38
  import RichTextFooter from "@plugin-richtext/field/rich-text-footer.svelte";
26
39
  import RichTextFloatingToolbar from "@plugin-richtext/toolbar/rich-text-floating-toolbar.svelte";
40
+ import RichTextLinkPlugin from "@plugin-richtext/link/rich-text-link-plugin.svelte";
41
+ import { registerSafeLinkTransform } from "@plugin-richtext/link/safe-link-transform";
42
+ import { richTextMarkdownTransformers } from "@plugin-richtext/field/rich-text-markdown";
27
43
  import RichTextSlashMenu from "@plugin-richtext/menu/rich-text-slash-menu.svelte";
28
44
  import { hasRichTextFeature } from "@plugin-richtext/field/rich-text-config";
29
45
  import {
@@ -48,15 +64,14 @@
48
64
  authoring,
49
65
  i18n,
50
66
  }: PluginFieldProps<RichTextDocument<unknown>, RichTextConfig> = $props();
51
- const field = $derived(binding.schema);
52
- const editingBlocked = $derived(binding.readOnly);
53
- const issues = $derived(binding.issues);
67
+ const { schema: field, readOnly: editingBlocked, issues } = $derived(binding);
54
68
  const inputARIA = $derived(
55
69
  fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
56
70
  );
57
- const placeholderText = $derived(i18n.t("plugin.richtext:editor.placeholder"));
58
71
  const visiblePlaceholder = $derived(
59
- field.admin.readOnly ? i18n.t("plugin.richtext:editor.empty") : placeholderText
72
+ editingBlocked
73
+ ? i18n.t("plugin.richtext:editor.empty")
74
+ : i18n.t("plugin.richtext:editor.placeholder")
60
75
  );
61
76
  let canvasElement = $state<HTMLElement | null>(null);
62
77
  // A mounted field hydrates once; the parent remounts it for schema/revision/locale changes.
@@ -79,38 +94,45 @@
79
94
 
80
95
  const theme = {
81
96
  heading: {
82
- h1: "ridu-richtext-heading ridu-richtext-h1 mt-[2.1rem] mb-[0.85rem] font-serif text-[2rem] leading-[1.12] tracking-[-0.015em] text-foreground font-normal text-balance max-[34rem]:text-[1.75rem]",
83
- h2: "ridu-richtext-heading ridu-richtext-h2 mt-[2.1rem] mb-[0.85rem] font-serif text-[1.75rem] leading-[1.12] text-foreground font-normal text-balance",
84
- h3: "ridu-richtext-heading ridu-richtext-h3 mt-[2.1rem] mb-[0.85rem] font-sans text-lg leading-[1.35] text-foreground font-semibold text-balance",
97
+ h1: "ridu-richtext-heading ridu-richtext-h1",
98
+ h2: "ridu-richtext-heading ridu-richtext-h2",
99
+ h3: "ridu-richtext-heading ridu-richtext-h3",
100
+ h4: "ridu-richtext-heading ridu-richtext-h4",
101
+ h5: "ridu-richtext-heading ridu-richtext-h5",
102
+ h6: "ridu-richtext-heading ridu-richtext-h6",
85
103
  },
86
- code: "ridu-richtext-code-block my-6 block overflow-x-auto rounded-[3px] border border-control-border bg-control px-[1.1rem] py-4 font-mono text-[12.5px] leading-[1.7] text-foreground-strong [tab-size:2]",
87
- hr: "ridu-richtext-rule my-8 block cursor-pointer border-0 border-t border-border py-[0.65rem]",
104
+ code: "ridu-richtext-code-block",
105
+ hr: "ridu-richtext-rule",
88
106
  hrSelected: "is-selected",
89
- link: "ridu-richtext-link cursor-pointer text-brand-primary-soft underline decoration-primary/40 decoration-1 underline-offset-[0.18em] hover:text-primary-hover hover:decoration-current",
107
+ link: "ridu-richtext-link",
90
108
  list: {
91
- checklist:
92
- "ridu-richtext-checklist mt-1 mb-[1.35rem] list-none ps-0 text-[15.5px] leading-7 text-foreground-body",
93
- listitemChecked: "ridu-richtext-list-item-checked line-through opacity-70",
109
+ checklist: "ridu-richtext-checklist",
110
+ listitemChecked: "ridu-richtext-list-item-checked",
94
111
  listitemUnchecked: "ridu-richtext-list-item-unchecked",
95
- listitem: "ridu-richtext-list-item ps-1",
112
+ listitem: "ridu-richtext-list-item",
113
+ olDepth: [
114
+ "ridu-richtext-list-ordered",
115
+ "ridu-richtext-list-alpha-upper",
116
+ "ridu-richtext-list-alpha-lower",
117
+ "ridu-richtext-list-roman-upper",
118
+ "ridu-richtext-list-roman-lower",
119
+ ],
96
120
  nested: { listitem: "ridu-richtext-list-item-nested" },
97
- ol: "ridu-richtext-list-ordered mt-1 mb-[1.35rem] list-decimal ps-[1.55rem] text-[15.5px] leading-7 text-foreground-body",
98
- ul: "ridu-richtext-list-unordered mt-1 mb-[1.35rem] list-disc ps-[1.55rem] text-[15.5px] leading-7 text-foreground-body",
121
+ ol: "ridu-richtext-list-ordered",
122
+ ul: "ridu-richtext-list-unordered",
99
123
  },
100
- paragraph:
101
- "ridu-richtext-paragraph relative mb-[0.9rem] text-[15.5px] leading-7 text-foreground-body text-pretty last:mb-0",
102
- quote:
103
- "ridu-richtext-quote my-[1.85rem] border-s-2 border-primary/55 py-[0.1rem] ps-5 font-serif text-[19px] italic leading-6 text-foreground-strong text-pretty",
124
+ paragraph: "ridu-richtext-paragraph",
125
+ quote: "ridu-richtext-quote",
104
126
  text: {
105
- bold: "ridu-richtext-bold font-semibold",
106
- code: "ridu-richtext-inline-code rounded-[3px] border border-control-border bg-control px-[0.32em] py-[0.12em] font-mono text-[0.86em] text-foreground-strong",
107
- italic: "ridu-richtext-italic italic",
127
+ bold: "ridu-richtext-bold",
128
+ code: "ridu-richtext-inline-code",
129
+ italic: "ridu-richtext-italic",
108
130
  subscript: "ridu-richtext-subscript",
109
131
  superscript: "ridu-richtext-superscript",
110
- strikethrough: "ridu-richtext-strikethrough line-through",
111
- underline: "ridu-richtext-underline underline underline-offset-[0.15em]",
132
+ strikethrough: "ridu-richtext-strikethrough",
133
+ underline: "ridu-richtext-underline",
112
134
  },
113
- upload: "ridu-richtext-upload m-0",
135
+ upload: "ridu-richtext-upload",
114
136
  };
115
137
 
116
138
  // The extension tree is identity-bearing and belongs to this field instance.
@@ -125,8 +147,38 @@
125
147
  dependencies: [
126
148
  RichTextExtension,
127
149
  HistoryExtension,
128
- ...(hasRichTextFeature(config, "lists") ? [ListExtension] : []),
150
+ ...(hasRichTextFeature(config, "lists") ? [ListExtension, CheckListExtension] : []),
129
151
  ],
152
+ register: (editor) =>
153
+ mergeRegister(
154
+ registerMarkdownShortcuts(editor, [
155
+ ...richTextMarkdownTransformers(config),
156
+ ...(hasRichTextFeature(config, "horizontal-rule")
157
+ ? DEFAULT_TRANSFORMERS.filter(
158
+ (transformer) =>
159
+ transformer.type === "element" &&
160
+ transformer.dependencies.includes(HorizontalRuleNode)
161
+ )
162
+ : []),
163
+ ]),
164
+ // The upstream horizontal-rule plugin treats a cross-block drag's root click
165
+ // as a gap click and collapses the range; Lexical handles gap clicks itself.
166
+ ...(hasRichTextFeature(config, "horizontal-rule")
167
+ ? [
168
+ editor.registerCommand(
169
+ INSERT_HORIZONTAL_RULE_COMMAND,
170
+ () => {
171
+ const selection = $getSelection();
172
+ if (!$isRangeSelection(selection)) return false;
173
+ $insertNodeToNearestRoot($createHorizontalRuleNode());
174
+ return true;
175
+ },
176
+ COMMAND_PRIORITY_EDITOR
177
+ ),
178
+ ]
179
+ : []),
180
+ ...(hasRichTextFeature(config, "links") ? [registerSafeLinkTransform(editor)] : [])
181
+ ),
130
182
  name: "@riducms/plugin-richtext/editor",
131
183
  namespace: `ridu-${field.id}`,
132
184
  nodes: [
@@ -153,6 +205,7 @@
153
205
  )
154
206
  );
155
207
  }
208
+
156
209
  function exportDocument() {
157
210
  const url = URL.createObjectURL(
158
211
  new Blob([JSON.stringify(initialValue, null, 2)], { type: "application/json" })
@@ -173,20 +226,18 @@
173
226
  readOnly={field.admin.readOnly}
174
227
  description={field.admin.description}
175
228
  errors={issues.map((issue) => issue.message)}
176
- class="min-w-0"
229
+ class="ridu-richtext-field"
177
230
  >
178
231
  <div
179
232
  class={[
180
- "ridu-richtext-editor min-w-0 border-b border-transparent pb-[0.65rem] transition-colors duration-150",
181
- issues.length > 0 && "border-b-destructive/22",
182
- field.admin.readOnly && "pt-[1.6rem]",
233
+ "ridu-richtext-editor",
183
234
  issues.length > 0 && "has-error",
184
- field.admin.readOnly && "is-read-only",
235
+ editingBlocked && "is-read-only",
185
236
  ]}
186
237
  aria-invalid={inputARIA["aria-invalid"]}
187
238
  >
188
239
  {#if recoveryIssue !== undefined}
189
- <p role="alert" class="my-3 text-sm text-destructive">
240
+ <p role="alert" class="ridu-richtext-recovery">
190
241
  {i18n.t("plugin.richtext:editor.recovery", { path: `${field.path}.${recoveryIssue}` })}
191
242
  </p>
192
243
  <Button variant="outline" onclick={exportDocument}>
@@ -194,41 +245,38 @@
194
245
  </Button>
195
246
  {:else}
196
247
  <LexicalExtensionComposer {extension} contentEditable={null}>
197
- <div class="relative" bind:this={canvasElement}>
248
+ <div class="ridu-richtext-canvas" bind:this={canvasElement}>
198
249
  <ContentEditable
199
250
  id={field.id}
200
- class={[
201
- "ridu-richtext-content relative z-1 min-h-44 pt-[1.35rem] pe-0 pb-3 ps-9 text-foreground-body caret-primary outline-none focus-visible:outline-none max-[34rem]:min-h-36 max-[34rem]:pt-4 max-[34rem]:ps-8",
202
- field.admin.readOnly
203
- ? "min-h-auto pt-0 ps-0 max-[34rem]:min-h-auto max-[34rem]:pt-0 max-[34rem]:ps-0"
204
- : "border-s border-border transition-colors duration-150",
205
- issues.length > 0 && !field.admin.readOnly && "border-s-destructive/72",
206
- ]}
251
+ class="ridu-richtext-content"
207
252
  ariaLabel={field.admin.label}
208
253
  ariaDescribedBy={inputARIA["aria-describedby"]}
209
254
  ariaErrorMessage={inputARIA["aria-errormessage"]}
210
255
  ariaInvalid={inputARIA["aria-invalid"]}
211
256
  ariaRequired={field.required}
212
- aria-keyshortcuts="Alt+Shift+ArrowUp Alt+Shift+ArrowDown"
257
+ aria-keyshortcuts={editingBlocked
258
+ ? undefined
259
+ : hasRichTextFeature(config, "links")
260
+ ? "Alt+Shift+ArrowUp Alt+Shift+ArrowDown Control+K Meta+K"
261
+ : "Alt+Shift+ArrowUp Alt+Shift+ArrowDown"}
213
262
  aria-placeholder={visiblePlaceholder}
214
263
  >
215
264
  {#snippet placeholder()}
216
- <span
217
- class={[
218
- "pointer-events-none absolute top-[1.35rem] start-9 z-0 text-[15.5px] leading-7 text-foreground-placeholder max-[34rem]:top-4 max-[34rem]:start-8",
219
- field.admin.readOnly && "top-0 start-0 max-[34rem]:top-0 max-[34rem]:start-0",
220
- ]}
221
- >
265
+ <span class="ridu-richtext-placeholder">
222
266
  {visiblePlaceholder}
223
267
  </span>
224
268
  {/snippet}
225
269
  </ContentEditable>
226
270
  </div>
227
- {#if hasRichTextFeature(config, "links")}<LinkPlugin />{/if}
228
- {#if hasRichTextFeature(config, "horizontal-rule")}<HorizontalRulePlugin />{/if}
271
+ {#if hasRichTextFeature(config, "links")}
272
+ <LinkPlugin />
273
+ {/if}
229
274
  {#if !editingBlocked}
230
275
  <RichTextSlashMenu {authoring} {config} />
231
- <RichTextFloatingToolbar />
276
+ <RichTextFloatingToolbar {config} />
277
+ {#if hasRichTextFeature(config, "links")}
278
+ <RichTextLinkPlugin />
279
+ {/if}
232
280
  {#if canvasElement !== null}
233
281
  <RichTextBlockToolbar anchorElement={canvasElement} {authoring} {config} />
234
282
  {/if}
@@ -242,7 +290,7 @@
242
290
  <RichTextEditabilityPlugin readOnly={editingBlocked} />
243
291
  <RichTextBlocksPlugin {authoring} {field} />
244
292
  <OnChangePlugin onChange={changed} ignoreSelectionChange />
245
- <RichTextFooter readOnly={field.admin.readOnly === true} />
293
+ <RichTextFooter readOnly={editingBlocked} />
246
294
  </LexicalExtensionComposer>
247
295
  {/if}
248
296
  </div>
@@ -1,17 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { getAdminI18n } from "@riducms/plugin";
3
3
  import { useLexicalComposerContext, useLexicalEditable } from "@hvniel/lexical-svelte";
4
- import {
5
- $createParagraphNode,
6
- $getRoot,
7
- $getSelection,
8
- $isParagraphNode,
9
- $isRangeSelection,
10
- } from "lexical";
4
+ import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical";
11
5
 
12
6
  const editor = useLexicalComposerContext()[0];
13
7
  const isEditable = useLexicalEditable();
14
8
  const i18n = getAdminI18n();
9
+ const componentID = $props.id();
10
+ const hintID = `${componentID}-hint`;
15
11
  let { readOnly = false }: { readOnly?: boolean } = $props();
16
12
 
17
13
  function focusAtEnd() {
@@ -27,8 +23,6 @@
27
23
  : $createParagraphNode();
28
24
  if (paragraph.getParent() === null) root.append(paragraph);
29
25
  paragraph.selectEnd();
30
- const selection = $getSelection();
31
- if ($isRangeSelection(selection)) selection.insertText("/");
32
26
  });
33
27
  },
34
28
  { defaultSelection: "rootEnd" }
@@ -38,14 +32,15 @@
38
32
 
39
33
  {#if !readOnly}
40
34
  <button
41
- class="mt-[0.55rem] inline-flex cursor-text items-center gap-[0.35rem] border-0 bg-transparent py-[0.35rem] font-mono text-[11px] leading-[1.2] text-foreground-sub transition-colors duration-150 hover:text-foreground-muted focus-visible:rounded focus-visible:text-foreground-muted focus-visible:outline-2 focus-visible:outline-ring/60 focus-visible:outline-offset-3 disabled:cursor-not-allowed disabled:opacity-45"
35
+ class="ridu-richtext-footer"
42
36
  type="button"
43
37
  disabled={!isEditable()}
38
+ aria-label={i18n.t("plugin.richtext:editor.insertParagraph")}
39
+ aria-describedby={hintID}
44
40
  onclick={focusAtEnd}
45
41
  >
46
42
  <span aria-hidden="true">+</span>
47
- {i18n.t("plugin.richtext:editor.addBlock")}
48
- <span class="text-foreground-sub max-[34rem]:hidden">
43
+ <span id={hintID} class="ridu-richtext-announcement">
49
44
  {i18n.t("plugin.richtext:editor.footerHint")}
50
45
  </span>
51
46
  </button>
@@ -0,0 +1,62 @@
1
+ import {
2
+ BOLD_ITALIC_STAR,
3
+ BOLD_ITALIC_UNDERSCORE,
4
+ BOLD_STAR,
5
+ BOLD_UNDERSCORE,
6
+ CHECK_LIST,
7
+ CODE,
8
+ HEADING,
9
+ INLINE_CODE,
10
+ ITALIC_STAR,
11
+ ITALIC_UNDERSCORE,
12
+ LINK,
13
+ ORDERED_LIST,
14
+ QUOTE,
15
+ STRIKETHROUGH,
16
+ UNORDERED_LIST,
17
+ type TextMatchTransformer,
18
+ type Transformer,
19
+ } from "@lexical/markdown";
20
+ import { $isLinkNode } from "@lexical/link";
21
+
22
+ import { hasRichTextFeature, type RichTextConfig } from "@plugin-richtext/field/rich-text-config";
23
+ import { normalizeLinkURL } from "@plugin-richtext/link/link-url";
24
+
25
+ const safeLink: TextMatchTransformer = {
26
+ ...LINK,
27
+ replace(textNode, match) {
28
+ // Match Lexical's CommonMark unescaping before validation. Pass the original
29
+ // match to its transformer so escaped text/title are still decoded only once.
30
+ const decoded = (match[2] ?? "")
31
+ .replace(/\\([!-/:-@[-`{-~])/g, "$1")
32
+ .replace(/&#(\d+);/g, (_, codePoint: string) => {
33
+ const value = Number(codePoint);
34
+ return value <= 0x10ffff ? String.fromCodePoint(value) : "\0";
35
+ });
36
+ const url = normalizeLinkURL(decoded);
37
+ if (url === undefined) return;
38
+
39
+ const replacement = LINK.replace?.(textNode, match);
40
+ const link = replacement?.getParent();
41
+ if ($isLinkNode(link)) link.setURL(url);
42
+ return replacement;
43
+ },
44
+ };
45
+
46
+ /** Shortcuts produce only nodes enabled by this field's canonical configuration. */
47
+ export function richTextMarkdownTransformers(config: RichTextConfig): Transformer[] {
48
+ return [
49
+ HEADING,
50
+ QUOTE,
51
+ ...(hasRichTextFeature(config, "lists") ? [CHECK_LIST, UNORDERED_LIST, ORDERED_LIST] : []),
52
+ ...(hasRichTextFeature(config, "code") ? [CODE, INLINE_CODE] : []),
53
+ BOLD_ITALIC_STAR,
54
+ BOLD_ITALIC_UNDERSCORE,
55
+ BOLD_STAR,
56
+ BOLD_UNDERSCORE,
57
+ ITALIC_STAR,
58
+ ITALIC_UNDERSCORE,
59
+ STRIKETHROUGH,
60
+ ...(hasRichTextFeature(config, "links") ? [safeLink] : []),
61
+ ];
62
+ }
@@ -0,0 +1,30 @@
1
+ import { formatUrl } from "@lexical/link";
2
+ import { renderableRichTextURL } from "@plugin-richtext/link-url-policy";
3
+
4
+ const relativeURL = /^(?:\/|\.|#)/;
5
+
6
+ /** Normalize an author-entered URL while rejecting schemes browsers could execute. */
7
+ export function normalizeLinkURL(value: string): string | undefined {
8
+ const trimmed = value.trim();
9
+ if (trimmed === "" || /[\u0000-\u001f\u007f\s]/.test(trimmed)) return undefined;
10
+
11
+ const normalized = formatUrl(trimmed);
12
+ const safeURL = renderableRichTextURL(normalized);
13
+ if (safeURL === undefined) return undefined;
14
+
15
+ try {
16
+ const parsed = new URL(safeURL, "https://ridu.invalid");
17
+ if (
18
+ !relativeURL.test(safeURL) &&
19
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
20
+ parsed.hostname === ""
21
+ )
22
+ return undefined;
23
+ if ((parsed.protocol === "mailto:" || parsed.protocol === "tel:") && parsed.pathname === "")
24
+ return undefined;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+
29
+ return safeURL;
30
+ }