Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

148 lines · 7.5 KBRaw History
1import { describe, expect, it } from "vitest";
2import { renderMarkdown, safeUrl, stripHtmlComments } from "../../src/web/markdown.js";
3import { localPath, safeNext } from "../../src/web/auth.js";
4
5const spaces = " ".repeat(50_000);
6
7function 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
13describe("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", "![a](".repeat(11_000)],
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
63describe("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
79describe("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
94describe("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("&lt;!-- kept --&gt;");
120 expect(renderMarkdown(" <!-- kept -->")).toBe("<pre><code>&lt;!-- kept --&gt;</code></pre>");
121 expect(renderMarkdown("`<!-- kept -->`")).toBe("<p><code>&lt;!-- kept --&gt;</code></p>");
122 expect(renderMarkdown("\\<!-- kept -->")).toBe("<p>&lt;!-- kept --&gt;</p>");
123 expect(renderMarkdown("a <!-- open")).toBe("<p>a &lt;!-- 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("&lt;script&gt;");
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});