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.

724 lines · 25.3 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 COMMENT = /^ {0,3}<!--/;
423const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+([^]*))?$/;
424const SEP_CELL = /^:?-+:?$/;
425
426function trimBlank(s: string): string {
427 let a = 0;
428 let b = s.length;
429 while (a < b && (s[a] === " " || s[a] === "\t")) a++;
430 while (b > a && (s[b - 1] === " " || s[b - 1] === "\t")) b--;
431 return s.slice(a, b);
432}
433
434/** A heading's text without its surrounding spaces and optional closing `#`s. */
435function headingText(raw: string): string {
436 const t = trimBlank(raw);
437 let j = t.length;
438 while (j > 0 && t[j - 1] === "#") j--;
439 if (j === t.length) return t;
440 if (j === 0) return "";
441 return t[j - 1] === " " || t[j - 1] === "\t" ? trimBlank(t.slice(0, j)) : t;
442}
443
444/** A table's delimiter row, like `| :-- | --: |`, checked cell by cell. */
445function tableSep(line: string): boolean {
446 let s = trimBlank(line);
447 if (s.startsWith("|")) s = s.slice(1);
448 if (s.endsWith("|")) s = s.slice(0, -1);
449 return s.split("|").every((cell) => SEP_CELL.test(trimBlank(cell)));
450}
451
452function splitRow(line: string): string[] {
453 let s = line.trim();
454 if (s.startsWith("|")) s = s.slice(1);
455 if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
456 const cells: string[] = [];
457 let cur = "";
458 let inCode = false;
459 for (let i = 0; i < s.length; i++) {
460 const ch = s[i]!;
461 if (ch === "\\" && s[i + 1] === "|") {
462 cur += "|";
463 i++;
464 continue;
465 }
466 if (ch === "`") inCode = !inCode;
467 if (ch === "|" && !inCode) {
468 cells.push(cur.trim());
469 cur = "";
470 continue;
471 }
472 cur += ch;
473 }
474 cells.push(cur.trim());
475 return cells;
476}
477
478function startsBlock(line: string, next: string | undefined): boolean {
479 return (
480 FENCE.test(line) ||
481 HEADING.test(line) ||
482 HR.test(line) ||
483 QUOTE.test(line) ||
484 COMMENT.test(line) ||
485 /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) ||
486 (line.includes("|") && next !== undefined && tableSep(next))
487 );
488}
489
490function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string {
491 if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`;
492 let i = 0;
493 const parts: string[] = [];
494 while (i < lines.length) {
495 const line = lines[i]!;
496 if (!line.trim()) {
497 i++;
498 continue;
499 }
500 // Fenced code.
501 const fence = FENCE.exec(line);
502 if (fence) {
503 const marker = fence[1]!;
504 const lang = fence[2] ?? "";
505 const indent = line.length - line.trimStart().length;
506 const body: string[] = [];
507 i++;
508 while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) {
509 body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), ""));
510 i++;
511 }
512 i++;
513 const named = /^[\w+#.-]{1,30}$/.test(lang) ? lang : "";
514 const hl = named && opts.highlight ? opts.highlight(body.join("\n"), named) : null;
515 const cls = named ? ` class="language-${escapeHtml(named)}${hl !== null ? " sh" : ""}"` : "";
516 parts.push(`<pre><code${cls}>${hl ?? escapeHtml(body.join("\n"))}</code></pre>`);
517 continue;
518 }
519 // Indented code (4 spaces), only outside lists.
520 if (/^( {4}|\t)/.test(line) && !tight) {
521 const body: string[] = [];
522 while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) {
523 body.push(lines[i]!.replace(/^( {4}|\t)/, ""));
524 i++;
525 }
526 while (body.length && !body[body.length - 1]!.trim()) body.pop();
527 parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
528 continue;
529 }
530 // HTML comments, hidden as GitHub hides them. A line that starts with "<!--" opens one that
531 // runs to the line holding "-->"; anything after the close on that line is rendered as usual.
532 // Left open, it hides the rest of the document (or of the list item or quote it is in).
533 const comment = COMMENT.exec(line);
534 if (comment) {
535 let j = i;
536 let close = line.indexOf("-->", comment[0].length - 2);
537 while (close < 0 && ++j < lines.length) close = lines[j]!.indexOf("-->");
538 if (close < 0) break;
539 const rest = lines[j]!.slice(close + 3).trimStart();
540 if (rest) lines[j] = rest;
541 i = rest ? j : j + 1;
542 continue;
543 }
544 const h = HEADING.exec(line);
545 if (h) {
546 const level = h[1]!.length;
547 const text = stripComments(headingText(h[2] ?? ""));
548 const html = inline(text, opts, depth);
549 const id = escapeHtml(`${opts.idPrefix ?? "h-"}${slugify(text)}`);
550 parts.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-hidden="true">#</a>${html}</h${level}>`);
551 i++;
552 continue;
553 }
554 if (HR.test(line)) {
555 parts.push("<hr>");
556 i++;
557 continue;
558 }
559 if (QUOTE.test(line)) {
560 const body: string[] = [];
561 while (i < lines.length && lines[i]!.trim()) {
562 const m = QUOTE.exec(lines[i]!);
563 if (m) body.push(m[1]!);
564 else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!);
565 else break;
566 i++;
567 }
568 parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`);
569 continue;
570 }
571 // Tables.
572 if (line.includes("|") && i + 1 < lines.length && tableSep(lines[i + 1]!)) {
573 const head = splitRow(line);
574 const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : ""));
575 i += 2;
576 const rows: string[][] = [];
577 while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) {
578 rows.push(splitRow(lines[i]!));
579 i++;
580 }
581 const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`;
582 let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`;
583 if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`;
584 parts.push(`${t}</table></div>`);
585 continue;
586 }
587 // Lists.
588 const li = LIST_ITEM.exec(line);
589 if (li) {
590 const ordered = /\d/.test(li[2]!);
591 const baseIndent = li[1]!.length;
592 const start = ordered ? parseInt(li[2]!, 10) : 1;
593 const items: string[][] = [];
594 let loose = false;
595 while (i < lines.length) {
596 const m = LIST_ITEM.exec(lines[i]!);
597 if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break;
598 const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length));
599 const item: string[] = [m[4] ?? ""];
600 i++;
601 let sawBlank = false;
602 while (i < lines.length) {
603 const l = lines[i]!;
604 if (!l.trim()) {
605 sawBlank = true;
606 item.push("");
607 i++;
608 continue;
609 }
610 const ind = l.length - l.trimStart().length;
611 if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) {
612 item.push(l.slice(Math.min(ind, contentIndent)));
613 sawBlank = false;
614 i++;
615 continue;
616 }
617 if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) {
618 // Lazy continuation of the item's paragraph.
619 item.push(l.trim());
620 i++;
621 continue;
622 }
623 break;
624 }
625 while (item.length && !item[item.length - 1]!.trim()) {
626 item.pop();
627 if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true;
628 }
629 if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true;
630 items.push(item);
631 }
632 const rendered = items.map((item) => {
633 let first = item[0] ?? "";
634 let task = "";
635 const tm = /^\[([ xX])\][ \t]+/.exec(first);
636 if (tm) {
637 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> `;
638 first = first.slice(tm[0].length);
639 }
640 const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose);
641 return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`;
642 });
643 parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`);
644 continue;
645 }
646 // Paragraph.
647 const para: string[] = [line.trimStart()];
648 i++;
649 while (i < lines.length && lines[i]!.trim()) {
650 // Setext headings (checked before "---" is taken as a thematic break).
651 if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) {
652 const level = lines[i]!.includes("=") ? 1 : 2;
653 const text = stripComments(para.join(" "));
654 const id = escapeHtml(`${opts.idPrefix ?? "h-"}${slugify(text)}`);
655 para.length = 0;
656 parts.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`);
657 i++;
658 break;
659 }
660 if (startsBlock(lines[i]!, lines[i + 1])) break;
661 para.push(lines[i]!.trimStart());
662 i++;
663 }
664 if (para.length) {
665 const html = inline(trimBlank(para.join("\n")), opts, depth);
666 parts.push(tight ? html : `<p>${html}</p>`);
667 }
668 }
669 return parts.join("\n");
670}
671
672/** Languages named by the document's code fences, to load grammars before rendering. */
673export function fenceLangs(source: string): string[] {
674 if (source.length > MAX_SOURCE) return [];
675 const out = new Set<string>();
676 for (const line of source.split("\n")) {
677 const lang = FENCE.exec(line)?.[2];
678 if (lang && /^[\w+#.-]{1,30}$/.test(lang)) out.add(lang);
679 }
680 return [...out];
681}
682
683/**
684 * Markdown without its HTML comments, for plain-text views such as email. Comments inside
685 * fenced code and code spans stay.
686 */
687export function stripHtmlComments(source: string): string {
688 if (!source.includes("<!--") || source.length > MAX_SOURCE) return source;
689 scanned = 0;
690 budget = scanBudget(source.length);
691 const out: string[] = [];
692 let prose: string[] = [];
693 let fence: RegExp | null = null;
694 const flush = () => {
695 if (prose.length) out.push(stripComments(prose.join("\n")));
696 prose = [];
697 };
698 for (const line of source.split("\n")) {
699 if (fence) {
700 out.push(line);
701 if (fence.test(line)) fence = null;
702 continue;
703 }
704 const open = FENCE.exec(line);
705 if (open) {
706 flush();
707 out.push(line);
708 fence = new RegExp(`^ {0,3}${open[1]![0] === "`" ? "`" : "~"}{${open[1]!.length},}\\s*$`);
709 continue;
710 }
711 prose.push(line);
712 }
713 flush();
714 return out.join("\n").replace(/\n[ \t]*(?:\n[ \t]*){2,}/g, "\n\n").trim();
715}
716
717/** Render Markdown to sanitized HTML. */
718export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string {
719 if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`;
720 const lines = source.replace(/\r\n?/g, "\n").split("\n");
721 scanned = 0;
722 budget = scanBudget(source.length);
723 return blocks(lines, opts, 0);
724}