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, /&lt;script&gt;x&lt;\/script&gt;/);
  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