@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.
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/index.js +12 -3
- package/dist/markdownEditorCodeMirror/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +298 -28
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +141 -10
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
- package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
- package/package.json +1 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
- package/src/markdownEditorCodeMirror/MarkdownView.mdx +173 -0
- package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +362 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +349 -0
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
- package/src/markdownEditorCodeMirror/index.ts +17 -2
- package/src/markdownEditorCodeMirror/livePreview.ts +391 -35
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +233 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +192 -10
- package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
- 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
|
+
"[](url)",
|
|
320
|
+
"",
|
|
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
|
+
"[](https://example.com)",
|
|
355
|
+
"",
|
|
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
|
+
"[](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
|
+
"",
|
|
515
|
+
"code shot",
|
|
516
|
+
],
|
|
517
|
+
[
|
|
518
|
+
"strong",
|
|
519
|
+
"",
|
|
520
|
+
"bold shot",
|
|
521
|
+
],
|
|
522
|
+
[
|
|
523
|
+
"a mix",
|
|
524
|
+
"",
|
|
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 `[](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 ```` 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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 ``
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1192
|
+
url: safeUrl,
|
|
1011
1193
|
})
|
|
1012
1194
|
} else {
|
|
1013
1195
|
ranges.push({
|
|
Binary file
|
|
@@ -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
|
+
}
|