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.

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