@charcuterie/ui 3.19.2 → 3.21.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 (48) hide show
  1. package/dist/Board/BoardCard.d.ts +95 -22
  2. package/dist/Board/BoardCard.d.ts.map +1 -1
  3. package/dist/Board/BoardCard.js +18 -2
  4. package/dist/Board/BoardCard.js.map +1 -1
  5. package/dist/MarkdownEditor/markdownSpans.d.ts +17 -4
  6. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  7. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  8. package/dist/MarkdownLine/MarkdownLine.d.ts +104 -0
  9. package/dist/MarkdownLine/MarkdownLine.d.ts.map +1 -0
  10. package/dist/MarkdownLine/MarkdownLine.js +187 -0
  11. package/dist/MarkdownLine/MarkdownLine.js.map +1 -0
  12. package/dist/MarkdownLine/inlineMarkdown.d.ts +82 -0
  13. package/dist/MarkdownLine/inlineMarkdown.d.ts.map +1 -0
  14. package/dist/MarkdownLine/inlineMarkdown.js +509 -0
  15. package/dist/MarkdownLine/inlineMarkdown.js.map +1 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/markdownEditorCodeMirror/index.d.ts +1 -1
  21. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  22. package/dist/markdownEditorCodeMirror/index.js +1 -1
  23. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  24. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +1 -1
  25. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  26. package/dist/safeUrls.d.ts.map +1 -0
  27. package/dist/safeUrls.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/Board/Board.stories.tsx +167 -0
  30. package/src/Board/Board.test.tsx +97 -0
  31. package/src/Board/BoardCard.tsx +135 -27
  32. package/src/MarkdownEditor/markdownSpans.ts +18 -5
  33. package/src/MarkdownLine/MarkdownLine.mdx +135 -0
  34. package/src/MarkdownLine/MarkdownLine.stories.tsx +290 -0
  35. package/src/MarkdownLine/MarkdownLine.test.tsx +241 -0
  36. package/src/MarkdownLine/MarkdownLine.tsx +331 -0
  37. package/src/MarkdownLine/inlineMarkdown.test.ts +253 -0
  38. package/src/MarkdownLine/inlineMarkdown.ts +768 -0
  39. package/src/index.ts +9 -2
  40. package/src/markdownEditorCodeMirror/index.ts +4 -4
  41. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +1 -1
  42. package/src/sourceRules.test.ts +17 -1
  43. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +0 -1
  44. package/dist/markdownEditorCodeMirror/safeUrls.js.map +0 -1
  45. /package/dist/{markdownEditorCodeMirror/safeUrls.d.ts → safeUrls.d.ts} +0 -0
  46. /package/dist/{markdownEditorCodeMirror/safeUrls.js → safeUrls.js} +0 -0
  47. /package/src/{markdownEditorCodeMirror/safeUrls.test.ts → safeUrls.test.ts} +0 -0
  48. /package/src/{markdownEditorCodeMirror/safeUrls.ts → safeUrls.ts} +0 -0
