Skip to content
200 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import { type Block, type Span, blocks, inline, plainText, safeHref } from "./chat-markdown.ts";
5
6/** Every link anywhere in parsed blocks. */
7function links(list: Block[]): string[] {
8 const out: string[] = [];
9 const walk = (spans: Span[]) => {
10 for (const span of spans) {
11 if (span.t === "link") out.push(span.href);
12 if ("c" in span) walk(span.c);
13 }
14 };
15 const each = (block: Block) => {
16 if (block.t === "p") block.lines.forEach(walk);
17 else if (block.t === "heading") walk(block.c);
18 else if (block.t === "quote") block.c.forEach(each);
19 else if (block.t === "list") block.items.forEach((item) => item.forEach(each));
20 else if (block.t === "table") [block.head, ...block.rows].forEach((row) => row.forEach(walk));
21 };
22 list.forEach(each);
23 return out;
24}
25
26test("formatting: bold, italic, strikethrough and code, as people and agents write them", () => {
27 assert.deepEqual(inline("**bold** __also__ *it* _it_ ~~gone~~ ~gone~ `x`"), [
28 { t: "strong", c: [{ t: "text", v: "bold" }] },
29 { t: "text", v: " " },
30 { t: "strong", c: [{ t: "text", v: "also" }] },
31 { t: "text", v: " " },
32 { t: "em", c: [{ t: "text", v: "it" }] },
33 { t: "text", v: " " },
34 { t: "em", c: [{ t: "text", v: "it" }] },
35 { t: "text", v: " " },
36 { t: "del", c: [{ t: "text", v: "gone" }] },
37 { t: "text", v: " " },
38 { t: "del", c: [{ t: "text", v: "gone" }] },
39 { t: "text", v: " " },
40 { t: "code", v: "x" },
41 ]);
42 assert.deepEqual(inline("**_both_**"), [{ t: "strong", c: [{ t: "em", c: [{ t: "text", v: "both" }] }] }]);
43 assert.deepEqual(inline("``a ` b``"), [{ t: "code", v: "a ` b" }]);
44});
45
46test("words that only look like formatting stay words", () => {
47 assert.deepEqual(inline("snake_case_name and 2*3*4"), [{ t: "text", v: "snake_case_name and 2*3*4" }]);
48 assert.deepEqual(inline("about ~5 min, ~/src"), [{ t: "text", v: "about ~5 min, ~/src" }]);
49 assert.deepEqual(inline("\\*not\\* \\_em\\_ \\`code\\` \\@nobody"), [{ t: "text", v: "*not* _em_ `code` @nobody" }]);
50 assert.deepEqual(inline("C:\\Users\\me"), [{ t: "text", v: "C:\\Users\\me" }]);
51});
52
53test("mentions, channels and references; never inside code or a link's text", () => {
54 assert.deepEqual(inline("ask @reviewer about #12, g1t#3 in #web"), [
55 { t: "text", v: "ask " },
56 { t: "mention", name: "reviewer" },
57 { t: "text", v: " about " },
58 { t: "ref", repo: null, number: 12 },
59 { t: "text", v: ", " },
60 { t: "ref", repo: "g1t", number: 3 },
61 { t: "text", v: " in " },
62 { t: "channel", name: "web" },
63 ]);
64 assert.deepEqual(inline("`@reviewer #12`"), [{ t: "code", v: "@reviewer #12" }]);
65 assert.deepEqual(inline("[ping @ana](https://x.example)"), [{ t: "link", href: "https://x.example", c: [{ t: "text", v: "ping @ana" }] }]);
66 assert.deepEqual(inline("me@example.com"), [{ t: "text", v: "me@example.com" }]);
67});
68
69test("links go only to the web, mail or this site", () => {
70 assert.deepEqual(inline("[docs](https://g1t.sh/docs) and https://g1t.sh/a."), [
71 { t: "link", href: "https://g1t.sh/docs", c: [{ t: "text", v: "docs" }] },
72 { t: "text", v: " and " },
73 { t: "link", href: "https://g1t.sh/a", c: [{ t: "text", v: "https://g1t.sh/a" }] },
74 { t: "text", v: "." },
75 ]);
76 assert.deepEqual(inline("<https://g1t.sh>"), [{ t: "link", href: "https://g1t.sh", c: [{ t: "text", v: "https://g1t.sh" }] }]);
77 assert.deepEqual(inline("https://en.wikipedia.org/wiki/Foo_(bar)"), [
78 { t: "link", href: "https://en.wikipedia.org/wiki/Foo_(bar)", c: [{ t: "text", v: "https://en.wikipedia.org/wiki/Foo_(bar)" }] },
79 ]);
80 assert.equal(safeHref("/acme/web/pull/3"), "/acme/web/pull/3");
81 assert.equal(safeHref("mailto:me@example.com"), "mailto:me@example.com");
82});
83
84test("XSS: script links, raw HTML and nested tricks are text", () => {
85 const evil = [
86 "[x](javascript:alert(1))",
87 "[x](JaVaScRiPt:alert(1))",
88 "[x]( javascript:alert(1))",
89 "[x](java\tscript:alert(1))",
90 "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
91 "[x](vbscript:msgbox(1))",
92 "[x](//evil.example)",
93 "[x](/\\evil.example)",
94 "[x](<javascript:alert(1)>)",
95 "![x](javascript:alert(1))",
96 "<javascript:alert(1)>",
97 "[**[x](javascript:alert(1))**](javascript:alert(2))",
98 "[a](https://ok.example)[b](javascript:alert(1))",
99 "javascript:alert(1)",
100 ];
101 for (const text of evil) {
102 for (const href of links(blocks(text))) assert.ok(/^(https?:\/\/|mailto:|\/(?![/\\]))/i.test(href), `${text} linked to ${href}`);
103 }
104 assert.equal(safeHref("javascript:alert(1)"), null);
105 assert.equal(safeHref("//evil.example"), null);
106 assert.equal(safeHref("/\\evil.example"), null);
107 assert.equal(safeHref("https://ok.example/\u0000x"), null);
108 assert.equal(safeHref("https://ok.example\\@evil.example"), null);
109
110 // HTML is never markup: it comes through as the text it is.
111 const html = blocks('<img src=x onerror="alert(1)"><script>alert(1)</script>\n<a href="javascript:alert(1)">x</a>');
112 assert.deepEqual(html, [
113 {
114 t: "p",
115 lines: [[{ t: "text", v: '<img src=x onerror="alert(1)"><script>alert(1)</script>' }], [{ t: "text", v: '<a href="javascript:alert(1)">x</a>' }]],
116 },
117 ]);
118 // An image is a link to it, never an image.
119 assert.deepEqual(inline("![logo](https://x.example/a.png)"), [{ t: "link", href: "https://x.example/a.png", c: [{ t: "text", v: "logo" }] }]);
120});
121
122test("blocks: paragraphs keep their line breaks; code, lists, quotes, headings and rules", () => {
123 const parsed = blocks("Plan:\n- one\n- two\n\n```ts\nconst a = 1;\n```\n> quoted\nlast");
124 assert.deepEqual(
125 parsed.map((block) => block.t),
126 ["p", "list", "code", "quote", "p"],
127 );
128 assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" });
129 assert.deepEqual(blocks("a\nb"), [{ t: "p", lines: [[{ t: "text", v: "a" }], [{ t: "text", v: "b" }]] }]);
130 assert.deepEqual(blocks("## Summary\n---\n#general"), [
131 { t: "heading", level: 2, c: [{ t: "text", v: "Summary" }] },
132 { t: "hr" },
133 { t: "p", lines: [[{ t: "channel", name: "general" }]] },
134 ]);
135 // A fence inside a fence, with a longer fence around it.
136 assert.deepEqual(blocks("````md\n```\ninner\n```\n````"), [{ t: "code", lang: "md", v: "```\ninner\n```" }]);
137 // An unclosed fence runs to the end.
138 assert.deepEqual(blocks("```\nopen"), [{ t: "code", lang: null, v: "open" }]);
139});
140
141test("lists: numbered from where they start, nested by indenting, items with more than a line", () => {
142 const [list] = blocks("3. three\n4. four\n - deeper\n more\n5. five");
143 assert.equal(list!.t, "list");
144 if (list!.t !== "list") return;
145 assert.equal(list.ordered, true);
146 assert.equal(list.start, 3);
147 assert.equal(list.items.length, 3);
148 const nested = list.items[1]![1]!;
149 assert.equal(nested.t, "list");
150 if (nested.t === "list") assert.deepEqual(nested.items[0], [{ t: "p", lines: [[{ t: "text", v: "deeper" }], [{ t: "text", v: "more" }]] }]);
151 // A year at the start of a line is not a list.
152 assert.deepEqual(blocks("We grew in\n2019. Then again.").map((b) => b.t), ["p"]);
153 // Blank lines between items keep one list.
154 assert.equal(blocks("- a\n\n- b").length, 1);
155 // A quote holds blocks of its own.
156 const [quote] = blocks("> - a\n> - b");
157 assert.ok(quote!.t === "quote" && quote.c[0]!.t === "list");
158});
159
160test("deep nesting is bounded", () => {
161 const deep = blocks(">".repeat(200) + " x");
162 assert.equal(deep.length, 1);
163 const list = blocks(Array.from({ length: 50 }, (_, i) => `${" ".repeat(i)}- ${i}`).join("\n"));
164 assert.equal(list.length, 1);
165});
166
167test("plain text: the words without the marks, for previews", () => {
168 assert.equal(plainText("**bold** and `code`\n\nnext [link](https://x.example)"), "bold and code next link");
169 assert.equal(plainText("## Release\n- one\n- **two**\n\n> said\n---\n1. first"), "Release one two said 1. first");
170 assert.equal(plainText("ping @ana in #web about g1t#3, snake_case_name"), "ping @ana in #web about g1t#3, snake_case_name");
171 assert.equal(plainText("\\*literal\\* ~~gone~~ ![logo](https://x.example/a.png)"), "*literal* gone logo");
172 assert.equal(plainText("```ts\nconst a = 1;\n```"), "const a = 1;");
173 assert.equal(plainText("<b>hi</b>"), "<b>hi</b>");
174 assert.equal(plainText("x".repeat(300), 140).length, 140);
175 assert.ok(plainText("x".repeat(300), 140).endsWith("…"));
176});
177
178test("tables: a header, a --- row with its alignment, then rows", () => {
179 const [table, after] = blocks("| Session | Length | Notes |\n| --- | ---: | :-: |\n| 1. Welcome | 45 min | `web` \\| `api` |\n| 2. Queue | 30 min |\n\nAfter.");
180 assert.ok(table!.t === "table");
181 assert.deepEqual(table.align, [null, "right", "center"]);
182 assert.deepEqual(table.head, [[{ t: "text", v: "Session" }], [{ t: "text", v: "Length" }], [{ t: "text", v: "Notes" }]]);
183 assert.equal(table.rows.length, 2);
184 // An escaped pipe stays in its cell; a short row is filled to the header's width.
185 assert.deepEqual(table.rows[0]![2], [{ t: "code", v: "web" }, { t: "text", v: " | " }, { t: "code", v: "api" }]);
186 assert.deepEqual(table.rows[1]![2], []);
187 assert.equal(after!.t, "p");
188 // Without outer pipes, and right after a paragraph.
189 assert.deepEqual(blocks("Plan:\na | b\n--|--\n1 | 2").map((b) => b.t), ["p", "table"]);
190 // A pipe inside backticks is not a column.
191 const [code] = blocks("| a |\n| - |\n| `x | y` |");
192 assert.ok(code!.t === "table" && code.rows[0]!.length === 1);
193 // Not tables: a rule under text, columns that don't match, a line with a pipe alone.
194 assert.deepEqual(blocks("a | b\n---").map((b) => b.t), ["p", "hr"]);
195 assert.deepEqual(blocks("| a | b |\n| --- |\n| 1 | 2 |").map((b) => b.t), ["p"]);
196 assert.deepEqual(blocks("either | or").map((b) => b.t), ["p"]);
197 // Links in cells are checked like any other.
198 assert.deepEqual(links(blocks("| x |\n| - |\n| [ok](https://x.example) [no](javascript:alert(1)) |")), ["https://x.example"]);
199 assert.equal(plainText("| a | b |\n|---|---|\n| 1 | 2 |"), "a · b 1 · 2");
200});