@charcuterie/ui 3.4.1 → 3.5.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.
@@ -1,11 +1,11 @@
1
1
  import { markdownLanguage } from "@codemirror/lang-markdown"
2
2
  import { describe, expect, test } from "vitest"
3
3
 
4
- import type {
5
- LivePreviewRange,
6
- LivePreviewSelection,
4
+ import type { LivePreviewSelection } from "./livePreviewRanges.ts"
5
+ import {
6
+ toLivePreviewRanges,
7
+ toLivePreviewTableRanges,
7
8
  } from "./livePreviewRanges.ts"
8
- import { toLivePreviewRanges } from "./livePreviewRanges.ts"
9
9
 
10
10
  /**
11
11
  * The real GFM parser, not a stub.
@@ -27,8 +27,27 @@ const toRanges = (
27
27
  tree: markdownLanguage.parser.parse(text),
28
28
  })
29
29
 
30
- const toTextOf = (text: string, range: LivePreviewRange) =>
31
- text.slice(range.from, range.to)
30
+ /** Anything with document offsets — a range, or a table cell. */
31
+ /**
32
+ * The block pass. Separate from `toRanges` because CodeMirror
33
+ * requires the split — see `toLivePreviewTableRanges`.
34
+ */
35
+ const toTableRanges = (
36
+ text: string,
37
+ selections: readonly LivePreviewSelection[] = [],
38
+ isRawMode = false,
39
+ ) =>
40
+ toLivePreviewTableRanges({
41
+ isRawMode,
42
+ selections,
43
+ text,
44
+ tree: markdownLanguage.parser.parse(text),
45
+ })
46
+
47
+ const toTextOf = (
48
+ text: string,
49
+ range: { from: number; to: number },
50
+ ) => text.slice(range.from, range.to)
32
51
 
33
52
  /**
34
53
  * Narrows away the `undefined` a `find` returns, failing the test
@@ -79,6 +98,38 @@ describe("toLivePreviewRanges", () => {
79
98
  })
80
99
  })
81
100
 
101
+ /**
102
+ * Not table-specific — but `\\|` is the only way to put a pipe in
103
+ * a cell, so this is where its absence showed.
104
+ */
105
+ describe("escapes", () => {
106
+ test("conceals the backslash and keeps what it escaped", () => {
107
+ const text = "a \\* b"
108
+
109
+ const markers = toRanges(text).filter(
110
+ (range) => range.type === "marker",
111
+ )
112
+
113
+ expect(
114
+ markers.map((range) => toTextOf(text, range)),
115
+ ).toEqual(["\\"])
116
+
117
+ expect(markers[0]).toMatchObject({
118
+ isConcealed: true,
119
+ })
120
+ })
121
+
122
+ test("brings it back for a caret inside it", () => {
123
+ const text = "a \\* b"
124
+
125
+ expect(
126
+ toRanges(text, atCaret(3)).find(
127
+ (range) => range.type === "marker",
128
+ ),
129
+ ).toMatchObject({ isConcealed: false })
130
+ })
131
+ })
132
+
82
133
  describe("headings", () => {
83
134
  test("scales the line and conceals the hashes with their space", () => {
84
135
  const text = "### Heading three"
@@ -303,6 +354,291 @@ describe("toLivePreviewRanges", () => {
303
354
  })
304
355
  })
305
356
 
357
+ /**
358
+ * The one construct whose meaning is geometry.
359
+ *
360
+ * Everything else here is a decoration hung on text CodeMirror is
361
+ * already drawing. A column is not: column two's text is on four
362
+ * different lines, so the markdown stops being drawn and a widget
363
+ * draws instead — which is why these tests assert *content*
364
+ * (rows, cells, segments) where the rest of the file asserts
365
+ * offsets.
366
+ */
367
+ describe("tables", () => {
368
+ const TABLE = [
369
+ "| Port | **Goes to** | Notes |",
370
+ "| :--- | ---: | :---: |",
371
+ "| 1 | uplink | see [docs](https://e.com) |",
372
+ "| 2 | | `shelf` |",
373
+ ].join("\n")
374
+
375
+ const toTable = (
376
+ text: string,
377
+ selections: readonly LivePreviewSelection[] = [],
378
+ ) => toFound(toTableRanges(text, selections)[0])
379
+
380
+ test("replaces the whole block, exactly", () => {
381
+ const text = `Before\n\n${TABLE}\n\nAfter\n`
382
+
383
+ const table = toTable(text)
384
+
385
+ // Byte-for-byte the table and nothing either side of it: a
386
+ // block replacement has to land on whole lines, and one
387
+ // character out is a thrown decoration rather than a
388
+ // misdrawn one.
389
+ expect(toTextOf(text, table)).toBe(TABLE)
390
+ })
391
+
392
+ test("renders no line decoration for a table it replaces", () => {
393
+ expect(
394
+ toRanges(TABLE).filter(
395
+ (range) =>
396
+ range.type === "line" &&
397
+ range.lineKind === "table",
398
+ ),
399
+ ).toHaveLength(0)
400
+ })
401
+
402
+ test("reads the header row as a header and the rest as rows", () => {
403
+ expect(
404
+ toTable(TABLE).rows.map((row) => row.isHeader),
405
+ ).toEqual([true, false, false])
406
+ })
407
+
408
+ /**
409
+ * The delimiter row is the column spec, not content — it is the
410
+ * one row a rendered table does not show, and it is where the
411
+ * alignment comes from instead.
412
+ */
413
+ test("takes alignment from the delimiter row", () => {
414
+ expect(
415
+ toTable(TABLE).rows[0]?.cells.map(
416
+ (cell) => cell.alignment,
417
+ ),
418
+ ).toEqual(["start", "end", "center"])
419
+ })
420
+
421
+ test("renders cell markup rather than the markers", () => {
422
+ expect(
423
+ toTable(TABLE).rows[0]?.cells[1]?.segments,
424
+ ).toEqual([
425
+ {
426
+ markKinds: ["strong"],
427
+ text: "Goes to",
428
+ type: "text",
429
+ },
430
+ ])
431
+ })
432
+
433
+ test("keeps a link in a cell clickable and drops its syntax", () => {
434
+ expect(
435
+ toTable(TABLE).rows[1]?.cells[2]?.segments,
436
+ ).toEqual([
437
+ { markKinds: [], text: "see ", type: "text" },
438
+ {
439
+ markKinds: ["linkText"],
440
+ text: "docs",
441
+ type: "text",
442
+ url: "https://e.com",
443
+ },
444
+ ])
445
+ })
446
+
447
+ /**
448
+ * The parser emits no `TableCell` for `| |`, so a cell-node
449
+ * walk would render the row one column short and shift every
450
+ * cell after it left. The `|` positions are the truth.
451
+ */
452
+ test("keeps an empty cell's column", () => {
453
+ const cells = toFound(toTable(TABLE).rows[2]).cells
454
+
455
+ expect(cells).toHaveLength(3)
456
+
457
+ expect(cells[1]?.segments).toEqual([])
458
+
459
+ expect(cells[2]?.segments).toEqual([
460
+ {
461
+ markKinds: ["code"],
462
+ text: "shelf",
463
+ type: "text",
464
+ },
465
+ ])
466
+ })
467
+
468
+ test("puts a cell's offsets on the cell's own text", () => {
469
+ const text = `Before\n\n${TABLE}\n`
470
+
471
+ const cell = toFound(
472
+ toFound(toTable(text).rows[1]).cells[1],
473
+ )
474
+
475
+ // The click target for that cell, and the reason it can be
476
+ // one: these are real document offsets, not indexes into a
477
+ // rendered string.
478
+ expect(toTextOf(text, cell)).toBe("uplink")
479
+ })
480
+
481
+ /**
482
+ * GFM drops cells past the header's width. An editor that did
483
+ * the same would put text the author typed on screen nowhere,
484
+ * so the widest row wins and the short rows gain empty cells.
485
+ */
486
+ test("pads every row to the widest row", () => {
487
+ const text = [
488
+ "| a | b |",
489
+ "| --- | --- |",
490
+ "| 1 | 2 | 3 |",
491
+ "| only |",
492
+ ].join("\n")
493
+
494
+ const table = toTable(text)
495
+
496
+ expect(
497
+ table.rows.map((row) => row.cells.length),
498
+ ).toEqual([3, 3, 3])
499
+
500
+ // A padded cell collapses to the end of its own row, so
501
+ // clicking one puts the caret where the missing `| |` would
502
+ // be typed.
503
+ const row = toFound(table.rows[2])
504
+
505
+ expect(toFound(row.cells[2])).toMatchObject({
506
+ from: row.to,
507
+ segments: [],
508
+ to: row.to,
509
+ })
510
+ })
511
+
512
+ test("renders an image in a cell as an image", () => {
513
+ const text = [
514
+ "| icon |",
515
+ "| --- |",
516
+ "| ![up](https://e.com/a.png) |",
517
+ ].join("\n")
518
+
519
+ expect(
520
+ toTable(text).rows[1]?.cells[0]?.segments,
521
+ ).toEqual([
522
+ {
523
+ alt: "up",
524
+ type: "image",
525
+ url: "https://e.com/a.png",
526
+ },
527
+ ])
528
+ })
529
+
530
+ /**
531
+ * A row that ends with a pipe ends there. Counting what follows
532
+ * as a column gave every table trailing whitespace touched an
533
+ * empty extra column that nothing in the markdown asked for —
534
+ * and trailing whitespace is invisible, so it looked like the
535
+ * renderer inventing a column at random.
536
+ */
537
+ test("ignores whitespace after the last pipe", () => {
538
+ const text =
539
+ "| a | b | \n| --- | --- |\n| 1 | 2 | \n"
540
+
541
+ expect(
542
+ toTable(text).rows.map((row) => row.cells.length),
543
+ ).toEqual([2, 2])
544
+ })
545
+
546
+ /**
547
+ * `\\|` is the only way to put a pipe *in* a cell, so a rendered
548
+ * table is exactly where the backslash must not show.
549
+ */
550
+ test("renders an escaped pipe as a pipe", () => {
551
+ const text =
552
+ "| a | b |\n| --- | --- |\n| x \\| y | 2 |\n"
553
+
554
+ expect(
555
+ toTable(text).rows[1]?.cells[0]?.segments,
556
+ ).toEqual([
557
+ { markKinds: [], text: "x | y", type: "text" },
558
+ ])
559
+ })
560
+
561
+ test("renders a table nested in a blockquote or a list", () => {
562
+ for (const text of [
563
+ "> | a | b |\n> | --- | --- |\n> | 1 | 2 |\n",
564
+ "- item\n\n | a | b |\n | --- | --- |\n | 1 | 2 |\n",
565
+ ]) {
566
+ expect(
567
+ toTable(text).rows.map((row) =>
568
+ row.cells.map((cell) => toTextOf(text, cell)),
569
+ ),
570
+ ).toEqual([
571
+ ["a", "b"],
572
+ ["1", "2"],
573
+ ])
574
+ }
575
+ })
576
+
577
+ test("renders a table with no leading or trailing pipes", () => {
578
+ const text = "a | b\n--- | ---\n1 | 2"
579
+
580
+ expect(
581
+ toTable(text).rows.map((row) =>
582
+ row.cells.map((cell) => toTextOf(text, cell)),
583
+ ),
584
+ ).toEqual([
585
+ ["a", "b"],
586
+ ["1", "2"],
587
+ ])
588
+ })
589
+
590
+ /**
591
+ * Same rule as a link or an image: the caret is what turns the
592
+ * rendering back into markup, because you cannot edit a column
593
+ * you cannot see.
594
+ */
595
+ test("stands down for a caret inside it", () => {
596
+ expect(toTableRanges(TABLE, atCaret(3))).toHaveLength(
597
+ 0,
598
+ )
599
+
600
+ // …and the pipes come back, monospaced, in its place.
601
+ expect(
602
+ toRanges(TABLE, atCaret(3)).filter(
603
+ (range) =>
604
+ range.type === "line" &&
605
+ range.lineKind === "table",
606
+ ),
607
+ ).toHaveLength(4)
608
+ })
609
+
610
+ test("stands down in raw mode", () => {
611
+ expect(toTableRanges(TABLE, [], true)).toHaveLength(0)
612
+ })
613
+
614
+ /**
615
+ * The two passes have to agree: the plugin steps out of the way
616
+ * of exactly the tables the field draws, or the pipes paint
617
+ * underneath a rendered table.
618
+ */
619
+ test("is the only pass that decorates a rendered table", () => {
620
+ expect(
621
+ toRanges(TABLE).filter(
622
+ (range) => range.from < TABLE.length,
623
+ ),
624
+ ).toHaveLength(0)
625
+ })
626
+
627
+ test("describes a view, never an edit", () => {
628
+ const text = `${TABLE}\n`
629
+
630
+ for (const row of toTable(text).rows) {
631
+ for (const cell of row.cells) {
632
+ expect(cell.from).toBeGreaterThanOrEqual(0)
633
+
634
+ expect(cell.to).toBeLessThanOrEqual(text.length)
635
+
636
+ expect(cell.from).toBeLessThanOrEqual(cell.to)
637
+ }
638
+ }
639
+ })
640
+ })
641
+
306
642
  describe("raw mode", () => {
307
643
  const text =
308
644
  "# Heading\n\n**bold** and ![img](https://e.com/a.png) and [x](https://e.com)\n"
@@ -398,11 +734,16 @@ describe("toLivePreviewRanges", () => {
398
734
  ).toEqual(["`", "`"])
399
735
  })
400
736
 
401
- test("decorates every line of a table", () => {
737
+ /**
738
+ * The *editing* view of a table. A caret in it means the
739
+ * rendered widget stood down, and every line goes monospaced so
740
+ * the columns line up as typed.
741
+ */
742
+ test("decorates every line of a table the caret is in", () => {
402
743
  const text = "| a | b |\n| --- | --- |\n| 1 | 2 |"
403
744
 
404
745
  expect(
405
- toRanges(text).filter(
746
+ toRanges(text, atCaret(2)).filter(
406
747
  (range) =>
407
748
  range.type === "line" &&
408
749
  range.lineKind === "table",