@charcuterie/ui 3.3.0 → 3.4.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 (45) hide show
  1. package/dist/Field/Field.d.ts.map +1 -1
  2. package/dist/Field/Field.js +19 -1
  3. package/dist/Field/Field.js.map +1 -1
  4. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
  6. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  7. package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
  8. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
  9. package/dist/MarkdownEditor/markdownCommands.js +47 -0
  10. package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
  11. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  12. package/dist/MarkdownEditor/markdownSpans.js +30 -0
  13. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  14. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
  15. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
  16. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
  17. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
  18. package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
  19. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
  20. package/dist/markdownEditorCodeMirror/index.js +14 -0
  21. package/dist/markdownEditorCodeMirror/index.js.map +1 -0
  22. package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
  23. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
  24. package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
  25. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
  26. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
  28. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
  29. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
  30. package/package.json +36 -1
  31. package/src/Field/Field.test.tsx +35 -0
  32. package/src/Field/Field.tsx +18 -0
  33. package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
  34. package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
  35. package/src/MarkdownEditor/markdownCommands.ts +60 -0
  36. package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
  37. package/src/MarkdownEditor/markdownSpans.ts +35 -0
  38. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
  39. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
  40. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
  41. package/src/markdownEditorCodeMirror/index.ts +26 -0
  42. package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
  43. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
  44. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
  45. package/src/sourceRules.test.ts +20 -0
