@charcuterie/ui 3.12.0 → 3.14.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 (53) hide show
  1. package/dist/Avatar/Avatar.d.ts +163 -0
  2. package/dist/Avatar/Avatar.d.ts.map +1 -0
  3. package/dist/Avatar/Avatar.js +139 -0
  4. package/dist/Avatar/Avatar.js.map +1 -0
  5. package/dist/Board/BoardLaneList.d.ts +29 -0
  6. package/dist/Board/BoardLaneList.d.ts.map +1 -1
  7. package/dist/Board/BoardLaneList.js +2 -1
  8. package/dist/Board/BoardLaneList.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
  14. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
  15. package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
  16. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
  17. package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
  18. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  19. package/dist/markdownEditorCodeMirror/index.js +12 -3
  20. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  21. package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
  22. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  23. package/dist/markdownEditorCodeMirror/livePreview.js +243 -26
  24. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  25. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  26. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +49 -7
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  28. package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
  29. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
  30. package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
  31. package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/Avatar/Avatar.mdx +92 -0
  34. package/src/Avatar/Avatar.stories.tsx +254 -0
  35. package/src/Avatar/Avatar.test.tsx +143 -0
  36. package/src/Avatar/Avatar.tsx +270 -0
  37. package/src/Board/Board.mdx +26 -0
  38. package/src/Board/Board.stories.tsx +47 -21
  39. package/src/Board/Board.test.tsx +62 -0
  40. package/src/Board/BoardLaneList.tsx +52 -1
  41. package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
  42. package/src/index.ts +5 -0
  43. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
  44. package/src/markdownEditorCodeMirror/MarkdownView.mdx +157 -0
  45. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +314 -0
  46. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +288 -0
  47. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  48. package/src/markdownEditorCodeMirror/index.ts +17 -2
  49. package/src/markdownEditorCodeMirror/livePreview.ts +329 -33
  50. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +64 -7
  51. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  52. package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
  53. package/src/sourceRules.test.ts +9 -1
@@ -32,6 +32,7 @@ import type {
32
32
  Range,
33
33
  } from "@codemirror/state"
