/** * A small, escape-first Markdown renderer for the browse file view. * * It is deliberately not CommonMark: it covers the block and inline constructs * that show up in repository READMEs — headings, paragraphs, fenced code, * blockquotes, nested and task lists, tables, code spans, emphasis, inline and * reference links, and images. Named and numeric character references are * decoded as literal text. Raw HTML is re-emitted through the allowlist in * `raw-html.ts`; `html: false` restores escape-only rendering, where the source * is shown as text and never executed. * * Relative links become `/view/` and relative images become `/raw/` * so they resolve from any page URL. The `/view` and `/raw` routes re-vet the * path at fetch time, so resolution here only has to avoid obvious escapes. * External images are rendered as links (the page CSP is `img-src 'self' data:`) * and non-http(s) schemes such as `javascript:` are dropped. * * Headings get a stable anchor id and are demoted one level so the embedding * page keeps the only `h1`. * * Kept free of the page renderer: it imports only `lib/escape.ts`, * `lib/highlight.ts`, `urls.ts`, and `raw-html.ts`, so `html.ts` can compose it * without a module cycle. The block scanner, the inline scanner, and their * per-document state stay in one module because they are a single pass; URL * policy and raw HTML do not. */ import { decodeCodePoint, escapeHtml } from "../../../lib/escape.ts"; import { fenceLanguage, highlightLines } from "../../../lib/highlight.ts"; import { closesSuppressed, closeHtmlSink, closeInlineTags, createHtmlSink, type HtmlBlockStart, type HtmlSink, htmlBlockStart, sanitizeHtmlBlock, sanitizeInlineTag, } from "./raw-html.ts"; import { emitAnchor, emitImage, emitImageLink } from "./emit.ts"; import { type LinkBase, resolveImage, resolveLink } from "./urls.ts"; /** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */ const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i; /** GFM starts an autolink at the line start or after whitespace or one of `*_~(`. */ function autolinkBoundary(text: string, index: number): boolean { return index === 0 || /[\s*_~(]/.test(text[index - 1]); } /** Drop the trailing punctuation GFM excludes, and an unclosed `)`. */ function trimAutolink(url: string): string { let end = url.replace(/[.,;:!?*_~]+$/, ""); while (end.endsWith(")") && (end.match(/\(/g)?.length ?? 0) < (end.match(/\)/g)?.length ?? 0)) { end = end.slice(0, -1).replace(/[.,;:!?*_~]+$/, ""); } return end; } /** A link reference definition: `[id]: dest "title"`. */ const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/; interface LinkDefinition { dest: string; title?: string; } /** Per-document render state: where links resolve, the definitions, and the raw-HTML sink. */ interface Context { link: LinkBase; defs: Map; /** Emit sanitized raw HTML; `false` escapes it as text. */ html: boolean; sink: HtmlSink; slug(text: string): string; } /** Render options; every field has the behaviour the browse page uses. */ export interface MarkdownOptions { /** Emit sanitized raw HTML (the default) or escape it (`false`). */ html?: boolean; /** The prefix local links are written under, e.g. `/files`; `""` for the origin's root. */ base?: string; } /** * Render a Markdown source string to a safe HTML fragment. Headings carry an * anchor id and are demoted one level, so the embedding page keeps the only * `h1`. */ export function renderMarkdown(source: string, baseDir = "", options: MarkdownOptions = {}): string { const html = options.html ?? true; const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/), html); const context = createContext({ dir: baseDir, base: options.base ?? "" }, defs, html); const rendered = renderBlocks(body, context); return html ? rendered + closeHtmlSink(context.sink) : rendered; } /** Turn heading text into a stable anchor id, dropping Markdown punctuation. */ function headingSlug(text: string): string { const plain = text .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/[`*_~]/g, "") .toLowerCase() .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/[\s-]+/g, "-") .replace(/^-+|-+$/g, ""); return plain || "section"; } /** Build the per-document render state, suffixing repeated heading slugs. */ function createContext(link: LinkBase, defs: Map, html: boolean): Context { const seen = new Map(); return { link, defs, html, sink: createHtmlSink(), slug(text: string): string { const base = headingSlug(text); const count = seen.get(base) ?? 0; seen.set(base, count + 1); return count === 0 ? base : `${base}-${count}`; }, }; } /** Fold a reference label the way CommonMark does: trim, collapse, case-fold. Not `lib/session-label.ts`, which names a session. */ function referenceLabel(label: string): string { return label.trim().replace(/\s+/g, " ").toLowerCase(); } /** * Lift link reference definitions out of the source before block rendering, so * a `[id]: dest` line becomes a definition rather than a paragraph and can be * referenced from anywhere in the document. Fences are skipped so a * definition-shaped line inside code stays literal. Definitions are * single-line; one inside a blockquote or list item is left in place as text. */ function collectDefinitions(lines: string[], html: boolean): { defs: Map; body: string[] } { const defs = new Map(); const body: string[] = []; let fence: Fence | undefined; let inHtml: "block" | { suppressed: string } | undefined; for (const line of lines) { if (fence) { const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line); if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = undefined; body.push(line); continue; } if (inHtml) { if (inHtml === "block") { if (isBlank(line)) inHtml = undefined; } else if (closesSuppressed(line, inHtml.suppressed)) { inHtml = undefined; } body.push(line); continue; } const open = matchFence(line); if (open) { fence = open; body.push(line); continue; } const start = html ? htmlBlockStart(line) : undefined; if (start) { if (start.kind === "suppressed") { inHtml = closesSuppressed(line, start.name) ? undefined : { suppressed: start.name }; } else { inHtml = "block"; } body.push(line); continue; } const match = DEFINITION.exec(line); if (!match) { body.push(line); continue; } const key = referenceLabel(match[1]); if (!defs.has(key)) { const dest = match[2].startsWith("<") && match[2].endsWith(">") ? match[2].slice(1, -1) : match[2]; defs.set(key, { dest, title: match[3] }); } body.push(""); } return { defs, body }; } function isBlank(line: string): boolean { return line.trim() === ""; } function indentOf(line: string): number { let width = 0; for (const char of line) { if (char === " ") width++; else if (char === "\t") width += 4; else break; } return width; } function stripIndent(line: string, columns: number): string { let index = 0; let width = 0; while (index < line.length && width < columns && line[index] === " ") { index++; width++; } return line.slice(index); } function renderBlocks(lines: string[], ctx: Context): string { const out: string[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (isBlank(line)) { i++; continue; } const fence = matchFence(line); if (fence) { const [html, next] = renderFence(lines, i, fence); out.push(html); i = next; continue; } const heading = matchHeading(line); if (heading) { const level = Math.min(heading.level + 1, 6); const id = ctx.slug(heading.text); out.push(`${renderInline(heading.text, ctx)}`); i++; continue; } if (isThematicBreak(line)) { out.push("
"); i++; continue; } if (isBlockquoteLine(line)) { const [html, next] = renderBlockquote(lines, i, ctx); out.push(html); i = next; continue; } // A list marker wins over a pipe, so `- | a | b |` stays a list item // instead of becoming a table whose first cell is `-`. if (matchListItem(line)) { const [html, next] = renderList(lines, i, ctx); out.push(html); i = next; continue; } if (i + 1 < lines.length && isTableStart(line, lines[i + 1])) { const [html, next] = renderTable(lines, i, ctx); out.push(html); i = next; continue; } const htmlBlock = ctx.html ? htmlBlockStart(line) : undefined; if (htmlBlock) { const [html, next] = renderHtmlBlock(lines, i, htmlBlock, ctx); if (html !== "") out.push(html); i = next; continue; } const [html, next] = renderParagraph(lines, i, ctx); out.push(html); i = next; } return out.join("\n"); } /** True when `lines[i]` opens a block that may interrupt a paragraph. */ function isBlockStart(lines: string[], i: number, html: boolean): boolean { const line = lines[i]; return ( matchFence(line) !== undefined || matchHeading(line) !== undefined || isThematicBreak(line) || isBlockquoteLine(line) || matchListItem(line) !== undefined || (html && htmlBlockStart(line) !== undefined) || (i + 1 < lines.length && isTableStart(line, lines[i + 1])) ); } function renderParagraph(lines: string[], start: number, ctx: Context): [string, number] { const collected: string[] = []; let i = start; for (; i < lines.length; i++) { if (isBlank(lines[i])) break; if (i > start && isBlockStart(lines, i, ctx.html)) break; collected.push(lines[i]); } const html = collected .map((line) => { let text = line.trimEnd(); const hard = / {2}$/.test(line) || /\\$/.test(text); if (text.endsWith("\\")) text = text.slice(0, -1); const rendered = renderInline(text.trim(), ctx); return hard ? `${rendered}
` : rendered; }) .join("\n"); return [`

${html}

`, i]; } /** * Render an HTML block. An allowed block runs to the next blank line, matching * what a reader expects from GitHub: everything up to the blank line is markup, * and Markdown resumes after it. A suppressed element (`