@@ -0,0 +1,431 @@
1
+ import { markdownLanguage } from "@codemirror/lang-markdown"
2
+ import { describe, expect, test } from "vitest"
3
+
4
+ import type {
5
+ LivePreviewRange,
6
+ LivePreviewSelection,
7
+ } from "./livePreviewRanges.ts"
8
+ import { toLivePreviewRanges } from "./livePreviewRanges.ts"
9
+
10
+ /**
11
+ * The real GFM parser, not a stub.
12
+ *
13
+ * These offsets are the ones the editor decorates at runtime, so a
14
+ * fixture parser would test the fixture. `markdownLanguage` is the
15
+ * GFM dialect — which is also what makes the bare-URL cases below
16
+ * meaningful, since strict CommonMark does not autolink.
17
+ */
18
+ const toRanges = (
19
+ text: string,
20
+ selections: readonly LivePreviewSelection[] = [],
21
+ isRawMode = false,
22
+ ) =>
23
+ toLivePreviewRanges({
24
+ isRawMode,
25
+ selections,
26
+ text,
27
+ tree: markdownLanguage.parser.parse(text),
28
+ })
29
+
30
+ const toTextOf = (text: string, range: LivePreviewRange) =>
31
+ text.slice(range.from, range.to)
32
+
33
+ /**
34
+ * Narrows away the `undefined` a `find` returns, failing the test
35
+ * with a useful message rather than a non-null assertion that would
36
+ * throw `Cannot read properties of undefined` three lines later.
37
+ */
38
+ const toFound = <FoundRange>(
39
+ range: FoundRange | undefined,
40
+ ): FoundRange => {
41
+ expect(range).toBeDefined()
42
+
43
+ return range as FoundRange
44
+ }
45
+
46
+ /** Selection collapsed at an offset, as a caret is. */
47
+ const atCaret = (offset: number) => [
48
+ { from: offset, to: offset },
49
+ ]
50
+
51
+ describe("toLivePreviewRanges", () => {
52
+ describe("the concealment invariant", () => {
53
+ test("never reports a range outside the document", () => {
54
+ const text =
55
+ "# Heading\n\n**bold** [link](https://example.com)\n\n- [ ] task\n"
56
+
57
+ for (const range of toRanges(text)) {
58
+ expect(range.from).toBeGreaterThanOrEqual(0)
59
+
60
+ expect(range.to).toBeLessThanOrEqual(text.length)
61
+
62
+ expect(range.from).toBeLessThanOrEqual(range.to)
63
+ }
64
+ })
65
+
66
+ test("conceals markers rather than describing an edit", () => {
67
+ const text = "**bold**"
68
+
69
+ const concealed = toRanges(text).filter(
70
+ (range) =>
71
+ range.type === "marker" && range.isConcealed,
72
+ )
73
+
74
+ // Both runs of asterisks, and nothing else. The document is
75
+ // untouched — these are view ranges over the original string.
76
+ expect(
77
+ concealed.map((range) => toTextOf(text, range)),
78
+ ).toEqual(["**", "**"])
79
+ })
80
+ })
81
+
82
+ describe("headings", () => {
83
+ test("scales the line and conceals the hashes with their space", () => {
84
+ const text = "### Heading three"
85
+
86
+ const ranges = toRanges(text)
87
+
88
+ expect(
89
+ ranges.find((range) => range.type === "line"),
90
+ ).toMatchObject({
91
+ from: 0,
92
+ lineKind: "heading3",
93
+ to: 0,
94
+ })
95
+
96
+ const marker = ranges.find(
97
+ (range) => range.type === "marker",
98
+ )
99
+
100
+ // "### " — the trailing space goes with the hashes, or the
101
+ // heading text sits one column in from the margin.
102
+ expect(toTextOf(text, toFound(marker))).toBe("### ")
103
+ })
104
+
105
+ test.each([
106
+ ["# one", "heading1"],
107
+ ["## two", "heading2"],
108
+ ["#### four", "heading4"],
109
+ ["##### five", "heading5"],
110
+ ["###### six", "heading6"],
111
+ ])("maps %s to %s", (text, lineKind) => {
112
+ expect(
113
+ toRanges(text).find(
114
+ (range) => range.type === "line",
115
+ ),
116
+ ).toMatchObject({ lineKind })
117
+ })
118
+ })
119
+
120
+ describe("the caret reveals what it is inside", () => {
121
+ test("keeps markers concealed while the caret is elsewhere", () => {
122
+ const text = "a **bold** word"
123
+
124
+ const marker = toRanges(text, atCaret(0)).find(
125
+ (range) => range.type === "marker",
126
+ )
127
+
128
+ expect(marker).toMatchObject({ isConcealed: true })
129
+ })
130
+
131
+ test("reveals markers once the caret is inside the construct", () => {
132
+ const text = "a **bold** word"
133
+
134
+ // Offset 5 is between the asterisks, inside "bold".
135
+ const markers = toRanges(text, atCaret(5)).filter(
136
+ (range) => range.type === "marker",
137
+ )
138
+
139
+ expect(markers).not.toHaveLength(0)
140
+
141
+ for (const marker of markers) {
142
+ expect(marker).toMatchObject({ isConcealed: false })
143
+ }
144
+ })
145
+
146
+ test("reveals a construct a selection merely touches", () => {
147
+ const text = "a **bold** word"
148
+
149
+ const markers = toRanges(text, [
150
+ { from: 0, to: 4 },
151
+ ]).filter((range) => range.type === "marker")
152
+
153
+ for (const marker of markers) {
154
+ expect(marker).toMatchObject({ isConcealed: false })
155
+ }
156
+ })
157
+ })
158
+
159
+ describe("links", () => {
160
+ test("marks the link text and carries its URL", () => {
161
+ const text = "see [the docs](https://example.com) now"
162
+
163
+ const linkText = toRanges(text).find(
164
+ (range) =>
165
+ range.type === "mark" &&
166
+ range.markKind === "linkText",
167
+ )
168
+
169
+ expect(toTextOf(text, toFound(linkText))).toBe(
170
+ "the docs",
171
+ )
172
+
173
+ expect(linkText).toMatchObject({
174
+ url: "https://example.com",
175
+ })
176
+ })
177
+
178
+ test("conceals the brackets and the URL together", () => {
179
+ const text = "see [the docs](https://example.com) now"
180
+
181
+ const concealed = toRanges(text)
182
+ .filter(
183
+ (range) =>
184
+ range.type === "marker" && range.isConcealed,
185
+ )
186
+ .map((range) => toTextOf(text, range))
187
+
188
+ expect(concealed).toEqual([
189
+ "[",
190
+ "](https://example.com)",
191
+ ])
192
+ })
193
+
194
+ /**
195
+ * Gap 2 of Docket's handoff, and the reason this work started:
196
+ * a URL pasted as plain text was not a link and could not
197
+ * become one without typing `[](…)` around it.
198
+ */
199
+ test("autolinks a bare URL that was never marked up", () => {
200
+ const text =
201
+ "ordered from https://www.monoprice.com/product?id=1234 last week"
202
+
203
+ const autolink = toRanges(text).find(
204
+ (range) =>
205
+ range.type === "mark" &&
206
+ range.markKind === "autolink",
207
+ )
208
+
209
+ expect(autolink).toMatchObject({
210
+ url: "https://www.monoprice.com/product?id=1234",
211
+ })
212
+
213
+ expect(toTextOf(text, toFound(autolink))).toBe(
214
+ "https://www.monoprice.com/product?id=1234",
215
+ )
216
+ })
217
+
218
+ test("does not autolink the URL inside an explicit link", () => {
219
+ const text = "[docs](https://example.com)"
220
+
221
+ expect(
222
+ toRanges(text).filter(
223
+ (range) =>
224
+ range.type === "mark" &&
225
+ range.markKind === "autolink",
226
+ ),
227
+ ).toHaveLength(0)
228
+ })
229
+ })
230
+
231
+ describe("images", () => {
232
+ test("replaces the markup with the image it describes", () => {
233
+ const text =
234
+ "![a screenshot](https://example.com/a.png)"
235
+
236
+ const image = toRanges(text).find(
237
+ (range) => range.type === "image",
238
+ )
239
+
240
+ expect(image).toMatchObject({
241
+ alt: "a screenshot",
242
+ from: 0,
243
+ to: text.length,
244
+ url: "https://example.com/a.png",
245
+ })
246
+ })
247
+
248
+ test("gives the markup back when the caret enters it", () => {
249
+ const text =
250
+ "![a screenshot](https://example.com/a.png)"
251
+
252
+ expect(
253
+ toRanges(text, atCaret(4)).filter(
254
+ (range) => range.type === "image",
255
+ ),
256
+ ).toHaveLength(0)
257
+ })
258
+ })
259
+
260
+ describe("task lists", () => {
261
+ test.each([
262
+ ["- [ ] open", false],
263
+ ["- [x] done", true],
264
+ ["- [X] done", true],
265
+ ])("reads %s as isChecked=%s", (text, isChecked) => {
266
+ expect(
267
+ toRanges(text).find(
268
+ (range) => range.type === "task",
269
+ ),
270
+ ).toMatchObject({ isChecked })
271
+ })
272
+
273
+ /**
274
+ * A checkbox inside a `contenteditable` has no `<label>` to
275
+ * inherit a name from, so it announces as an unlabelled
276
+ * checkbox — useless in exactly the place a task list is read.
277
+ */
278
+ test("carries the item's text as the checkbox's name", () => {
279
+ expect(
280
+ toRanges(
281
+ "- [ ] Photograph the current cabling",
282
+ ).find((range) => range.type === "task"),
283
+ ).toMatchObject({
284
+ label: "Photograph the current cabling",
285
+ })
286
+ })
287
+
288
+ test("keeps the bullet visible and never conceals it", () => {
289
+ const text = "- [ ] open"
290
+
291
+ const markers = toRanges(text).filter(
292
+ (range) => range.type === "marker",
293
+ )
294
+
295
+ // The `-` is content to a reader, not syntax noise.
296
+ expect(markers).toContainEqual(
297
+ expect.objectContaining({
298
+ isConcealed: false,
299
+ from: 0,
300
+ to: 1,
301
+ }),
302
+ )
303
+ })
304
+ })
305
+
306
+ describe("raw mode", () => {
307
+ const text =
308
+ "# Heading\n\n**bold** and ![img](https://e.com/a.png) and [x](https://e.com)\n"
309
+
310
+ test("conceals nothing", () => {
311
+ expect(
312
+ toRanges(text, [], true).filter(
313
+ (range) =>
314
+ range.type === "marker" && range.isConcealed,
315
+ ),
316
+ ).toHaveLength(0)
317
+ })
318
+
319
+ test("replaces nothing with a widget", () => {
320
+ const ranges = toRanges(text, [], true)
321
+
322
+ expect(
323
+ ranges.filter(
324
+ (range) =>
325
+ range.type === "image" || range.type === "task",
326
+ ),
327
+ ).toHaveLength(0)
328
+ })
329
+
330
+ test("rescales no line", () => {
331
+ expect(
332
+ toRanges(text, [], true).filter(
333
+ (range) => range.type === "line",
334
+ ),
335
+ ).toHaveLength(0)
336
+ })
337
+
338
+ test("still colours the syntax", () => {
339
+ // The toggle shows the markup; it does not turn the editor
340
+ // into a plain textarea.
341
+ expect(
342
+ toRanges(text, [], true).filter(
343
+ (range) => range.type === "mark",
344
+ ),
345
+ ).not.toHaveLength(0)
346
+ })
347
+ })
348
+
349
+ describe("blocks", () => {
350
+ test("decorates every line of a fenced code block", () => {
351
+ const text = "```js\nconst a = 1\nconst b = 2\n```"
352
+
353
+ const lines = toRanges(text).filter(
354
+ (range) => range.type === "line",
355
+ )
356
+
357
+ expect(lines).toHaveLength(4)
358
+
359
+ for (const line of lines) {
360
+ expect(line).toMatchObject({ lineKind: "code" })
361
+
362
+ // Zero-length, anchored at a line start.
363
+ expect(line.from).toBe(line.to)
364
+ }
365
+ })
366
+
367
+ /**
368
+ * Concealing a fence leaves a blank line where it used to be, at
369
+ * the top and bottom of the block — which reads as a rendering
370
+ * bug rather than as tidiness.
371
+ */
372
+ test("keeps the fence and its language visible", () => {
373
+ const text = "```sh\nip -br addr show\n```"
374
+
375
+ const markers = toRanges(text).filter(
376
+ (range) => range.type === "marker",
377
+ )
378
+
379
+ expect(
380
+ markers.map((range) => toTextOf(text, range)),
381
+ ).toEqual(["```", "sh", "```"])
382
+
383
+ for (const marker of markers) {
384
+ expect(marker).toMatchObject({ isConcealed: false })
385
+ }
386
+ })
387
+
388
+ test("still conceals inline backticks", () => {
389
+ const text = "run `ls` now"
390
+
391
+ expect(
392
+ toRanges(text)
393
+ .filter(
394
+ (range) =>
395
+ range.type === "marker" && range.isConcealed,
396
+ )
397
+ .map((range) => toTextOf(text, range)),
398
+ ).toEqual(["`", "`"])
399
+ })
400
+
401
+ test("decorates every line of a table", () => {
402
+ const text = "| a | b |\n| --- | --- |\n| 1 | 2 |"
403
+
404
+ expect(
405
+ toRanges(text).filter(
406
+ (range) =>
407
+ range.type === "line" &&
408
+ range.lineKind === "table",
409
+ ),
410
+ ).toHaveLength(3)
411
+ })
412
+
413
+ /**
414
+ * The bar says "quote", so the `>` does not have to. Contrast
415
+ * with the bullet test above, where nothing else would.
416
+ */
417
+ test("decorates a blockquote and conceals its marker", () => {
418
+ const text = "> quoted"
419
+
420
+ const ranges = toRanges(text)
421
+
422
+ expect(
423
+ ranges.find((range) => range.type === "line"),
424
+ ).toMatchObject({ lineKind: "blockquote" })
425
+
426
+ expect(
427
+ ranges.find((range) => range.type === "marker"),
428
+ ).toMatchObject({ isConcealed: true })
429
+ })
430
+ })
431
+ })