34
34
  import {
35
+ Facet,
35
36
  Prec,
36
37
  StateEffect,
37
38
  StateField,
@@ -76,6 +77,72 @@ const URL_ATTRIBUTE = "data-charcuterie-url"
76
77
  */
77
78
  const CELL_OFFSET_ATTRIBUTE = "data-charcuterie-cell-offset"
78
79
 
80
+ /**
81
+ * What kind of surface this is, and what its checkboxes may do.
82
+ *
83
+ * A facet rather than closure state, for the same reason raw mode is
84
+ * a `StateField`: the decoration builders below are module-level
85
+ * singletons — one `ViewPlugin`, one `StateField` — and rebuilding
86
+ * them per instance would mean a second copy of the whole live
87
+ * preview for every surface on the page. They read the answer out of
88
+ * the state they were already handed.
89
+ *
90
+ * It is also what lets `MarkdownView` put the task flag in a
91
+ * `Compartment` and flip it without discarding the view, exactly as
92
+ * the editors already flip editability.
93
+ */
94
+ export type LivePreviewOptions = {
95
+ /**
96
+ * Draw a **document** rather than an editing surface: real
97
+ * anchors, heading roles, and a table that is a table instead of a
98
+ * way back into the markdown behind it.
99
+ *
100
+ * Off by default, because the editors are the surface this module
101
+ * was written for and every one of these would be wrong inside a
102
+ * `contenteditable`.
103
+ */
104
+ hasDocumentSemantics?: boolean
105
+ /** Whether a `- [ ]` checkbox can be ticked. Defaults to `true`. */
106
+ isTaskListInteractive?: boolean
107
+ }
108
+
109
+ /**
110
+ * The facet itself is exported so a consumer can put **only this**
111
+ * in a `Compartment` and flip one answer without reconfiguring the
112
+ * whole extension — which would tear down and recreate the
113
+ * `StateField`s beside it.
114
+ *
115
+ * A later input wins per key, and a key nobody stated keeps the
116
+ * default. That is a merge rather than "the last one wins outright",
117
+ * so an override that only cares about checkboxes cannot silently
118
+ * turn document semantics back off.
119
+ */
120
+ export const livePreviewOptions = Facet.define<
121
+ LivePreviewOptions,
122
+ Required<LivePreviewOptions>
123
+ >({
124
+ combine: (values) =>
125
+ values.reduce<Required<LivePreviewOptions>>(
126
+ (merged, value) => ({
127
+ hasDocumentSemantics:
128
+ value.hasDocumentSemantics ??
129
+ merged.hasDocumentSemantics,
130
+ isTaskListInteractive:
131
+ value.isTaskListInteractive ??
132
+ merged.isTaskListInteractive,
133
+ }),
134
+ {
135
+ hasDocumentSemantics: false,
136
+ isTaskListInteractive: true,
137
+ },
138
+ ),
139
+ })
140
+
141
+ const toOptions = (
142
+ state: EditorState,
143
+ ): Required<LivePreviewOptions> =>
144
+ state.facet(livePreviewOptions)
145
+
79
146
  /** Toggle the "edit Markdown" mode. */
80
147
  export const setLivePreviewRawMode =
81
148
  StateEffect.define<boolean>()
@@ -138,6 +205,66 @@ const focusTracker = EditorView.focusChangeEffect.of(
138
205
  (_state, isFocusing) => setIsEditorFocused.of(isFocusing),
139
206
  )
140
207
 
208
+ /**
209
+ * How the read view says "heading" to something that is not looking
210
+ * at the font size.
211
+ *
212
+ * The editing surface deliberately has none of this. Its content is
213
+ * a `role="textbox"`, and ARIA flattens a textbox's contents to
214
+ * text — a heading role inside one is not exposed, so adding it
215
+ * there would be a decoration that lies about what it does. The read
216
+ * view is a `role="article"`, where the same markup is a real
217
+ * heading and the reader's H key steps between sections.
218
+ *
219
+ * A `Decoration.line` carries the role, which is exactly right for
220
+ * an ATX heading: the parser only ever emits `ATXHeading1`…`6`, and
221
+ * one of those is one line by definition.
222
+ */
223
+ const HEADING_LEVELS: Partial<
224
+ Record<LivePreviewLineKind, string>
225
+ > = {
226
+ heading1: "1",
227
+ heading2: "2",
228
+ heading3: "3",
229
+ heading4: "4",
230
+ heading5: "5",
231
+ heading6: "6",
232
+ }
233
+
234
+ const toLineAttributes = (
235
+ lineKind: LivePreviewLineKind,
236
+ hasDocumentSemantics: boolean,
237
+ ) => {
238
+ const level = HEADING_LEVELS[lineKind]
239
+
240
+ return hasDocumentSemantics && level
241
+ ? { "aria-level": level, role: "heading" }
242
+ : undefined
243
+ }
244
+
245
+ /**
246
+ * A link's DOM, in a surface where a real anchor is allowed.
247
+ *
248
+ * The editor cannot have one. Its content is `contenteditable`, and
249
+ * an `<a href>` inside an editable region is a focus target the
250
+ * caret has to share the line with — which is why the editor paints
251
+ * a `<span>` and opens the URL from a `mousedown` handler instead,
252
+ * and why clicking link *text* there cannot place a caret.
253
+ *
254
+ * A read view has no caret to protect, so the link is simply a link:
255
+ * tabbable, activated by Enter, copyable from the context menu, and
256
+ * announced as a link rather than as coloured text. That difference
257
+ * is the one place the two surfaces deliberately emit different DOM,
258
+ * and it is a strictly larger set of things a reader can do.
259
+ */
260
+ const toLinkAttributes = (url: string) => ({
261
+ href: url,
262
+ // Not optional with `target="_blank"`: without it the opened page
263
+ // gets a live `window.opener` back into this one.
264
+ rel: "noopener noreferrer",
265
+ target: "_blank",
266
+ })
267
+
141
268
  const LINE_CLASSES: Record<LivePreviewLineKind, string> = {
142
269
  blockquote: "cm-md-blockquote",
143
270
  code: "cm-md-codeblock",
@@ -187,6 +314,15 @@ class ImageWidget extends WidgetType {
187
314
 
188
315
  image.setAttribute("alt", this.alt)
189
316
 
317
+ /**
318
+ * A remote image in a document nobody here wrote is a beacon:
319
+ * fetching it tells its host that this reader opened this file,
320
+ * and the default `Referer` tells it which page they opened it
321
+ * from. The image is still fetched — that is what rendering it
322
+ * means — but it learns nothing about where from.
323
+ */
324
+ image.setAttribute("referrerpolicy", "no-referrer")
325
+
190
326
  image.className = "cm-md-image"
191
327
 
192
328
  // The document's height is unknown until the image decodes, and
@@ -213,6 +349,7 @@ class TaskWidget extends WidgetType {
213
349
  constructor(
214
350
  private readonly isChecked: boolean,
215
351
  private readonly label: string,
352
+ private readonly isInteractive: boolean,
216
353
  ) {
217
354
  super()
218
355
  }
@@ -220,6 +357,7 @@ class TaskWidget extends WidgetType {
220
357
  eq(other: TaskWidget) {
221
358
  return (
222
359
  other.isChecked === this.isChecked &&
360
+ other.isInteractive === this.isInteractive &&
223
361
  other.label === this.label
224
362
  )
225
363
  }
@@ -249,6 +387,30 @@ class TaskWidget extends WidgetType {
249
387
  this.label === "" ? "Task" : this.label,
250
388
  )
251
389
 
390
+ /**
391
+ * A read view's checkbox is **inert by default**, and `disabled`
392
+ * rather than hidden or repainted as a glyph.
393
+ *
394
+ * The state is a fact about the document and belongs on screen;
395
+ * the ability to change it is not, when the document is a file
396
+ * fetched at a commit hash and there is nowhere for a tick to go.
397
+ * A disabled checkbox says both of those at once — it shows
398
+ * checked or unchecked, and it announces "unavailable", so the
399
+ * reader is never left clicking something that swallows the
400
+ * click.
401
+ *
402
+ * A consumer that *does* have somewhere to put the change passes
403
+ * `onToggleTask`, and this comes back to life. That is the same
404
+ * shape as `onUploadImage` on the editors: omit the handler and
405
+ * the capability is simply not offered, which beats offering one
406
+ * that half works.
407
+ */
408
+ if (!this.isInteractive) {
409
+ checkbox.disabled = true
410
+
411
+ return checkbox
412
+ }
413
+
252
414
  checkbox.addEventListener("mousedown", (event) => {
253
415
  // The checkbox owns the click; without this CodeMirror also
254
416
  // moves the caret into the widget's slot on the way past.
@@ -285,6 +447,7 @@ class TaskWidget extends WidgetType {
285
447
  const toSegmentNode = (
286
448
  segment: LivePreviewTableSegment,
287
449
  view: EditorView,
450
+ hasDocumentSemantics: boolean,
288
451
  ) => {
289
452
  if (segment.type === "image") {
290
453
  const image = document.createElement("img")
@@ -293,6 +456,8 @@ const toSegmentNode = (
293
456
 
294
457
  image.setAttribute("alt", segment.alt)
295
458
 
459
+ image.setAttribute("referrerpolicy", "no-referrer")
460
+
296
461
  image.className = "cm-md-image"
297
462
 
298
463
  image.addEventListener("load", () => {
@@ -306,19 +471,33 @@ const toSegmentNode = (
306
471
  return document.createTextNode(segment.text)
307
472
  }
308
473
 
309
- const span = document.createElement("span")
474
+ // A link in a cell follows the same rule as a link in a
475
+ // paragraph: an anchor where an anchor is allowed, a painted span
476
+ // with a click handler where the caret owns the line.
477
+ const element: HTMLElement =
478
+ segment.url && hasDocumentSemantics
479
+ ? document.createElement("a")
480
+ : document.createElement("span")
310
481
 
311
- span.className = segment.markKinds
482
+ element.className = segment.markKinds
312
483
  .map((markKind) => MARK_CLASSES[markKind])
313
484
  .join(" ")
314
485
 
315
486
  if (segment.url) {
316
- span.setAttribute(URL_ATTRIBUTE, segment.url)
487
+ if (hasDocumentSemantics) {
488
+ for (const [name, value] of Object.entries(
489
+ toLinkAttributes(segment.url),
490
+ )) {
491
+ element.setAttribute(name, value)
492
+ }
493
+ } else {
494
+ element.setAttribute(URL_ATTRIBUTE, segment.url)
495
+ }
317
496
  }
318
497
 
319
- span.textContent = segment.text
498
+ element.textContent = segment.text
320
499
 
321
- return span
500
+ return element
322
501
  }
323
502
 
324
503
  /**
@@ -346,6 +525,7 @@ class TableWidget extends WidgetType {
346
525
  private readonly source: string,
347
526
  private readonly rows: readonly LivePreviewTableRow[],
348
527
  private readonly tableFrom: number,
528
+ private readonly hasDocumentSemantics: boolean,
349
529
  ) {
350
530
  super()
351
531
  }
@@ -383,7 +563,13 @@ class TableWidget extends WidgetType {
383
563
  )
384
564
 
385
565
  for (const segment of cell.segments) {
386
- cellElement.append(toSegmentNode(segment, view))
566
+ cellElement.append(
567
+ toSegmentNode(
568
+ segment,
569
+ view,
570
+ this.hasDocumentSemantics,
571
+ ),
572
+ )
387
573
  }
388
574
 
389
575
  rowElement.append(cellElement)
@@ -400,6 +586,34 @@ class TableWidget extends WidgetType {
400
586
 
401
587
  scroll.append(table)
402
588
 
589
+ /**
590
+ * The way back into the markdown, and only where there is one.
591
+ *
592
+ * A read view has no caret to put in a cell, so this handler
593
+ * would be a click that steals the selection and gives nothing
594
+ * back — and `cursor: text` on the cells would be advertising
595
+ * an edit that cannot happen. The table there is simply a table.
596
+ */
597
+ if (this.hasDocumentSemantics) {
598
+ scroll.classList.add("cm-md-table-static")
599
+
600
+ /**
601
+ * A scroll box a keyboard can reach.
602
+ *
603
+ * A table wider than its column scrolls sideways, and in the
604
+ * editor that box is inside a `contenteditable` — already
605
+ * focusable, already scrollable with the arrow keys. Here it
606
+ * is inside content that is deliberately *not* a tab stop, so
607
+ * without this the only way to see column four is a pointer.
608
+ * axe reports it as `scrollable-region-focusable` and is
609
+ * right: it is WCAG 2.1.1, and it is the one thing a read view
610
+ * has to add back that the editor got for free.
611
+ */
612
+ scroll.tabIndex = 0
613
+
614
+ return scroll
615
+ }
616
+
403
617
  scroll.addEventListener("mousedown", (event) => {
404
618
  const target = event.target
405
619
 
@@ -458,24 +672,36 @@ const toDecorations = (view: EditorView): DecorationSet => {
458
672
  false,
459
673
  )
460
674
 
675
+ const { hasDocumentSemantics, isTaskListInteractive } =
676
+ toOptions(view.state)
677
+
461
678
  const text = view.state.doc.toString()
462
679
 
463
680
  /**
464
- * An unfocused editor reveals nothing.
681
+ * An unfocused editor reveals nothing, and a document reveals
682
+ * nothing ever.
465
683
  *
466
- * Without this the caret's default resting place — offset 0 —
467
- * counts as "inside" whatever construct starts the document, so a
468
- * task whose description opens with a heading rendered its `#` on
469
- * load and no other heading's. It looked like a concealment bug
470
- * and was really a focus bug: there is no caret in the document
471
- * until someone puts one there.
684
+ * Without the focus half, the caret's default resting place —
685
+ * offset 0 — counts as "inside" whatever construct starts the
686
+ * document, so a task whose description opens with a heading
687
+ * rendered its `#` on load and no other heading's. It looked like
688
+ * a concealment bug and was really a focus bug: there is no caret
689
+ * in the document until someone puts one there.
690
+ *
691
+ * The document half is stated rather than inherited. A read view's
692
+ * content is not editable, so it cannot take focus and `hasFocus`
693
+ * is already false — but that is a property of how CodeMirror
694
+ * happens to treat a `contenteditable="false"` element, and
695
+ * "markup never comes back in a read view" is a promise the
696
+ * component makes, not a side effect it is relying on.
472
697
  */
473
- const selections = view.hasFocus
474
- ? view.state.selection.ranges.map((range) => ({
475
- from: range.from,
476
- to: range.to,
477
- }))
478
- : []
698
+ const selections =
699
+ view.hasFocus && !hasDocumentSemantics
700
+ ? view.state.selection.ranges.map((range) => ({
701
+ from: range.from,
702
+ to: range.to,
703
+ }))
704
+ : []
479
705
 
480
706
  const decorations: Range<Decoration>[] = []
481
707
 
@@ -502,6 +728,10 @@ const toDecorations = (view: EditorView): DecorationSet => {
502
728
  case "line": {
503
729
  decorations.push(
504
730
  Decoration.line({
731
+ attributes: toLineAttributes(
732
+ range.lineKind,
733
+ hasDocumentSemantics,
734
+ ),
505
735
  class: LINE_CLASSES[range.lineKind],
506
736
  }).range(range.from),
507
737
  )
@@ -513,9 +743,14 @@ const toDecorations = (view: EditorView): DecorationSet => {
513
743
  decorations.push(
514
744
  Decoration.mark({
515
745
  attributes: range.url
516
- ? { [URL_ATTRIBUTE]: range.url }
746
+ ? hasDocumentSemantics
747
+ ? toLinkAttributes(range.url)
748
+ : { [URL_ATTRIBUTE]: range.url }
517
749
  : undefined,
518
750
  class: MARK_CLASSES[range.markKind],
751
+ ...(range.url && hasDocumentSemantics
752
+ ? { tagName: "a" }
753
+ : {}),
519
754
  }).range(range.from, range.to),
520
755
  )
521
756
 
@@ -543,6 +778,7 @@ const toDecorations = (view: EditorView): DecorationSet => {
543
778
  widget: new TaskWidget(
544
779
  range.isChecked,
545
780
  range.label,
781
+ isTaskListInteractive,
546
782
  ),
547
783
  }).range(range.from, range.to),
548
784
  )
@@ -577,15 +813,16 @@ const toTableDecorations = (state: EditorState) => {
577
813
  false,
578
814
  )
579
815
 
580
- const selections = state.field(
581
- isEditorFocusedField,
582
- false,
583
- )
584
- ? state.selection.ranges.map((range) => ({
585
- from: range.from,
586
- to: range.to,
587
- }))
588
- : []
816
+ const { hasDocumentSemantics } = toOptions(state)
817
+
818
+ const selections =
819
+ state.field(isEditorFocusedField, false) &&
820
+ !hasDocumentSemantics
821
+ ? state.selection.ranges.map((range) => ({
822
+ from: range.from,
823
+ to: range.to,
824
+ }))
825
+ : []
589
826
 
590
827
  const text = state.doc.toString()
591
828
 
@@ -605,6 +842,7 @@ const toTableDecorations = (state: EditorState) => {
605
842
  text.slice(range.from, range.to),
606
843
  range.rows,
607
844
  range.from,
845
+ hasDocumentSemantics,
608
846
  ),
609
847
  }).range(range.from, range.to),
610
848
  ),
@@ -624,6 +862,11 @@ const livePreviewTableField =
624
862
  effect.is(setLivePreviewRawMode) ||
625
863
  effect.is(setIsEditorFocused),
626
864
  ) ||
865
+ // A `Compartment` reconfigure — `MarkdownView` turning its
866
+ // checkboxes on or off — arrives as neither a doc change
867
+ // nor an effect this field would otherwise notice.
868
+ transaction.startState.facet(livePreviewOptions) !==
869
+ transaction.state.facet(livePreviewOptions) ||
627
870
  // Parsing is incremental and time-sliced, so a long
628
871
  // document's tables arrive over several transactions that
629
872
  // change nothing else. Comparing the tree by identity is
@@ -661,7 +904,12 @@ const livePreviewPlugin = ViewPlugin.fromClass(
661
904
  transaction.effects.some((effect) =>
662
905
  effect.is(setLivePreviewRawMode),
663
906
  ),
664
- )
907
+ ) ||
908
+ // The same `Compartment` reconfigure the block pass watches
909
+ // for: a checkbox that just became operable is a widget
910
+ // that has to be rebuilt.
911
+ update.startState.facet(livePreviewOptions) !==
912
+ update.state.facet(livePreviewOptions)
665
913
  ) {
666
914
  this.decorations = toDecorations(update.view)
667
915
  }
@@ -872,6 +1120,19 @@ const livePreviewTheme = EditorView.theme({
872
1120
  cursor: "pointer",
873
1121
  textDecoration: "underline",
874
1122
  },
1123
+ /**
1124
+ * The read view's links are real anchors, so they are in the tab
1125
+ * order — and a focus ring is the whole reason that is worth
1126
+ * having. CodeMirror injects its own stylesheet and nothing here
1127
+ * is DOM Tailwind scans, so the ring is written from the same
1128
+ * custom properties the `focus-visible` utilities generate.
1129
+ */
1130
+ ".cm-md-link:focus-visible": {
1131
+ borderRadius: "var(--radius-sm)",
1132
+ outline:
1133
+ "var(--focus-ring-width) solid var(--color-focus-ring)",
1134
+ outlineOffset: "var(--focus-ring-offset)",
1135
+ },
875
1136
  /**
876
1137
  * The dimmed marker.
877
1138
  *
@@ -940,6 +1201,23 @@ const livePreviewTheme = EditorView.theme({
940
1201
  backgroundColor: "var(--color-surface-sunken)",
941
1202
  fontWeight: "var(--font-weight-semibold)",
942
1203
  },
1204
+ /**
1205
+ * A read view's table takes no click, so it stops advertising
1206
+ * one. `cursor: text` on a cell is a promise that clicking there
1207
+ * puts a caret in the markdown behind it, and there is no
1208
+ * markdown behind it to reach.
1209
+ */
1210
+ ".cm-md-table-static .cm-md-table-rendered td, .cm-md-table-static .cm-md-table-rendered th":
1211
+ {
1212
+ cursor: "auto",
1213
+ },
1214
+ /** A focusable box says so when it has focus. */
1215
+ ".cm-md-table-static:focus-visible": {
1216
+ borderRadius: "var(--radius-sm)",
1217
+ outline:
1218
+ "var(--focus-ring-width) solid var(--color-focus-ring)",
1219
+ outlineOffset: "var(--focus-ring-offset)",
1220
+ },
943
1221
  /**
944
1222
  * The scroll box, not the table.
945
1223
  *
@@ -981,7 +1259,10 @@ const rawModeClass = EditorView.editorAttributes.of(
981
1259
  : null,
982
1260
  )
983
1261
 
984
- export const livePreview = (): Extension => [
1262
+ export const livePreview = (
1263
+ options: LivePreviewOptions = {},
1264
+ ): Extension => [
1265
+ livePreviewOptions.of(options),
985
1266
  livePreviewRawModeField,
986
1267
  isEditorFocusedField,
987
1268
  focusTracker,
@@ -991,7 +1272,22 @@ export const livePreview = (): Extension => [
991
1272
  livePreviewTableField,
992
1273
  livePreviewPlugin,
993
1274
  livePreviewTheme,
994
- openLinkOnClick,
995
- tableStepKeymap,
1275
+ /**
1276
+ * The two extensions a **document** does not get, and both are
1277
+ * absences with a reason rather than an oversight.
1278
+ *
1279
+ * `openLinkOnClick` exists because the editor's links are painted
1280
+ * `<span>`s that a `mousedown` has to translate into a
1281
+ * navigation. A document's links are real `<a href>` elements, so
1282
+ * installing this as well would open every link twice — once from
1283
+ * the handler and once from the browser.
1284
+ *
1285
+ * `tableStepKeymap` steps the *caret* into a rendered table so its
1286
+ * markdown can be reached. A document has no caret and nothing
1287
+ * behind the table to reach.
1288
+ */
1289
+ options.hasDocumentSemantics
1290
+ ? []
1291
+ : [openLinkOnClick, tableStepKeymap],
996
1292
  rawModeClass,
997
1293
  ]
@@ -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
  *
@@ -928,11 +933,21 @@ const toWalker = ({
928
933
 
929
934
  const closeMark = openMark?.nextSibling
930
935
 
936
+ const safeUrl = urlNode
937
+ ? toSafeImageUrl(
938
+ text.slice(urlNode.from, urlNode.to),
939
+ )
940
+ : undefined
941
+
931
942
  // An image whose caret is inside it is markup again: you
932
- // cannot edit a URL you cannot see.
943
+ // cannot edit a URL you cannot see. An image whose URL did
944
+ // not survive `toSafeImageUrl` stays markup **always**,
945
+ // which is the honest answer for a document this surface
946
+ // did not write: the reader is shown the source rather than
947
+ // a broken frame or a silently dropped line.
933
948
  if (
934
949
  !isRawMode &&
935
- urlNode &&
950
+ safeUrl !== undefined &&
936
951
  openMark &&
937
952
  closeMark &&
938
953
  !isInside(selections, node.from, node.to)
@@ -942,12 +957,27 @@ const toWalker = ({
942
957
  from: node.from,
943
958
  to: node.to,
944
959
  type: "image",
945
- url: text.slice(urlNode.from, urlNode.to),
960
+ url: safeUrl,
946
961
  })
947
962
 
948
963
  return false
949
964
  }
950
965
 
966
+ /**
967
+ * An image whose URL was refused shows its **whole**
968
+ * construct, markers and all.
969
+ *
970
+ * Not descending is what does it: the `![`, `](` and `)`
971
+ * would otherwise reach the generic concealment rule below
972
+ * and vanish, leaving the alt text welded to a URL with no
973
+ * punctuation between them. This is the same result the
974
+ * refused-link path gets, by the same reasoning — a reader
975
+ * shown the source can see what the file tried to do.
976
+ */
977
+ if (safeUrl === undefined && !isRawMode) {
978
+ return false
979
+ }
980
+
951
981
  return true
952
982
  }
953
983
 
@@ -966,13 +996,35 @@ const toWalker = ({
966
996
 
967
997
  const closeMark = openMark?.nextSibling
968
998
 
969
- if (urlNode && openMark && closeMark) {
999
+ const safeUrl = urlNode
1000
+ ? toSafeLinkUrl(
1001
+ text.slice(urlNode.from, urlNode.to),
1002
+ )
1003
+ : undefined
1004
+
1005
+ /**
1006
+ * A link whose URL did not survive the guard is **not a
1007
+ * link at all**, and not a link-coloured span that quietly
1008
+ * does nothing either.
1009
+ *
1010
+ * Returning here skips the `linkText` mark *and* the two
1011
+ * `pushMarker` calls below, so `[click me](javascript:…)`
1012
+ * keeps its brackets, keeps its parentheses and reads as
1013
+ * exactly the characters the file contains. A reader who
1014
+ * can see the trap is better served than one shown a
1015
+ * confident blue word that silently refuses to work.
1016
+ */
1017
+ if (safeUrl === undefined) {
1018
+ return true
1019
+ }
1020
+
1021
+ if (openMark && closeMark) {
970
1022
  ranges.push({
971
1023
  from: openMark.to,
972
1024
  markKind: "linkText",
973
1025
  to: closeMark.from,
974
1026
  type: "mark",
975
- url: text.slice(urlNode.from, urlNode.to),
1027
+ url: safeUrl,
976
1028
  })
977
1029
  }
978
1030
 
@@ -998,16 +1050,21 @@ const toWalker = ({
998
1050
  // paste a link, get a link, with no `[](…)` typed at all.
999
1051
  const parentName = nodeRef.node.parent?.name
1000
1052
 
1053
+ const safeUrl = toSafeLinkUrl(
1054
+ text.slice(nodeRef.from, nodeRef.to),
1055
+ )
1056
+
1001
1057
  if (
1002
1058
  parentName !== "Link" &&
1003
- parentName !== "Image"
1059
+ parentName !== "Image" &&
1060
+ safeUrl !== undefined
1004
1061
  ) {
1005
1062
  ranges.push({
1006
1063
  from: nodeRef.from,
1007
1064
  markKind: "autolink",
1008
1065
  to: nodeRef.to,
1009
1066
  type: "mark",
1010
- url: text.slice(nodeRef.from, nodeRef.to),
1067
+ url: safeUrl,
1011
1068
  })
1012
1069
  } else {
1013
1070
  ranges.push({