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.

732 lines · 25.8 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, except HTML comments, which
4 * are dropped as GitHub drops them; only http(s), mailto-free relative and
5 * fragment URLs survive in links and images.
6 *
7 * Supported: ATX headings, paragraphs, hard breaks, fenced code, thematic
8 * breaks, blockquotes, ordered/unordered/task lists (nested), tables, inline
9 * code, bold, italic, strikethrough, links, images and bare/auto links.
10 */
11
12export interface MarkdownBase {
13 /** Repository owner and name, for resolving relative links. */
14 owner: string;
15 repo: string;
16 /** Ref (branch, tag or sha) that relative links point at. */
17 ref: string;
18 /** Directory of the document inside the repository ("" for the root). */
19 dir: string;
20}
21
22export interface MarkdownOptions {
23 base?: MarkdownBase;
24 /** Syntax highlighter for fenced code: HTML for the code, or null for plain text. */
25 highlight?: (code: string, lang: string) => string | null;
26 /** Prefix for heading ids and fragment links (default "h-", which keeps user content clear of the page's own ids). */
27 idPrefix?: string;
28 /**
29 * Where an image's src (absolute, or relative to this site) should point; null shows its alt
30 * text instead. The web pages send images from other sites through Diffinery's image proxy
31 * this way (lib/camo.ts). Links keep their original targets.
32 */
33 image?: (src: string) => string | null;
34}
35
36const MAX_SOURCE = 512 * 1024;
37const MAX_DEPTH = 12;
38const MAX_INLINE = 60_000;
39const MAX_SCAN = 2000;
40/**
41 * Characters that link, code and emphasis lookahead may scan in one render: 4 per source
42 * character, where real documents scan well under 1. Each unmatched `[`, `*` or `_` scans up
43 * to MAX_SCAN ahead, so a crafted paragraph would cost MAX_SCAN times its length; past the
44 * budget the rest of the inline text is shown escaped, without formatting.
45 */
46const scanBudget = (sourceLength: number): number => 250_000 + 4 * sourceLength;
47// Rendering is synchronous, so one counter per isolate is safe.
48let scanned = 0;
49let budget = 0;
50
51const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
52export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESC[c]!);
53
54const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
55
56export function slugify(text: string): string {
57 return text
58 .toLowerCase()
59 .replace(/<[^<>]*>/g, "")
60 .replace(/[^\p{L}\p{N}\s_-]/gu, "")
61 .trim()
62 .replace(/\s+/g, "-")
63 .slice(0, 80);
64}
65
66/** Normalise "a/./b/../c" style paths; returns null when the path escapes the root. */
67function normalise(path: string): string | null {
68 const out: string[] = [];
69 for (const seg of path.split("/")) {
70 if (!seg || seg === ".") continue;
71 if (seg === "..") {
72 if (!out.length) return null;
73 out.pop();
74 } else out.push(seg);
75 }
76 return out.join("/");
77}
78
79/**
80 * Validate and resolve a URL from Markdown source. Returns null for anything
81 * that is not http(s), a fragment, or a relative path.
82 */
83export function safeUrl(url: string, kind: "link" | "image", opts: MarkdownOptions): string | null {
84 const u = url.trim();
85 if (!u) return null;
86 // Browsers ignore control characters and whitespace when parsing schemes.
87 const probe = u.replace(/[\x00-\x20\x7f]/g, "");
88 if (/^https?:\/\//i.test(probe)) return u;
89 if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || /^[/\\]{2}/.test(probe)) return null;
90 if (u.startsWith("#")) return kind === "link" ? `#${opts.idPrefix ?? "h-"}${slugify(decodeSafe(u.slice(1)))}` : null;
91 const base = opts.base;
92 if (!base) return u.startsWith("/") ? u : null;
93 const hashAt = u.search(/[?#]/);
94 const pathPart = hashAt >= 0 ? u.slice(0, hashAt) : u;
95 const suffix = hashAt >= 0 ? u.slice(hashAt) : "";
96 const joined = pathPart.startsWith("/") ? pathPart : `${base.dir ? `${base.dir}/` : ""}${pathPart}`;
97 const norm = normalise(decodeSafe(joined));
98 if (norm === null) return null;
99 const root = `/${base.owner}/${base.repo}`;
100 if (kind === "image") return `${root}/raw/${encPath(base.ref)}/${encPath(norm)}`;
101 const isDir = pathPart.endsWith("/") || norm === "";
102 return `${root}/${isDir ? "tree" : "blob"}/${encPath(base.ref)}${norm ? `/${encPath(norm)}` : ""}${suffix.replace(/[<>"']/g, "")}`;
103}
104
105function decodeSafe(s: string): string {
106 try {
107 return decodeURIComponent(s);
108 } catch {
109 return s;
110 }
111}
112
113// ---------------------------------------------------------------- inline
114
115/**
116 * Text with its HTML comments (`<!-- … -->`, also `<!-->` and `<!--->`) removed. Code spans and
117 * backslash-escaped `<` are kept as written, and an unclosed `<!--` stays as text.
118 */
119function stripComments(src: string): string {
120 if (!src.includes("<!--")) return src;
121 let out = "";
122 let i = 0;
123 const n = src.length;
124 // Every comment closes at or before the last "-->", so an opener past it is text: no rescans.
125 const lastClose = src.lastIndexOf("-->");
126 while (i < n) {
127 if (scanned > budget) return out + src.slice(i);
128 const ch = src[i]!;
129 if (ch === "\\") {
130 out += src.slice(i, i + 2);
131 i += 2;
132 continue;
133 }
134 if (ch === "`") {
135 let run = 1;
136 while (src[i + run] === "`") run++;
137 const fence = "`".repeat(run);
138 let close = src.indexOf(fence, i + run);
139 while (close >= 0 && src[close + run] === "`") {
140 let skip = close;
141 while (src[skip] === "`") skip++;
142 close = src.indexOf(fence, skip);
143 }
144 scanned += (close < 0 ? n : close) - i;
145 const end = close < 0 ? i + run : close + run;
146 out += src.slice(i, end);
147 i = end;
148 continue;
149 }
150 if (ch === "<" && src.startsWith("<!--", i) && lastClose >= i + 2) {
151 const close = src.indexOf("-->", i + 2);
152 scanned += close - i;
153 i = close + 3;
154 continue;
155 }
156 out += ch;
157 i++;
158 }
159 return out;
160}
161
162function findClosing(src: string, delim: string, from: number, wordy: boolean): number {
163 let at = from;
164 const limit = Math.min(src.length, from + MAX_SCAN * 2);
165 const window = src.slice(0, limit);
166 for (;;) {
167 const idx = window.indexOf(delim, at);
168 scanned += (idx < 0 ? limit : idx) - at + 1;
169 if (idx < 0) return -1;
170 const before = src[idx - 1];
171 const after = src[idx + delim.length];
172 if (idx > from && before !== undefined && !/\s/.test(before) && (!wordy || after === undefined || !/[\p{L}\p{N}]/u.test(after))) {
173 // When closing a single delimiter, skip over a double run ("**") that belongs to nested strong text.
174 if (delim.length === 1 && after === delim && src[idx + 2] !== delim) {
175 at = idx + 2;
176 continue;
177 }
178 return idx;
179 }
180 at = idx + 1;
181 }
182}
183
184interface LinkMatch {
185 text: string;
186 url: string;
187 title: string | null;
188 end: number;
189}
190
191/** Parse `[text](url "title")` starting at the "[" at `i`. */
192function parseLink(src: string, i: number): LinkMatch | null {
193 let depth = 0;
194 let j = i;
195 const limit = Math.min(src.length, i + MAX_SCAN);
196 for (; j < limit; j++) {
197 const ch = src[j];
198 if (ch === "\\") {
199 j++;
200 continue;
201 }
202 if (ch === "`") {
203 const close = src.indexOf("`", j + 1);
204 if (close > 0 && close < limit) j = close;
205 continue;
206 }
207 if (ch === "[") depth++;
208 else if (ch === "]") {
209 depth--;
210 if (depth === 0) break;
211 }
212 }
213 scanned += j - i;
214 if (depth !== 0 || src[j] !== "]" || src[j + 1] !== "(") return null;
215 const text = src.slice(i + 1, j);
216 let k = j + 2;
217 while (src[k] === " ") k++;
218 let url = "";
219 if (src[k] === "<") {
220 const close = src.indexOf(">", k);
221 scanned += (close < 0 ? src.length : close) - k;
222 if (close < 0 || close - k > MAX_SCAN) return null;
223 url = src.slice(k + 1, close);
224 k = close + 1;
225 } else {
226 let parens = 0;
227 const start = k;
228 for (; k < src.length && k - start < MAX_SCAN; k++) {
229 const ch = src[k]!;
230 if (ch === "\\") {
231 k++;
232 continue;
233 }
234 if (/\s/.test(ch)) break;
235 if (ch === "(") parens++;
236 else if (ch === ")") {
237 if (parens === 0) break;
238 parens--;
239 }
240 }
241 scanned += k - start;
242 url = src.slice(start, k).replace(/\\([()])/g, "$1");
243 }
244 while (src[k] === " ") k++;
245 let title: string | null = null;
246 const q = src[k];
247 if (q === '"' || q === "'" || q === "(") {
248 const closeCh = q === "(" ? ")" : q;
249 const close = src.indexOf(closeCh, k + 1);
250 scanned += (close < 0 ? src.length : close) - k;
251 if (close < 0) return null;
252 title = src.slice(k + 1, close);
253 k = close + 1;
254 while (src[k] === " ") k++;
255 }
256 if (src[k] !== ")") return null;
257 return { text, url, title, end: k + 1 };
258}
259
260const BARE_URL = /^https?:\/\/[^\s<>"'`]*[^\s<>"'`.,:;!?)\]}*_~]/i;
261
262function inline(text: string, opts: MarkdownOptions, depth = 0): string {
263 if (text.length > MAX_INLINE || depth > MAX_DEPTH) return escapeHtml(text);
264 const src = stripComments(text);
265 let out = "";
266 let i = 0;
267 const n = src.length;
268 while (i < n) {
269 if (scanned > budget) return out + escapeHtml(src.slice(i));
270 const ch = src[i]!;
271 // Backslash escapes and hard line breaks.
272 if (ch === "\\") {
273 const next = src[i + 1];
274 if (next === "\n") {
275 out += "<br>\n";
276 i += 2;
277 continue;
278 }
279 if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) {
280 out += escapeHtml(next);
281 i += 2;
282 continue;
283 }
284 }
285 // Two or more spaces before a newline are a hard line break. Decided from the source:
286 // looking at the end of `out` would flatten the whole string on every line.
287 if (ch === " ") {
288 let run = 1;
289 while (src[i + run] === " ") run++;
290 if (run >= 2 && src[i + run] === "\n") {
291 out += "<br>\n";
292 i += run + 1;
293 continue;
294 }
295 out += " ".repeat(run);
296 i += run;
297 continue;
298 }
299 // Code spans.
300 if (ch === "`") {
301 let run = 1;
302 while (src[i + run] === "`") run++;
303 const fence = "`".repeat(run);
304 let close = src.indexOf(fence, i + run);
305 while (close >= 0 && src[close + run] === "`") {
306 let skip = close;
307 while (src[skip] === "`") skip++;
308 close = src.indexOf(fence, skip);
309 }
310 scanned += (close < 0 ? n : close) - i;
311 if (close >= 0) {
312 let code = src.slice(i + run, close).replace(/\n/g, " ");
313 if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1);
314 out += `<code>${escapeHtml(code)}</code>`;
315 i = close + run;
316 continue;
317 }
318 out += fence;
319 i += run;
320 continue;
321 }
322 // Images.
323 if (ch === "!" && src[i + 1] === "[") {
324 const m = parseLink(src, i + 1);
325 if (m) {
326 const safe = safeUrl(m.url, "image", opts);
327 const url = safe && opts.image ? opts.image(safe) : safe;
328 const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, ""));
329 if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`;
330 else out += alt;
331 i = m.end;
332 continue;
333 }
334 }
335 // Links.
336 if (ch === "[") {
337 const m = parseLink(src, i);
338 if (m) {
339 const url = safeUrl(m.url, "link", opts);
340 const label = inline(m.text, opts, depth + 1);
341 if (url) {
342 const external = /^https?:/i.test(url);
343 out += `<a href="${escapeHtml(url)}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""}${external ? ' rel="nofollow noopener noreferrer"' : ""}>${label}</a>`;
344 } else out += label;
345 i = m.end;
346 continue;
347 }
348 }
349 // Autolinks <https://…>.
350 if (ch === "<") {
351 const m = /^<(https?:\/\/[^\s<>]+)>/i.exec(src.slice(i, i + MAX_SCAN));
352 if (m) {
353 const url = escapeHtml(m[1]!);
354 out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
355 i += m[0].length;
356 continue;
357 }
358 }
359 // Bare URLs.
360 if ((ch === "h" || ch === "H") && (i === 0 || /[\s(]/.test(src[i - 1]!))) {
361 const m = BARE_URL.exec(src.slice(i, i + MAX_SCAN));
362 if (m) {
363 const url = escapeHtml(m[0]);
364 out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
365 i += m[0].length;
366 continue;
367 }
368 }
369 // Strikethrough.
370 if (ch === "~" && src[i + 1] === "~" && src[i + 2] && !/\s/.test(src[i + 2]!)) {
371 const close = findClosing(src, "~~", i + 2, false);
372 if (close > i + 2) {
373 out += `<del>${inline(src.slice(i + 2, close), opts, depth + 1)}</del>`;
374 i = close + 2;
375 continue;
376 }
377 }
378 // Emphasis.
379 if (ch === "*" || ch === "_") {
380 let run = 1;
381 while (src[i + run] === ch) run++;
382 const wordy = ch === "_";
383 const prev = src[i - 1];
384 const nextCh = src[i + run];
385 const canOpen = nextCh !== undefined && !/\s/.test(nextCh) && !(wordy && prev !== undefined && /[\p{L}\p{N}]/u.test(prev));
386 let matched = false;
387 if (canOpen && run <= 3) {
388 for (const size of run === 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1]) {
389 const delim = ch.repeat(size);
390 const start = i + run;
391 const close = findClosing(src, delim, start, wordy);
392 if (close > start) {
393 const inner = inline(src.slice(start, close), opts, depth + 1);
394 out += escapeHtml(ch.repeat(run - size));
395 out += size === 3 ? `<strong><em>${inner}</em></strong>` : size === 2 ? `<strong>${inner}</strong>` : `<em>${inner}</em>`;
396 i = close + size;
397 matched = true;
398 break;
399 }
400 }
401 }
402 if (matched) continue;
403 out += escapeHtml(ch.repeat(run));
404 i += run;
405 continue;
406 }
407 out += ESC[ch] ?? ch;
408 i++;
409 }
410 return out;
411}
412
413// ---------------------------------------------------------------- blocks
414
415// Block patterns run on every line, so none may backtrack: no two adjacent quantifiers that
416// can match the same characters, and `[^]` rather than `.`, which stops at U+2028 and fails
417// the `$` after it. Trailing spaces and closing `#`s are trimmed by hand (see headingText).
418const FENCE = /^ {0,3}(`{3,}(?!`)|~{3,}(?!~))(?![^`]*`)\s*([^`\s]*)/;
419const HEADING = /^ {0,3}(#{1,6})(?:[ \t]([^]*))?$/;
420const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
421const QUOTE = /^ {0,3}> ?([^]*)$/;
422const ALERT = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i;
423const COMMENT = /^ {0,3}<!--/;
424const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+([^]*))?$/;
425const SEP_CELL = /^:?-+:?$/;
426
427function trimBlank(s: string): string {
428 let a = 0;
429 let b = s.length;
430 while (a < b && (s[a] === " " || s[a] === "\t")) a++;
431 while (b > a && (s[b - 1] === " " || s[b - 1] === "\t")) b--;
432 return s.slice(a, b);
433}
434
435/** A heading's text without its surrounding spaces and optional closing `#`s. */
436function headingText(raw: string): string {
437 const t = trimBlank(raw);
438 let j = t.length;
439 while (j > 0 && t[j - 1] === "#") j--;
440 if (j === t.length) return t;
441 if (j === 0) return "";
442 return t[j - 1] === " " || t[j - 1] === "\t" ? trimBlank(t.slice(0, j)) : t;
443}
444
445/** A table's delimiter row, like `| :-- | --: |`, checked cell by cell. */
446function tableSep(line: string): boolean {
447 let s = trimBlank(line);
448 if (s.startsWith("|")) s = s.slice(1);
449 if (s.endsWith("|")) s = s.slice(0, -1);
450 return s.split("|").every((cell) => SEP_CELL.test(trimBlank(cell)));
451}
452
453function splitRow(line: string): string[] {
454 let s = line.trim();
455 if (s.startsWith("|")) s = s.slice(1);
456 if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
457 const cells: string[] = [];
458 let cur = "";
459 let inCode = false;
460 for (let i = 0; i < s.length; i++) {
461 const ch = s[i]!;
462 if (ch === "\\" && s[i + 1] === "|") {
463 cur += "|";
464 i++;
465 continue;
466 }
467 if (ch === "`") inCode = !inCode;
468 if (ch === "|" && !inCode) {
469 cells.push(cur.trim());
470 cur = "";
471 continue;
472 }
473 cur += ch;
474 }
475 cells.push(cur.trim());
476 return cells;
477}
478
479function startsBlock(line: string, next: string | undefined): boolean {
480 return (
481 FENCE.test(line) ||
482 HEADING.test(line) ||
483 HR.test(line) ||
484 QUOTE.test(line) ||
485 COMMENT.test(line) ||
486 /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) ||
487 (line.includes("|") && next !== undefined && tableSep(next))
488 );
489}
490
491function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string {
492 if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`;
493 let i = 0;
494 const parts: string[] = [];
495 while (i < lines.length) {
496 const line = lines[i]!;
497 if (!line.trim()) {
498 i++;
499 continue;
500 }
501 // Fenced code.
502 const fence = FENCE.exec(line);
503 if (fence) {
504 const marker = fence[1]!;
505 const lang = fence[2] ?? "";
506 const indent = line.length - line.trimStart().length;
507 const body: string[] = [];
508 i++;
509 while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) {
510 body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), ""));
511 i++;
512 }
513 i++;
514 const named = /^[\w+#.-]{1,30}$/.test(lang) ? lang : "";
515 const hl = named && opts.highlight ? opts.highlight(body.join("\n"), named) : null;
516 const cls = named ? ` class="language-${escapeHtml(named)}${hl !== null ? " sh" : ""}"` : "";
517 parts.push(`<pre><code${cls}>${hl ?? escapeHtml(body.join("\n"))}</code></pre>`);
518 continue;
519 }
520 // Indented code (4 spaces), only outside lists.
521 if (/^( {4}|\t)/.test(line) && !tight) {
522 const body: string[] = [];
523 while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) {
524 body.push(lines[i]!.replace(/^( {4}|\t)/, ""));
525 i++;
526 }
527 while (body.length && !body[body.length - 1]!.trim()) body.pop();
528 parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
529 continue;
530 }
531 // HTML comments, hidden as GitHub hides them. A line that starts with "<!--" opens one that
532 // runs to the line holding "-->"; anything after the close on that line is rendered as usual.
533 // Left open, it hides the rest of the document (or of the list item or quote it is in).
534 const comment = COMMENT.exec(line);
535 if (comment) {
536 let j = i;
537 let close = line.indexOf("-->", comment[0].length - 2);
538 while (close < 0 && ++j < lines.length) close = lines[j]!.indexOf("-->");
539 if (close < 0) break;
540 const rest = lines[j]!.slice(close + 3).trimStart();
541 if (rest) lines[j] = rest;
542 i = rest ? j : j + 1;
543 continue;
544 }
545 const h = HEADING.exec(line);
546 if (h) {
547 const level = h[1]!.length;
548 const text = stripComments(headingText(h[2] ?? ""));
549 const html = inline(text, opts, depth);
550 const id = escapeHtml(`${opts.idPrefix ?? "h-"}${slugify(text)}`);
551 parts.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-hidden="true">#</a>${html}</h${level}>`);
552 i++;
553 continue;
554 }
555 if (HR.test(line)) {
556 parts.push("<hr>");
557 i++;
558 continue;
559 }
560 if (QUOTE.test(line)) {
561 const body: string[] = [];
562 while (i < lines.length && lines[i]!.trim()) {
563 const m = QUOTE.exec(lines[i]!);
564 if (m) body.push(m[1]!);
565 else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!);
566 else break;
567 i++;
568 }
569 // GitHub's alerts: a quote whose first line is only [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] or [!CAUTION].
570 const alert = ALERT.exec(body[0] ?? "");
571 if (alert && body.length > 1) {
572 const kind = alert[1]!.toLowerCase();
573 parts.push(`<div class="markdown-alert markdown-alert-${kind}"><p class="markdown-alert-title">${kind[0]!.toUpperCase()}${kind.slice(1)}</p>${blocks(body.slice(1), opts, depth + 1)}</div>`);
574 continue;
575 }
576 parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`);
577 continue;
578 }
579 // Tables.
580 if (line.includes("|") && i + 1 < lines.length && tableSep(lines[i + 1]!)) {
581 const head = splitRow(line);
582 const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : ""));
583 i += 2;
584 const rows: string[][] = [];
585 while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) {
586 rows.push(splitRow(lines[i]!));
587 i++;
588 }
589 const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`;
590 let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`;
591 if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`;
592 parts.push(`${t}</table></div>`);
593 continue;
594 }
595 // Lists.
596 const li = LIST_ITEM.exec(line);
597 if (li) {
598 const ordered = /\d/.test(li[2]!);
599 const baseIndent = li[1]!.length;
600 const start = ordered ? parseInt(li[2]!, 10) : 1;
601 const items: string[][] = [];
602 let loose = false;
603 while (i < lines.length) {
604 const m = LIST_ITEM.exec(lines[i]!);
605 if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break;
606 const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length));
607 const item: string[] = [m[4] ?? ""];
608 i++;
609 let sawBlank = false;
610 while (i < lines.length) {
611 const l = lines[i]!;
612 if (!l.trim()) {
613 sawBlank = true;
614 item.push("");
615 i++;
616 continue;
617 }
618 const ind = l.length - l.trimStart().length;
619 if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) {
620 item.push(l.slice(Math.min(ind, contentIndent)));
621 sawBlank = false;
622 i++;
623 continue;
624 }
625 if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) {
626 // Lazy continuation of the item's paragraph.
627 item.push(l.trim());
628 i++;
629 continue;
630 }
631 break;
632 }
633 while (item.length && !item[item.length - 1]!.trim()) {
634 item.pop();
635 if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true;
636 }
637 if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true;
638 items.push(item);
639 }
640 const rendered = items.map((item) => {
641 let first = item[0] ?? "";
642 let task = "";
643 const tm = /^\[([ xX])\][ \t]+/.exec(first);
644 if (tm) {
645 task = tm[1] !== " " ? `<span class="task-box on" role="img" aria-label="Done"></span> ` : `<span class="task-box" role="img" aria-label="To do"></span> `;
646 first = first.slice(tm[0].length);
647 }
648 const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose);
649 return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`;
650 });
651 parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`);
652 continue;
653 }
654 // Paragraph.
655 const para: string[] = [line.trimStart()];
656 i++;
657 while (i < lines.length && lines[i]!.trim()) {
658 // Setext headings (checked before "---" is taken as a thematic break).
659 if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) {
660 const level = lines[i]!.includes("=") ? 1 : 2;
661 const text = stripComments(para.join(" "));
662 const id = escapeHtml(`${opts.idPrefix ?? "h-"}${slugify(text)}`);
663 para.length = 0;
664 parts.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`);
665 i++;
666 break;
667 }
668 if (startsBlock(lines[i]!, lines[i + 1])) break;
669 para.push(lines[i]!.trimStart());
670 i++;
671 }
672 if (para.length) {
673 const html = inline(trimBlank(para.join("\n")), opts, depth);
674 parts.push(tight ? html : `<p>${html}</p>`);
675 }
676 }
677 return parts.join("\n");
678}
679
680/** Languages named by the document's code fences, to load grammars before rendering. */
681export function fenceLangs(source: string): string[] {
682 if (source.length > MAX_SOURCE) return [];
683 const out = new Set<string>();
684 for (const line of source.split("\n")) {
685 const lang = FENCE.exec(line)?.[2];
686 if (lang && /^[\w+#.-]{1,30}$/.test(lang)) out.add(lang);
687 }
688 return [...out];
689}
690
691/**
692 * Markdown without its HTML comments, for plain-text views such as email. Comments inside
693 * fenced code and code spans stay.
694 */
695export function stripHtmlComments(source: string): string {
696 if (!source.includes("<!--") || source.length > MAX_SOURCE) return source;
697 scanned = 0;
698 budget = scanBudget(source.length);
699 const out: string[] = [];
700 let prose: string[] = [];
701 let fence: RegExp | null = null;
702 const flush = () => {
703 if (prose.length) out.push(stripComments(prose.join("\n")));
704 prose = [];
705 };
706 for (const line of source.split("\n")) {
707 if (fence) {
708 out.push(line);
709 if (fence.test(line)) fence = null;
710 continue;
711 }
712 const open = FENCE.exec(line);
713 if (open) {
714 flush();
715 out.push(line);
716 fence = new RegExp(`^ {0,3}${open[1]![0] === "`" ? "`" : "~"}{${open[1]!.length},}\\s*$`);
717 continue;
718 }
719 prose.push(line);
720 }
721 flush();
722 return out.join("\n").replace(/\n[ \t]*(?:\n[ \t]*){2,}/g, "\n\n").trim();
723}
724
725/** Render Markdown to sanitized HTML. */
726export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string {
727 if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`;
728 const lines = source.replace(/\r\n?/g, "\n").split("\n");
729 scanned = 0;
730 budget = scanBudget(source.length);
731 return blocks(lines, opts, 0);
732}