| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { renderMarkdown, safeUrl, stripHtmlComments } from "../../src/web/markdown.js"; |
| 3 | import { localPath, safeNext } from "../../src/web/auth.js"; |
| 4 | |
| 5 | const spaces = " ".repeat(50_000); |
| 6 | |
| 7 | function timed(source: string): { html: string; ms: number } { |
| 8 | const t = performance.now(); |
| 9 | const html = renderMarkdown(source); |
| 10 | return { html, ms: performance.now() - t }; |
| 11 | } |
| 12 | |
| 13 | describe("markdown block patterns", () => { |
| 14 | // Each of these took seconds with a single backtracking regex per line. |
| 15 | it.each([ |
| 16 | ["a fence followed by spaces and a backtick", "```" + spaces + "`"], |
| 17 | ["a heading with trailing spaces before a line separator", "# a" + spaces + "
"], |
| 18 | ["a heading with spaces before text", "# a" + spaces + "b"], |
| 19 | ["a list item with spaces before a line separator", "-" + spaces + "
"], |
| 20 | ["a table delimiter row with spaces before text", "a|b\n|-" + spaces + "x"], |
| 21 | ["a dash run with spaces before text", "a|b\n---" + spaces + "x"], |
| 22 | ])("renders %s in linear time", (_name, source) => { |
| 23 | expect(timed(source).ms).toBeLessThan(500); |
| 24 | }); |
| 25 | |
| 26 | // Unmatched link and emphasis openers each scan ahead; a budget per render bounds the total. |
| 27 | it.each([ |
| 28 | ["unclosed links", "[a](".repeat(14_000)], |
| 29 | ["unclosed images", "], |
| 30 | ["emphasis openers that never close", "_1.".repeat(19_000)], |
| 31 | ["list items of emphasis openers", "- " + "*> ".repeat(19_000)], |
| 32 | ["many such paragraphs", ("_1.".repeat(19_000) + "\n\n").repeat(8)], |
| 33 | ])("bounds the lookahead for %s", (_name, source) => { |
| 34 | expect(timed(source).ms).toBeLessThan(500); |
| 35 | }); |
| 36 | |
| 37 | it("formats ordinary text in full", () => { |
| 38 | const text = Array.from({ length: 2000 }, (_, i) => `Item ${i} has *emphasis*, \`code\` and a [link](/p/${i}).`).join("\n\n"); |
| 39 | const html = renderMarkdown(text); |
| 40 | expect(html.match(/<em>/g)?.length).toBe(2000); |
| 41 | expect(html.match(/<a href=/g)?.length).toBe(2000); |
| 42 | }); |
| 43 | |
| 44 | it("still parses headings, fences, lists and tables", () => { |
| 45 | expect(renderMarkdown("## Title ##")).toContain(">Title</h2>"); |
| 46 | expect(renderMarkdown("# foo#")).toContain(">foo#</h1>"); |
| 47 | expect(renderMarkdown("#\tTabbed ")).toContain(">Tabbed</h1>"); |
| 48 | expect(renderMarkdown("# ###")).toMatch(/<h1 [^>]*><a [^>]*>#<\/a><\/h1>/); |
| 49 | expect(renderMarkdown("#nope")).toContain("<p>#nope</p>"); |
| 50 | expect(renderMarkdown("```js extra\nx\n```")).toContain('class="language-js'); |
| 51 | expect(renderMarkdown("``` a`b\nx")).not.toContain("<pre>"); |
| 52 | expect(renderMarkdown("~~~ a`b\nx")).not.toContain("<pre>"); |
| 53 | expect(renderMarkdown("- one\n- two")).toContain("<li>one</li>"); |
| 54 | const table = renderMarkdown("| a | b |\n| :-- | --: |\n| 1 | 2 |"); |
| 55 | expect(table).toContain('<th style="text-align:left">a</th>'); |
| 56 | expect(table).toContain('<td style="text-align:right">2</td>'); |
| 57 | expect(renderMarkdown("a | b\n-|-")).toContain("<table>"); |
| 58 | expect(renderMarkdown("a | b\n-||-")).not.toContain("<table>"); |
| 59 | expect(renderMarkdown("a | b\n|")).not.toContain("<table>"); |
| 60 | }); |
| 61 | }); |
| 62 | |
| 63 | describe("same-site paths", () => { |
| 64 | it("refuses paths that browsers resolve to another site", () => { |
| 65 | for (const p of ["//evil.com", "/\\evil.com", "/\t/evil.com", "/\n/evil.com", "/\r\n/evil.com", "https://evil.com", "evil.com", "", 7, null]) { |
| 66 | expect(localPath(p)).toBeNull(); |
| 67 | expect(safeNext(p)).toBe("/"); |
| 68 | } |
| 69 | expect(safeNext("/owner/repo/pulls?q=is:open")).toBe("/owner/repo/pulls?q=is:open"); |
| 70 | expect(safeNext("/a b")).toBe("/a b"); |
| 71 | }); |
| 72 | |
| 73 | it("refuses Markdown links that start with a slash and a backslash in either order", () => { |
| 74 | for (const u of ["\\/evil.com", "/\\evil.com", "\\\\evil.com", "//evil.com", "/\t/evil.com"]) expect(safeUrl(u, "link", {})).toBeNull(); |
| 75 | expect(safeUrl("/docs", "link", {})).toBe("/docs"); |
| 76 | }); |
| 77 | }); |
| 78 | |
| 79 | describe("markdown over many lines", () => { |
| 80 | it.each([ |
| 81 | ["short lines", "&\n".repeat(29_000)], |
| 82 | ["hard line breaks", "a \n".repeat(14_000)], |
| 83 | ["a long tilde run", "~".repeat(59_000) + "`"], |
| 84 | ["a long backtick run", "`".repeat(59_000) + "x`"], |
| 85 | ])("renders %s in linear time", (_name, source) => { |
| 86 | expect(timed(source).ms).toBeLessThan(500); |
| 87 | }); |
| 88 | |
| 89 | it("keeps hard line breaks", () => { |
| 90 | expect(renderMarkdown("one \ntwo\nthree \nfour")).toBe("<p>one<br>\ntwo\nthree<br>\nfour</p>"); |
| 91 | }); |
| 92 | }); |
| 93 | |
| 94 | describe("HTML comments", () => { |
| 95 | const attribution = '<!-- claude-projects-attribution: {"github_login":"someone"} -->\n_Requested by **Someone**_\n\nBody.'; |
| 96 | |
| 97 | it("hides a comment on its own lines, as GitHub does", () => { |
| 98 | expect(renderMarkdown(attribution)).toBe("<p><em>Requested by <strong>Someone</strong></em></p>\n<p>Body.</p>"); |
| 99 | expect(renderMarkdown("Before.\n\n<!--\nhidden\n\nstill hidden\n-->\n\nAfter.")).toBe("<p>Before.</p>\n<p>After.</p>"); |
| 100 | expect(renderMarkdown("<!---->\n<!-->\n<!--->\nShown.")).toBe("<p>Shown.</p>"); |
| 101 | }); |
| 102 | |
| 103 | it("ends a paragraph at a comment line and renders what follows the close", () => { |
| 104 | expect(renderMarkdown("One\n<!-- x -->\nTwo")).toBe("<p>One</p>\n<p>Two</p>"); |
| 105 | expect(renderMarkdown("<!-- a\nb --> **after**")).toBe("<p><strong>after</strong></p>"); |
| 106 | }); |
| 107 | |
| 108 | it("hides comments inside text, headings, lists, quotes and tables", () => { |
| 109 | expect(renderMarkdown("a <!-- b --> c")).toBe("<p>a c</p>"); |
| 110 | expect(renderMarkdown("a <!-- b\nc --> d")).toBe("<p>a d</p>"); |
| 111 | expect(renderMarkdown("# Title <!-- note -->")).toBe('<h1 id="h-title"><a class="anchor" href="#h-title" aria-hidden="true">#</a>Title </h1>'); |
| 112 | expect(renderMarkdown("- one <!-- x -->\n- two")).toBe("<ul><li>one </li><li>two</li></ul>"); |
| 113 | expect(renderMarkdown("> <!-- x -->\n> quoted")).toBe("<blockquote><p>quoted</p></blockquote>"); |
| 114 | expect(renderMarkdown("| a <!-- x --> |\n| - |\n| b |")).toContain("<th>a </th>"); |
| 115 | expect(renderMarkdown("*a <!-- * --> b*")).toBe("<p><em>a b</em></p>"); |
| 116 | }); |
| 117 | |
| 118 | it("keeps comments in code, escaped and unclosed ones as text", () => { |
| 119 | expect(renderMarkdown("```html\n<!-- kept -->\n```")).toContain("<!-- kept -->"); |
| 120 | expect(renderMarkdown(" <!-- kept -->")).toBe("<pre><code><!-- kept --></code></pre>"); |
| 121 | expect(renderMarkdown("`<!-- kept -->`")).toBe("<p><code><!-- kept --></code></p>"); |
| 122 | expect(renderMarkdown("\\<!-- kept -->")).toBe("<p><!-- kept --></p>"); |
| 123 | expect(renderMarkdown("a <!-- open")).toBe("<p>a <!-- open</p>"); |
| 124 | }); |
| 125 | |
| 126 | it("hides the rest of the document after a comment line that never closes, as GitHub does", () => { |
| 127 | expect(renderMarkdown("Shown.\n\n<!-- open\n\nhidden")).toBe("<p>Shown.</p>"); |
| 128 | }); |
| 129 | |
| 130 | it("never lets markup through around a comment", () => { |
| 131 | const html = renderMarkdown('<!-- x --><script>alert(1)</script>\n\na <!-- y --><img src=x onerror=alert(1)> b'); |
| 132 | expect(html).not.toMatch(/<(script|img)\b/); |
| 133 | expect(html).toContain("<script>"); |
| 134 | }); |
| 135 | |
| 136 | it("renders many comment openers in linear time", () => { |
| 137 | expect(timed("a <!-- ".repeat(40_000)).ms).toBeLessThan(500); |
| 138 | expect(timed("a <!-- b --> ".repeat(30_000)).ms).toBeLessThan(500); |
| 139 | expect(timed("<!-- x -->\n".repeat(40_000)).ms).toBeLessThan(500); |
| 140 | expect(timed("`` <!-- ` ".repeat(30_000) + "-->").ms).toBeLessThan(500); |
| 141 | }); |
| 142 | |
| 143 | it("strips comments from plain text, outside code", () => { |
| 144 | expect(stripHtmlComments(attribution)).toBe("_Requested by **Someone**_\n\nBody."); |
| 145 | expect(stripHtmlComments("a\n\n<!-- x -->\n\nb `<!-- y -->`\n```\n<!-- z -->\n```")).toBe("a\n\nb `<!-- y -->`\n```\n<!-- z -->\n```"); |
| 146 | expect(stripHtmlComments("no comments\n\n\n\nhere")).toBe("no comments\n\n\n\nhere"); |
| 147 | }); |
| 148 | }); |