import { markdownLanguage } from "@codemirror/lang-markdown" import { describe, expect, test } from "vitest" import type { LivePreviewSelection } from "./livePreviewRanges.ts" import { toLivePreviewRanges, toLivePreviewTableRanges, } from "./livePreviewRanges.ts" /** * The real GFM parser, not a stub. * * These offsets are the ones the editor decorates at runtime, so a * fixture parser would test the fixture. `markdownLanguage` is the * GFM dialect — which is also what makes the bare-URL cases below * meaningful, since strict CommonMark does not autolink. */ const toRanges = ( text: string, selections: readonly LivePreviewSelection[] = [], isRawMode = false, ) => toLivePreviewRanges({ isRawMode, selections, text, tree: markdownLanguage.parser.parse(text), }) /** Anything with document offsets — a range, or a table cell. */ /** * The block pass. Separate from `toRanges` because CodeMirror * requires the split — see `toLivePreviewTableRanges`. */ const toTableRanges = ( text: string, selections: readonly LivePreviewSelection[] = [], isRawMode = false, ) => toLivePreviewTableRanges({ isRawMode, selections, text, tree: markdownLanguage.parser.parse(text), }) const toTextOf = ( text: string, range: { from: number; to: number }, ) => text.slice(range.from, range.to) /** * Narrows away the `undefined` a `find` returns, failing the test * with a useful message rather than a non-null assertion that would * throw `Cannot read properties of undefined` three lines later. */ const toFound = ( range: FoundRange | undefined, ): FoundRange => { expect(range).toBeDefined() return range as FoundRange } /** Selection collapsed at an offset, as a caret is. */ const atCaret = (offset: number) => [ { from: offset, to: offset }, ] describe("toLivePreviewRanges", () => { describe("the concealment invariant", () => { test("never reports a range outside the document", () => { const text = "# Heading\n\n**bold** [link](https://example.com)\n\n- [ ] task\n" for (const range of toRanges(text)) { expect(range.from).toBeGreaterThanOrEqual(0) expect(range.to).toBeLessThanOrEqual(text.length) expect(range.from).toBeLessThanOrEqual(range.to) } }) test("conceals markers rather than describing an edit", () => { const text = "**bold**" const concealed = toRanges(text).filter( (range) => range.type === "marker" && range.isConcealed, ) // Both runs of asterisks, and nothing else. The document is // untouched — these are view ranges over the original string. expect( concealed.map((range) => toTextOf(text, range)), ).toEqual(["**", "**"]) }) }) /** * Not table-specific — but `\\|` is the only way to put a pipe in * a cell, so this is where its absence showed. */ describe("escapes", () => { test("conceals the backslash and keeps what it escaped", () => { const text = "a \\* b" const markers = toRanges(text).filter( (range) => range.type === "marker", ) expect( markers.map((range) => toTextOf(text, range)), ).toEqual(["\\"]) expect(markers[0]).toMatchObject({ isConcealed: true, }) }) test("brings it back for a caret inside it", () => { const text = "a \\* b" expect( toRanges(text, atCaret(3)).find( (range) => range.type === "marker", ), ).toMatchObject({ isConcealed: false }) }) }) describe("headings", () => { test("scales the line and conceals the hashes with their space", () => { const text = "### Heading three" const ranges = toRanges(text) expect( ranges.find((range) => range.type === "line"), ).toMatchObject({ from: 0, lineKind: "heading3", to: 0, }) const marker = ranges.find( (range) => range.type === "marker", ) // "### " — the trailing space goes with the hashes, or the // heading text sits one column in from the margin. expect(toTextOf(text, toFound(marker))).toBe("### ") }) test.each([ ["# one", "heading1"], ["## two", "heading2"], ["#### four", "heading4"], ["##### five", "heading5"], ["###### six", "heading6"], ])("maps %s to %s", (text, lineKind) => { expect( toRanges(text).find( (range) => range.type === "line", ), ).toMatchObject({ lineKind }) }) }) describe("the caret reveals what it is inside", () => { test("keeps markers concealed while the caret is elsewhere", () => { const text = "a **bold** word" const marker = toRanges(text, atCaret(0)).find( (range) => range.type === "marker", ) expect(marker).toMatchObject({ isConcealed: true }) }) test("reveals markers once the caret is inside the construct", () => { const text = "a **bold** word" // Offset 5 is between the asterisks, inside "bold". const markers = toRanges(text, atCaret(5)).filter( (range) => range.type === "marker", ) expect(markers).not.toHaveLength(0) for (const marker of markers) { expect(marker).toMatchObject({ isConcealed: false }) } }) test("reveals a construct a selection merely touches", () => { const text = "a **bold** word" const markers = toRanges(text, [ { from: 0, to: 4 }, ]).filter((range) => range.type === "marker") for (const marker of markers) { expect(marker).toMatchObject({ isConcealed: false }) } }) }) describe("links", () => { test("marks the link text and carries its URL", () => { const text = "see [the docs](https://example.com) now" const linkText = toRanges(text).find( (range) => range.type === "mark" && range.markKind === "linkText", ) expect(toTextOf(text, toFound(linkText))).toBe( "the docs", ) expect(linkText).toMatchObject({ url: "https://example.com", }) }) test("conceals the brackets and the URL together", () => { const text = "see [the docs](https://example.com) now" const concealed = toRanges(text) .filter( (range) => range.type === "marker" && range.isConcealed, ) .map((range) => toTextOf(text, range)) expect(concealed).toEqual([ "[", "](https://example.com)", ]) }) /** * Gap 2 of Docket's handoff, and the reason this work started: * a URL pasted as plain text was not a link and could not * become one without typing `[](…)` around it. */ test("autolinks a bare URL that was never marked up", () => { const text = "ordered from https://www.monoprice.com/product?id=1234 last week" const autolink = toRanges(text).find( (range) => range.type === "mark" && range.markKind === "autolink", ) expect(autolink).toMatchObject({ url: "https://www.monoprice.com/product?id=1234", }) expect(toTextOf(text, toFound(autolink))).toBe( "https://www.monoprice.com/product?id=1234", ) }) test("does not autolink the URL inside an explicit link", () => { const text = "[docs](https://example.com)" expect( toRanges(text).filter( (range) => range.type === "mark" && range.markKind === "autolink", ), ).toHaveLength(0) }) /** * The bug that took whole documents down to raw source. * * The closing `]` was read as `openMark.nextSibling`, which is * the bracket only while the link text is plain prose. Any * inline construct in the text becomes that sibling instead, so * the link text measured `[` → the construct's own start: * **zero-length**. `Decoration.mark` rejects an empty range, * the throw destroys the view plugin, and every decoration in * the file goes with it. * * ``[`file.md`](path)`` is not an exotic spelling. It was in * 69 of the 127 Docket task descriptions that had one. * * These assert the range, not a rendering. A whole-document * render would go green on the *quiet* half of this bug — the * cases below that produced a non-empty but wrong mark. */ describe("inline markup inside the link text", () => { test.each([ ["a code span", "[`file.md`](url)", "`file.md`"], ["strong", "[**bold**](url)", "**bold**"], ["emphasis", "[*thin*](url)", "*thin*"], ["strikethrough", "[~~gone~~](url)", "~~gone~~"], [ "a mix, after plain text", "[a *b* `c` **d**](url)", "a *b* `c` **d**", ], [ "markup then plain text", "[**b** then more](url)", "**b** then more", ], ["nesting", "[**`both`**](url)", "**`both`**"], ["an escape", "[a\\]b](url)", "a\\]b"], [ "an image", "[![alt](a.png)](url)", "![alt](a.png)", ], ])( "spans the whole link text through %s", (_case, text, expected) => { const linkText = toRanges(text).find( (range) => range.type === "mark" && range.markKind === "linkText", ) expect(toTextOf(text, toFound(linkText))).toBe( expected, ) }, ) /** * The invariant, swept rather than enumerated. * * Every caret position in the document, in both modes. A * caret changes which constructs are revealed, and reveal * state feeds the same bracket arithmetic — so "the mark is * non-empty with no caret in the file" is a weaker promise * than the one the plugin needs. `Decoration.mark` throws on * an empty range wherever the caret happens to be. */ test("emits no empty mark at any caret position, in either mode", () => { const empties: string[] = [] for (const text of [ "see [`file.md`](https://example.com) now", "see [**bold**](https://example.com) now", "[a *b* `c` **d**](https://example.com)", "[![alt](https://example.com/a.png)](https://example.com)", "![`code` alt](https://example.com/a.png)", "[](https://example.com)", "[a\\]b](https://example.com)", ]) { for (const isRawMode of [false, true]) { for ( let offset = 0; offset <= text.length; offset += 1 ) { for (const range of toRanges( text, atCaret(offset), isRawMode, )) { if ( range.type === "mark" && range.from >= range.to ) { empties.push( `${text} @${offset} raw=${isRawMode}`, ) } } } } } expect(empties).toEqual([]) }) test("never emits an empty mark, whatever the nesting", () => { for (const text of [ "[`file.md`](url)", "[**bold**](url)", "[*thin*](url)", "[~~gone~~](url)", "[a *b* `c` **d**](url)", "[**`both`**](url)", "[![alt](a.png)](url)", "[a\\]b](url)", ]) { for (const range of toRanges(text)) { if (range.type === "mark") { // `Mark decorations may not be empty` — the throw // that destroyed the plugin. expect(range.to).toBeGreaterThan(range.from) } } } }) test("conceals only the brackets, not the text between them", () => { const text = "see [`file.md`](https://example.com) now" const concealed = toRanges(text) .filter( (range) => range.type === "marker" && range.isConcealed, ) .map((range) => toTextOf(text, range)) // The `](url)` run is the point. Before the fix it started // one character after `[` and swallowed the link text // whole, so dropping the empty mark on its own would have // rendered an invisible link rather than a fixed one. expect(concealed).toContain( "](https://example.com)", ) expect(concealed).not.toContain( "`file.md`](https://example.com)", ) }) test("still carries the URL onto the nested case", () => { const text = "[`file.md`](https://example.com)" expect( toRanges(text).find( (range) => range.type === "mark" && range.markKind === "linkText", ), ).toMatchObject({ url: "https://example.com" }) }) test("leaves the nested construct's own marks intact", () => { const text = "[`file.md`](https://example.com)" expect( toRanges(text).find( (range) => range.type === "mark" && range.markKind === "code", ), ).toBeDefined() }) }) /** * `[](url)` has no text, so there is nothing to mark. This is * the *only* empty case — every other one was the bug above, * which is why the guard in `livePreviewRanges` is written as * `openMark.to < closeMark.from` and not as a filter over the * finished list. */ test("emits no link-text mark for a link with no text", () => { const text = "[](https://example.com)" expect( toRanges(text).filter( (range) => range.type === "mark" && range.markKind === "linkText", ), ).toHaveLength(0) }) }) describe("images", () => { test("replaces the markup with the image it describes", () => { const text = "![a screenshot](https://example.com/a.png)" const image = toRanges(text).find( (range) => range.type === "image", ) expect(image).toMatchObject({ alt: "a screenshot", from: 0, to: text.length, url: "https://example.com/a.png", }) }) test("gives the markup back when the caret enters it", () => { const text = "![a screenshot](https://example.com/a.png)" expect( toRanges(text, atCaret(4)).filter( (range) => range.type === "image", ), ).toHaveLength(0) }) /** * The same `nextSibling` mistake, without the crash: an image * emits a replace range over its whole construct, which is * never empty, so a marked-up alt came out as `""` and nothing * threw. `alt` is an attribute rather than a span, so it is * read as plain text — which is what CommonMark says an alt is. */ test.each([ [ "a code span", "![`code` shot](https://example.com/a.png)", "code shot", ], [ "strong", "![**bold** shot](https://example.com/a.png)", "bold shot", ], [ "a mix", "![*a* `b` **c**](https://example.com/a.png)", "a b c", ], [ "an escape", "![a\\]b](https://example.com/a.png)", "a]b", ], ])( "reads the alt text as plain text through %s", (_case, text, expected) => { expect( toRanges(text).find( (range) => range.type === "image", ), ).toMatchObject({ alt: expected }) }, ) }) describe("task lists", () => { test.each([ ["- [ ] open", false], ["- [x] done", true], ["- [X] done", true], ])("reads %s as isChecked=%s", (text, isChecked) => { expect( toRanges(text).find( (range) => range.type === "task", ), ).toMatchObject({ isChecked }) }) /** * A checkbox inside a `contenteditable` has no `