markdown

tcmts.examples.notes.markdown · #ywpm3nol · tsx file

// tcm: namespace notes
// A small markdown renderer. It's React elements and nothing else (no state, no DOM, no server), so tcm places it
// "anywhere": the same definition renders a note on the server, and the editor's live preview in the browser.
import assert from "node:assert/strict";

/** A block of markdown: what {@link parseBlocks} splits a note into, and {@link Markdown} draws. */
export type Block =
  | { kind: "heading"; level: number; text: string }
  | { kind: "paragraph"; text: string }
  | { kind: "list"; items: string[] }
  | { kind: "code"; code: string };

/** A run of text inside a block, as {@link parseInline} finds them. */
export type Inline = { kind: "text" | "strong" | "em" | "code"; text: string } | { kind: "link"; text: string; href: string };

/**
 * Split markdown into blocks: `#` headings, `-` lists, fenced code, and paragraphs between blank lines.
 *
 * ```ts
 * parseBlocks("# Title\n\nSome *text*\non two lines\n\n- one\n- two")
 * ```
 */
export function parseBlocks(text: string): Block[] {
  const blocks: Block[] = [];
  const lines = text.replace(/\r\n?/g, "\n").split("\n");
  let paragraph: string[] = [];
  const flush = () => {
    if (paragraph.length) blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
    paragraph = [];
  };
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]!;
    const heading = /^(#{1,3})\s+(.*)$/.exec(line);
    const item = /^\s*[-*+]\s+(.*)$/.exec(line);
    if (line.startsWith("```")) {
      flush();
      const code: string[] = [];
      while (++i < lines.length && !lines[i]!.startsWith("```")) code.push(lines[i]!);
      blocks.push({ kind: "code", code: code.join("\n") });
    } else if (heading) {
      flush();
      blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]!.trim() });
    } else if (item) {
      flush();
      const last = blocks.at(-1);
      if (last?.kind === "list") last.items.push(item[1]!);
      else blocks.push({ kind: "list", items: [item[1]!] });
    } else if (!line.trim()) flush();
    else paragraph.push(line.trim());
  }
  flush();
  return blocks;
}

/**
 * Find `**strong**`, `*emphasis*`, `` `code` `` and `[links](https://…)` in a line of text.
 *
 * ```ts
 * parseInline("a **bold** and `code` [link](https://react.dev)")
 * ```
 */
export function parseInline(text: string): Inline[] {
  const out: Inline[] = [];
  const pattern = /\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)/g;
  let at = 0;
  for (const m of text.matchAll(pattern)) {
    if (m.index > at) out.push({ kind: "text", text: text.slice(at, m.index) });
    if (m[1] !== undefined) out.push({ kind: "strong", text: m[1] });
    else if (m[2] !== undefined) out.push({ kind: "em", text: m[2] });
    else if (m[3] !== undefined) out.push({ kind: "code", text: m[3] });
    else out.push({ kind: "link", text: m[4]!, href: m[5]! });
    at = m.index + m[0].length;
  }
  if (at < text.length) out.push({ kind: "text", text: text.slice(at) });
  return out;
}

/** Only web links become links: a note can't smuggle in a `javascript:` URL. */
export const safeHref = (href: string): string | undefined => (/^https?:\/\//.test(href) ? href : undefined);

/** One line of markdown text, with its {@link parseInline | inline} formatting. */
export function InlineText({ text }: { text: string }) {
  return (
    <>
      {parseInline(text).map((part, i) =>
        part.kind === "strong" ? (
          <strong key={i}>{part.text}</strong>
        ) : part.kind === "em" ? (
          <em key={i}>{part.text}</em>
        ) : part.kind === "code" ? (
          <code key={i}>{part.text}</code>
        ) : part.kind === "link" ? (
          <a key={i} href={safeHref(part.href)} target="_blank" rel="noreferrer">
            {part.text}
          </a>
        ) : (
          part.text
        ),
      )}
    </>
  );
}

/**
 * A note's body, rendered. Used by `notes.NotePreview` (a server component: it renders there) and by
 * `notes.NoteEditor` (a browser component: it renders there, as you type). One definition, one hash.
 */
export function Markdown({ text }: { text: string }) {
  return (
    <div className="markdown">
      {parseBlocks(text).map((block, i) =>
        block.kind === "heading" ? (
          block.level === 1 ? <h2 key={i}><InlineText text={block.text} /></h2> : <h3 key={i}><InlineText text={block.text} /></h3>
        ) : block.kind === "list" ? (
          <ul key={i}>
            {block.items.map((item, j) => (
              <li key={j}>
                <InlineText text={item} />
              </li>
            ))}
          </ul>
        ) : block.kind === "code" ? (
          <pre key={i}>{block.code}</pre>
        ) : (
          <p key={i}>
            <InlineText text={block.text} />
          </p>
        ),
      )}
    </div>
  );
}

/** Blocks: headings, lists, code fences, and paragraphs joined across lines. */
export const testParseBlocks = () => {
  assert.deepEqual(parseBlocks("# Title\n\nSome *text*\non two lines\n\n- one\n- two\n\n```\ncode\n```"), [
    { kind: "heading", level: 1, text: "Title" },
    { kind: "paragraph", text: "Some *text* on two lines" },
    { kind: "list", items: ["one", "two"] },
    { kind: "code", code: "code" },
  ]);
};

/** Inline formatting, and links that only go to the web. */
export const testParseInline = () => {
  assert.deepEqual(parseInline("a **b** *c* `d` [e](https://f.dev)"), [
    { kind: "text", text: "a " },
    { kind: "strong", text: "b" },
    { kind: "text", text: " " },
    { kind: "em", text: "c" },
    { kind: "text", text: " " },
    { kind: "code", text: "d" },
    { kind: "text", text: " " },
    { kind: "link", text: "e", href: "https://f.dev" },
  ]);
  assert.equal(safeHref("javascript:alert(1)"), undefined);
};

used by