@charcuterie/ui 3.13.0 → 3.14.1

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 (32) hide show
  1. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
  2. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
  3. package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
  4. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
  5. package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
  6. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  7. package/dist/markdownEditorCodeMirror/index.js +12 -3
  8. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  9. package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
  10. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  11. package/dist/markdownEditorCodeMirror/livePreview.js +298 -28
  12. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  13. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  14. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +141 -10
  15. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  16. package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
  17. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
  18. package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
  19. package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
  20. package/package.json +1 -1
  21. package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
  22. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
  23. package/src/markdownEditorCodeMirror/MarkdownView.mdx +173 -0
  24. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +362 -0
  25. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +349 -0
  26. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  27. package/src/markdownEditorCodeMirror/index.ts +17 -2
  28. package/src/markdownEditorCodeMirror/livePreview.ts +391 -35
  29. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +233 -0
  30. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +192 -10
  31. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  32. package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
@@ -277,6 +277,200 @@ describe("toLivePreviewRanges", () => {
277
277
  ),
278
278
  ).toHaveLength(0)
279
279
  })
280
+
281
+ /**
282
+ * The bug that took whole documents down to raw source.
283
+ *
284
+ * The closing `]` was read as `openMark.nextSibling`, which is
285
+ * the bracket only while the link text is plain prose. Any
286
+ * inline construct in the text becomes that sibling instead, so
287
+ * the link text measured `[` → the construct's own start:
288
+ * **zero-length**. `Decoration.mark` rejects an empty range,
289
+ * the throw destroys the view plugin, and every decoration in
290
+ * the file goes with it.
291
+ *
292
+ * ``[`file.md`](path)`` is not an exotic spelling. It was in
293
+ * 69 of the 127 Docket task descriptions that had one.
294
+ *
295
+ * These assert the range, not a rendering. A whole-document
296
+ * render would go green on the *quiet* half of this bug — the
297
+ * cases below that produced a non-empty but wrong mark.
298
+ */
299
+ describe("inline markup inside the link text", () => {
300
+ test.each([
301
+ ["a code span", "[`file.md`](url)", "`file.md`"],
302
+ ["strong", "[**bold**](url)", "**bold**"],
303
+ ["emphasis", "[*thin*](url)", "*thin*"],
304
+ ["strikethrough", "[~~gone~~](url)", "~~gone~~"],
305
+ [
306
+ "a mix, after plain text",
307
+ "[a *b* `c` **d**](url)",
308
+ "a *b* `c` **d**",
309
+ ],
310
+ [
311
+ "markup then plain text",
312
+ "[**b** then more](url)",
313
+ "**b** then more",
314
+ ],
315
+ ["nesting", "[**`both`**](url)", "**`both`**"],
316
+ ["an escape", "[a\\]b](url)", "a\\]b"],
317
+ [
318
+ "an image",
319
+ "[![alt](a.png)](url)",
320
+ "![alt](a.png)",
321
+ ],
322
+ ])(
323
+ "spans the whole link text through %s",
324
+ (_case, text, expected) => {
325
+ const linkText = toRanges(text).find(
326
+ (range) =>
327
+ range.type === "mark" &&
328
+ range.markKind === "linkText",
329
+ )
330
+
331
+ expect(toTextOf(text, toFound(linkText))).toBe(
332
+ expected,
333
+ )
334
+ },
335
+ )
336
+
337
+ /**
338
+ * The invariant, swept rather than enumerated.
339
+ *
340
+ * Every caret position in the document, in both modes. A
341
+ * caret changes which constructs are revealed, and reveal
342
+ * state feeds the same bracket arithmetic — so "the mark is
343
+ * non-empty with no caret in the file" is a weaker promise
344
+ * than the one the plugin needs. `Decoration.mark` throws on
345
+ * an empty range wherever the caret happens to be.
346
+ */
347
+ test("emits no empty mark at any caret position, in either mode", () => {
348
+ const empties: string[] = []
349
+
350
+ for (const text of [
351
+ "see [`file.md`](https://example.com) now",
352
+ "see [**bold**](https://example.com) now",
353
+ "[a *b* `c` **d**](https://example.com)",
354
+ "[![alt](https://example.com/a.png)](https://example.com)",
355
+ "![`code` alt](https://example.com/a.png)",
356
+ "[](https://example.com)",
357
+ "[a\\]b](https://example.com)",
358
+ ]) {
359
+ for (const isRawMode of [false, true]) {
360
+ for (
361
+ let offset = 0;
362
+ offset <= text.length;
363
+ offset += 1
364
+ ) {
365
+ for (const range of toRanges(
366
+ text,
367
+ atCaret(offset),
368
+ isRawMode,
369
+ )) {
370
+ if (
371
+ range.type === "mark" &&
372
+ range.from >= range.to
373
+ ) {
374
+ empties.push(
375
+ `${text} @${offset} raw=${isRawMode}`,
376
+ )
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ expect(empties).toEqual([])
384
+ })
385
+
386
+ test("never emits an empty mark, whatever the nesting", () => {
387
+ for (const text of [
388
+ "[`file.md`](url)",
389
+ "[**bold**](url)",
390
+ "[*thin*](url)",
391
+ "[~~gone~~](url)",
392
+ "[a *b* `c` **d**](url)",
393
+ "[**`both`**](url)",
394
+ "[![alt](a.png)](url)",
395
+ "[a\\]b](url)",
396
+ ]) {
397
+ for (const range of toRanges(text)) {
398
+ if (range.type === "mark") {
399
+ // `Mark decorations may not be empty` — the throw
400
+ // that destroyed the plugin.
401
+ expect(range.to).toBeGreaterThan(range.from)
402
+ }
403
+ }
404
+ }
405
+ })
406
+
407
+ test("conceals only the brackets, not the text between them", () => {
408
+ const text =
409
+ "see [`file.md`](https://example.com) now"
410
+
411
+ const concealed = toRanges(text)
412
+ .filter(
413
+ (range) =>
414
+ range.type === "marker" && range.isConcealed,
415
+ )
416
+ .map((range) => toTextOf(text, range))
417
+
418
+ // The `](url)` run is the point. Before the fix it started
419
+ // one character after `[` and swallowed the link text
420
+ // whole, so dropping the empty mark on its own would have
421
+ // rendered an invisible link rather than a fixed one.
422
+ expect(concealed).toContain(
423
+ "](https://example.com)",
424
+ )
425
+
426
+ expect(concealed).not.toContain(
427
+ "`file.md`](https://example.com)",
428
+ )
429
+ })
430
+
431
+ test("still carries the URL onto the nested case", () => {
432
+ const text = "[`file.md`](https://example.com)"
433
+
434
+ expect(
435
+ toRanges(text).find(
436
+ (range) =>
437
+ range.type === "mark" &&
438
+ range.markKind === "linkText",
439
+ ),
440
+ ).toMatchObject({ url: "https://example.com" })
441
+ })
442
+
443
+ test("leaves the nested construct's own marks intact", () => {
444
+ const text = "[`file.md`](https://example.com)"
445
+
446
+ expect(
447
+ toRanges(text).find(
448
+ (range) =>
449
+ range.type === "mark" &&
450
+ range.markKind === "code",
451
+ ),
452
+ ).toBeDefined()
453
+ })
454
+ })
455
+
456
+ /**
457
+ * `[](url)` has no text, so there is nothing to mark. This is
458
+ * the *only* empty case — every other one was the bug above,
459
+ * which is why the guard in `livePreviewRanges` is written as
460
+ * `openMark.to < closeMark.from` and not as a filter over the
461
+ * finished list.
462
+ */
463
+ test("emits no link-text mark for a link with no text", () => {
464
+ const text = "[](https://example.com)"
465
+
466
+ expect(
467
+ toRanges(text).filter(
468
+ (range) =>
469
+ range.type === "mark" &&
470
+ range.markKind === "linkText",
471
+ ),
472
+ ).toHaveLength(0)
473
+ })
280
474
  })
281
475
 
282
476
  describe("images", () => {
@@ -306,6 +500,45 @@ describe("toLivePreviewRanges", () => {
306
500
  ),
307
501
  ).toHaveLength(0)
308
502
  })
503
+
504
+ /**
505
+ * The same `nextSibling` mistake, without the crash: an image
506
+ * emits a replace range over its whole construct, which is
507
+ * never empty, so a marked-up alt came out as `""` and nothing
508
+ * threw. `alt` is an attribute rather than a span, so it is
509
+ * read as plain text — which is what CommonMark says an alt is.
510
+ */
511
+ test.each([
512
+ [
513
+ "a code span",
514
+ "![`code` shot](https://example.com/a.png)",
515
+ "code shot",
516
+ ],
517
+ [
518
+ "strong",
519
+ "![**bold** shot](https://example.com/a.png)",
520
+ "bold shot",
521
+ ],
522
+ [
523
+ "a mix",
524
+ "![*a* `b` **c**](https://example.com/a.png)",
525
+ "a b c",
526
+ ],
527
+ [
528
+ "an escape",
529
+ "![a\\]b](https://example.com/a.png)",
530
+ "a]b",
531
+ ],
532
+ ])(
533
+ "reads the alt text as plain text through %s",
534
+ (_case, text, expected) => {
535
+ expect(
536
+ toRanges(text).find(
537
+ (range) => range.type === "image",
538
+ ),
539
+ ).toMatchObject({ alt: expected })
540
+ },
541
+ )
309
542
  })
310
543
 
311
544
  describe("task lists", () => {
@@ -49,6 +49,11 @@ import type {
49
49
  Tree,
50
50
  } from "@lezer/common"
51
51
 
52
+ import {
53
+ toSafeImageUrl,
54
+ toSafeLinkUrl,
55
+ } from "./safeUrls.ts"
56
+
52
57
  /**
53
58
  * A block-level treatment applied to a whole line.
54
59
  *
@@ -328,6 +333,115 @@ const toChildNamed = (
328
333
  return null
329
334
  }
330
335
 
336
+ /**
337
+ * The closing `]` of a `Link` or an `Image`.
338
+ *
339
+ * **Not** `openMark.nextSibling`. That sibling is the closing
340
+ * bracket only while the text between the brackets is plain prose,
341
+ * because plain prose gets no node of its own. Put any inline
342
+ * construct in there — ``[`file.md`](path)``, `[**bold**](path)`,
343
+ * `[~~struck~~](path)` — and the second child is that construct,
344
+ * so the old walk measured the link text as `[` → the construct's
345
+ * own start and got a **zero-length** range. CodeMirror rejects an
346
+ * empty mark decoration (`Mark decorations may not be empty`), the
347
+ * throw destroys the view plugin, and every decoration in the
348
+ * document goes with it: the whole file drops to raw source.
349
+ *
350
+ * Text *before* the construct failed more quietly and just as
351
+ * wrongly. `[a *b*](path)` marked `a ` as the link and then
352
+ * concealed `*b*](path)`, because the concealment marker starts
353
+ * where this bracket is thought to be.
354
+ *
355
+ * The bracket is the first `LinkMark` **child** after the opener. A
356
+ * nested image's brackets belong to the `Image` node, not to this
357
+ * one, so a direct-sibling walk steps over `[![alt](a.png)](url)`
358
+ * without mistaking `![` for the end of the link text.
359
+ */
360
+ const toCloseMark = (
361
+ openMark: SyntaxNode | null,
362
+ text: string,
363
+ ): SyntaxNode | null => {
364
+ for (
365
+ let sibling = openMark?.nextSibling;
366
+ sibling;
367
+ sibling = sibling.nextSibling
368
+ ) {
369
+ if (
370
+ sibling.name === "LinkMark" &&
371
+ // Redundant against today's parser — the first `LinkMark`
372
+ // after the opener is always `]`, for a link and an image
373
+ // alike. It is here to state the contract: this function
374
+ // returns a **closing bracket**, and it returns nothing
375
+ // rather than the wrong node if that ever stops holding.
376
+ text.slice(sibling.from, sibling.to) === "]"
377
+ ) {
378
+ return sibling
379
+ }
380
+ }
381
+
382
+ return null
383
+ }
384
+
385
+ /**
386
+ * The syntax spans `toPlainText` drops, gathered depth-first.
387
+ *
388
+ * `Escape` contributes only its backslash — `\]` reads as `]`.
389
+ */
390
+ const toSyntaxSpans = (
391
+ node: SyntaxNode,
392
+ spans: { from: number; to: number }[],
393
+ ) => {
394
+ for (
395
+ let child = node.firstChild;
396
+ child;
397
+ child = child.nextSibling
398
+ ) {
399
+ if (CONCEALABLE_MARK_NAMES.has(child.name)) {
400
+ spans.push({ from: child.from, to: child.to })
401
+ } else if (child.name === "Escape") {
402
+ spans.push({ from: child.from, to: child.from + 1 })
403
+ } else {
404
+ toSyntaxSpans(child, spans)
405
+ }
406
+ }
407
+
408
+ return spans
409
+ }
410
+
411
+ /**
412
+ * What `[from, to)` reads as once its inline syntax is removed.
413
+ *
414
+ * An image's `alt` is an attribute rather than a span a reader can
415
+ * look at, so ``![`code` shot](a.png)`` has to arrive as
416
+ * `code shot` — never as its own source, and never as the empty
417
+ * string the old sibling walk produced. CommonMark says the same:
418
+ * alt is the plain-text rendering of the inline content.
419
+ *
420
+ * Tree order is ascending, so the spans need no sort.
421
+ */
422
+ const toPlainText = (
423
+ node: SyntaxNode,
424
+ text: string,
425
+ from: number,
426
+ to: number,
427
+ ) => {
428
+ let offset = from
429
+
430
+ let plainText = ""
431
+
432
+ for (const span of toSyntaxSpans(node, [])) {
433
+ if (span.from < offset || span.to > to) {
434
+ continue
435
+ }
436
+
437
+ plainText += text.slice(offset, span.from)
438
+
439
+ offset = span.to
440
+ }
441
+
442
+ return plainText + text.slice(offset, to)
443
+ }
444
+
331
445
  /**
332
446
  * A heading's `#` run, plus the whitespace after it.
333
447
  *
@@ -926,28 +1040,58 @@ const toWalker = ({
926
1040
 
927
1041
  const openMark = node.firstChild
928
1042
 
929
- const closeMark = openMark?.nextSibling
1043
+ const closeMark = toCloseMark(openMark, text)
1044
+
1045
+ const safeUrl = urlNode
1046
+ ? toSafeImageUrl(
1047
+ text.slice(urlNode.from, urlNode.to),
1048
+ )
1049
+ : undefined
930
1050
 
931
1051
  // An image whose caret is inside it is markup again: you
932
- // cannot edit a URL you cannot see.
1052
+ // cannot edit a URL you cannot see. An image whose URL did
1053
+ // not survive `toSafeImageUrl` stays markup **always**,
1054
+ // which is the honest answer for a document this surface
1055
+ // did not write: the reader is shown the source rather than
1056
+ // a broken frame or a silently dropped line.
933
1057
  if (
934
1058
  !isRawMode &&
935
- urlNode &&
1059
+ safeUrl !== undefined &&
936
1060
  openMark &&
937
1061
  closeMark &&
938
1062
  !isInside(selections, node.from, node.to)
939
1063
  ) {
940
1064
  ranges.push({
941
- alt: text.slice(openMark.to, closeMark.from),
1065
+ alt: toPlainText(
1066
+ node,
1067
+ text,
1068
+ openMark.to,
1069
+ closeMark.from,
1070
+ ),
942
1071
  from: node.from,
943
1072
  to: node.to,
944
1073
  type: "image",
945
- url: text.slice(urlNode.from, urlNode.to),
1074
+ url: safeUrl,
946
1075
  })
947
1076
 
948
1077
  return false
949
1078
  }
950
1079
 
1080
+ /**
1081
+ * An image whose URL was refused shows its **whole**
1082
+ * construct, markers and all.
1083
+ *
1084
+ * Not descending is what does it: the `![`, `](` and `)`
1085
+ * would otherwise reach the generic concealment rule below
1086
+ * and vanish, leaving the alt text welded to a URL with no
1087
+ * punctuation between them. This is the same result the
1088
+ * refused-link path gets, by the same reasoning — a reader
1089
+ * shown the source can see what the file tried to do.
1090
+ */
1091
+ if (safeUrl === undefined && !isRawMode) {
1092
+ return false
1093
+ }
1094
+
951
1095
  return true
952
1096
  }
953
1097
 
@@ -964,15 +1108,48 @@ const toWalker = ({
964
1108
 
965
1109
  const openMark = node.firstChild
966
1110
 
967
- const closeMark = openMark?.nextSibling
1111
+ const closeMark = toCloseMark(openMark, text)
1112
+
1113
+ const safeUrl = urlNode
1114
+ ? toSafeLinkUrl(
1115
+ text.slice(urlNode.from, urlNode.to),
1116
+ )
1117
+ : undefined
1118
+
1119
+ /**
1120
+ * A link whose URL did not survive the guard is **not a
1121
+ * link at all**, and not a link-coloured span that quietly
1122
+ * does nothing either.
1123
+ *
1124
+ * Returning here skips the `linkText` mark *and* the two
1125
+ * `pushMarker` calls below, so `[click me](javascript:…)`
1126
+ * keeps its brackets, keeps its parentheses and reads as
1127
+ * exactly the characters the file contains. A reader who
1128
+ * can see the trap is better served than one shown a
1129
+ * confident blue word that silently refuses to work.
1130
+ */
1131
+ if (safeUrl === undefined) {
1132
+ return true
1133
+ }
968
1134
 
969
- if (urlNode && openMark && closeMark) {
1135
+ /**
1136
+ * `openMark.to < closeMark.from` is the genuinely-empty
1137
+ * case and only that: `[](url)`, which has no text to mark.
1138
+ * Nested inline markup used to land here too — see
1139
+ * `toCloseMark` — and the guard would have hidden that bug
1140
+ * rather than fixed it, so it stays narrow on purpose.
1141
+ */
1142
+ if (
1143
+ openMark &&
1144
+ closeMark &&
1145
+ openMark.to < closeMark.from
1146
+ ) {
970
1147
  ranges.push({
971
1148
  from: openMark.to,
972
1149
  markKind: "linkText",
973
1150
  to: closeMark.from,
974
1151
  type: "mark",
975
- url: text.slice(urlNode.from, urlNode.to),
1152
+ url: safeUrl,
976
1153
  })
977
1154
  }
978
1155
 
@@ -998,16 +1175,21 @@ const toWalker = ({
998
1175
  // paste a link, get a link, with no `[](…)` typed at all.
999
1176
  const parentName = nodeRef.node.parent?.name
1000
1177
 
1178
+ const safeUrl = toSafeLinkUrl(
1179
+ text.slice(nodeRef.from, nodeRef.to),
1180
+ )
1181
+
1001
1182
  if (
1002
1183
  parentName !== "Link" &&
1003
- parentName !== "Image"
1184
+ parentName !== "Image" &&
1185
+ safeUrl !== undefined
1004
1186
  ) {
1005
1187
  ranges.push({
1006
1188
  from: nodeRef.from,
1007
1189
  markKind: "autolink",
1008
1190
  to: nodeRef.to,
1009
1191
  type: "mark",
1010
- url: text.slice(nodeRef.from, nodeRef.to),
1192
+ url: safeUrl,
1011
1193
  })
1012
1194
  } else {
1013
1195
  ranges.push({
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The scheme guard, for markdown this surface did not write.
3
+ *
4
+ * `MarkdownView` renders files fetched from a git host, so its
5
+ * document is hostile input by default. Most of what that usually
6
+ * means is already answered by construction here: there is no
7
+ * `innerHTML` anywhere in this subpath, every rendered node is
8
+ * `createElement` plus `textContent`, and CodeMirror's state holds
9
+ * the source as *text* — so a `<script>` tag in a fetched README is
10
+ * literal characters on screen and never a script.
11
+ *
12
+ * One thing that construction did not cover, and it was live in the
13
+ * editor as well: **a URL is taken from the document and used as a
14
+ * URL.** `[click me](javascript:…)` conceals its markup, paints as a
15
+ * link, and `window.open` then runs the script in this origin. The
16
+ * image path is the same shape one step down — `![](…)` writes an
17
+ * `src` verbatim.
18
+ *
19
+ * So every URL crosses this module before it reaches an `href`, an
20
+ * `src` or `window.open`, in **both** components. It is a defect in
21
+ * a shape the two share, which makes it a defect in the layer they
22
+ * share.
23
+ *
24
+ * ### An allowlist, and nothing clever
25
+ *
26
+ * Denying `javascript:` is the version that has a bug. Browsers
27
+ * throw tabs and newlines away before they read a scheme, so
28
+ * `JaVaScript:`, a tab in the middle of the word, and a leading
29
+ * newline are one URL to a navigation and three different strings to
30
+ * a blocklist. This normalises the way a browser does, asks what
31
+ * scheme is left, and accepts only names it recognises. A URL with
32
+ * **no** scheme is relative and is always fine.
33
+ */
34
+
35
+ /**
36
+ * A scheme, per RFC 3986: a letter, then letters, digits, `+`, `-`
37
+ * or `.`, then the colon. Anchored, and the character class has no
38
+ * `/` in it — so `notes/2026:rack.md` is read as the relative path
39
+ * it is rather than as a `notes/2026` scheme.
40
+ */
41
+ const SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/
42
+
43
+ /**
44
+ * `[text](<url with spaces>)` is CommonMark's escape hatch for a URL
45
+ * that contains a space, and the parser hands the angle brackets
46
+ * through as part of the `URL` node. They are not part of the URL.
47
+ */
48
+ const toBareUrl = (url: string) => url.replace(/^<|>$/g, "")
49
+
50
+ /**
51
+ * What a browser discards before it looks at a URL: the C0 control
52
+ * range and the space. Stripping all of it is deliberately *more*
53
+ * aggressive than the URL standard, which removes only tab and the
54
+ * two newline characters — erring this way can only turn a URL that
55
+ * would have been treated as relative into one that is rejected,
56
+ * which is the harmless direction.
57
+ */
58
+ const toNormalizedUrl = (url: string) =>
59
+ toBareUrl(url)
60
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the entire point
61
+ .replace(/[\u0000-\u0020]/g, "")
62
+ .toLowerCase()
63
+
64
+ const toSchemeName = (url: string) =>
65
+ SCHEME_PATTERN.exec(toNormalizedUrl(url))?.[1]
66
+
67
+ /**
68
+ * The schemes a link may carry.
69
+ *
70
+ * `mailto:` and `tel:` are here because a README that prints a
71
+ * contact address is ordinary and neither one can execute. `file:`
72
+ * is not, because it reads the *reader's* disk.
73
+ */
74
+ const LINK_SCHEMES = new Set([
75
+ "http",
76
+ "https",
77
+ "mailto",
78
+ "tel",
79
+ ])
80
+
81
+ /** The schemes an image's `src` may carry, `data:` aside. */
82
+ const IMAGE_SCHEMES = new Set(["http", "https"])
83
+
84
+ /**
85
+ * `data:`, allowed for an image type only — which is not the hole it
86
+ * looks like.
87
+ *
88
+ * A `data:image/svg+xml` document loaded through `<img>` is a
89
+ * replaced element: no script execution, no network, no reach into
90
+ * the embedding page. That is why it is the one inline format worth
91
+ * having, and why this repo's own `Avatar` story uses it for a
92
+ * portrait that needs no fixture file. `data:text/html` is a
93
+ * different thing entirely and does not match this.
94
+ */
95
+ const DATA_IMAGE_PATTERN = /^data:image\/[a-z0-9.+-]+[,;]/
96
+
97
+ /**
98
+ * The link's URL, or `undefined` when it must not become one.
99
+ *
100
+ * A rejected URL is not rewritten and not blanked — the text stays
101
+ * exactly where the author put it and simply stops being a link.
102
+ * That is the honest failure: the reader still sees every character
103
+ * of the source, and can still read the URL itself in the
104
+ * raw-markdown view, but nothing on the page will navigate to it.
105
+ */
106
+ export const toSafeLinkUrl = (
107
+ url: string,
108
+ ): string | undefined => {
109
+ const scheme = toSchemeName(url)
110
+
111
+ if (scheme === undefined) {
112
+ // No scheme at all: a relative path, a `#fragment`, a `?query`,
113
+ // or a protocol-relative `//host/path`. None of them can name an
114
+ // executable scheme, so all of them are fine.
115
+ return toBareUrl(url)
116
+ }
117
+
118
+ return LINK_SCHEMES.has(scheme)
119
+ ? toBareUrl(url)
120
+ : undefined
121
+ }
122
+
123
+ /** The image's `src`, or `undefined` when it must not have one. */
124
+ export const toSafeImageUrl = (
125
+ url: string,
126
+ ): string | undefined => {
127
+ const scheme = toSchemeName(url)
128
+
129
+ if (scheme === undefined) {
130
+ return toBareUrl(url)
131
+ }
132
+
133
+ if (scheme === "data") {
134
+ return DATA_IMAGE_PATTERN.test(toNormalizedUrl(url))
135
+ ? toBareUrl(url)
136
+ : undefined
137
+ }
138
+
139
+ return IMAGE_SCHEMES.has(scheme)
140
+ ? toBareUrl(url)
141
+ : undefined
142
+ }