@charcuterie/ui 3.19.2 → 3.20.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 +54 -5
  2. package/dist/Board/BoardCard.d.ts.map +1 -1
  3. package/dist/Board/BoardCard.js +2 -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 +67 -0
  30. package/src/Board/Board.test.tsx +35 -0
  31. package/src/Board/BoardCard.tsx +65 -10
  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,331 @@
1
+ import type { ReactNode } from "react"
2
+ import { Fragment } from "react"
3
+
4
+ import {
5
+ FOCUS_RING_CLASS,
6
+ INTENT_CONTENT_CLASS,
7
+ } from "../intentStyles.ts"
8
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
9
+ import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
10
+ import { toClassName } from "../toClassName.ts"
11
+ import type { InlineMarkdownRun } from "./inlineMarkdown.ts"
12
+ import { toInlineMarkdownRuns } from "./inlineMarkdown.ts"
13
+
14
+ export type MarkdownLineProps = {
15
+ className?: string
16
+ /**
17
+ * Where the text that is **not** itself a link points — the task
18
+ * a card opens, usually.
19
+ *
20
+ * Given one, every plain run becomes its own anchor to it and a
21
+ * markdown link stays a separate anchor beside them. That is the
22
+ * whole reason this prop exists rather than the caller wrapping
23
+ * the component in an `<a>`: an anchor inside an anchor is invalid
24
+ * HTML, the browser closes the outer one, and the second half of
25
+ * the title silently stops opening the task.
26
+ *
27
+ * Left out, the runs are plain text and only the markdown links
28
+ * are clickable — which is right for a heading, a table cell, or
29
+ * anywhere the line is not itself a destination.
30
+ *
31
+ * ⚠️ **A markdown link splits this into more than one anchor**, and
32
+ * each one is named by the words it actually covers — `Ship`, then
33
+ * `#53`, then `tonight`. That is honest (what is announced is what
34
+ * is on screen, per WCAG 2.5.3) and it is not *good*: `tonight` is
35
+ * a poor name for a link to a task. Giving them all one name was
36
+ * tried and is worse — two links with the same name are ambiguous
37
+ * to anything driving the page by name, which this library's own
38
+ * `expectAgentDrivable` refuses outright. The conclusion is a
39
+ * design one: a link usually belongs in a task's **description**,
40
+ * where it has a sentence around it, rather than in its title.
41
+ */
42
+ href?: string
43
+ /**
44
+ * The caller has already wrapped this line in a link — so draw the
45
+ * marks and emit **no anchors at all**, not even for a markdown
46
+ * link.
47
+ *
48
+ * The third and last answer to "who owns the link here", after
49
+ * `href` (this component does) and neither (the markdown links do,
50
+ * and nothing else is clickable). It exists because a whole-card
51
+ * link is a real and common shape — Docket's triage cards wrap the
52
+ * title *and* the body, so middle-click opens the proposal — and
53
+ * inside one, an anchor of any kind is an anchor inside an anchor.
54
+ *
55
+ * That trap has teeth even when no author ever typed a link: a
56
+ * **bare URL autolinks**, so a captured note whose title ends in
57
+ * `https://…` would nest one without anybody writing `[]()`.
58
+ *
59
+ * A markdown link's text still renders, with its marks; it simply
60
+ * does not navigate on its own. The card's link is what a click
61
+ * follows.
62
+ */
63
+ isInsideLink?: boolean
64
+ /**
65
+ * The markdown. **Inline constructs only** — see
66
+ * `inlineMarkdown.ts`; a `#` or a `- ` is a literal character
67
+ * here, and a newline collapses to a space.
68
+ */
69
+ value: string
70
+ }
71
+
72
+ /**
73
+ * The link paint, matched to `.cm-md-link` in the CodeMirror
74
+ * surface's theme rather than to `TextLink`'s.
75
+ *
76
+ * `TextLink` would be the obvious reach and it is the wrong one: it
77
+ * declares `rounded-xs`, a cursor and a hover rule that belong to a
78
+ * link in a paragraph, and — the deciding one — it has no way to
79
+ * inherit the title's own weight, so a link inside a bold title
80
+ * would un-bold itself. Two rules is the whole of what is shared
81
+ * with `.cm-md-link`, and sharing them by value is what keeps the
82
+ * editor and the card showing one answer.
83
+ */
84
+ const LINK_CLASS = toClassName(
85
+ INTENT_CONTENT_CLASS.accent,
86
+ "underline decoration-1 underline-offset-2 hover:decoration-2",
87
+ FOCUS_RING_CLASS,
88
+ )
89
+
90
+ /**
91
+ * `0.9em` and not a step on the type scale.
92
+ *
93
+ * A monospace face at the same `font-size` as the text around it
94
+ * reads bigger — fixed advance width, taller x-height — so a file
95
+ * name in a title would shout. `em` rather than a fixed size so a
96
+ * code span in a 24px heading and one in a 13px card row are both
97
+ * proportional to their line. This is `.cm-md-code`'s rule, copied
98
+ * by value for the same reason as the link's.
99
+ */
100
+ const CODE_CLASS =
101
+ "font-mono text-[0.9em] text-intent-info-content"
102
+
103
+ /**
104
+ * Position IS the identity here, which is why every `key` in this
105
+ * file is an index.
106
+ *
107
+ * The usual objection to an index key does not reach this component.
108
+ * The list is a **pure function of `value`** — nothing inserts,
109
+ * removes or re-orders a run, and no run holds component state for a
110
+ * key to preserve — so two renders of the same string produce the
111
+ * same runs in the same order, and two renders of different strings
112
+ * are a different line entirely. Content cannot stand in for it: a
113
+ * title like `**Ship** it **Ship**` has two runs with identical text
114
+ * and identical marks, and keying by either would collide.
115
+ */
116
+ const toMarkedContent = (
117
+ run: InlineMarkdownRun,
118
+ key: number,
119
+ ): ReactNode => {
120
+ /**
121
+ * AN UNMARKED RUN IS A TEXT NODE, NOT A WRAPPED ONE — and this is
122
+ * an accessibility fix rather than a saving.
123
+ *
124
+ * Every run used to come back inside a keyed `<span>`. It looked
125
+ * identical, and it silently broke every accessible name on the
126
+ * page: the name computation **trims** each element child's
127
+ * contribution before joining them, so
128
+ * `<span>Deduplicate </span><code>~/archive</code><span> by</span>`
129
+ * announces as `Deduplicate~/archiveby`. A text node keeps its
130
+ * spaces, so the same line as text-node / `<code>` / text-node
131
+ * announces correctly.
132
+ *
133
+ * Caught by `Board`'s move handle, which is named after the card's
134
+ * title. On screen the two are indistinguishable — the spaces are
135
+ * still painted either way — which is why `MarkdownLine.test.tsx`
136
+ * asserts the NAME rather than the text.
137
+ */
138
+ if (
139
+ !run.isCode &&
140
+ !run.isEmphasis &&
141
+ !run.isStrikethrough &&
142
+ !run.isStrong
143
+ ) {
144
+ return run.text
145
+ }
146
+
147
+ // Innermost first: the element that carries the text, then the
148
+ // marks wrapped around it. `<code>` is innermost because it is the
149
+ // only one that changes the *face* rather than a variation of it.
150
+ let content: ReactNode = run.isCode ? (
151
+ <code className={CODE_CLASS}>{run.text}</code>
152
+ ) : (
153
+ run.text
154
+ )
155
+
156
+ if (run.isEmphasis) {
157
+ content = <em>{content}</em>
158
+ }
159
+
160
+ if (run.isStrong) {
161
+ content = <strong>{content}</strong>
162
+ }
163
+
164
+ if (run.isStrikethrough) {
165
+ // `<s>`, not `<del>`. `<del>` means "removed from the document"
166
+ // and carries an editorial history a title does not have; `<s>`
167
+ // is "no longer accurate", which is what a struck-through task
168
+ // name says.
169
+ content = (
170
+ <s className="text-content-secondary">{content}</s>
171
+ )
172
+ }
173
+
174
+ // A `Fragment` rather than a `<span>`: it carries the key React
175
+ // needs for an array child and adds no element to the DOM, so the
176
+ // mark element sits directly inside the anchor and the name
177
+ // computation sees no extra boundary to trim.
178
+ return <Fragment key={key}>{content}</Fragment>
179
+ }
180
+
181
+ /**
182
+ * One line of markdown, drawn with its inline marks and nothing
183
+ * else.
184
+ *
185
+ * A **title** is what this is for. Docket's cards carry names like
186
+ * ``Ingest 53 movies from `Downloads/MOVIES` into `G:\Movies` ``,
187
+ * and a grid of them set in one flat weight is a wall the eye cannot
188
+ * scan — the parts that differ between two cards are exactly the
189
+ * parts markdown would have marked. So the marks earn their place
190
+ * by making a list of names *scannable*, which is the same argument
191
+ * as the priority bar and the project colour, one layer down.
192
+ *
193
+ * ### It is not a small `MarkdownView`
194
+ *
195
+ * `MarkdownView` renders a document, through CodeMirror, from the
196
+ * optional `@charcuterie/ui/markdown-editor-codemirror` entry point.
197
+ * Neither half of that suits a name: an editor state and a syntax
198
+ * tree per card is an absurd price for forty characters, and the
199
+ * main entry may not reach the CodeMirror stack at all. This parses
200
+ * the inline grammar directly and returns spans.
201
+ *
202
+ * The two therefore have to agree, and `inlineMarkdown.ts` says how
203
+ * that is kept true — most importantly that both call the **same**
204
+ * URL guard, `safeUrls.ts`, which moved up out of the CodeMirror
205
+ * subpath when this landed.
206
+ *
207
+ * ### The `href` prop is the load-bearing one
208
+ *
209
+ * A card title is usually itself a link. A title may also *contain*
210
+ * one. Nested anchors are invalid HTML and the browser silently
211
+ * closes the outer element, so `href` makes the component emit
212
+ * **siblings** — the plain runs anchored to `href`, the markdown
213
+ * links anchored to their own destination — which reads as one
214
+ * continuous line and behaves as two different destinations.
215
+ *
216
+ * ```tsx
217
+ * <MarkdownLine href={`/tasks/${task.id}`} value={task.title} />
218
+ * ```
219
+ *
220
+ * Anywhere a *string* is required — an `aria-label`, a `title`
221
+ * attribute, `document.title` — call `toPlainMarkdownText` rather
222
+ * than re-stripping the markup with a regex of your own. It is the
223
+ * same parse, so it cannot drift from what is on screen.
224
+ */
225
+ export const MarkdownLine = ({
226
+ className,
227
+ href,
228
+ isInsideLink = false,
229
+ value,
230
+ }: MarkdownLineProps): ReactNode => {
231
+ const RouterLink = useRouterLink()
232
+
233
+ const runs = toInlineMarkdownRuns(value)
234
+
235
+ /**
236
+ * Consecutive runs that share a destination become **one** anchor.
237
+ *
238
+ * Not cosmetic: an anchor per run would make `**Ship** it` two tab
239
+ * stops and two entries in a screen reader's link list for one
240
+ * destination, and would break the hover underline into pieces at
241
+ * every mark boundary.
242
+ */
243
+ const groups: {
244
+ href?: string
245
+ runs: InlineMarkdownRun[]
246
+ }[] = []
247
+
248
+ for (const run of runs) {
249
+ const last = groups.at(-1)
250
+
251
+ if (last && last.href === run.href) {
252
+ last.runs.push(run)
253
+
254
+ continue
255
+ }
256
+
257
+ groups.push({
258
+ ...(run.href === undefined ? {} : { href: run.href }),
259
+ runs: [run],
260
+ })
261
+ }
262
+
263
+ return (
264
+ <span className={className}>
265
+ {groups.map((group, groupIndex) => {
266
+ const content = group.runs.map(toMarkedContent)
267
+
268
+ if (isInsideLink) {
269
+ // No anchor, and no link paint either: a word painted as
270
+ // a link that cannot be followed is worse than plain text.
271
+ return (
272
+ <Fragment
273
+ // biome-ignore lint/suspicious/noArrayIndexKey: position is the identity — see `toMarkedContent`
274
+ key={groupIndex}
275
+ >
276
+ {content}
277
+ </Fragment>
278
+ )
279
+ }
280
+
281
+ if (group.href !== undefined) {
282
+ // A destination the injected router cannot serve opens in
283
+ // a new tab rather than replacing the app: a title that
284
+ // names a pull request is a reference, and following it
285
+ // should not lose the board the reader was on.
286
+ const isRouted = getIsRoutedHref(group.href)
287
+
288
+ const LinkElement = isRouted ? RouterLink : "a"
289
+
290
+ return (
291
+ <LinkElement
292
+ className={LINK_CLASS}
293
+ href={group.href}
294
+ // biome-ignore lint/suspicious/noArrayIndexKey: position is the identity — see `toMarkedContent`
295
+ key={groupIndex}
296
+ rel={
297
+ isRouted ? undefined : "noopener noreferrer"
298
+ }
299
+ target={isRouted ? undefined : "_blank"}
300
+ >
301
+ {content}
302
+ </LinkElement>
303
+ )
304
+ }
305
+
306
+ if (href === undefined) {
307
+ // biome-ignore lint/suspicious/noArrayIndexKey: position is the identity — see `toMarkedContent`
308
+ return <span key={groupIndex}>{content}</span>
309
+ }
310
+
311
+ const isRouted = getIsRoutedHref(href)
312
+
313
+ const LinkElement = isRouted ? RouterLink : "a"
314
+
315
+ return (
316
+ <LinkElement
317
+ className={toClassName(
318
+ "hover:underline",
319
+ FOCUS_RING_CLASS,
320
+ )}
321
+ href={href}
322
+ // biome-ignore lint/suspicious/noArrayIndexKey: position is the identity — see `toMarkedContent`
323
+ key={groupIndex}
324
+ >
325
+ {content}
326
+ </LinkElement>
327
+ )
328
+ })}
329
+ </span>
330
+ )
331
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The inline grammar, asserted as data.
3
+ *
4
+ * Kept away from React on purpose: what `MarkdownLine` renders is a
5
+ * question about elements, and what the markdown *means* is a
6
+ * question about runs. Mixing them is how a parser bug gets
7
+ * diagnosed as a styling bug.
8
+ *
9
+ * Fixture strings are INVENTED. This repo is published.
10
+ */
11
+
12
+ import { describe, expect, test } from "vitest"
13
+
14
+ import {
15
+ toInlineMarkdownRuns,
16
+ toPlainMarkdownText,
17
+ } from "./inlineMarkdown.ts"
18
+
19
+ /** The runs, with the marks that are off left out — signal only. */
20
+ const toShape = (value: string) =>
21
+ toInlineMarkdownRuns(value).map((run) => ({
22
+ ...(run.href === undefined ? {} : { href: run.href }),
23
+ ...(run.isCode ? { isCode: true } : {}),
24
+ ...(run.isEmphasis ? { isEmphasis: true } : {}),
25
+ ...(run.isStrikethrough
26
+ ? { isStrikethrough: true }
27
+ : {}),
28
+ ...(run.isStrong ? { isStrong: true } : {}),
29
+ text: run.text,
30
+ }))
31
+
32
+ describe("marks", () => {
33
+ test("bold, italic, code and strikethrough each mark their run", () => {
34
+ expect(toShape("**bold**")).toEqual([
35
+ { isStrong: true, text: "bold" },
36
+ ])
37
+
38
+ expect(toShape("*italic*")).toEqual([
39
+ { isEmphasis: true, text: "italic" },
40
+ ])
41
+
42
+ expect(toShape("`code`")).toEqual([
43
+ { isCode: true, text: "code" },
44
+ ])
45
+
46
+ expect(toShape("~~gone~~")).toEqual([
47
+ { isStrikethrough: true, text: "gone" },
48
+ ])
49
+ })
50
+
51
+ test("`__` is bold and `_` is italic, the same as their asterisks", () => {
52
+ expect(toShape("__bold__")).toEqual([
53
+ { isStrong: true, text: "bold" },
54
+ ])
55
+
56
+ expect(toShape("_italic_")).toEqual([
57
+ { isEmphasis: true, text: "italic" },
58
+ ])
59
+ })
60
+
61
+ test("marks nest, and the inner run carries both", () => {
62
+ expect(toShape("**bold and *both* here**")).toEqual([
63
+ { isStrong: true, text: "bold and " },
64
+ { isEmphasis: true, isStrong: true, text: "both" },
65
+ { isStrong: true, text: " here" },
66
+ ])
67
+ })
68
+
69
+ test("an unclosed marker is literal text", () => {
70
+ expect(toShape("2 * 3 is 6")).toEqual([
71
+ { text: "2 * 3 is 6" },
72
+ ])
73
+
74
+ expect(toShape("**not closed")).toEqual([
75
+ { text: "**not closed" },
76
+ ])
77
+ })
78
+
79
+ test("a backslash escapes a marker", () => {
80
+ expect(toShape(String.raw`\*not italic\*`)).toEqual([
81
+ { text: "*not italic*" },
82
+ ])
83
+ })
84
+ })
85
+
86
+ describe("the file-name cases this was built for", () => {
87
+ /**
88
+ * The owner's own example, and the reason the feature exists:
89
+ *
90
+ * *"Ingest 53 movies from `Downloads/MOVIES` into `G:\Movies`"*
91
+ */
92
+ test("a Windows path in a code span keeps its backslash", () => {
93
+ expect(
94
+ toShape(
95
+ String.raw`Ingest 53 movies from \`Downloads/MOVIES\` into \`G:\Movies\``.replaceAll(
96
+ String.raw`\``,
97
+ "`",
98
+ ),
99
+ ),
100
+ ).toEqual([
101
+ { text: "Ingest 53 movies from " },
102
+ { isCode: true, text: "Downloads/MOVIES" },
103
+ { text: " into " },
104
+ { isCode: true, text: String.raw`G:\Movies` },
105
+ ])
106
+ })
107
+
108
+ test("snake_case survives — an underscore inside a word marks nothing", () => {
109
+ expect(
110
+ toShape("Run ingest_the_files.sh nightly"),
111
+ ).toEqual([{ text: "Run ingest_the_files.sh nightly" }])
112
+ })
113
+
114
+ test("a bare backslash before a letter stays a backslash", () => {
115
+ expect(toShape(String.raw`G:\Movies`)).toEqual([
116
+ { text: String.raw`G:\Movies` },
117
+ ])
118
+ })
119
+
120
+ test("a fence of two holds a literal backtick", () => {
121
+ expect(toShape("`` ` ``")).toEqual([
122
+ { isCode: true, text: "`" },
123
+ ])
124
+ })
125
+ })
126
+
127
+ describe("links", () => {
128
+ test("link text becomes runs that carry the destination", () => {
129
+ expect(
130
+ toShape("see [the PR](/pulls/53) first"),
131
+ ).toEqual([
132
+ { text: "see " },
133
+ { href: "/pulls/53", text: "the PR" },
134
+ { text: " first" },
135
+ ])
136
+ })
137
+
138
+ test("marks inside link text survive, and stay linked", () => {
139
+ expect(toShape("[**PR** 53](/pulls/53)")).toEqual([
140
+ { href: "/pulls/53", isStrong: true, text: "PR" },
141
+ { href: "/pulls/53", text: " 53" },
142
+ ])
143
+ })
144
+
145
+ test("a destination's title is not part of the URL", () => {
146
+ expect(
147
+ toShape('[x](/pulls/53 "The pull request")'),
148
+ ).toEqual([{ href: "/pulls/53", text: "x" }])
149
+ })
150
+
151
+ test("a bare URL is a link, and a trailing full stop is not part of it", () => {
152
+ expect(
153
+ toShape("Read https://example.invalid/notes."),
154
+ ).toEqual([
155
+ { text: "Read " },
156
+ {
157
+ href: "https://example.invalid/notes",
158
+ text: "https://example.invalid/notes",
159
+ },
160
+ { text: "." },
161
+ ])
162
+ })
163
+
164
+ test("a parenthesis the URL opened is kept", () => {
165
+ expect(
166
+ toShape("https://example.invalid/Rack_(shelf)"),
167
+ ).toEqual([
168
+ {
169
+ href: "https://example.invalid/Rack_(shelf)",
170
+ text: "https://example.invalid/Rack_(shelf)",
171
+ },
172
+ ])
173
+ })
174
+
175
+ test("`www.` autolinks, because the CodeMirror surface does", () => {
176
+ expect(toShape("www.example.invalid/x")).toEqual([
177
+ {
178
+ href: "https://www.example.invalid/x",
179
+ text: "www.example.invalid/x",
180
+ },
181
+ ])
182
+ })
183
+
184
+ test("an angle autolink is its own text", () => {
185
+ expect(toShape("<https://example.invalid/x>")).toEqual([
186
+ {
187
+ href: "https://example.invalid/x",
188
+ text: "https://example.invalid/x",
189
+ },
190
+ ])
191
+ })
192
+
193
+ /**
194
+ * The guard is `../safeUrls.ts`, shared with the CodeMirror
195
+ * surface. What is asserted here is the *response* to a refusal:
196
+ * every character of the source stays on screen, so a reader can
197
+ * see the trap rather than being shown a link-coloured word that
198
+ * quietly does nothing.
199
+ */
200
+ test("a refused scheme keeps its whole source and links nothing", () => {
201
+ expect(
202
+ toShape("[click me](javascript:alert(1))"),
203
+ ).toEqual([{ text: "[click me](javascript:alert(1))" }])
204
+ })
205
+
206
+ test("a link inside link text does not nest", () => {
207
+ expect(toShape("[a [b](/b) c](/a)")).toEqual([
208
+ { href: "/a", text: "a [b](/b) c" },
209
+ ])
210
+ })
211
+ })
212
+
213
+ describe("it is a line, and it is inline-only", () => {
214
+ test("block markup is literal", () => {
215
+ expect(toShape("# Not a heading")).toEqual([
216
+ { text: "# Not a heading" },
217
+ ])
218
+
219
+ expect(toShape("- Not a list item")).toEqual([
220
+ { text: "- Not a list item" },
221
+ ])
222
+
223
+ expect(toShape("> Not a quote")).toEqual([
224
+ { text: "> Not a quote" },
225
+ ])
226
+ })
227
+
228
+ test("a newline collapses to a space", () => {
229
+ expect(toShape("first\n second")).toEqual([
230
+ { text: "first second" },
231
+ ])
232
+ })
233
+
234
+ test("an empty string is no runs at all", () => {
235
+ expect(toShape("")).toEqual([])
236
+ })
237
+ })
238
+
239
+ describe("toPlainMarkdownText", () => {
240
+ test("gives the words with every marker gone", () => {
241
+ expect(
242
+ toPlainMarkdownText(
243
+ "Ingest **53** movies into `G:/Movies` — [see the plan](/plan)",
244
+ ),
245
+ ).toBe("Ingest 53 movies into G:/Movies — see the plan")
246
+ })
247
+
248
+ test("leaves a string with no markup exactly as it was", () => {
249
+ expect(toPlainMarkdownText("Buy more filament")).toBe(
250
+ "Buy more filament",
251
+ )
252
+ })
253
+ })