import type { ReactNode } from 'react'; import { languageForName, refractor } from './language'; /* ============================================================================ markdown — the subset of GitHub-flavoured markdown a review comment uses Comments are stored and edited as plain text; this turns that text into nodes for display. It is deliberately hand-rolled rather than a markdown dependency: the review UI already owns its own syntax highlighting, the bundle is served out of the binary, and the shapes that actually turn up in a review comment are a small, stable list — fenced code (highlighted through refractor, same theme as the diff), `inline code`, **bold**, *italic*, ~~strike~~, [links](https://…), bare URLs, bullet/numbered lists (nested, with `- [ ]` task items), # headings, > quotes, --- rules, and paragraphs. Tables and reference-style links are not supported; they render as the raw text they were typed as, which is the same thing a reader would have seen before any of this existed. Soft line breaks are hard breaks, as they are in a GitHub comment box — people type comments in lines, not in paragraphs. ========================================================================== */ const FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*(\S*)/; const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/; const HR = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/; const QUOTE = /^ {0,3}>[ \t]?(.*)$/; const BULLET = /^([ \t]*)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/; const TASK = /^\[([ xX])\][ \t]+/; const LINK = /^\[((?:[^\][\\]|\\.)*)\]\([ \t]*)]*)>?(?:[ \t]+"[^"]*")?[ \t]*\)/; const AUTOLINK = /^<(https?:\/\/[^\s<>]+|mailto:[^\s<>]+)>/; const BARE_URL = /^https?:\/\/[^\s<>()[\]"'`]+/; const PUNCT = /[\\`*_{}[\]()#+\-.!~>|]/; // renderMarkdown turns a comment body into nodes. The result is a fragment's // worth of children, so the caller keeps ownership of the wrapper — it needs // the `md` class on it for the styles to apply. export function renderMarkdown(body: string): ReactNode { // Tabs only ever appear as indentation in practice, and every block rule // below measures indentation in spaces. return <>{blocks(body.replace(/\t/g, ' ').split('\n'), 'b')}; } // ---- blocks --------------------------------------------------------------- function indentOf(line: string): number { return line.length - line.trimStart().length; } function startsBlock(line: string): boolean { return ( FENCE.test(line) || HEADING.test(line) || HR.test(line) || QUOTE.test(line) || BULLET.test(line) ); } function blocks(lines: string[], key: string): ReactNode[] { const out: ReactNode[] = []; let i = 0; let n = 0; while (i < lines.length) { const line = lines[i]; const k = `${key}-${n++}`; if (!line.trim()) { i++; continue; } const fence = FENCE.exec(line); if (fence) { const marker = fence[1]; let end = i + 1; while (end < lines.length) { const close = lines[end].trim(); if (close.startsWith(marker[0].repeat(marker.length)) && /^(`+|~+)$/.test(close)) break; end++; } out.push(codeBlock(lines.slice(i + 1, end).join('\n'), fence[2], k)); // An unterminated fence swallows the rest of the comment, which is what // every markdown renderer does with it. i = end + 1; continue; } const heading = HEADING.exec(line); if (heading) { const Tag = `h${heading[1].length}` as 'h1'; out.push( {inline(heading[2], k)} , ); i++; continue; } // Checked before lists: `- - -` is a rule, not three nested bullets. if (HR.test(line)) { out.push(
); i++; continue; } if (QUOTE.test(line)) { const quoted: string[] = []; while (i < lines.length) { const m = QUOTE.exec(lines[i]); if (!m) break; quoted.push(m[1]); i++; } out.push(
{blocks(quoted, k)}
, ); continue; } if (BULLET.test(line)) { const [node, next] = list(lines, i, k); out.push(node); i = next; continue; } const para: string[] = []; while (i < lines.length && lines[i].trim() && !startsBlock(lines[i])) { para.push(lines[i]); i++; } out.push(

{inline(para.join('\n'), k)}

, ); } return out; } // list consumes one whole list — every item at the starting indentation, plus // each item's continuation lines — and returns it with the index just past it. function list(lines: string[], start: number, key: string): [ReactNode, number] { const first = BULLET.exec(lines[start])!; const base = first[1].length; const ordered = /\d/.test(first[2]); // How far an item's continuation lines are indented: past the marker. const pad = base + first[2].length + 1; const items: string[][] = []; let i = start; while (i < lines.length) { const line = lines[i]; if (!line.trim()) { // A blank line is inside the list only if the list carries on after it. const next = lines[i + 1]; if ( next && next.trim() && (indentOf(next) > base || (BULLET.test(next) && indentOf(next) >= base)) ) { items[items.length - 1]?.push(''); i++; continue; } break; } const marker = BULLET.exec(line); const indent = indentOf(line); // A marker at (about) the base indentation opens the next item; anything // indented past it belongs to the item that is open. if (marker && !HR.test(line) && indent <= base + 1) { items.push([marker[3]]); i++; continue; } if (indent > base && items.length > 0) { items[items.length - 1].push(line.slice(Math.min(indent, pad))); i++; continue; } break; } const Tag = (ordered ? 'ol' : 'ul') as 'ol'; const node = ( {items.map((item, idx) => listItem(item, `${key}-${idx}`))} ); return [node, i]; } // listItem renders one list item. Its opening lines stay inline so a tight list // keeps its tight spacing; anything after them (a nested list, a fenced block, // a second paragraph) goes through the block parser. function listItem(lines: string[], key: string): ReactNode { let split = 0; while (split < lines.length && lines[split].trim() && !startsBlock(lines[split])) split++; let lead = lines.slice(0, split).join('\n'); const task = TASK.exec(lead); if (task) lead = lead.slice(task[0].length); return (
  • {task && ( )} {lead ? inline(lead, key) : null} {blocks(lines.slice(split), key)}
  • ); } // ---- code ----------------------------------------------------------------- // The shape refractor hands back: hast, narrowed to what a highlighted tree // actually contains. interface HastNode { type: string; value?: string; properties?: { className?: string[] | string }; children?: HastNode[]; } function codeBlock(code: string, info: string, key: string): ReactNode { const lang = languageForName(info); return (
          
            {lang
              ? hast(refractor.highlight(code, lang).children as unknown as HastNode[])
              : code}
          
        
    ); } function hast(nodes: HastNode[]): ReactNode[] { return nodes.map((node, i) => { if (node.type === 'text') return node.value ?? ''; const cls = node.properties?.className; return ( {hast(node.children ?? [])} ); }); } // ---- inline --------------------------------------------------------------- // Only schemes a comment has any business linking to. Anything else — and // `javascript:` above all — renders as the text it was typed as. function safeUrl(url: string): string | null { return /^(https?:\/\/|mailto:)/i.test(url) ? url : null; } // closer finds the index of the closing delimiter for one that opened at // `from`, skipping escaped ones. -1 when the delimiter is never closed, which // makes it ordinary text. function closer(src: string, from: number, delim: string): number { let i = from; while (i < src.length) { const at = src.indexOf(delim, i); if (at < 0) return -1; if (src[at - 1] === '\\') { i = at + delim.length; continue; } return at; } return -1; } function inline(src: string, key: string): ReactNode[] { const out: ReactNode[] = []; let text = ''; let n = 0; const flush = () => { if (text) out.push(text); text = ''; }; const push = (node: ReactNode) => { flush(); out.push(node); }; const nextKey = () => `${key}-i${n++}`; let i = 0; while (i < src.length) { const ch = src[i]; if (ch === '\\' && PUNCT.test(src[i + 1] ?? '')) { text += src[i + 1]; i += 2; continue; } // A comment box turns the newlines you typed into the breaks you meant. if (ch === '\n') { push(
    ); i++; continue; } if (ch === '`') { const run = /^`+/.exec(src.slice(i))![0]; const end = src.indexOf(run, i + run.length); if (end > 0) { const code = src.slice(i + run.length, end).replace(/\n/g, ' '); push( {code.length > 2 && code.startsWith(' ') && code.endsWith(' ') ? code.slice(1, -1) : code} , ); i = end + run.length; continue; } } if (ch === '[') { const link = LINK.exec(src.slice(i)); const url = link && safeUrl(link[2]); if (link && url) { push( {inline(link[1], nextKey())} , ); i += link[0].length; continue; } } if (ch === '<') { const auto = AUTOLINK.exec(src.slice(i)); if (auto) { push( {auto[1]} , ); i += auto[0].length; continue; } } // A URL typed on its own, linked where it starts a word so the `//` inside // some other string is left alone. if (ch === 'h' && !/[\w/]/.test(src[i - 1] ?? '')) { const bare = BARE_URL.exec(src.slice(i)); if (bare) { // Sentence punctuation right after a URL is the sentence's, not the // URL's. const url = bare[0].replace(/[.,;:!?]+$/, ''); push( {url} , ); i += url.length; continue; } } if (ch === '~' && src[i + 1] === '~') { const end = closer(src, i + 2, '~~'); if (end > i + 2) { push({inline(src.slice(i + 2, end), nextKey())}); i = end + 2; continue; } } if (ch === '*' || ch === '_') { // One marker is italic, two are bold, three are both — past that it is // someone drawing a line of asterisks, and the run is just text. const run = Math.min(3, /^(?:\*+|_+)/.exec(src.slice(i))![0].length); const delim = ch.repeat(run); // `_` only marks emphasis at a word boundary, so snake_case_names survive // being talked about. const wordish = ch === '_' && /\w/.test(src[i - 1] ?? ''); const end = wordish ? -1 : closer(src, i + run, delim); const body = end < 0 ? '' : src.slice(i + run, end); // Markers have to sit against what they emphasise, or `2 * 3 * 4` comes // out italic; `_` also has to close on a word boundary. const hugs = !!body && !/^\s|\s$/.test(body); const closes = ch === '*' || !/\w/.test(src[end + run] ?? ' '); if (hugs && closes) { const content = inline(body, nextKey()); push( run === 1 ? ( {content} ) : run === 2 ? ( {content} ) : ( {content} ), ); i = end + run; continue; } // A run that emphasises nothing is text — all of it. Consuming it whole // keeps a stray `**` from leaving a `*` behind to pair with the next one. text += delim; i += run; continue; } text += ch; i++; } flush(); return out; } // ---- plain text ----------------------------------------------------------- // markdownToText strips the markup back off, for the places that show a // comment as one clamped line of preview: the rail's cards and a collapsed // resolved thread. Rendering markdown there would fight the clamp, but showing // the raw `**syntax**` is worse than showing neither. export function markdownToText(body: string): string { const lines: string[] = []; let fenced = false; for (const line of body.replace(/\t/g, ' ').split('\n')) { if (FENCE.test(line)) { fenced = !fenced; continue; } if (fenced) { lines.push(line.trim()); continue; } lines.push( line .replace(HEADING, '$2') .replace(QUOTE, '$1') .replace(BULLET, '$1$3') .replace(TASK, ''), ); } return lines .join('\n') .replace(/^ {0,3}(?:[-*_][ \t]*){3,}$/gm, '') // rules leave nothing to read .replace(/!?\[((?:[^\][\\]|\\.)*)\]\([^)]*\)/g, '$1') // links keep their text .replace(/<(https?:\/\/[^\s<>]+)>/g, '$1') .replace(/`+/g, '') .replace(/(\*\*|__|~~)/g, '') .replace(/(^|[^\w\\])[*_]([^\s*_][^*_]*)[*_]/g, '$1$2') .replace(/\\([\\`*_{}[\]()#+\-.!~>|])/g, '$1') .replace(/\n{3,}/g, '\n\n') .trim(); }