Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge main into Artifacts Phase 2 | 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | |
| 3 | ||
| 4 | import { type Block, type Span, blocks, inline, plainText, safeHref } from "./chat-markdown.ts"; | |
| 5 | ||
| 6 | /** Every link anywhere in parsed blocks. */ | |
| 7 | function 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)); | |
| The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work. | 20 | else if (block.t === "table") [block.head, ...block.rows].forEach((row) => row.forEach(walk)); |
| Merge main into Artifacts Phase 2 | 21 | }; |
| 22 | list.forEach(each); | |
| 23 | return out; | |
| 24 | } | |
| 25 | ||
| 26 | test("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 | ||
| 46 | test("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 | ||
| 53 | test("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 | ||
| 69 | test("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 | ||
| 84 | test("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 | ")", | |
| 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(""), [{ t: "link", href: "https://x.example/a.png", c: [{ t: "text", v: "logo" }] }]); | |
| 120 | }); | |
| 121 | ||
| 122 | test("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 | ||
| 141 | test("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 | ||
| 160 | test("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 | ||
| 167 | test("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~~ "), "*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 | }); | |
| The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work. | 177 | |
| 178 | test("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 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.