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]*([^\s<>)]*)>?(?:[ \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(
{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 = (
{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(
{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(