@charcuterie/ui 3.13.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 (31) 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 +243 -26
  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 +49 -7
  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 +157 -0
  24. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +314 -0
  25. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +288 -0
  26. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  27. package/src/markdownEditorCodeMirror/index.ts +17 -2
  28. package/src/markdownEditorCodeMirror/livePreview.ts +329 -33
  29. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +64 -7
  30. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  31. package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
@@ -0,0 +1,373 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import {
3
+ markdown,
4
+ markdownLanguage,
5
+ } from "@codemirror/lang-markdown"
6
+ import {
7
+ Annotation,
8
+ Compartment,
9
+ EditorState,
10
+ } from "@codemirror/state"
11
+ import { EditorView } from "@codemirror/view"
12
+ import type { ReactNode } from "react"
13
+ import { useEffect, useRef } from "react"
14
+
15
+ import type { SlotProps } from "../slotProps.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+ import {
18
+ livePreview,
19
+ livePreviewOptions,
20
+ } from "./livePreview.ts"
21
+
22
+ export type MarkdownViewProps = SlotProps & {
23
+ className?: string
24
+ /**
25
+ * The accessible name of the region, as `aria-label`.
26
+ *
27
+ * Worth passing on anything that is not the only thing on the
28
+ * page. A read view is announced as an article, and "article" with
29
+ * no name tells a reader stepping through the page nothing about
30
+ * which article it is.
31
+ */
32
+ label?: string
33
+ /**
34
+ * Called when the reader ticks a `- [ ]` box, with the **whole**
35
+ * next markdown document — the same shape the editors' `onChange`
36
+ * hands back, so the same autosave wires to both.
37
+ *
38
+ * **Passing it is what makes the checkboxes operable at all.**
39
+ * Leave it out and they render disabled: the state is still on
40
+ * screen, and nothing pretends the reader can change it. That is
41
+ * deliberate rather than defensive. Two of the three surfaces this
42
+ * component was built for — a rendered comment, and a repo file
43
+ * fetched at a commit hash — have nowhere to put a tick, and a
44
+ * checkbox that visibly toggles and then loses the change on
45
+ * reload is worse than one that says up front it cannot.
46
+ *
47
+ * The view still applies the tick to its own document
48
+ * immediately, so the box responds under the pointer. If the
49
+ * consumer rejects the change, the next `value` it passes puts it
50
+ * back.
51
+ */
52
+ onToggleTask?: (nextValue: string) => void
53
+ /**
54
+ * The markdown to render, and **controlled** — unlike either
55
+ * editor.
56
+ *
57
+ * The editors take `defaultValue` because reassigning their
58
+ * document from outside would throw away the undo history that
59
+ * belonged to it, which is a real cost and is why they ask for a
60
+ * `key` instead. A view has no undo history and no caret to
61
+ * protect, so it has nothing to lose by simply rendering what it
62
+ * is given: fetch a file at a different commit hash, pass the new
63
+ * string, and the page changes. No remount, no `key`, no stale
64
+ * document above a new title.
65
+ */
66
+ value: string
67
+ }
68
+
69
+ /**
70
+ * Tells the update listener that a document change came from a new
71
+ * `value`, not from a reader.
72
+ *
73
+ * Without it, syncing the prop into the document fires
74
+ * `onToggleTask` with the string the consumer just passed in — a
75
+ * loop that looks like an autosave storm and is really an echo.
76
+ */
77
+ const isValueSync = Annotation.define<boolean>()
78
+
79
+ /**
80
+ * Rendered markdown, with no toolbar and nothing to type into.
81
+ *
82
+ * ## Why this is a component and not `isReadOnly` on the editors
83
+ *
84
+ * Both editors already have `isReadOnly`, and it means *an editor
85
+ * you cannot type in*: the nine formatting buttons are still in the
86
+ * DOM, still queryable, still in the accessibility tree, drawn in
87
+ * their disabled colours above a document nobody is editing. That is
88
+ * the right shape for a form field that is temporarily locked. It is
89
+ * the wrong shape for reading a file, and a `hasToolbar={false}`
90
+ * prop would not fix it — the type would still carry
91
+ * `onUploadImage`, `icons`, `placeholder` and `rawModeLabel`, none
92
+ * of which mean anything to a reader, and "no toolbar" would be a
93
+ * branch that could be got wrong rather than a thing that does not
94
+ * exist.
95
+ *
96
+ * So this is its own component, the way `Listbox`, `Combobox` and
97
+ * `Picker` are their own components rather than three modes of one.
98
+ * The props are the four a reader needs, and there is no code path
99
+ * in this file that could render a toolbar.
100
+ *
101
+ * ## Why it is in the CodeMirror subpath
102
+ *
103
+ * Because the requirement is that it renders **the same** as the
104
+ * editor a consumer edits the same document in — and the only way
105
+ * to guarantee that is to be the same code, not to be a second
106
+ * implementation that agrees today.
107
+ *
108
+ * A `react-markdown` reader in the main barrel was the obvious
109
+ * alternative and is the one that goes wrong slowly: two parsers,
110
+ * two table implementations, two answers to what a bare URL does,
111
+ * and a bug report six months later that says "the table changed
112
+ * when I saved it". This shares `livePreviewRanges.ts` — the 1,200
113
+ * lines that decide what a document *looks* like — with
114
+ * `MarkdownEditorCodeMirror`, and the theme with it too. There is no
115
+ * second renderer to keep in step.
116
+ *
117
+ * The cost is that it lives behind the same optional peers, so a
118
+ * consumer who wants only the reader still installs CodeMirror. That
119
+ * is the honest trade and it is the right way round: an app that
120
+ * renders markdown in three places and edits it in one should pay
121
+ * once.
122
+ *
123
+ * ## What differs from the editor, and why each one differs
124
+ *
125
+ * | | editor | this |
126
+ * | --- | --- | --- |
127
+ * | Toolbar | nine buttons | **none in the DOM** |
128
+ * | Value | `defaultValue`, uncontrolled | `value`, **controlled** |
129
+ * | Content role | `textbox` | **`article`** |
130
+ * | Headings | scaled text | scaled **and `role="heading"`** |
131
+ * | Links | painted span, opened by a handler | **real `<a href>`** |
132
+ * | Markers | revealed by the caret | never revealed |
133
+ * | Task boxes | always operable | **inert unless `onToggleTask`** |
134
+ *
135
+ * Every one of those follows from there being no caret. The editor
136
+ * paints a span instead of an anchor because an `<a href>` inside a
137
+ * `contenteditable` competes with the caret for the line; a document
138
+ * has no caret, so the link is simply a link — tabbable, activated
139
+ * by Enter, and copyable from the context menu. The editor reveals
140
+ * markup when the selection touches it because you cannot edit a URL
141
+ * you cannot see; there is nothing here to edit.
142
+ *
143
+ * ## Hostile markdown
144
+ *
145
+ * This renders files fetched from a git host, so the document is
146
+ * untrusted by default, and the answer is mostly structural rather
147
+ * than a sanitiser:
148
+ *
149
+ * - **Raw HTML in the source is never parsed as HTML.** There is no
150
+ * `innerHTML` in this subpath, no `dangerouslySetInnerHTML`, and
151
+ * no sanitiser to have a bug in. CodeMirror's state holds the
152
+ * source as *text*, and every rendered node is `createElement`
153
+ * plus `textContent`. A `<script>` in a fetched README is drawn
154
+ * as the characters `<script>`. `MarkdownView.test.tsx` asserts
155
+ * it against the tag most likely to be tried.
156
+ * - **A URL is checked before it is used as one**, because that was
157
+ * the one place the document reached the platform:
158
+ * `[click](javascript:…)` used to paint as a link and run. Both
159
+ * components now go through `safeUrls.ts`, which allowlists
160
+ * schemes the way a browser reads them.
161
+ * - **The reader is not a beacon.** Remote images carry
162
+ * `referrerpolicy="no-referrer"`, so a fetched image cannot learn
163
+ * which page loaded it.
164
+ *
165
+ * What is *not* claimed: this does not stop a document from linking
166
+ * somewhere unpleasant, and it does not fetch anything on the
167
+ * reader's behalf beyond the images the markdown names.
168
+ */
169
+ export const MarkdownView = ({
170
+ className,
171
+ label,
172
+ onToggleTask,
173
+ value,
174
+ ...receivedSlotProps
175
+ }: MarkdownViewProps): ReactNode => {
176
+ const baseId = useUniqueId()
177
+
178
+ const hostRef = useRef<HTMLDivElement>(null)
179
+
180
+ const viewRef = useRef<EditorView | null>(null)
181
+
182
+ const optionsRef = useRef(new Compartment())
183
+
184
+ /**
185
+ * The handler reaches the view through a ref, never a closure.
186
+ *
187
+ * The `EditorView` is built once — rebuilding it on a new callback
188
+ * identity would drop the scroll position mid-read — so its
189
+ * extensions capture *this* object and the render loop keeps the
190
+ * object current.
191
+ */
192
+ const handlerRef = useRef(onToggleTask)
193
+
194
+ handlerRef.current = onToggleTask
195
+
196
+ /**
197
+ * Frozen at mount, and named so rather than read from the render
198
+ * closure, because the build-once effect below is honestly
199
+ * dependency-free rather than lint-suppressed.
200
+ */
201
+ const mountRef = useRef({
202
+ ariaDescribedBy: receivedSlotProps["aria-describedby"],
203
+ id: receivedSlotProps.id ?? baseId,
204
+ label,
205
+ value,
206
+ })
207
+
208
+ useEffect(() => {
209
+ const host = hostRef.current
210
+
211
+ if (!host) {
212
+ return
213
+ }
214
+
215
+ const view = new EditorView({
216
+ parent: host,
217
+ state: EditorState.create({
218
+ doc: mountRef.current.value,
219
+ extensions: [
220
+ // GFM, not strict CommonMark — the same argument as the
221
+ // editor's: it is what autolinks a bare URL, renders a
222
+ // task list, and parses a table. A reader that disagreed
223
+ // with the editor about any of those would be the bug this
224
+ // component exists to avoid.
225
+ markdown({ base: markdownLanguage }),
226
+ EditorView.lineWrapping,
227
+ livePreview({ hasDocumentSemantics: true }),
228
+ /**
229
+ * The one answer that can change after mount, and only
230
+ * that answer.
231
+ *
232
+ * A `Compartment` holding the whole `livePreview()` would
233
+ * reconfigure the `StateField`s inside it — raw mode,
234
+ * focus, the table pass — which means destroying and
235
+ * recreating them to change a boolean about checkboxes.
236
+ * Compartmenting the facet alone reconfigures one value.
237
+ */
238
+ optionsRef.current.of(
239
+ livePreviewOptions.of({
240
+ isTaskListInteractive: Boolean(
241
+ handlerRef.current,
242
+ ),
243
+ }),
244
+ ),
245
+ /**
246
+ * `editable`, and deliberately **not**
247
+ * `EditorState.readOnly`.
248
+ *
249
+ * `editable` is the DOM half: it writes
250
+ * `contenteditable="false"`, which is what stops typing,
251
+ * pasting, dropping and IME, and what keeps the content
252
+ * out of the tab order. It is the whole of what this
253
+ * surface needs, because no keymap and no input handler is
254
+ * installed for `readOnly` to guard against.
255
+ *
256
+ * `readOnly` also makes CodeMirror write
257
+ * `aria-readonly="true"` onto the content element, and
258
+ * there is no way to unset an attribute it decides to
259
+ * write. On `role="article"` that attribute is one ARIA
260
+ * does not define, which axe reports and which is a fair
261
+ * report: "read-only" is a statement about a *field*, and
262
+ * this is not one. Nothing is lost by leaving it off, so it
263
+ * is left off rather than papered over.
264
+ */
265
+ EditorView.editable.of(false),
266
+ EditorView.contentAttributes.of({
267
+ /**
268
+ * `article`, not the `textbox` CodeMirror writes.
269
+ *
270
+ * This is the difference between reading a document and
271
+ * being trapped in a text field. A screen reader entering
272
+ * `role="textbox"` switches to forms mode and reads the
273
+ * contents as one flat string — no heading navigation, no
274
+ * table navigation, no list structure — which for an
275
+ * editor is exactly right and for a reader deletes every
276
+ * affordance the markup was rendered for.
277
+ */
278
+ role: "article",
279
+ /**
280
+ * CodeMirror writes `aria-multiline="true"` into the
281
+ * content element's attributes, and there is no way to
282
+ * unset one: its `updateAttrs` calls `setAttribute` with
283
+ * whatever it is handed, so `undefined` would arrive as
284
+ * the string "undefined".
285
+ *
286
+ * `false` is both the true answer — this is not a
287
+ * multiline textbox, because it is not a textbox — and
288
+ * the value axe explicitly ignores on an element carrying
289
+ * `contenteditable`. An attribute ARIA does not define
290
+ * for `article` is inert either way; this way the gate
291
+ * agrees.
292
+ */
293
+ "aria-multiline": "false",
294
+ ...(mountRef.current.label
295
+ ? { "aria-label": mountRef.current.label }
296
+ : {}),
297
+ ...(mountRef.current.ariaDescribedBy
298
+ ? {
299
+ "aria-describedby":
300
+ mountRef.current.ariaDescribedBy,
301
+ }
302
+ : {}),
303
+ id: mountRef.current.id,
304
+ }),
305
+ EditorView.updateListener.of((update) => {
306
+ if (
307
+ !update.docChanged ||
308
+ update.transactions.some((transaction) =>
309
+ transaction.annotation(isValueSync),
310
+ )
311
+ ) {
312
+ return
313
+ }
314
+
315
+ // The only change a reader can make is a checkbox, so
316
+ // this is that checkbox and nothing else.
317
+ handlerRef.current?.(
318
+ update.state.doc.toString(),
319
+ )
320
+ }),
321
+ ],
322
+ }),
323
+ })
324
+
325
+ viewRef.current = view
326
+
327
+ return () => {
328
+ view.destroy()
329
+
330
+ viewRef.current = null
331
+ }
332
+ }, [])
333
+
334
+ useEffect(() => {
335
+ const view = viewRef.current
336
+
337
+ if (!view || view.state.doc.toString() === value) {
338
+ return
339
+ }
340
+
341
+ view.dispatch({
342
+ annotations: isValueSync.of(true),
343
+ changes: {
344
+ from: 0,
345
+ insert: value,
346
+ to: view.state.doc.length,
347
+ },
348
+ })
349
+ }, [value])
350
+
351
+ // Through a compartment rather than a rebuild, so a consumer that
352
+ // turns ticking on and off — a task that becomes editable — keeps
353
+ // its scroll position.
354
+ useEffect(() => {
355
+ viewRef.current?.dispatch({
356
+ effects: optionsRef.current.reconfigure(
357
+ livePreviewOptions.of({
358
+ isTaskListInteractive: Boolean(onToggleTask),
359
+ }),
360
+ ),
361
+ })
362
+ }, [onToggleTask])
363
+
364
+ return (
365
+ <div
366
+ className={toClassName(
367
+ "text-content-primary text-md",
368
+ className,
369
+ )}
370
+ ref={hostRef}
371
+ />
372
+ )
373
+ }
@@ -1,6 +1,13 @@
1
1
  /**
2
- * `@charcuterie/ui/markdown-editor-codemirror` — the opt-in live
3
- * preview surface.
2
+ * `@charcuterie/ui/markdown-editor-codemirror` — the opt-in
3
+ * CodeMirror markdown surface: the live-preview **editor**, and the
4
+ * read-only **view** that renders the same document the same way.
5
+ *
6
+ * Both are here rather than in two subpaths because there is one
7
+ * dependency, not two. A consumer who wants only `MarkdownView`
8
+ * still installs CodeMirror; splitting the entry point would suggest
9
+ * otherwise and would give the shared `livePreview` module two ways
10
+ * into the bundle.
4
11
  *
5
12
  * Its own entry point, and every CodeMirror package it needs is an
6
13
  * **optional** peer, so a consumer that never imports this subpath
@@ -9,8 +16,10 @@
9
16
  * is worth having and is not worth charging everybody for.
10
17
  */
11
18
 
19
+ export type { LivePreviewOptions } from "./livePreview.ts"
12
20
  export {
13
21
  livePreview,
22
+ livePreviewOptions,
14
23
  livePreviewRawModeField,
15
24
  setLivePreviewRawMode,
16
25
  } from "./livePreview.ts"
@@ -24,3 +33,9 @@ export type {
24
33
  export { toLivePreviewRanges } from "./livePreviewRanges.ts"
25
34
  export type { MarkdownEditorCodeMirrorProps } from "./MarkdownEditorCodeMirror.tsx"
26
35
  export { MarkdownEditorCodeMirror } from "./MarkdownEditorCodeMirror.tsx"
36
+ export type { MarkdownViewProps } from "./MarkdownView.tsx"
37
+ export { MarkdownView } from "./MarkdownView.tsx"
38
+ export {
39
+ toSafeImageUrl,
40
+ toSafeLinkUrl,
41
+ } from "./safeUrls.ts"