markdown
tcmts.examples.userinput.markdown · #xlptj5mc · tsx file
// tcm: namespace userinput.design
// Markdown, as people write feedback in it: paragraphs and line breaks, headings, lists, quotes, code, links,
// emphasis. Rendered as React elements, never as HTML from the record: whatever a post says, it can't put markup in
// the page. Links go only to http(s) and mailto addresses.
import assert from "node:assert/strict";
import { Fragment } from "react";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
/** A block of a Markdown document. */
export type Block =
| { kind: "paragraph"; lines: string[] }
| { kind: "heading"; level: number; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "quote"; lines: string[] }
| { kind: "code"; lang: string; text: string }
| { kind: "rule" };
/**
* A Markdown document's blocks.
*
* ```ts
* blocksOf("# Title\n\nSome *text*.\n\n- one\n- two")
* ```
*/
export function blocksOf(text: string): Block[] {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const out: Block[] = [];
let i = 0;
const isBlank = (l: string | undefined) => l === undefined || !l.trim();
const listItem = /^\s{0,3}([-*+]|\d{1,3}[.)])\s+(.*)$/;
while (i < lines.length) {
const line = lines[i]!;
if (isBlank(line)) {
i++;
continue;
}
const fence = /^\s{0,3}(```|~~~)\s*([\w+-]*)\s*$/.exec(line);
if (fence) {
const body: string[] = [];
for (i++; i < lines.length && !lines[i]!.trim().startsWith(fence[1]!); i++) body.push(lines[i]!);
i++;
out.push({ kind: "code", lang: fence[2] ?? "", text: body.join("\n") });
continue;
}
const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
if (heading) {
out.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! });
i++;
continue;
}
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) {
out.push({ kind: "rule" });
i++;
continue;
}
if (/^\s{0,3}>/.test(line)) {
const quoted: string[] = [];
for (; i < lines.length && /^\s{0,3}>/.test(lines[i]!); i++) quoted.push(lines[i]!.replace(/^\s{0,3}>\s?/, ""));
out.push({ kind: "quote", lines: quoted });
continue;
}
const item = listItem.exec(line);
if (item) {
const ordered = /\d/.test(item[1]!);
const items: string[] = [];
for (; i < lines.length; i++) {
const m = listItem.exec(lines[i]!);
if (m && /\d/.test(m[1]!) === ordered) items.push(m[2]!);
else if (!isBlank(lines[i]) && /^\s{2,}/.test(lines[i]!) && items.length) items[items.length - 1] += `\n${lines[i]!.trim()}`;
else break;
}
out.push({ kind: "list", ordered, items });
continue;
}
const para: string[] = [];
for (; i < lines.length && !isBlank(lines[i]) && !/^\s{0,3}(```|~~~|#{1,6}\s|>)/.test(lines[i]!) && !listItem.exec(lines[i]!); i++) para.push(lines[i]!);
out.push({ kind: "paragraph", lines: para });
}
return out;
}
/** Whether a link may be followed: web and mail addresses only (never `javascript:`). */
export const safeHref = (href: string): string | undefined => (/^(https?:\/\/|mailto:)/i.test(href.trim()) ? href.trim() : undefined);
/** Inline Markdown as elements: `code`, links (`[text](url)`), bare URLs, **bold**, *italic*, ~~struck~~. Inside a link's
* text (`inLink`), a URL is just text: a link can't hold another. */
export function inline(text: string, key = "i", inLink = false): ReactNode[] {
const out: ReactNode[] = [];
const pattern = /(`+)([^`]+?)\1|\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?"'])|(\*\*|__)(.+?)\6|(\*|_)(?!\s)(.+?)(?<!\s)\8|~~(.+?)~~/g;
let last = 0;
let n = 0;
for (const m of text.matchAll(pattern)) {
if (m.index! > last) out.push(text.slice(last, m.index));
const k = `${key}.${n++}`;
if (m[2] !== undefined) out.push(<code key={k}>{m[2]}</code>);
else if (m[3] !== undefined) {
const href = safeHref(m[4]!);
out.push(href && !inLink ? <a key={k} href={href} target="_blank" rel="noreferrer noopener">{inline(m[3], k, true)}</a> : <Fragment key={k}>{inline(m[3], k, inLink)}</Fragment>);
} else if (m[5] !== undefined) out.push(inLink ? m[5] : <a key={k} href={m[5]} target="_blank" rel="noreferrer noopener">{m[5]}</a>);
else if (m[7] !== undefined) out.push(<strong key={k}>{inline(m[7], k, inLink)}</strong>);
else if (m[9] !== undefined) out.push(<em key={k}>{inline(m[9], k, inLink)}</em>);
else if (m[10] !== undefined) out.push(<s key={k}>{inline(m[10], k, inLink)}</s>);
last = m.index! + m[0].length;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
/** Lines of a paragraph, with the line breaks people typed kept. */
export function withBreaks(lines: string[], key: string): ReactNode[] {
return lines.flatMap((l, i) => (i ? [<br key={`${key}.br${i}`} />, ...inline(l, `${key}.${i}`)] : inline(l, `${key}.${i}`)));
}
/** A Markdown block as an element. Headings start at `h3`: a post's are within the page's own. */
export function BlockView({ block, id }: { block: Block; id: string }) {
switch (block.kind) {
case "paragraph":
return <p>{withBreaks(block.lines, id)}</p>;
case "heading": {
const H = `h${Math.min(6, block.level + 2)}` as "h3";
return <H>{inline(block.text, id)}</H>;
}
case "list": {
const L = block.ordered ? "ol" : "ul";
return <L>{block.items.map((it, i) => <li key={i}>{withBreaks(it.split("\n"), `${id}.${i}`)}</li>)}</L>;
}
case "quote":
return <blockquote><Markdown text={block.lines.join("\n")} className="" /></blockquote>;
case "code":
return <pre><code data-lang={block.lang || undefined}>{block.text}</code></pre>;
case "rule":
return <hr />;
}
}
/** Markdown, as a post's body. */
export function Markdown({ text, className = "body" }: { text: string; className?: string }) {
return <div className={className}>{blocksOf(text).map((b, i) => <BlockView key={i} block={b} id={`b${i}`} />)}</div>;
}
/** Markdown as plain text, for an excerpt: its words, without the marks around them. */
export function plainText(text: string): string {
return blocksOf(text)
.map((b) =>
b.kind === "paragraph" || b.kind === "quote" ? b.lines.join(" ") : b.kind === "heading" ? b.text : b.kind === "list" ? b.items.join(" · ") : b.kind === "code" ? b.text : "",
)
.join(" ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/(\*\*|__|~~|`+)(.+?)\1/g, "$2")
.replace(/(^|\W)[*_](?!\s)(.+?)(?<!\s)[*_](?=\W|$)/g, "$1$2")
.replace(/\s+/g, " ")
.trim();
}
export const testMarkdownBlocks = () => {
assert.deepEqual(blocksOf("# Title\n\nline one\nline two\n\n- a\n- b\n\n1. x\n2. y\n\n> quoted\n\n```ts\nconst x = 1;\n```\n\n---"), [
{ kind: "heading", level: 1, text: "Title" },
{ kind: "paragraph", lines: ["line one", "line two"] },
{ kind: "list", ordered: false, items: ["a", "b"] },
{ kind: "list", ordered: true, items: ["x", "y"] },
{ kind: "quote", lines: ["quoted"] },
{ kind: "code", lang: "ts", text: "const x = 1;" },
{ kind: "rule" },
]);
};
export const testMarkdownIsSafe = () => {
const html = renderToStaticMarkup(<Markdown text={"<script>x</script> **bold** and *it* `c` [ok](https://a.b) [bad](javascript:alert(1)) https://c.d/e."} />);
assert.match(html, /<script>x<\/script>/);
assert.match(html, /<strong>bold<\/strong> and <em>it<\/em> <code>c<\/code>/);
assert.match(html, /<a href="https:\/\/a.b"[^>]*>ok<\/a>/);
assert.doesNotMatch(html, /javascript:/i);
assert.match(html, /<a href="https:\/\/c.d\/e"[^>]*>https:\/\/c.d\/e<\/a>\./);
assert.match(renderToStaticMarkup(<Markdown text={"a\nb"} />), /<p>a<br\/>b<\/p>/);
assert.equal(plainText("It would be **lovely** to have a [dark](https://x.y) board.\n\n- matches\n- easier"), "It would be lovely to have a dark board. matches · easier");
// a link whose text is a URL is one link
assert.equal(renderToStaticMarkup(<Markdown text="[https://x.y](https://x.y)" />).match(/<a /g)?.length, 1);
};
used by