| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { type DocNode, docToMarkdown, escapeText, markdownToDoc } from "./chat-compose.ts"; |
| 5 | |
| 6 | const text = (value: string, ...marks: (string | { type: string; attrs?: Record<string, unknown> })[]): DocNode => ({ |
| 7 | type: "text", |
| 8 | text: value, |
| 9 | ...(marks.length ? { marks: marks.map((mark) => (typeof mark === "string" ? { type: mark } : mark)) } : {}), |
| 10 | }); |
| 11 | const p = (...content: DocNode[]): DocNode => (content.length ? { type: "paragraph", content } : { type: "paragraph" }); |
| 12 | const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content }); |
| 13 | |
| 14 | test("formatting is written as Markdown", () => { |
| 15 | assert.equal( |
| 16 | docToMarkdown(doc(p(text("bold", "bold"), text(" "), text("it", "italic"), text(" "), text("gone", "strike"), text(" "), text("a*b", "code")))), |
| 17 | "**bold** _it_ ~~gone~~ `a*b`", |
| 18 | ); |
| 19 | // Marks that overlap open and close once. |
| 20 | assert.equal(docToMarkdown(doc(p(text("a", "bold"), text("b", "bold", "italic"), text("c", "bold")))), "**a_b_c**"); |
| 21 | // Spaces at a mark's edges sit outside it. |
| 22 | assert.equal(docToMarkdown(doc(p(text("say "), text(" hi ", "bold"), text("there")))), "say **hi** there"); |
| 23 | assert.equal(docToMarkdown(doc(p(text("tick ` in", "code")))), "`` tick ` in ``"); |
| 24 | }); |
| 25 | |
| 26 | test("links: with their text, or bare when the text is the address", () => { |
| 27 | const link = (href: string) => ({ type: "link", attrs: { href } }); |
| 28 | assert.equal(docToMarkdown(doc(p(text("the docs", link("https://g1t.sh/docs"))))), "[the docs](https://g1t.sh/docs)"); |
| 29 | assert.equal(docToMarkdown(doc(p(text("https://g1t.sh", link("https://g1t.sh"))))), "https://g1t.sh"); |
| 30 | assert.equal(docToMarkdown(doc(p(text("x", link("https://a.example/a b"))))), "[x](<https://a.example/a%20b>)"); |
| 31 | }); |
| 32 | |
| 33 | test("each paragraph is a line; an empty one is a blank line", () => { |
| 34 | assert.equal(docToMarkdown(doc(p(text("one")), p(text("two")), p(), p(text("three")))), "one\ntwo\n\nthree"); |
| 35 | assert.equal(docToMarkdown(doc(p(), p(text("x")), p())), "x"); |
| 36 | }); |
| 37 | |
| 38 | test("text that only looks like Markdown is escaped, and mentions stay mentions", () => { |
| 39 | assert.equal(escapeText("2*3 and snake_case and _edge_ and [x] and `t` and ~5"), "2\\*3 and snake_case and \\_edge\\_ and \\[x\\] and \\`t\\` and \\~5"); |
| 40 | assert.equal(docToMarkdown(doc(p(text("- not a list")), p(text("1. not either")), p(text("# nor this")), p(text("> or this")))), "\\- not a list\n1\\. not either\n\\# nor this\n\\> or this"); |
| 41 | assert.equal(docToMarkdown(doc(p(text("ask @ana in #web about #12")))), "ask @ana in #web about #12"); |
| 42 | }); |
| 43 | |
| 44 | test("lists, quotes and code blocks", () => { |
| 45 | const item = (...content: DocNode[]): DocNode => ({ type: "listItem", content }); |
| 46 | assert.equal( |
| 47 | docToMarkdown( |
| 48 | doc( |
| 49 | p(text("Plan:")), |
| 50 | { type: "bulletList", content: [item(p(text("one"))), item(p(text("two")), { type: "orderedList", attrs: { start: 1 }, content: [item(p(text("deep")))] })] }, |
| 51 | p(text("after")), |
| 52 | ), |
| 53 | ), |
| 54 | "Plan:\n\n- one\n- two\n 1. deep\n\nafter", |
| 55 | ); |
| 56 | assert.equal(docToMarkdown(doc({ type: "orderedList", attrs: { start: 3 }, content: [item(p(text("c"))), item(p(text("d")))] })), "3. c\n4. d"); |
| 57 | assert.equal(docToMarkdown(doc({ type: "blockquote", content: [p(text("quoted")), p(), p(text("more"))] })), "> quoted\n>\n> more"); |
| 58 | assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;\n\n\nconst b = `x`;" }] })), "```ts\nconst a = 1;\n\n\nconst b = `x`;\n```"); |
| 59 | assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "```\nfence\n```" }] })), "````\n```\nfence\n```\n````"); |
| 60 | }); |
| 61 | |
| 62 | test("Markdown comes back into the composer as it was written", () => { |
| 63 | const samples = [ |
| 64 | "**bold** _it_ ~~gone~~ `code`", |
| 65 | "one\ntwo\n\nthree", |
| 66 | "Plan:\n\n- one\n- two\n 1. deep\n\nafter", |
| 67 | "3. c\n4. d", |
| 68 | "> quoted\n>\n> more", |
| 69 | "```ts\nconst a = 1;\n\nconst b = 2;\n```", |
| 70 | "[the docs](https://g1t.sh/docs) and https://g1t.sh", |
| 71 | "ask @ana in #web about g1t#12", |
| 72 | "\\- not a list and 2\\*3 and snake_case", |
| 73 | "**a _b_ c**", |
| 74 | ]; |
| 75 | for (const sample of samples) assert.equal(docToMarkdown(markdownToDoc(sample)), sample, sample); |
| 76 | }); |
| 77 | |
| 78 | test("Markdown the composer cannot write still comes in", () => { |
| 79 | // A heading is a bold line; a rule is left out; an image is a link. |
| 80 | assert.equal(docToMarkdown(markdownToDoc("## Notes\n---\ntext")), "**Notes**\ntext"); |
| 81 | assert.equal(docToMarkdown(markdownToDoc("*star em* and __strong__ and ~one~")), "_star em_ and **strong** and ~~one~~"); |
| 82 | assert.deepEqual(markdownToDoc(""), { type: "doc", content: [{ type: "paragraph" }] }); |
| 83 | }); |