@@ -0,0 +1,768 @@
1
+ /**
2
+ * Markdown's **inline** half, parsed into flat runs of marked text.
3
+ *
4
+ * ### Why this is not the CodeMirror parser
5
+ *
6
+ * `@charcuterie/ui/markdown-editor-codemirror` already renders
7
+ * markdown, through `@lezer/markdown`. It is the right answer for a
8
+ * document and the wrong one for a **name**: an editor instance per
9
+ * card is a CodeMirror state, a view, a syntax tree and a decoration
10
+ * set for a string that is usually under sixty characters, and a
11
+ * backlog grid draws dozens of them at once. The whole CodeMirror
12
+ * stack is also an *optional peer* of this package on purpose — the
13
+ * main entry may not reach it, so a title renderer that lives in the
14
+ * main entry cannot borrow it even if the cost were acceptable.
15
+ *
16
+ * So this is a second parser, which is a thing to be nervous about:
17
+ * two parsers are two answers to "what does this markdown mean", and
18
+ * that divergence is exactly the bug the consumer's
19
+ * renderer-agreement test exists to catch. Three things keep it
20
+ * honest:
21
+ *
22
+ * 1. **It is inline-only, and says so.** A `#`, a `>`, a `|` table
23
+ * row and a `- ` list marker are literal characters here. There
24
+ * is no block level to disagree about, because there is no block
25
+ * level.
26
+ * 2. **The URL guard is shared, not copied.** Both surfaces call
27
+ * `toSafeLinkUrl` from `../safeUrls.ts` — one module, one
28
+ * allowlist. It moved up out of the CodeMirror subpath when this
29
+ * landed, for that reason.
30
+ * 3. **The flanking rules are CommonMark's**, not a convenient
31
+ * approximation. That is what makes `ingest_the_files.sh` a file
32
+ * name rather than a title with an italic word in the middle of
33
+ * it, and file names in titles are the case this was built for.
34
+ *
35
+ * ### The shape it produces
36
+ *
37
+ * A **flat** list of runs, not a tree. Marks compose by set union, so
38
+ * `**bold [link](/x) tail**` is three runs that all carry `isStrong`
39
+ * and of which one carries an `href` — which is precisely the shape
40
+ * `MarkdownLine` needs to render a link *beside* the surrounding
41
+ * anchor rather than inside it. A tree would have to be flattened
42
+ * back into this before it could be drawn.
43
+ */
44
+
45
+ import { toSafeLinkUrl } from "../safeUrls.ts"
46
+
47
+ /**
48
+ * One run of text, with every mark that covers it.
49
+ *
50
+ * `href` is present only on a run that came from `[text](url)` or
51
+ * from a bare URL — never inherited from the caller's own link. Who
52
+ * the *plain* text points at is `MarkdownLine`'s question, not the
53
+ * parser's.
54
+ */
55
+ export type InlineMarkdownRun = {
56
+ href?: string
57
+ isCode: boolean
58
+ isEmphasis: boolean
59
+ isStrikethrough: boolean
60
+ isStrong: boolean
61
+ text: string
62
+ }
63
+
64
+ type MarkState = {
65
+ isCode: boolean
66
+ isEmphasis: boolean
67
+ isStrikethrough: boolean
68
+ isStrong: boolean
69
+ }
70
+
71
+ const NO_MARKS: MarkState = {
72
+ isCode: false,
73
+ isEmphasis: false,
74
+ isStrikethrough: false,
75
+ isStrong: false,
76
+ }
77
+
78
+ /**
79
+ * ASCII punctuation, per CommonMark — the set a backslash may
80
+ * escape, and the set the flanking rules ask about. Spelled as
81
+ * explicit ranges rather than `\p{P}`: CommonMark's list is ASCII
82
+ * and Unicode's is very much larger, and the difference decides
83
+ * whether a typographic quote can close an emphasis run.
84
+ */
85
+ const PUNCTUATION_PATTERN = /[!-/:-@[-`{-~]/
86
+
87
+ const WHITESPACE_PATTERN = /\s/
88
+
89
+ const getIsPunctuation = (char: string | undefined) =>
90
+ char !== undefined && PUNCTUATION_PATTERN.test(char)
91
+
92
+ const getIsWhitespace = (char: string | undefined) =>
93
+ char !== undefined && WHITESPACE_PATTERN.test(char)
94
+
95
+ /**
96
+ * A delimiter run that could **open** emphasis: it is not followed
97
+ * by whitespace, and if it is followed by punctuation then it is
98
+ * preceded by whitespace or punctuation.
99
+ *
100
+ * This is CommonMark's "left-flanking delimiter run", and the reason
101
+ * `2 * 3 * 4` is arithmetic rather than an italic `3`.
102
+ */
103
+ const getIsLeftFlanking = (
104
+ source: string,
105
+ start: number,
106
+ end: number,
107
+ ): boolean => {
108
+ const before = source[start - 1]
109
+ const after = source[end]
110
+
111
+ if (after === undefined || getIsWhitespace(after)) {
112
+ return false
113
+ }
114
+
115
+ return (
116
+ !getIsPunctuation(after) ||
117
+ before === undefined ||
118
+ getIsWhitespace(before) ||
119
+ getIsPunctuation(before)
120
+ )
121
+ }
122
+
123
+ /** The mirror of the rule above, for a run that could **close**. */
124
+ const getIsRightFlanking = (
125
+ source: string,
126
+ start: number,
127
+ end: number,
128
+ ): boolean => {
129
+ const before = source[start - 1]
130
+ const after = source[end]
131
+
132
+ if (before === undefined || getIsWhitespace(before)) {
133
+ return false
134
+ }
135
+
136
+ return (
137
+ !getIsPunctuation(before) ||
138
+ after === undefined ||
139
+ getIsWhitespace(after) ||
140
+ getIsPunctuation(after)
141
+ )
142
+ }
143
+
144
+ /**
145
+ * `_` is stricter than `*` at both ends, and this is the whole
146
+ * reason a snake_case file name survives.
147
+ *
148
+ * CommonMark forbids an underscore run that is flanked on *both*
149
+ * sides from opening or closing, unless the outer side is
150
+ * punctuation. Inside `ingest_the_files`, the first `_` has a letter
151
+ * on each side — left-flanking and right-flanking at once — so it
152
+ * opens nothing, and the title reads as the file name it is. An `*`
153
+ * in the same position would open, which is exactly why the two
154
+ * characters are not interchangeable and why a parser that treats
155
+ * them as one has this bug.
156
+ */
157
+ const getCanOpen = (
158
+ source: string,
159
+ start: number,
160
+ end: number,
161
+ marker: string,
162
+ ): boolean => {
163
+ const isLeftFlanking = getIsLeftFlanking(
164
+ source,
165
+ start,
166
+ end,
167
+ )
168
+
169
+ if (marker !== "_") {
170
+ return isLeftFlanking
171
+ }
172
+
173
+ return (
174
+ isLeftFlanking &&
175
+ (!getIsRightFlanking(source, start, end) ||
176
+ getIsPunctuation(source[start - 1]))
177
+ )
178
+ }
179
+
180
+ const getCanClose = (
181
+ source: string,
182
+ start: number,
183
+ end: number,
184
+ marker: string,
185
+ ): boolean => {
186
+ const isRightFlanking = getIsRightFlanking(
187
+ source,
188
+ start,
189
+ end,
190
+ )
191
+
192
+ if (marker !== "_") {
193
+ return isRightFlanking
194
+ }
195
+
196
+ return (
197
+ isRightFlanking &&
198
+ (!getIsLeftFlanking(source, start, end) ||
199
+ getIsPunctuation(source[end]))
200
+ )
201
+ }
202
+
203
+ /**
204
+ * A code span's content, trimmed CommonMark's way: line endings
205
+ * become spaces, and **one** space comes off each end when there is
206
+ * one at both ends and the content is not all spaces.
207
+ *
208
+ * That last rule is what lets `` ` `` be written as `` `` ` `` ``.
209
+ */
210
+ const toCodeText = (raw: string): string => {
211
+ const flattened = raw.replace(/\r\n|\r|\n/g, " ")
212
+
213
+ if (
214
+ flattened.length > 2 &&
215
+ flattened.startsWith(" ") &&
216
+ flattened.endsWith(" ") &&
217
+ flattened.trim() !== ""
218
+ ) {
219
+ return flattened.slice(1, -1)
220
+ }
221
+
222
+ return flattened
223
+ }
224
+
225
+ /**
226
+ * How far a backtick fence's twin is, or `-1`.
227
+ *
228
+ * The closing fence must be the **same length**: a run of three
229
+ * backticks does not close a run of two, which is what makes
230
+ * `` `` a`b `` `` hold a literal backtick.
231
+ */
232
+ const findCodeSpanEnd = (
233
+ source: string,
234
+ from: number,
235
+ fenceLength: number,
236
+ ): number => {
237
+ let index = from
238
+
239
+ while (index < source.length) {
240
+ if (source[index] !== "`") {
241
+ index += 1
242
+ continue
243
+ }
244
+
245
+ let end = index
246
+
247
+ while (source[end] === "`") {
248
+ end += 1
249
+ }
250
+
251
+ if (end - index === fenceLength) {
252
+ return index
253
+ }
254
+
255
+ index = end
256
+ }
257
+
258
+ return -1
259
+ }
260
+
261
+ /**
262
+ * `[text](url)` starting at `index`, or `undefined`.
263
+ *
264
+ * Bracket depth is counted so `[see [1]](/x)` reads whole, and a
265
+ * code span inside the text is stepped over so a `]` in backticks
266
+ * does not end it early. The destination allows one level of nesting
267
+ * — `(/a(b)c)` — and CommonMark's `<…>` form, which `toSafeLinkUrl`
268
+ * unwraps.
269
+ */
270
+ const readLink = (
271
+ source: string,
272
+ index: number,
273
+ ):
274
+ | { end: number; text: string; url: string }
275
+ | undefined => {
276
+ let depth = 1
277
+ let cursor = index + 1
278
+
279
+ while (cursor < source.length && depth > 0) {
280
+ const char = source[cursor]
281
+
282
+ if (char === "\\") {
283
+ cursor += 2
284
+ continue
285
+ }
286
+
287
+ if (char === "`") {
288
+ let fenceEnd = cursor
289
+
290
+ while (source[fenceEnd] === "`") {
291
+ fenceEnd += 1
292
+ }
293
+
294
+ const close = findCodeSpanEnd(
295
+ source,
296
+ fenceEnd,
297
+ fenceEnd - cursor,
298
+ )
299
+
300
+ cursor =
301
+ close === -1
302
+ ? fenceEnd
303
+ : close + (fenceEnd - cursor)
304
+
305
+ continue
306
+ }
307
+
308
+ if (char === "[") {
309
+ depth += 1
310
+ }
311
+
312
+ if (char === "]") {
313
+ depth -= 1
314
+
315
+ if (depth === 0) {
316
+ break
317
+ }
318
+ }
319
+
320
+ cursor += 1
321
+ }
322
+
323
+ if (depth !== 0 || source[cursor + 1] !== "(") {
324
+ return undefined
325
+ }
326
+
327
+ const text = source.slice(index + 1, cursor)
328
+
329
+ let destinationDepth = 1
330
+ let destinationCursor = cursor + 2
331
+
332
+ while (
333
+ destinationCursor < source.length &&
334
+ destinationDepth > 0
335
+ ) {
336
+ const char = source[destinationCursor]
337
+
338
+ if (char === "\\") {
339
+ destinationCursor += 2
340
+ continue
341
+ }
342
+
343
+ if (char === "(") {
344
+ destinationDepth += 1
345
+ }
346
+
347
+ if (char === ")") {
348
+ destinationDepth -= 1
349
+
350
+ if (destinationDepth === 0) {
351
+ break
352
+ }
353
+ }
354
+
355
+ destinationCursor += 1
356
+ }
357
+
358
+ if (destinationDepth !== 0) {
359
+ return undefined
360
+ }
361
+
362
+ return {
363
+ end: destinationCursor + 1,
364
+ text,
365
+ /**
366
+ * A destination may carry a title — `[x](/a "Tooltip")` — which
367
+ * is not part of the URL. Split on the first unescaped space
368
+ * unless the whole thing is the `<…>` form, where spaces are
369
+ * allowed and the brackets are the boundary.
370
+ */
371
+ url: source
372
+ .slice(cursor + 2, destinationCursor)
373
+ .trim()
374
+ .replace(/^(<[^>]*>|\S+)\s[\s\S]*$/, "$1"),
375
+ }
376
+ }
377
+
378
+ /**
379
+ * A bare URL, GFM-style, or `undefined`.
380
+ *
381
+ * The trailing-punctuation walk-back is GFM's and it is not
382
+ * decoration: a URL at the end of a sentence is written
383
+ * `see https://example.invalid/x.` and the full stop is the
384
+ * sentence's, not the URL's. A closing parenthesis is kept only when
385
+ * the URL opened one, so
386
+ * `https://example.invalid/Rack_(shelf)` survives whole while
387
+ * `(see https://example.invalid/x)` does not eat the bracket.
388
+ *
389
+ * `www.` is here because GFM autolinks it and the CodeMirror surface
390
+ * therefore does too — a title that renders one link in the editor
391
+ * and none on the card is the divergence this file promised not to
392
+ * have. A bare **email** address is deliberately not autolinked: GFM
393
+ * does it, and this fleet's rule is that no personal address goes
394
+ * anywhere near a publishable repo, so the narrowing is a decision
395
+ * rather than an oversight.
396
+ */
397
+ const readBareUrl = (
398
+ source: string,
399
+ index: number,
400
+ ):
401
+ | { end: number; text: string; url: string }
402
+ | undefined => {
403
+ const before = source[index - 1]
404
+
405
+ // GFM requires the run to start at a boundary — otherwise
406
+ // `xhttps://…` and a path segment that merely contains `www.`
407
+ // would both light up.
408
+ if (
409
+ before !== undefined &&
410
+ !getIsWhitespace(before) &&
411
+ !getIsPunctuation(before)
412
+ ) {
413
+ return undefined
414
+ }
415
+
416
+ const match = /^(?:https?:\/\/|www\.)[^\s<]+/.exec(
417
+ source.slice(index),
418
+ )
419
+
420
+ if (!match) {
421
+ return undefined
422
+ }
423
+
424
+ let raw = match[0]
425
+
426
+ while (raw.length > 0) {
427
+ const last = raw.at(-1) ?? ""
428
+
429
+ if (/[!"'.,:;?*_~]/.test(last)) {
430
+ raw = raw.slice(0, -1)
431
+ continue
432
+ }
433
+
434
+ if (
435
+ last === ")" &&
436
+ raw.split(")").length > raw.split("(").length
437
+ ) {
438
+ raw = raw.slice(0, -1)
439
+ continue
440
+ }
441
+
442
+ break
443
+ }
444
+
445
+ // `https://` on its own is a scheme, not a destination.
446
+ if (/^(?:https?:\/\/|www\.)$/.test(raw)) {
447
+ return undefined
448
+ }
449
+
450
+ return {
451
+ end: index + raw.length,
452
+ text: raw,
453
+ url: raw.startsWith("www.") ? `https://${raw}` : raw,
454
+ }
455
+ }
456
+
457
+ /**
458
+ * The index just past the closing `marker`, or `-1`.
459
+ *
460
+ * Scanning skips escapes and code spans so a delimiter that is
461
+ * inside backticks cannot close one that is outside them.
462
+ */
463
+ const findEmphasisEnd = (
464
+ source: string,
465
+ from: number,
466
+ marker: string,
467
+ ): number => {
468
+ let index = from
469
+
470
+ while (index < source.length) {
471
+ const char = source[index]
472
+
473
+ if (char === "\\") {
474
+ index += 2
475
+ continue
476
+ }
477
+
478
+ if (char === "`") {
479
+ let fenceEnd = index
480
+
481
+ while (source[fenceEnd] === "`") {
482
+ fenceEnd += 1
483
+ }
484
+
485
+ const close = findCodeSpanEnd(
486
+ source,
487
+ fenceEnd,
488
+ fenceEnd - index,
489
+ )
490
+
491
+ index =
492
+ close === -1 ? fenceEnd : close + (fenceEnd - index)
493
+
494
+ continue
495
+ }
496
+
497
+ if (
498
+ source.startsWith(marker, index) &&
499
+ getCanClose(
500
+ source,
501
+ index,
502
+ index + marker.length,
503
+ marker[0] ?? "",
504
+ )
505
+ ) {
506
+ return index
507
+ }
508
+
509
+ index += 1
510
+ }
511
+
512
+ return -1
513
+ }
514
+
515
+ /**
516
+ * Longest marker first, or `**bold**` is read as an empty italic
517
+ * followed by the word.
518
+ */
519
+ const EMPHASIS_MARKERS = [
520
+ { marker: "~~", markName: "isStrikethrough" },
521
+ { marker: "**", markName: "isStrong" },
522
+ { marker: "__", markName: "isStrong" },
523
+ { marker: "*", markName: "isEmphasis" },
524
+ { marker: "_", markName: "isEmphasis" },
525
+ ] as const
526
+
527
+ const toRuns = (
528
+ source: string,
529
+ marks: MarkState,
530
+ href: string | undefined,
531
+ ): InlineMarkdownRun[] => {
532
+ const runs: InlineMarkdownRun[] = []
533
+
534
+ let plain = ""
535
+ let index = 0
536
+
537
+ const pushPlain = () => {
538
+ if (plain === "") {
539
+ return
540
+ }
541
+
542
+ runs.push({
543
+ ...marks,
544
+ ...(href === undefined ? {} : { href }),
545
+ text: plain,
546
+ })
547
+
548
+ plain = ""
549
+ }
550
+
551
+ while (index < source.length) {
552
+ const char = source[index] ?? ""
553
+
554
+ if (
555
+ char === "\\" &&
556
+ getIsPunctuation(source[index + 1])
557
+ ) {
558
+ plain += source[index + 1]
559
+ index += 2
560
+ continue
561
+ }
562
+
563
+ if (char === "`") {
564
+ let fenceEnd = index
565
+
566
+ while (source[fenceEnd] === "`") {
567
+ fenceEnd += 1
568
+ }
569
+
570
+ const fenceLength = fenceEnd - index
571
+
572
+ const close = findCodeSpanEnd(
573
+ source,
574
+ fenceEnd,
575
+ fenceLength,
576
+ )
577
+
578
+ if (close !== -1) {
579
+ pushPlain()
580
+
581
+ runs.push({
582
+ ...marks,
583
+ ...(href === undefined ? {} : { href }),
584
+ isCode: true,
585
+ text: toCodeText(source.slice(fenceEnd, close)),
586
+ })
587
+
588
+ index = close + fenceLength
589
+ continue
590
+ }
591
+
592
+ plain += source.slice(index, fenceEnd)
593
+ index = fenceEnd
594
+ continue
595
+ }
596
+
597
+ // A link inside a link is not a link. CommonMark forbids the
598
+ // nesting outright, and here it would also produce a run whose
599
+ // `href` had two answers.
600
+ if (char === "[" && href === undefined) {
601
+ const link = readLink(source, index)
602
+
603
+ if (link) {
604
+ const safeUrl = toSafeLinkUrl(link.url)
605
+
606
+ if (safeUrl === undefined) {
607
+ /**
608
+ * The refused URL keeps **every character of its source**,
609
+ * brackets and parentheses included — the same choice
610
+ * `livePreviewRanges.ts` makes, and for the same reason: a
611
+ * reader who can see `[click me](javascript:…)` is better
612
+ * served than one shown a confident blue word that
613
+ * silently does nothing.
614
+ */
615
+ plain += source.slice(index, link.end)
616
+ index = link.end
617
+ continue
618
+ }
619
+
620
+ pushPlain()
621
+ runs.push(...toRuns(link.text, marks, safeUrl))
622
+ index = link.end
623
+ continue
624
+ }
625
+ }
626
+
627
+ // `<https://…>`, the angle-bracket autolink. Its text is the URL
628
+ // and nothing inside it is parsed.
629
+ if (char === "<" && href === undefined) {
630
+ const autolink =
631
+ /^<([a-z][a-z\d+.-]*:[^\s<>]*)>/i.exec(
632
+ source.slice(index),
633
+ )
634
+
635
+ const safeUrl =
636
+ autolink?.[1] === undefined
637
+ ? undefined
638
+ : toSafeLinkUrl(autolink[1])
639
+
640
+ if (autolink && safeUrl !== undefined) {
641
+ pushPlain()
642
+
643
+ runs.push({
644
+ ...marks,
645
+ href: safeUrl,
646
+ text: autolink[1] ?? "",
647
+ })
648
+
649
+ index += autolink[0].length
650
+ continue
651
+ }
652
+ }
653
+
654
+ if (
655
+ href === undefined &&
656
+ (char === "h" || char === "w")
657
+ ) {
658
+ const bare = readBareUrl(source, index)
659
+
660
+ const safeUrl =
661
+ bare === undefined
662
+ ? undefined
663
+ : toSafeLinkUrl(bare.url)
664
+
665
+ if (bare && safeUrl !== undefined) {
666
+ pushPlain()
667
+
668
+ runs.push({
669
+ ...marks,
670
+ href: safeUrl,
671
+ text: bare.text,
672
+ })
673
+
674
+ index = bare.end
675
+ continue
676
+ }
677
+ }
678
+
679
+ const emphasis = EMPHASIS_MARKERS.find(
680
+ ({ marker, markName }) =>
681
+ !marks[markName] &&
682
+ source.startsWith(marker, index) &&
683
+ getCanOpen(
684
+ source,
685
+ index,
686
+ index + marker.length,
687
+ marker[0] ?? "",
688
+ ),
689
+ )
690
+
691
+ if (emphasis) {
692
+ const contentStart = index + emphasis.marker.length
693
+
694
+ const close = findEmphasisEnd(
695
+ source,
696
+ contentStart,
697
+ emphasis.marker,
698
+ )
699
+
700
+ if (close !== -1 && close > contentStart) {
701
+ pushPlain()
702
+
703
+ runs.push(
704
+ ...toRuns(
705
+ source.slice(contentStart, close),
706
+ { ...marks, [emphasis.markName]: true },
707
+ href,
708
+ ),
709
+ )
710
+
711
+ index = close + emphasis.marker.length
712
+ continue
713
+ }
714
+ }
715
+
716
+ plain += char
717
+ index += 1
718
+ }
719
+
720
+ pushPlain()
721
+
722
+ return runs
723
+ }
724
+
725
+ /**
726
+ * Everything that makes a title a **line** rather than a document,
727
+ * applied before a character is parsed.
728
+ *
729
+ * A newline is collapsed to a space rather than honoured, because
730
+ * every surface that draws one of these clamps to one or two lines
731
+ * anyway — a title that arrived with a hard break would otherwise
732
+ * push its own second half out of a box that was measured for one
733
+ * line. Leading and trailing whitespace goes with it.
734
+ */
735
+ const toSingleLine = (value: string) =>
736
+ value.replace(/\s*(?:\r\n|\r|\n)\s*/g, " ").trim()
737
+
738
+ /**
739
+ * The inline runs of `value`, in order.
740
+ *
741
+ * Exported because a consumer occasionally needs the runs without
742
+ * React — Docket's search highlighter walks them to find which run a
743
+ * match landed in — and because it is the seam every test in this
744
+ * directory drives.
745
+ */
746
+ export const toInlineMarkdownRuns = (
747
+ value: string,
748
+ ): InlineMarkdownRun[] =>
749
+ toRuns(toSingleLine(value), NO_MARKS, undefined)
750
+
751
+ /**
752
+ * The same string with its markers **gone** — what the title says,
753
+ * with nothing about how it is painted.
754
+ *
755
+ * This is not a convenience. Every `aria-label`, `title` attribute,
756
+ * `document.title` and confirmation sentence that names a task needs
757
+ * the words without the punctuation, and deriving it from the same
758
+ * parse is the only way it cannot drift from what is on screen. A
759
+ * second regex that "strips markdown" is how the card comes to read
760
+ * `Ingest 53 movies` while the tooltip reads
761
+ * `Ingest 53 movies from ⁠`Downloads/MOVIES⁠``.
762
+ */
763
+ export const toPlainMarkdownText = (
764
+ value: string,
765
+ ): string =>
766
+ toInlineMarkdownRuns(value)
767
+ .map((run) => run.text)
768
+ .join("")