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.

544 lines · 18.7 KBRaw History
1/**
2 * A small, safe Markdown renderer for READMEs, issues, pull requests and job
3 * summaries. All raw HTML in the source is escaped; only http(s), mailto-free
4 * relative and fragment URLs survive in links and images.
5 *
6 * Supported: ATX headings, paragraphs, hard breaks, fenced code, thematic
7 * breaks, blockquotes, ordered/unordered/task lists (nested), tables, inline
8 * code, bold, italic, strikethrough, links, images and bare/auto links.
9 */
10
11export interface MarkdownBase {
12 /** Repository owner and name, for resolving relative links. */
13 owner: string;
14 repo: string;
15 /** Ref (branch, tag or sha) that relative links point at. */
16 ref: string;
17 /** Directory of the document inside the repository ("" for the root). */
18 dir: string;
19}
20
21export interface MarkdownOptions {
22 base?: MarkdownBase;
23}
24
25const MAX_SOURCE = 512 * 1024;
26const MAX_DEPTH = 12;
27const MAX_INLINE = 60_000;
28const MAX_SCAN = 2000;
29
30const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
31export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESC[c]!);
32
33const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
34
35export function slugify(text: string): string {
36 return text
37 .toLowerCase()
38 .replace(/<[^>]*>/g, "")
39 .replace(/[^\p{L}\p{N}\s_-]/gu, "")
40 .trim()
41 .replace(/\s+/g, "-")
42 .slice(0, 80);
43}
44
45/** Normalise "a/./b/../c" style paths; returns null when the path escapes the root. */
46function normalise(path: string): string | null {
47 const out: string[] = [];
48 for (const seg of path.split("/")) {
49 if (!seg || seg === ".") continue;
50 if (seg === "..") {
51 if (!out.length) return null;
52 out.pop();
53 } else out.push(seg);
54 }
55 return out.join("/");
56}
57
58/**
59 * Validate and resolve a URL from Markdown source. Returns null for anything
60 * that is not http(s), a fragment, or a relative path.
61 */
62export function safeUrl(url: string, kind: "link" | "image", opts: MarkdownOptions): string | null {
63 const u = url.trim();
64 if (!u) return null;
65 // Browsers ignore control characters and whitespace when parsing schemes.
66 const probe = u.replace(/[\x00-\x20\x7f]/g, "");
67 if (/^https?:\/\//i.test(probe)) return u;
68 if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || probe.startsWith("//") || probe.startsWith("\\\\") || probe.startsWith("/\\")) return null;
69 if (u.startsWith("#")) return kind === "link" ? `#h-${slugify(decodeSafe(u.slice(1)))}` : null;
70 const base = opts.base;
71 if (!base) return u.startsWith("/") ? u : null;
72 const hashAt = u.search(/[?#]/);
73 const pathPart = hashAt >= 0 ? u.slice(0, hashAt) : u;
74 const suffix = hashAt >= 0 ? u.slice(hashAt) : "";
75 const joined = pathPart.startsWith("/") ? pathPart : `${base.dir ? `${base.dir}/` : ""}${pathPart}`;
76 const norm = normalise(decodeSafe(joined));
77 if (norm === null) return null;
78 const root = `/${base.owner}/${base.repo}`;
79 if (kind === "image") return `${root}/raw/${encPath(base.ref)}/${encPath(norm)}`;
80 const isDir = pathPart.endsWith("/") || norm === "";
81 return `${root}/${isDir ? "tree" : "blob"}/${encPath(base.ref)}${norm ? `/${encPath(norm)}` : ""}${suffix.replace(/[<>"']/g, "")}`;
82}
83
84function decodeSafe(s: string): string {
85 try {
86 return decodeURIComponent(s);
87 } catch {
88 return s;
89 }
90}
91
92// ---------------------------------------------------------------- inline
93
94function findClosing(src: string, delim: string, from: number, wordy: boolean): number {
95 let at = from;
96 const limit = Math.min(src.length, from + MAX_SCAN * 2);
97 for (;;) {
98 const idx = src.indexOf(delim, at);
99 if (idx < 0 || idx >= limit) return -1;
100 const before = src[idx - 1];
101 const after = src[idx + delim.length];
102 if (idx > from && before !== undefined && !/\s/.test(before) && (!wordy || after === undefined || !/[\p{L}\p{N}]/u.test(after))) {
103 // When closing a single delimiter, skip over a double run ("**") that belongs to nested strong text.
104 if (delim.length === 1 && after === delim && src[idx + 2] !== delim) {
105 at = idx + 2;
106 continue;
107 }
108 return idx;
109 }
110 at = idx + 1;
111 }
112}
113
114interface LinkMatch {
115 text: string;
116 url: string;
117 title: string | null;
118 end: number;
119}
120
121/** Parse `[text](url "title")` starting at the "[" at `i`. */
122function parseLink(src: string, i: number): LinkMatch | null {
123 let depth = 0;
124 let j = i;
125 const limit = Math.min(src.length, i + MAX_SCAN);
126 for (; j < limit; j++) {
127 const ch = src[j];
128 if (ch === "\\") {
129 j++;
130 continue;
131 }
132 if (ch === "`") {
133 const close = src.indexOf("`", j + 1);
134 if (close > 0 && close < limit) j = close;
135 continue;
136 }
137 if (ch === "[") depth++;
138 else if (ch === "]") {
139 depth--;
140 if (depth === 0) break;
141 }
142 }
143 if (depth !== 0 || src[j] !== "]" || src[j + 1] !== "(") return null;
144 const text = src.slice(i + 1, j);
145 let k = j + 2;
146 while (src[k] === " ") k++;
147 let url = "";
148 if (src[k] === "<") {
149 const close = src.indexOf(">", k);
150 if (close < 0 || close - k > MAX_SCAN) return null;
151 url = src.slice(k + 1, close);
152 k = close + 1;
153 } else {
154 let parens = 0;
155 const start = k;
156 for (; k < src.length && k - start < MAX_SCAN; k++) {
157 const ch = src[k]!;
158 if (ch === "\\") {
159 k++;
160 continue;
161 }
162 if (/\s/.test(ch)) break;
163 if (ch === "(") parens++;
164 else if (ch === ")") {
165 if (parens === 0) break;
166 parens--;
167 }
168 }
169 url = src.slice(start, k).replace(/\\([()])/g, "$1");
170 }
171 while (src[k] === " ") k++;
172 let title: string | null = null;
173 const q = src[k];
174 if (q === '"' || q === "'" || q === "(") {
175 const closeCh = q === "(" ? ")" : q;
176 const close = src.indexOf(closeCh, k + 1);
177 if (close < 0) return null;
178 title = src.slice(k + 1, close);
179 k = close + 1;
180 while (src[k] === " ") k++;
181 }
182 if (src[k] !== ")") return null;
183 return { text, url, title, end: k + 1 };
184}
185
186const BARE_URL = /^https?:\/\/[^\s<>"'`]*[^\s<>"'`.,:;!?)\]}*_~]/i;
187
188function inline(src: string, opts: MarkdownOptions, depth = 0): string {
189 if (src.length > MAX_INLINE || depth > MAX_DEPTH) return escapeHtml(src);
190 let out = "";
191 let i = 0;
192 const n = src.length;
193 while (i < n) {
194 const ch = src[i]!;
195 // Backslash escapes and hard line breaks.
196 if (ch === "\\") {
197 const next = src[i + 1];
198 if (next === "\n") {
199 out += "<br>\n";
200 i += 2;
201 continue;
202 }
203 if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) {
204 out += escapeHtml(next);
205 i += 2;
206 continue;
207 }
208 }
209 if (ch === "\n") {
210 if (out.endsWith(" ")) out = `${out.trimEnd()}<br>`;
211 out += "\n";
212 i++;
213 continue;
214 }
215 // Code spans.
216 if (ch === "`") {
217 let run = 1;
218 while (src[i + run] === "`") run++;
219 const fence = "`".repeat(run);
220 let close = src.indexOf(fence, i + run);
221 while (close >= 0 && src[close + run] === "`") {
222 let skip = close;
223 while (src[skip] === "`") skip++;
224 close = src.indexOf(fence, skip);
225 }
226 if (close >= 0) {
227 let code = src.slice(i + run, close).replace(/\n/g, " ");
228 if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1);
229 out += `<code>${escapeHtml(code)}</code>`;
230 i = close + run;
231 continue;
232 }
233 out += fence;
234 i += run;
235 continue;
236 }
237 // Images.
238 if (ch === "!" && src[i + 1] === "[") {
239 const m = parseLink(src, i + 1);
240 if (m) {
241 const url = safeUrl(m.url, "image", opts);
242 const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, ""));
243 if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`;
244 else out += alt;
245 i = m.end;
246 continue;
247 }
248 }
249 // Links.
250 if (ch === "[") {
251 const m = parseLink(src, i);
252 if (m) {
253 const url = safeUrl(m.url, "link", opts);
254 const label = inline(m.text, opts, depth + 1);
255 if (url) {
256 const external = /^https?:/i.test(url);
257 out += `<a href="${escapeHtml(url)}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""}${external ? ' rel="nofollow noopener noreferrer"' : ""}>${label}</a>`;
258 } else out += label;
259 i = m.end;
260 continue;
261 }
262 }
263 // Autolinks <https://…>.
264 if (ch === "<") {
265 const m = /^<(https?:\/\/[^\s<>]+)>/i.exec(src.slice(i, i + MAX_SCAN));
266 if (m) {
267 const url = escapeHtml(m[1]!);
268 out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
269 i += m[0].length;
270 continue;
271 }
272 }
273 // Bare URLs.
274 if ((ch === "h" || ch === "H") && (i === 0 || /[\s(]/.test(src[i - 1]!))) {
275 const m = BARE_URL.exec(src.slice(i, i + MAX_SCAN));
276 if (m) {
277 const url = escapeHtml(m[0]);
278 out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
279 i += m[0].length;
280 continue;
281 }
282 }
283 // Strikethrough.
284 if (ch === "~" && src[i + 1] === "~" && src[i + 2] && !/\s/.test(src[i + 2]!)) {
285 const close = findClosing(src, "~~", i + 2, false);
286 if (close > i + 2) {
287 out += `<del>${inline(src.slice(i + 2, close), opts, depth + 1)}</del>`;
288 i = close + 2;
289 continue;
290 }
291 }
292 // Emphasis.
293 if (ch === "*" || ch === "_") {
294 let run = 1;
295 while (src[i + run] === ch) run++;
296 const wordy = ch === "_";
297 const prev = src[i - 1];
298 const nextCh = src[i + run];
299 const canOpen = nextCh !== undefined && !/\s/.test(nextCh) && !(wordy && prev !== undefined && /[\p{L}\p{N}]/u.test(prev));
300 let matched = false;
301 if (canOpen && run <= 3) {
302 for (const size of run === 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1]) {
303 const delim = ch.repeat(size);
304 const start = i + run;
305 const close = findClosing(src, delim, start, wordy);
306 if (close > start) {
307 const inner = inline(src.slice(start, close), opts, depth + 1);
308 out += escapeHtml(ch.repeat(run - size));
309 out += size === 3 ? `<strong><em>${inner}</em></strong>` : size === 2 ? `<strong>${inner}</strong>` : `<em>${inner}</em>`;
310 i = close + size;
311 matched = true;
312 break;
313 }
314 }
315 }
316 if (matched) continue;
317 out += escapeHtml(ch.repeat(run));
318 i += run;
319 continue;
320 }
321 out += ESC[ch] ?? ch;
322 i++;
323 }
324 return out;
325}
326
327// ---------------------------------------------------------------- blocks
328
329const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)[^`]*$/;
330const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
331const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
332const QUOTE = /^ {0,3}> ?(.*)$/;
333const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+(.*))?$/;
334const TABLE_SEP = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
335
336function splitRow(line: string): string[] {
337 let s = line.trim();
338 if (s.startsWith("|")) s = s.slice(1);
339 if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
340 const cells: string[] = [];
341 let cur = "";
342 let inCode = false;
343 for (let i = 0; i < s.length; i++) {
344 const ch = s[i]!;
345 if (ch === "\\" && s[i + 1] === "|") {
346 cur += "|";
347 i++;
348 continue;
349 }
350 if (ch === "`") inCode = !inCode;
351 if (ch === "|" && !inCode) {
352 cells.push(cur.trim());
353 cur = "";
354 continue;
355 }
356 cur += ch;
357 }
358 cells.push(cur.trim());
359 return cells;
360}
361
362function startsBlock(line: string, next: string | undefined): boolean {
363 return (
364 FENCE.test(line) ||
365 HEADING.test(line) ||
366 HR.test(line) ||
367 QUOTE.test(line) ||
368 /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) ||
369 (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-"))
370 );
371}
372
373function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string {
374 if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`;
375 let i = 0;
376 const parts: string[] = [];
377 while (i < lines.length) {
378 const line = lines[i]!;
379 if (!line.trim()) {
380 i++;
381 continue;
382 }
383 // Fenced code.
384 const fence = FENCE.exec(line);
385 if (fence) {
386 const marker = fence[1]!;
387 const lang = fence[2] ?? "";
388 const indent = line.length - line.trimStart().length;
389 const body: string[] = [];
390 i++;
391 while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) {
392 body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), ""));
393 i++;
394 }
395 i++;
396 const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : "";
397 parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
398 continue;
399 }
400 // Indented code (4 spaces), only outside lists.
401 if (/^( {4}|\t)/.test(line) && !tight) {
402 const body: string[] = [];
403 while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) {
404 body.push(lines[i]!.replace(/^( {4}|\t)/, ""));
405 i++;
406 }
407 while (body.length && !body[body.length - 1]!.trim()) body.pop();
408 parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
409 continue;
410 }
411 const h = HEADING.exec(line);
412 if (h) {
413 const level = h[1]!.length;
414 const text = h[2] ?? "";
415 const html = inline(text, opts, depth);
416 const slug = slugify(text);
417 parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${html}</h${level}>`);
418 i++;
419 continue;
420 }
421 if (HR.test(line)) {
422 parts.push("<hr>");
423 i++;
424 continue;
425 }
426 if (QUOTE.test(line)) {
427 const body: string[] = [];
428 while (i < lines.length && lines[i]!.trim()) {
429 const m = QUOTE.exec(lines[i]!);
430 if (m) body.push(m[1]!);
431 else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!);
432 else break;
433 i++;
434 }
435 parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`);
436 continue;
437 }
438 // Tables.
439 if (line.includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!) && lines[i + 1]!.includes("-")) {
440 const head = splitRow(line);
441 const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : ""));
442 i += 2;
443 const rows: string[][] = [];
444 while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) {
445 rows.push(splitRow(lines[i]!));
446 i++;
447 }
448 const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`;
449 let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`;
450 if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`;
451 parts.push(`${t}</table></div>`);
452 continue;
453 }
454 // Lists.
455 const li = LIST_ITEM.exec(line);
456 if (li) {
457 const ordered = /\d/.test(li[2]!);
458 const baseIndent = li[1]!.length;
459 const start = ordered ? parseInt(li[2]!, 10) : 1;
460 const items: string[][] = [];
461 let loose = false;
462 while (i < lines.length) {
463 const m = LIST_ITEM.exec(lines[i]!);
464 if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break;
465 const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length));
466 const item: string[] = [m[4] ?? ""];
467 i++;
468 let sawBlank = false;
469 while (i < lines.length) {
470 const l = lines[i]!;
471 if (!l.trim()) {
472 sawBlank = true;
473 item.push("");
474 i++;
475 continue;
476 }
477 const ind = l.length - l.trimStart().length;
478 if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) {
479 item.push(l.slice(Math.min(ind, contentIndent)));
480 sawBlank = false;
481 i++;
482 continue;
483 }
484 if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) {
485 // Lazy continuation of the item's paragraph.
486 item.push(l.trim());
487 i++;
488 continue;
489 }
490 break;
491 }
492 while (item.length && !item[item.length - 1]!.trim()) {
493 item.pop();
494 if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true;
495 }
496 if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true;
497 items.push(item);
498 }
499 const rendered = items.map((item) => {
500 let first = item[0] ?? "";
501 let task = "";
502 const tm = /^\[([ xX])\][ \t]+/.exec(first);
503 if (tm) {
504 task = `<input type="checkbox" disabled${tm[1] !== " " ? " checked" : ""}> `;
505 first = first.slice(tm[0].length);
506 }
507 const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose);
508 return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`;
509 });
510 parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`);
511 continue;
512 }
513 // Paragraph.
514 const para: string[] = [line.trimStart()];
515 i++;
516 while (i < lines.length && lines[i]!.trim()) {
517 // Setext headings (checked before "---" is taken as a thematic break).
518 if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) {
519 const level = lines[i]!.includes("=") ? 1 : 2;
520 const text = para.join(" ");
521 const slug = slugify(text);
522 para.length = 0;
523 parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`);
524 i++;
525 break;
526 }
527 if (startsBlock(lines[i]!, lines[i + 1])) break;
528 para.push(lines[i]!.trimStart());
529 i++;
530 }
531 if (para.length) {
532 const html = inline(para.join("\n").replace(/[ \t]+$/, ""), opts, depth);
533 parts.push(tight ? html : `<p>${html}</p>`);
534 }
535 }
536 return parts.join("\n");
537}
538
539/** Render Markdown to sanitized HTML. */
540export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string {
541 if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`;
542 const lines = source.replace(/\r\n?/g, "\n").split("\n");
543 return blocks(lines, opts, 0);
544}