Files
playpen/web/src/lib/markdown.tsx
T

502 lines
14 KiB
TypeScript

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(
<Tag key={k} className="md-h">
{inline(heading[2], k)}
</Tag>,
);
i++;
continue;
}
// Checked before lists: `- - -` is a rule, not three nested bullets.
if (HR.test(line)) {
out.push(<hr key={k} className="md-hr" />);
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(
<blockquote key={k} className="md-quote">
{blocks(quoted, k)}
</blockquote>,
);
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(
<p key={k} className="md-p">
{inline(para.join('\n'), k)}
</p>,
);
}
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 = (
<Tag key={key} className="md-list" start={ordered ? Number.parseInt(first[2], 10) || 1 : undefined}>
{items.map((item, idx) => listItem(item, `${key}-${idx}`))}
</Tag>
);
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 (
<li key={key} className={task ? 'md-item is-task' : 'md-item'}>
{task && (
<input
className="md-check"
type="checkbox"
checked={task[1] !== ' '}
disabled
readOnly
/>
)}
{lead ? inline(lead, key) : null}
{blocks(lines.slice(split), key)}
</li>
);
}
// ---- 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 (
<pre key={key} className="md-pre">
<code>
{lang
? hast(refractor.highlight(code, lang).children as unknown as HastNode[])
: code}
</code>
</pre>
);
}
function hast(nodes: HastNode[]): ReactNode[] {
return nodes.map((node, i) => {
if (node.type === 'text') return node.value ?? '';
const cls = node.properties?.className;
return (
<span key={i} className={Array.isArray(cls) ? cls.join(' ') : cls}>
{hast(node.children ?? [])}
</span>
);
});
}
// ---- 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(<br key={nextKey()} />);
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 key={nextKey()} className="md-code">
{code.length > 2 && code.startsWith(' ') && code.endsWith(' ')
? code.slice(1, -1)
: code}
</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(
<a
key={nextKey()}
className="md-link"
href={url}
target="_blank"
rel="noreferrer noopener"
>
{inline(link[1], nextKey())}
</a>,
);
i += link[0].length;
continue;
}
}
if (ch === '<') {
const auto = AUTOLINK.exec(src.slice(i));
if (auto) {
push(
<a
key={nextKey()}
className="md-link"
href={auto[1]}
target="_blank"
rel="noreferrer noopener"
>
{auto[1]}
</a>,
);
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(
<a
key={nextKey()}
className="md-link"
href={url}
target="_blank"
rel="noreferrer noopener"
>
{url}
</a>,
);
i += url.length;
continue;
}
}
if (ch === '~' && src[i + 1] === '~') {
const end = closer(src, i + 2, '~~');
if (end > i + 2) {
push(<del key={nextKey()}>{inline(src.slice(i + 2, end), nextKey())}</del>);
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 ? (
<em key={nextKey()}>{content}</em>
) : run === 2 ? (
<strong key={nextKey()}>{content}</strong>
) : (
<strong key={nextKey()}>
<em>{content}</em>
</strong>
),
);
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();
}