Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · zod

Markdown Blog Engine with a Hand-written Parser Demo

A split-pane Markdown editor for a blog, running on a parser I wrote for it. Type on the left and the right side shows the rendered post with a table of contents and reading time, a Google result and social card preview, and the escaped HTML. Frontmatter is checked with zod, and unsafe links, missing alt text and skipped headings appear as problems you can click.

Read the case study →

Interactive demo

samiullah.tech/lab/markdown-blog-engine

135 words · 1 min read · 0 errors, 0 warnings

Ln 1, Col 1Ctrl+B · Ctrl+I · Ctrl+K · Ctrl+E

problems

  • ✓ No problems. Frontmatter is valid.

## How it works

markdown.ts parses in two passes. A line-based block pass finds headings, fences, lists (nested by indentation), quotes and tables; an inline pass handles emphasis, code, links and images. The output is a typed AST, not HTML.

Safety comes from the design: raw HTML is never interpreted, React escapes all text, and every link and image URL goes through safeUrl(), which only allows http(s), mailto and relative URLs. A javascript: link is rendered as plain text and reported as an error.

post.ts reads the frontmatter with a small YAML subset reader, validates it with a strict zod schema, and walks the AST once to build the table of contents, count words for reading time (225 per minute) and collect content warnings with their line numbers.

## View source

The code running in the window above, exactly as deployed.

app/lab/markdown-blog-engine/markdown.ts488 lines
/**
 * A small, safe Markdown parser: source -> AST -> (React elements | HTML string).
 *
 * Supported: ATX headings, paragraphs, **strong**, *em*, ~~del~~, `code`, fenced code with a language,
 * blockquotes, ordered/unordered/task lists (nested by indentation), GFM tables, horizontal rules,
 * links, images and <https://autolinks>.
 *
 * Safety model: raw HTML is never interpreted (it stays text), and every URL goes through `safeUrl`, which only
 * allows http(s), mailto and relative links. Rendering to React escapes text automatically; `toHtml` escapes by hand.
 */

export type Inline =
  | { type: "text"; value: string }
  | { type: "strong" | "em" | "del"; children: Inline[] }
  | { type: "code"; value: string }
  | { type: "link"; href: string | null; rawHref: string; children: Inline[] }
  | { type: "image"; src: string | null; rawSrc: string; alt: string }
  | { type: "br" };

export type ListItem = { checked: boolean | null; children: Block[] };

export type Block =
  | { type: "heading"; depth: 1 | 2 | 3 | 4 | 5 | 6; id: string; children: Inline[]; line: number }
  | { type: "paragraph"; children: Inline[]; line: number }
  | { type: "code"; lang: string; value: string; line: number }
  | { type: "blockquote"; children: Block[]; line: number }
  | { type: "list"; ordered: boolean; start: number; tight: boolean; items: ListItem[]; line: number }
  | { type: "table"; align: ("left" | "center" | "right" | null)[]; head: Inline[][]; rows: Inline[][][]; line: number }
  | { type: "hr"; line: number };

/* ------------------------------------------------------------------ */
/* URLs                                                                */
/* ------------------------------------------------------------------ */

/** Allow http(s), mailto, and relative URLs. Anything with another scheme (javascript:, data:, vbscript:) is dropped. */
export function safeUrl(raw: string): string | null {
  const url = raw.trim().replace(/^<|>$/g, "");
  // Strip control characters and whitespace that browsers ignore inside schemes ("java\nscript:").
  const compact = url.replace(/[\u0000- \u007f-\u009f]/g, "");
  if (/^(https?:|mailto:)/i.test(compact)) return url;
  if (/^[a-z][a-z0-9+.-]*:/i.test(compact)) return null;
  return url;
}

export function slugify(text: string): string {
  return (
    text
      .toLowerCase()
      .normalize("NFKD")
      .replace(/[̀-ͯ]/g, "")
      .replace(/[^a-z0-9\s-]/g, "")
      .trim()
      .replace(/[\s-]+/g, "-")
      .slice(0, 80) || "section"
  );
}

/* ------------------------------------------------------------------ */
/* Inline parser                                                       */
/* ------------------------------------------------------------------ */

const ESCAPABLE = "\\`*_{}[]()#+-.!|~<>";

/** Find the closing `]` that matches the `[` at `open`, honouring nesting and escapes. */
function matchBracket(s: string, open: number): number {
  let depth = 0;
  for (let i = open; i < s.length; i++) {
    if (s[i] === "\\") {
      i++;
      continue;
    }
    if (s[i] === "[") depth++;
    else if (s[i] === "]" && --depth === 0) return i;
  }
  return -1;
}

/** Parse `(url "title")` starting at `open`; returns the url and the index after `)`. */
function parseDestination(s: string, open: number): { url: string; end: number } | null {
  if (s[open] !== "(") return null;
  let depth = 0;
  for (let i = open; i < s.length; i++) {
    if (s[i] === "(") depth++;
    else if (s[i] === ")" && --depth === 0) {
      const inner = s.slice(open + 1, i).trim();
      const url = inner.replace(/\s+"[^"]*"$/, "").trim();
      return { url, end: i + 1 };
    }
  }
  return null;
}

export function parseInline(src: string): Inline[] {
  const out: Inline[] = [];
  let text = "";
  const flush = () => {
    if (text) out.push({ type: "text", value: text });
    text = "";
  };

  for (let i = 0; i < src.length; ) {
    const c = src[i]!;

    if (c === "\\" && i + 1 < src.length && ESCAPABLE.includes(src[i + 1]!)) {
      text += src[i + 1];
      i += 2;
      continue;
    }

    // Hard break: two spaces or a backslash before a newline.
    if (c === "\n") {
      if (/ {2,}$/.test(text)) {
        text = text.replace(/ +$/, "");
        flush();
        out.push({ type: "br" });
      } else text += " ";
      i++;
      continue;
    }

    if (c === "`") {
      const run = /^`+/.exec(src.slice(i))![0];
      const close = src.indexOf(run, i + run.length);
      if (close !== -1) {
        flush();
        out.push({ type: "code", value: src.slice(i + run.length, close).replace(/^ (.*) $/, "$1") });
        i = close + run.length;
        continue;
      }
      text += run;
      i += run.length;
      continue;
    }

    if (c === "<") {
      const m = /^<(https?:\/\/[^\s<>]+|mailto:[^\s<>]+)>/.exec(src.slice(i));
      if (m) {
        flush();
        out.push({ type: "link", href: safeUrl(m[1]!), rawHref: m[1]!, children: [{ type: "text", value: m[1]!.replace(/^mailto:/, "") }] });
        i += m[0].length;
        continue;
      }
    }

    if (c === "!" && src[i + 1] === "[") {
      const close = matchBracket(src, i + 1);
      const dest = close !== -1 ? parseDestination(src, close + 1) : null;
      if (dest) {
        flush();
        out.push({ type: "image", src: safeUrl(dest.url), rawSrc: dest.url, alt: src.slice(i + 2, close) });
        i = dest.end;
        continue;
      }
    }

    if (c === "[") {
      const close = matchBracket(src, i);
      const dest = close !== -1 ? parseDestination(src, close + 1) : null;
      if (dest) {
        flush();
        out.push({ type: "link", href: safeUrl(dest.url), rawHref: dest.url, children: parseInline(src.slice(i + 1, close)) });
        i = dest.end;
        continue;
      }
    }

    // Emphasis: **x** / __x__ (strong), *x* / _x_ (em), ~~x~~ (del). The content must not start or end with a space.
    const delim = src.startsWith("**", i) ? "**" : src.startsWith("__", i) ? "__" : src.startsWith("~~", i) ? "~~" : c === "*" || c === "_" ? c : null;
    if (delim) {
      const intraword = delim[0] === "_" && /\w/.test(src[i - 1] ?? "");
      let close = src.indexOf(delim, i + delim.length);
      // For single * or _, skip over a double delimiter so "*a **b** c*" nests correctly.
      while (delim.length === 1 && close !== -1 && src[close + 1] === delim) close = src.indexOf(delim, close + 2);
      const inner = close === -1 ? "" : src.slice(i + delim.length, close);
      const closesWord = delim[0] === "_" && /\w/.test(src[close + delim.length] ?? "");
      if (!intraword && !closesWord && inner && !/^\s|\s$/.test(inner)) {
        flush();
        const type = delim === "~~" ? "del" : delim.length === 2 ? "strong" : "em";
        out.push({ type, children: parseInline(inner) });
        i = close + delim.length;
        continue;
      }
    }

    text += c;
    i++;
  }
  flush();
  return out;
}

export function inlineText(nodes: Inline[]): string {
  return nodes
    .map((n) => {
      if (n.type === "text" || n.type === "code") return n.value;
      if (n.type === "image") return n.alt;
      if (n.type === "br") return " ";
      return inlineText(n.children);
    })
    .join("");
}

/* ------------------------------------------------------------------ */
/* Block parser                                                        */
/* ------------------------------------------------------------------ */

const RE = {
  fence: /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/,
  heading: /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/,
  hr: /^ {0,3}([-*_])(\s*\1){2,}\s*$/,
  quote: /^ {0,3}> ?/,
  bullet: /^( {0,3})([-*+])\s+(.*)$/,
  ordered: /^( {0,3})(\d{1,9})[.)]\s+(.*)$/,
  tableSep: /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/,
  blank: /^\s*$/,
};

const splitRow = (line: string) =>
  line
    .trim()
    .replace(/^\||\|$/g, "")
    .split(/(?<!\\)\|/)
    .map((c) => c.trim().replace(/\\\|/g, "|"));

function startsBlock(line: string, next: string | undefined): boolean {
  return (
    RE.fence.test(line) ||
    RE.heading.test(line) ||
    RE.hr.test(line) ||
    RE.quote.test(line) ||
    RE.bullet.test(line) ||
    RE.ordered.test(line) ||
    (line.includes("|") && next !== undefined && RE.tableSep.test(next))
  );
}

type Ctx = { ids: Map<string, number> };

function uniqueId(ctx: Ctx, text: string) {
  const base = slugify(text);
  const n = ctx.ids.get(base) ?? 0;
  ctx.ids.set(base, n + 1);
  return n ? `${base}-${n}` : base;
}

function parseLines(lines: string[], ctx: Ctx, offset: number): Block[] {
  const blocks: Block[] = [];
  let i = 0;
  while (i < lines.length) {
    const line = lines[i]!;
    const lineNo = offset + i + 1;

    if (RE.blank.test(line)) {
      i++;
      continue;
    }

    const fence = RE.fence.exec(line);
    if (fence) {
      const marker = fence[1]!;
      const body: string[] = [];
      i++;
      while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) body.push(lines[i++]!);
      i++; // closing fence (or end of input: an unclosed fence runs to the end, like CommonMark)
      blocks.push({ type: "code", lang: fence[2] ?? "", value: body.join("\n"), line: lineNo });
      continue;
    }

    const h = RE.heading.exec(line);
    if (h) {
      const children = parseInline(h[2]!);
      blocks.push({ type: "heading", depth: h[1]!.length as 1 | 2 | 3 | 4 | 5 | 6, id: uniqueId(ctx, inlineText(children)), children, line: lineNo });
      i++;
      continue;
    }

    if (RE.hr.test(line)) {
      blocks.push({ type: "hr", line: lineNo });
      i++;
      continue;
    }

    if (RE.quote.test(line)) {
      const inner: string[] = [];
      while (i < lines.length && (RE.quote.test(lines[i]!) || (!RE.blank.test(lines[i]!) && inner.length && !startsBlock(lines[i]!, lines[i + 1])))) {
        inner.push(lines[i]!.replace(RE.quote, ""));
        i++;
      }
      blocks.push({ type: "blockquote", children: parseLines(inner, ctx, offset + i - inner.length), line: lineNo });
      continue;
    }

    const bullet = RE.bullet.exec(line);
    const ordered = RE.ordered.exec(line);
    if (bullet || ordered) {
      const isOrdered = !!ordered;
      const itemRe = isOrdered ? RE.ordered : RE.bullet;
      const items: ListItem[] = [];
      let tight = true;
      let start = isOrdered ? Number(ordered![2]) : 1;
      if (!Number.isFinite(start)) start = 1;
      while (i < lines.length) {
        const m = itemRe.exec(lines[i]!);
        if (!m) break;
        const indent = m[1]!.length + (isOrdered ? m[2]!.length + 2 : 2);
        const content = [m[3]!];
        const itemLine = offset + i + 1;
        i++;
        // Continuation: indented lines, or blank lines followed by indented lines.
        while (i < lines.length) {
          const l = lines[i]!;
          if (RE.blank.test(l)) {
            const nextIndented = lines[i + 1] !== undefined && /^\s{2,}\S/.test(lines[i + 1]!);
            if (!nextIndented) break;
            tight = false;
            content.push("");
            i++;
            continue;
          }
          const lead = /^\s*/.exec(l)![0].length;
          if (lead >= Math.min(indent, 2)) content.push(l.slice(Math.min(lead, indent)));
          else if (!startsBlock(l, lines[i + 1]) && content.at(-1) !== "") content.push(l); // lazy paragraph continuation
          else break;
          i++;
        }
        if (i < lines.length && RE.blank.test(lines[i]!) && lines[i + 1] !== undefined && itemRe.test(lines[i + 1]!)) {
          tight = false;
          i++;
        }
        let checked: boolean | null = null;
        const task = /^\[([ xX])\]\s+/.exec(content[0]!);
        if (task) {
          checked = task[1] !== " ";
          content[0] = content[0]!.slice(task[0].length);
        }
        items.push({ checked, children: parseLines(content, ctx, itemLine - 1) });
      }
      blocks.push({ type: "list", ordered: isOrdered, start, tight, items, line: lineNo });
      continue;
    }

    if (line.includes("|") && lines[i + 1] !== undefined && RE.tableSep.test(lines[i + 1]!)) {
      const head = splitRow(line);
      const align = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : null));
      i += 2;
      const rows: Inline[][][] = [];
      while (i < lines.length && lines[i]!.includes("|") && !RE.blank.test(lines[i]!)) {
        const cells = splitRow(lines[i]!);
        rows.push(head.map((_, k) => parseInline(cells[k] ?? "")));
        i++;
      }
      blocks.push({ type: "table", align: head.map((_, k) => align[k] ?? null), head: head.map((c) => parseInline(c)), rows, line: lineNo });
      continue;
    }

    // Paragraph: until a blank line or the start of another block.
    const para: string[] = [line];
    i++;
    while (i < lines.length && !RE.blank.test(lines[i]!) && !startsBlock(lines[i]!, lines[i + 1])) para.push(lines[i++]!);
    blocks.push({ type: "paragraph", children: parseInline(para.join("\n").trim()), line: lineNo });
  }
  return blocks;
}

/** Parse a Markdown body. `lineOffset` lets reported line numbers account for frontmatter above the body. */
export function parseMarkdown(src: string, lineOffset = 0): Block[] {
  return parseLines(src.replace(/\r\n?/g, "\n").replace(/\t/g, "    ").split("\n"), { ids: new Map() }, lineOffset);
}

/* ------------------------------------------------------------------ */
/* HTML output (for the export tab and RSS)                            */
/* ------------------------------------------------------------------ */

export const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");

function inlineHtml(nodes: Inline[]): string {
  return nodes
    .map((n) => {
      switch (n.type) {
        case "text":
          return escapeHtml(n.value);
        case "code":
          return `<code>${escapeHtml(n.value)}</code>`;
        case "strong":
          return `<strong>${inlineHtml(n.children)}</strong>`;
        case "em":
          return `<em>${inlineHtml(n.children)}</em>`;
        case "del":
          return `<del>${inlineHtml(n.children)}</del>`;
        case "br":
          return "<br>";
        case "link":
          return n.href ? `<a href="${escapeHtml(n.href)}"${/^https?:/i.test(n.href) ? ' rel="noopener noreferrer"' : ""}>${inlineHtml(n.children)}</a>` : inlineHtml(n.children);
        case "image":
          return n.src ? `<img src="${escapeHtml(n.src)}" alt="${escapeHtml(n.alt)}" loading="lazy">` : "";
      }
    })
    .join("");
}

export function toHtml(blocks: Block[], indent = ""): string {
  return blocks
    .map((b) => {
      switch (b.type) {
        case "heading":
          return `${indent}<h${b.depth} id="${b.id}">${inlineHtml(b.children)}</h${b.depth}>`;
        case "paragraph":
          return `${indent}<p>${inlineHtml(b.children)}</p>`;
        case "code":
          return `${indent}<pre><code${b.lang ? ` class="language-${escapeHtml(b.lang)}"` : ""}>${escapeHtml(b.value)}</code></pre>`;
        case "hr":
          return `${indent}<hr>`;
        case "blockquote":
          return `${indent}<blockquote>\n${toHtml(b.children, indent + "  ")}\n${indent}</blockquote>`;
        case "list": {
          const tag = b.ordered ? "ol" : "ul";
          const start = b.ordered && b.start !== 1 ? ` start="${b.start}"` : "";
          const items = b.items
            .map((it) => {
              const box = it.checked === null ? "" : `<input type="checkbox" disabled${it.checked ? " checked" : ""}> `;
              const only = it.children.length === 1 && it.children[0]!.type === "paragraph" && b.tight;
              // Tight lists render their paragraphs without <p>, like CommonMark.
              const inner = b.tight
                ? it.children.map((c) => (c.type === "paragraph" ? `${indent}    ${inlineHtml(c.children)}` : toHtml([c], indent + "    "))).join("\n")
                : toHtml(it.children, indent + "    ");
              const body = only ? inlineHtml((it.children[0] as { children: Inline[] }).children) : `\n${inner}\n${indent}  `;
              return `${indent}  <li>${box}${body}</li>`;
            })
            .join("\n");
          return `${indent}<${tag}${start}>\n${items}\n${indent}</${tag}>`;
        }
        case "table": {
          const cell = (tag: string, k: number, c: Inline[]) => `<${tag}${b.align[k] ? ` style="text-align:${b.align[k]}"` : ""}>${inlineHtml(c)}</${tag}>`;
          const head = `<tr>${b.head.map((c, k) => cell("th", k, c)).join("")}</tr>`;
          const rows = b.rows.map((r) => `${indent}    <tr>${r.map((c, k) => cell("td", k, c)).join("")}</tr>`).join("\n");
          return `${indent}<table>\n${indent}  <thead>${head}</thead>\n${indent}  <tbody>\n${rows}\n${indent}  </tbody>\n${indent}</table>`;
        }
      }
    })
    .join("\n");
}

/* ------------------------------------------------------------------ */
/* Tiny syntax highlighter for code blocks                             */
/* ------------------------------------------------------------------ */

export type Token = { kind: "kw" | "str" | "com" | "num" | "fn" | "plain"; value: string };

const KEYWORDS: Record<string, string[]> = {
  js: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "import", "from", "export", "default", "async", "await", "new", "class", "extends", "type", "interface", "true", "false", "null", "undefined", "throw", "try", "catch"],
  css: ["@media", "@import", "!important"],
  bash: ["npm", "npx", "pnpm", "cd", "git", "echo", "export", "sudo"],
  sql: ["select", "from", "where", "insert", "into", "values", "update", "set", "delete", "create", "table", "index", "on", "join", "left", "group", "by", "order", "limit", "and", "or", "not", "null", "primary", "key"],
};
const LANG_ALIAS: Record<string, keyof typeof KEYWORDS> = { js: "js", jsx: "js", ts: "js", tsx: "js", javascript: "js", typescript: "js", json: "js", css: "css", bash: "bash", sh: "bash", shell: "bash", sql: "sql" };

export function highlight(code: string, lang: string): Token[] {
  const family = LANG_ALIAS[lang.toLowerCase()];
  if (!family) return [{ kind: "plain", value: code }];
  const kw = new Set(KEYWORDS[family]);
  const comment = family === "sql" ? /^--[^\n]*/ : family === "bash" ? /^#[^\n]*/ : /^(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/;
  const tokens: Token[] = [];
  let rest = code;
  const push = (kind: Token["kind"], value: string) => {
    const last = tokens.at(-1);
    if (last && last.kind === kind && kind === "plain") last.value += value;
    else tokens.push({ kind, value });
  };
  while (rest) {
    let m: RegExpExecArray | null;
    if ((m = comment.exec(rest))) push("com", m[0]);
    else if ((m = /^("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/.exec(rest))) push("str", m[0]);
    else if ((m = /^\b\d+(\.\d+)?\b/.exec(rest))) push("num", m[0]);
    else if ((m = /^[@!]?[A-Za-z_$][\w$-]*/.exec(rest))) {
      const word = m[0];
      if (kw.has(family === "sql" ? word.toLowerCase() : word)) push("kw", word);
      else if (rest[word.length] === "(") push("fn", word);
      else push("plain", word);
    } else {
      push("plain", rest[0]!);
      rest = rest.slice(1);
      continue;
    }
    rest = rest.slice(m[0].length);
  }
  return tokens;
}
app/lab/markdown-blog-engine/post.ts180 lines
/**
 * Post loading: frontmatter (a small YAML subset) validated with zod, then body parsing, TOC, reading time
 * and content lint. In the production design this runs at build time for every .mdx file and a failure stops the
 * build with the file name and line; here it runs on every keystroke.
 */
import { z } from "zod";
import { inlineText, parseMarkdown, slugify, type Block, type Inline } from "./markdown";

export const TAGS = ["nextjs", "react", "typescript", "css", "performance", "seo", "accessibility", "testing", "postgresql", "career"] as const;

export const frontmatterSchema = z
  .object({
    title: z.string({ required_error: "title is required" }).min(10, "title: at least 10 characters").max(70, "title: at most 70 characters"),
    description: z
      .string({ required_error: "description is required" })
      .min(50, "description: at least 50 characters (search engines may write their own otherwise)")
      .max(160, "description: at most 160 characters"),
    date: z.string({ required_error: "date is required" }).regex(/^\d{4}-\d{2}-\d{2}$/, "date: use YYYY-MM-DD").refine((d) => !Number.isNaN(Date.parse(d)), "date: not a real date"),
    tags: z.array(z.enum(TAGS, { errorMap: () => ({ message: `tags: allowed values are ${TAGS.join(", ")}` }) })).min(1, "tags: add at least one").max(5, "tags: at most 5"),
    slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug: lowercase letters, digits and single dashes").optional(),
    draft: z.boolean().default(false),
    cover: z.string().optional(),
  })
  .strict();

export type Frontmatter = z.infer<typeof frontmatterSchema>;
export type Issue = { level: "error" | "warning"; line: number | null; message: string };

type Scalar = string | number | boolean | string[];

/** Parse `key: value` lines. Supports quoted strings, numbers, booleans, [a, b] and "- item" lists. */
export function parseYaml(text: string, firstLine: number): { data: Record<string, Scalar>; lines: Record<string, number>; issues: Issue[] } {
  const data: Record<string, Scalar> = {};
  const lines: Record<string, number> = {};
  const issues: Issue[] = [];
  const rows = text.split("\n");
  let listKey: string | null = null;
  rows.forEach((row, i) => {
    const line = firstLine + i;
    if (!row.trim() || row.trim().startsWith("#")) return;
    const item = /^\s+-\s+(.*)$/.exec(row);
    if (item && listKey) {
      (data[listKey] as string[]).push(unquote(item[1]!));
      return;
    }
    const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(row);
    if (!m) {
      issues.push({ level: "error", line, message: `Cannot read this frontmatter line: “${row.trim()}”` });
      return;
    }
    const [, key, raw] = m as unknown as [string, string, string];
    if (key in data) issues.push({ level: "error", line, message: `${key} is defined twice` });
    lines[key] = line;
    listKey = null;
    const v = raw.trim();
    if (v === "") {
      data[key] = [];
      listKey = key;
    } else if (v.startsWith("[")) {
      if (!v.endsWith("]")) issues.push({ level: "error", line, message: `${key}: list is missing its closing ]` });
      data[key] = v
        .replace(/^\[|\]$/g, "")
        .split(",")
        .map((s) => unquote(s.trim()))
        .filter(Boolean);
    } else if (v === "true" || v === "false") data[key] = v === "true";
    else if (/^-?\d+(\.\d+)?$/.test(v)) data[key] = Number(v);
    else data[key] = unquote(v);
  });
  return { data, lines, issues };
}

function unquote(s: string) {
  const m = /^"(.*)"$|^'(.*)'$/.exec(s);
  return m ? (m[1] ?? m[2] ?? "").replace(/\\"/g, '"') : s;
}

export type TocEntry = { depth: number; text: string; id: string };

export type ParsedPost = {
  frontmatter: Frontmatter | null;
  raw: Record<string, Scalar>;
  blocks: Block[];
  toc: TocEntry[];
  words: number;
  minutes: number;
  slug: string;
  issues: Issue[];
};

const WPM = 225;

export function parsePost(source: string): ParsedPost {
  const src = source.replace(/\r\n?/g, "\n");
  const issues: Issue[] = [];
  let body = src;
  let bodyOffset = 0;
  let raw: Record<string, Scalar> = {};
  let lines: Record<string, number> = {};

  const fm = /^---\n([\s\S]*?)\n---\s*(\n|$)/.exec(src);
  if (fm) {
    const parsed = parseYaml(fm[1]!, 2);
    raw = parsed.data;
    lines = parsed.lines;
    issues.push(...parsed.issues);
    body = src.slice(fm[0].length);
    bodyOffset = fm[0].split("\n").length - 1;
  } else {
    issues.push({ level: "error", line: 1, message: "Missing frontmatter: start the file with --- … ---" });
  }

  let frontmatter: Frontmatter | null = null;
  const r = frontmatterSchema.safeParse(raw);
  if (r.success) frontmatter = r.data;
  else
    for (const i of r.error.issues) {
      const key = String(i.path[0] ?? "");
      const message = i.code === "unrecognized_keys" ? `Unknown key(s): ${i.keys.join(", ")}` : i.message;
      issues.push({ level: "error", line: lines[key] ?? (i.code === "unrecognized_keys" ? lines[i.keys[0]!] ?? null : 1), message });
    }

  const blocks = parseMarkdown(body, bodyOffset);
  const toc: TocEntry[] = [];
  let words = 0;
  let prevDepth = 1;

  const walk = (bs: Block[]) => {
    for (const b of bs) {
      if (b.type === "heading") {
        const text = inlineText(b.children);
        if (b.depth === 1) issues.push({ level: "warning", line: b.line, message: "Use ## for sections: the post title is already the page's only H1." });
        else if (b.depth > prevDepth + 1) issues.push({ level: "warning", line: b.line, message: `Heading level jumps from H${prevDepth} to H${b.depth}.` });
        if (b.depth >= 2 && b.depth <= 3) toc.push({ depth: b.depth, text, id: b.id });
        prevDepth = b.depth;
        words += count(text);
      } else if (b.type === "paragraph") {
        const n = count(inlineText(b.children));
        words += n;
        if (n > 150) issues.push({ level: "warning", line: b.line, message: `Long paragraph (${n} words). Consider splitting it.` });
        checkInline(b.children, b.line, issues);
      } else if (b.type === "code") {
        if (!b.lang) issues.push({ level: "warning", line: b.line, message: "Code block has no language, so it cannot be highlighted." });
      } else if (b.type === "blockquote") walk(b.children);
      else if (b.type === "list")
        for (const it of b.items) walk(it.children);
      else if (b.type === "table") {
        for (const row of [b.head, ...b.rows]) for (const c of row) words += count(inlineText(c));
      }
    }
  };
  walk(blocks);

  const slug = frontmatter?.slug ?? slugify(typeof raw.title === "string" ? raw.title : "untitled");
  return { frontmatter, raw, blocks, toc, words, minutes: Math.max(1, Math.round(words / WPM)), slug, issues: issues.sort((a, b) => (a.line ?? 0) - (b.line ?? 0)) };
}

const count = (s: string) => s.split(/\s+/).filter(Boolean).length;

function checkInline(nodes: Inline[], line: number, issues: Issue[]) {
  for (const n of nodes) {
    if (n.type === "link") {
      if (!n.href) issues.push({ level: "error", line, message: `Blocked unsafe link “${n.rawHref.slice(0, 40)}”. Only http(s), mailto and relative links are allowed.` });
      else if (!inlineText(n.children).trim()) issues.push({ level: "warning", line, message: "Link has no text." });
      else if (/^(click here|here|link|read more)$/i.test(inlineText(n.children).trim())) issues.push({ level: "warning", line, message: `Link text “${inlineText(n.children)}” does not say where it goes.` });
      checkInline(n.children, line, issues);
    } else if (n.type === "image") {
      if (!n.src) issues.push({ level: "error", line, message: "Blocked unsafe image URL." });
      if (!n.alt.trim()) issues.push({ level: "warning", line, message: "Image has no alt text." });
    } else if (n.type === "strong" || n.type === "em" || n.type === "del") checkInline(n.children, line, issues);
  }
}

/** Google shows roughly 600px of title (~60 characters) and ~155 characters of description. */
export function truncate(s: string, max: number) {
  if (s.length <= max) return s;
  const cut = s.slice(0, max - 1);
  return cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 15)).trimEnd() + "…";
}
app/lab/markdown-blog-engine/Render.tsx167 lines
import type { ReactNode } from "react";
import { highlight, type Block, type Inline } from "./markdown";

/** AST -> React. Text is rendered as React children, so it is escaped by React; no innerHTML anywhere. */

const TOKEN_CLASS = {
  kw: "text-[#c084fc]",
  str: "text-[#4ade80]",
  com: "text-[#71717a] italic",
  num: "text-[#fbbf24]",
  fn: "text-[#60a5fa]",
  plain: "",
} as const;

function inline(nodes: Inline[], key = ""): ReactNode[] {
  return nodes.map((n, i) => {
    const k = `${key}${i}`;
    switch (n.type) {
      case "text":
        return n.value;
      case "code":
        return (
          <code key={k} className="rounded bg-bg-muted px-1 py-0.5 font-mono text-[0.9em]">
            {n.value}
          </code>
        );
      case "strong":
        return <strong key={k}>{inline(n.children, k)}</strong>;
      case "em":
        return <em key={k}>{inline(n.children, k)}</em>;
      case "del":
        return <del key={k}>{inline(n.children, k)}</del>;
      case "br":
        return <br key={k} />;
      case "link":
        return n.href ? (
          <a key={k} href={n.href} className="text-accent underline" target={/^https?:/i.test(n.href) ? "_blank" : undefined} rel="noopener noreferrer">
            {inline(n.children, k)}
          </a>
        ) : (
          <span key={k} className="rounded bg-[#f87171]/15 px-1 text-danger" title={`Blocked: ${n.rawHref}`}>
            {inline(n.children, k)} <span className="font-mono text-[10px]">[blocked link]</span>
          </span>
        );
      case "image":
        // The demo does not fetch images from the network; it shows a placeholder with the alt text and URL instead.
        return n.src ? (
          <span key={k} role="img" aria-label={n.alt || "image without alt text"} className="my-2 flex aspect-[16/7] w-full flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-bg-muted p-3 text-center">
            <span aria-hidden="true" className="text-2xl text-fg-muted">
              ▣
            </span>
            <span aria-hidden="true" className={`text-xs ${n.alt ? "text-fg" : "text-danger"}`}>
              {n.alt || "missing alt text"}
            </span>
            <span aria-hidden="true" className="max-w-full truncate font-mono text-[10px] text-fg-muted">
              {n.src}
            </span>
          </span>
        ) : (
          <span key={k} className="rounded bg-[#f87171]/15 px-1 font-mono text-[10px] text-danger">
            [blocked image]
          </span>
        );
    }
  });
}

export function Render({ blocks, k = "" }: { blocks: Block[]; k?: string }) {
  return (
    <>
      {blocks.map((b, i) => {
        const key = `${k}${i}`;
        switch (b.type) {
          case "heading": {
            const H = `h${Math.min(6, b.depth + 1)}` as "h2"; // shift down: the page title is the only h1
            const size = ["", "text-2xl", "text-xl", "text-lg", "text-base", "text-sm", "text-sm"][b.depth];
            return (
              <H key={key} id={`md-${b.id}`} className={`mt-6 mb-2 scroll-mt-4 font-semibold tracking-tight ${size}`}>
                {inline(b.children)}
              </H>
            );
          }
          case "paragraph":
            return (
              <p key={key} className="my-3 leading-relaxed">
                {inline(b.children)}
              </p>
            );
          case "hr":
            return <hr key={key} className="my-6 border-border" />;
          case "blockquote":
            return (
              <blockquote key={key} className="my-3 border-l-4 border-accent/60 pl-4 text-fg-muted">
                <Render blocks={b.children} k={key + "-"} />
              </blockquote>
            );
          case "code":
            return (
              <figure key={key} className="my-4 overflow-hidden rounded-md border border-border bg-[#0b0b0d]">
                {b.lang && <figcaption className="border-b border-[#27272a] px-3 py-1 font-mono text-[10px] text-[#71717a]">{b.lang}</figcaption>}
                <pre className="overflow-x-auto p-3 font-mono text-[12.5px] leading-relaxed text-[#e4e4e7]">
                  <code>
                    {highlight(b.value, b.lang).map((t, j) => (
                      <span key={j} className={TOKEN_CLASS[t.kind]}>
                        {t.value}
                      </span>
                    ))}
                  </code>
                </pre>
              </figure>
            );
          case "list": {
            const L = b.ordered ? "ol" : "ul";
            return (
              <L key={key} start={b.ordered && b.start !== 1 ? b.start : undefined} className={`my-3 space-y-1 pl-6 ${b.ordered ? "list-decimal" : "list-disc"}`}>
                {b.items.map((it, j) => {
                  const only = b.tight && it.children.length === 1 && it.children[0]!.type === "paragraph";
                  return (
                    <li key={j} className={it.checked !== null ? "-ml-6 list-none" : ""}>
                      {it.checked !== null && <input type="checkbox" checked={it.checked} readOnly disabled className="mr-2 align-middle" aria-label={it.checked ? "done" : "not done"} />}
                      {only ? (
                        inline((it.children[0] as { children: Inline[] }).children)
                      ) : b.tight ? (
                        // Tight list: paragraphs inline, nested blocks (sub-lists) as blocks.
                        it.children.map((c, ci) => (c.type === "paragraph" ? <span key={ci}>{inline(c.children)}</span> : <Render key={ci} blocks={[c]} k={`${key}-${j}-${ci}-`} />))
                      ) : (
                        <Render blocks={it.children} k={`${key}-${j}-`} />
                      )}
                    </li>
                  );
                })}
              </L>
            );
          }
          case "table":
            return (
              <div key={key} className="my-4 overflow-x-auto">
                <table className="w-full border-collapse text-sm">
                  <thead>
                    <tr>
                      {b.head.map((c, j) => (
                        <th key={j} scope="col" className="border-b border-border px-2 py-1.5 font-semibold" style={{ textAlign: b.align[j] ?? "left" }}>
                          {inline(c)}
                        </th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {b.rows.map((r, ri) => (
                      <tr key={ri}>
                        {r.map((c, j) => (
                          <td key={j} className="border-b border-border px-2 py-1.5" style={{ textAlign: b.align[j] ?? "left" }}>
                            {inline(c)}
                          </td>
                        ))}
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            );
        }
      })}
    </>
  );
}
app/lab/markdown-blog-engine/Demo.tsx469 lines
"use client";

import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { toHtml } from "./markdown";
import { parsePost, truncate, type ParsedPost } from "./post";
import { Render } from "./Render";
import { SEED, SITE, type Post } from "./seed";

const KEY = "lab:markdown-blog-engine:v1";

const NEW_POST = (date: string) => `---
title: "A new post title of a useful length"
description: "One or two sentences that tell a searcher exactly what they will learn in this post, between 50 and 160 characters."
date: ${date}
tags: [react]
draft: true
---

Start writing here. Use **bold**, *italic*, \`code\` and [links](https://example.com).

## First section
`;

type Tab = "preview" | "seo" | "html";

export default function Demo() {
  const [posts, setPosts] = useState<Post[]>(SEED);
  const [activeId, setActiveId] = useState("p1");
  const [tab, setTab] = useState<Tab>("preview");
  const [mobileView, setMobileView] = useState<"write" | "output">("write");
  const [ready, setReady] = useState(false);
  const [caret, setCaret] = useState({ line: 1, col: 1 });
  const [copied, setCopied] = useState(false);
  const area = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) {
        const s = JSON.parse(raw) as { posts: Post[]; activeId: string };
        if (Array.isArray(s.posts) && s.posts.length) {
          setPosts(s.posts);
          setActiveId(s.posts.some((p) => p.id === s.activeId) ? s.activeId : s.posts[0]!.id);
        }
      }
    } catch {
      /* ignore */
    }
    setReady(true);
  }, []);
  useEffect(() => {
    if (!ready) return;
    try {
      localStorage.setItem(KEY, JSON.stringify({ posts, activeId }));
    } catch {
      /* ignore */
    }
  }, [posts, activeId, ready]);

  const post = posts.find((p) => p.id === activeId) ?? posts[0]!;
  const source = useDeferredValue(post.source);
  const parsed = useMemo(() => parsePost(source), [source]);
  const errors = parsed.issues.filter((i) => i.level === "error").length;
  const warnings = parsed.issues.length - errors;

  const setSource = (s: string) => setPosts((ps) => ps.map((p) => (p.id === post.id ? { ...p, source: s } : p)));

  /** Wrap the selection (or a placeholder) with markers, keeping the inner text selected. */
  function wrap(before: string, after = before, placeholder = "text") {
    const ta = area.current;
    if (!ta) return;
    const { selectionStart: a, selectionEnd: b, value } = ta;
    const inner = value.slice(a, b) || placeholder;
    const next = value.slice(0, a) + before + inner + after + value.slice(b);
    setSource(next);
    requestAnimationFrame(() => {
      ta.focus();
      ta.setSelectionRange(a + before.length, a + before.length + inner.length);
    });
  }
  function prefixLine(prefix: string) {
    const ta = area.current;
    if (!ta) return;
    const { selectionStart: a, value } = ta;
    const start = value.lastIndexOf("\n", a - 1) + 1;
    setSource(value.slice(0, start) + prefix + value.slice(start));
    requestAnimationFrame(() => {
      ta.focus();
      ta.setSelectionRange(a + prefix.length, a + prefix.length);
    });
  }
  function jumpTo(line: number | null) {
    const ta = area.current;
    if (!ta || !line) return;
    const lines = ta.value.split("\n");
    const pos = lines.slice(0, line - 1).reduce((t, l) => t + l.length + 1, 0);
    setMobileView("write");
    requestAnimationFrame(() => {
      ta.focus();
      ta.setSelectionRange(pos, pos + (lines[line - 1]?.length ?? 0));
      ta.scrollTop = Math.max(0, (line - 4) * 20);
    });
  }
  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (!(e.ctrlKey || e.metaKey)) return;
    const shortcuts: Record<string, [string, string, string]> = {
      b: ["**", "**", "text"],
      i: ["*", "*", "text"],
      k: ["[", "](https://)", "link text"],
      e: ["`", "`", "code"],
    };
    const s = shortcuts[e.key.toLowerCase()];
    if (!s) return;
    e.preventDefault();
    wrap(...s);
  }
  function trackCaret() {
    const ta = area.current;
    if (!ta) return;
    const before = ta.value.slice(0, ta.selectionStart);
    const line = before.split("\n").length;
    setCaret({ line, col: ta.selectionStart - before.lastIndexOf("\n") });
  }

  function newPost() {
    const id = `p${Date.now().toString(36)}`;
    setPosts((ps) => [...ps, { id, source: NEW_POST(new Date().toISOString().slice(0, 10)) }]);
    setActiveId(id);
    setMobileView("write");
  }
  function deletePost() {
    if (posts.length <= 1) return;
    const rest = posts.filter((p) => p.id !== post.id);
    setPosts(rest);
    setActiveId(rest[0]!.id);
  }
  function reset() {
    setPosts(SEED);
    setActiveId("p1");
    setTab("preview");
  }

  const tb = "rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent hover:text-accent";
  const html = useMemo(() => (tab === "html" ? toHtml(parsed.blocks) : ""), [tab, parsed.blocks]);

  return (
    <div>
      {/* ---------- top bar ---------- */}
      <div className="flex flex-wrap items-end gap-2 border-b border-border p-3">
        <label className="min-w-[14rem] flex-1 sm:flex-none">
          <span className="text-xs font-medium text-fg-muted">Post</span>
          <select value={post.id} onChange={(e) => setActiveId(e.target.value)} className="mt-1 w-full rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm sm:w-72">
            {posts.map((p) => {
              const t = /title:\s*"?([^"\n]*)/.exec(p.source)?.[1] ?? "Untitled";
              return (
                <option key={p.id} value={p.id}>
                  {t.slice(0, 48) || "Untitled"}
                </option>
              );
            })}
          </select>
        </label>
        <button type="button" onClick={newPost} className={tb + " py-1.5"}>
          + new post
        </button>
        <button type="button" onClick={deletePost} disabled={posts.length <= 1} className={tb + " py-1.5 hover:border-danger hover:text-danger disabled:opacity-40"}>
          delete
        </button>
        <button type="button" onClick={reset} className={tb + " py-1.5 hover:border-danger hover:text-danger"}>
          reset demo
        </button>
        <p className="ml-auto font-mono text-xs text-fg-muted" aria-live="polite">
          {parsed.words} words · {parsed.minutes} min read ·{" "}
          <span className={errors ? "text-danger" : "text-accent"}>
            {errors} error{errors === 1 ? "" : "s"}
          </span>
          , <span className={warnings ? "text-warn-fg" : ""}>{warnings} warning{warnings === 1 ? "" : "s"}</span>
        </p>
      </div>

      <div className="flex border-b border-border lg:hidden" role="group" aria-label="View">
        {(["write", "output"] as const).map((v) => (
          <button key={v} type="button" aria-pressed={mobileView === v} onClick={() => setMobileView(v)} className={`flex-1 py-2 font-mono text-xs ${mobileView === v ? "bg-accent-soft text-fg" : "text-fg-muted"}`}>
            {v === "write" ? "Write" : "Preview / SEO"}
          </button>
        ))}
      </div>

      <div className="grid lg:grid-cols-2">
        {/* ---------- editor ---------- */}
        <div className={`${mobileView === "write" ? "flex" : "hidden"} min-w-0 flex-col border-border lg:flex lg:border-r`}>
          <div className="flex flex-wrap gap-1 border-b border-border px-3 py-2" role="toolbar" aria-label="Formatting">
            <button type="button" className={tb} onClick={() => wrap("**")} title="Bold (Ctrl+B)">
              <b>B</b>
            </button>
            <button type="button" className={tb} onClick={() => wrap("*")} title="Italic (Ctrl+I)">
              <i>I</i>
            </button>
            <button type="button" className={tb} onClick={() => wrap("`", "`", "code")} title="Inline code (Ctrl+E)">
              {"</>"}
            </button>
            <button type="button" className={tb} onClick={() => wrap("[", "](https://)", "link text")} title="Link (Ctrl+K)">
              link
            </button>
            <button type="button" className={tb} onClick={() => prefixLine("## ")}>
              H2
            </button>
            <button type="button" className={tb} onClick={() => prefixLine("### ")}>
              H3
            </button>
            <button type="button" className={tb} onClick={() => prefixLine("- ")}>
              • list
            </button>
            <button type="button" className={tb} onClick={() => prefixLine("> ")}>
              quote
            </button>
            <button type="button" className={tb} onClick={() => wrap("\n```ts\n", "\n```\n", "const x = 1;")}>
              code block
            </button>
          </div>
          <label htmlFor="md-source" className="sr-only">
            Markdown source with frontmatter
          </label>
          <textarea
            id="md-source"
            ref={area}
            value={post.source}
            onChange={(e) => {
              setSource(e.target.value);
              trackCaret();
            }}
            onKeyDown={onKeyDown}
            onKeyUp={trackCaret}
            onClick={trackCaret}
            spellCheck={false}
            className="min-h-[30rem] w-full flex-1 resize-y bg-bg p-3 font-mono text-[13px] leading-5 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
          />
          <div className="flex justify-between border-t border-border px-3 py-1 font-mono text-[11px] text-fg-muted">
            <span>
              Ln {caret.line}, Col {caret.col}
            </span>
            <span>Ctrl+B · Ctrl+I · Ctrl+K · Ctrl+E</span>
          </div>
          <section aria-labelledby="issues-h" className="border-t border-border">
            <h3 id="issues-h" className="px-3 pt-2 font-mono text-xs font-semibold text-fg-muted">
              problems
            </h3>
            <ul className="max-h-44 overflow-y-auto px-1 pb-2">
              {parsed.issues.length === 0 && <li className="px-2 py-1 text-xs text-accent">✓ No problems. Frontmatter is valid.</li>}
              {parsed.issues.map((i, n) => (
                <li key={n}>
                  <button type="button" onClick={() => jumpTo(i.line)} className="flex w-full gap-2 rounded px-2 py-1 text-left text-xs hover:bg-bg-muted">
                    <span className={i.level === "error" ? "text-danger" : "text-warn-fg"} aria-label={i.level}>
                      {i.level === "error" ? "✕" : "⚠"}
                    </span>
                    <span className="w-10 shrink-0 font-mono text-fg-muted">{i.line ? `L${i.line}` : ""}</span>
                    <span>{i.message}</span>
                  </button>
                </li>
              ))}
            </ul>
          </section>
        </div>

        {/* ---------- output ---------- */}
        <div className={`${mobileView === "output" ? "block" : "hidden"} min-w-0 bg-bg-elev lg:block`}>
          <div role="tablist" aria-label="Output" className="flex border-b border-border">
            {(
              [
                ["preview", "Preview"],
                ["seo", "SEO"],
                ["html", "HTML"],
              ] as const
            ).map(([id, label]) => (
              <button
                key={id}
                role="tab"
                id={`md-tab-${id}`}
                aria-selected={tab === id}
                aria-controls="md-panel"
                tabIndex={tab === id ? 0 : -1}
                onClick={() => setTab(id)}
                onKeyDown={(e) => {
                  const ids: Tab[] = ["preview", "seo", "html"];
                  if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
                  const next = ids[(ids.indexOf(id) + (e.key === "ArrowRight" ? 1 : 2)) % 3]!;
                  setTab(next);
                  document.getElementById(`md-tab-${next}`)?.focus();
                }}
                className={`border-b-2 px-4 py-2.5 font-mono text-xs ${tab === id ? "border-accent text-fg" : "border-transparent text-fg-muted hover:text-fg"}`}
              >
                {label}
              </button>
            ))}
          </div>
          <div id="md-panel" role="tabpanel" aria-labelledby={`md-tab-${tab}`} className="max-h-[46rem] overflow-y-auto">
            {tab === "preview" && <PreviewPane parsed={parsed} />}
            {tab === "seo" && <SeoPane parsed={parsed} />}
            {tab === "html" && (
              <div className="space-y-2 p-4">
                <div className="flex items-center justify-between">
                  <p className="text-xs text-fg-muted">Escaped HTML output, as it would go into an RSS feed or a static page.</p>
                  <button
                    type="button"
                    className={tb}
                    onClick={async () => {
                      try {
                        await navigator.clipboard.writeText(html);
                        setCopied(true);
                        setTimeout(() => setCopied(false), 1500);
                      } catch {
                        /* clipboard blocked */
                      }
                    }}
                  >
                    {copied ? "copied ✓" : "copy"}
                  </button>
                </div>
                <pre className="overflow-x-auto rounded-md bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed whitespace-pre-wrap text-[#a1a1aa]">{html}</pre>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

function formatDate(iso: string | undefined) {
  if (!iso || Number.isNaN(Date.parse(iso))) return "no date";
  const d = new Date(iso + "T00:00:00Z");
  return `${d.getUTCDate()} ${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}

function PreviewPane({ parsed }: { parsed: ParsedPost }) {
  const title = typeof parsed.raw.title === "string" ? parsed.raw.title : "Untitled";
  const tags = Array.isArray(parsed.raw.tags) ? parsed.raw.tags : [];
  return (
    <article className="p-4 sm:p-6">
      {parsed.frontmatter?.draft && <p className="mb-2 inline-block rounded bg-warn-bg px-2 py-0.5 font-mono text-[11px] text-warn-fg">draft · hidden in production</p>}
      <h2 className="text-3xl font-bold tracking-tight">{title}</h2>
      <p className="mt-2 font-mono text-xs text-fg-muted">
        {formatDate(typeof parsed.raw.date === "string" ? parsed.raw.date : undefined)} · {parsed.minutes} min read
        {tags.map((t) => (
          <span key={t} className="ml-2 rounded-full border border-border px-2 py-0.5">
            #{t}
          </span>
        ))}
      </p>
      {parsed.toc.length > 1 && (
        <nav aria-label="On this page" className="mt-5 rounded-md border border-border bg-bg p-3">
          <p className="font-mono text-xs font-semibold text-fg-muted">On this page</p>
          <ol className="mt-1 space-y-0.5 text-sm">
            {parsed.toc.map((t) => (
              <li key={t.id} className={t.depth === 3 ? "pl-4" : ""}>
                <a href={`#md-${t.id}`} className="text-accent hover:underline">
                  {t.text}
                </a>
              </li>
            ))}
          </ol>
        </nav>
      )}
      <div className="mt-4 text-[15px]">
        <Render blocks={parsed.blocks} />
      </div>
      {parsed.blocks.length === 0 && <p className="mt-6 text-sm text-fg-muted">The body is empty. Start typing on the left.</p>}
    </article>
  );
}

function Meter({ label, value, min, ideal, max }: { label: string; value: number; min: number; ideal: number; max: number }) {
  const ok = value >= min && value <= max;
  return (
    <div className="text-xs">
      <div className="flex justify-between">
        <span>{label}</span>
        <span className={`font-mono ${ok ? "text-accent" : "text-danger"}`}>
          {value} / {ideal} chars
        </span>
      </div>
      <div className="mt-1 h-1.5 overflow-hidden rounded bg-bg-muted" aria-hidden="true">
        <div className={`h-full ${ok ? "bg-accent" : "bg-danger"}`} style={{ width: `${Math.min(100, (value / max) * 100)}%` }} />
      </div>
    </div>
  );
}

function SeoPane({ parsed }: { parsed: ParsedPost }) {
  const title = typeof parsed.raw.title === "string" ? parsed.raw.title : "";
  const description = typeof parsed.raw.description === "string" ? parsed.raw.description : "";
  const date = typeof parsed.raw.date === "string" ? parsed.raw.date : "";
  const url = `https://${SITE.host}/blog/${parsed.slug}`;
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: title,
    description,
    datePublished: date,
    url,
    wordCount: parsed.words,
    timeRequired: `PT${parsed.minutes}M`,
    keywords: Array.isArray(parsed.raw.tags) ? parsed.raw.tags.join(", ") : undefined,
  };
  return (
    <div className="space-y-6 p-4 sm:p-6">
      <section aria-labelledby="g-h">
        <h3 id="g-h" className="font-mono text-xs font-semibold text-fg-muted">
          search result preview
        </h3>
        <div className="mt-2 rounded-lg border border-border bg-[#ffffff] p-4 font-[arial,sans-serif]">
          <div className="flex items-center gap-2">
            <span className="grid size-7 place-items-center rounded-full bg-[#f1f3f4] text-[11px] font-bold text-[#15803d]" aria-hidden="true">
              E
            </span>
            <div className="min-w-0 leading-tight">
              <p className="text-[14px] text-[#202124]">{SITE.name}</p>
              <p className="truncate text-[12px] text-[#4d5156]">
                {SITE.host} › blog › {parsed.slug}
              </p>
            </div>
          </div>
          <p className="mt-1.5 text-[20px] leading-snug text-[#1a0dab]">{truncate(title || "Untitled", 60)}</p>
          <p className="mt-1 text-[14px] leading-snug text-[#4d5156]">
            {date && <span className="text-[#70757a]">{formatDate(date)} — </span>}
            {truncate(description || "No description: search engines will pick text from the page.", 155)}
          </p>
        </div>
        <div className="mt-3 grid gap-2 sm:grid-cols-2">
          <Meter label="Title" value={title.length} min={10} ideal={60} max={60} />
          <Meter label="Description" value={description.length} min={50} ideal={155} max={160} />
        </div>
      </section>

      <section aria-labelledby="og-h">
        <h3 id="og-h" className="font-mono text-xs font-semibold text-fg-muted">
          social card (og:image 1200 × 630)
        </h3>
        <div className="mt-2 overflow-hidden rounded-lg border border-border">
          <div className="relative flex aspect-[1200/630] flex-col justify-between bg-[#0b0b0d] p-[5%] text-[#ededed]">
            <div className="absolute inset-0 bg-[linear-gradient(rgb(255_255_255/0.05)_1px,transparent_1px),linear-gradient(90deg,rgb(255_255_255/0.05)_1px,transparent_1px)] bg-[size:24px_24px]" aria-hidden="true" />
            <p className="relative font-mono text-[clamp(9px,2vw,14px)] text-[#4ade80]">{SITE.host}</p>
            <p className="relative line-clamp-3 text-[clamp(16px,4vw,32px)] leading-tight font-bold">{title || "Untitled"}</p>
            <p className="relative font-mono text-[clamp(9px,1.8vw,13px)] text-[#a1a1aa]">
              {formatDate(date)} · {parsed.minutes} min read
            </p>
          </div>
          <div className="border-t border-border bg-bg p-3">
            <p className="text-[11px] text-fg-muted uppercase">{SITE.host}</p>
            <p className="truncate text-sm font-semibold">{title || "Untitled"}</p>
            <p className="line-clamp-2 text-xs text-fg-muted">{description}</p>
          </div>
        </div>
      </section>

      <section aria-labelledby="ld-h">
        <h3 id="ld-h" className="font-mono text-xs font-semibold text-fg-muted">
          canonical + JSON-LD
        </h3>
        <p className="mt-2 font-mono text-xs break-all">
          <span className="text-fg-muted">canonical </span>
          {url}
        </p>
        <pre className="mt-2 overflow-x-auto rounded-md bg-[#0b0b0d] p-3 font-mono text-[12px] text-[#a1a1aa]">{JSON.stringify(jsonLd, null, 2)}</pre>
      </section>
    </div>
  );
}
app/lab/markdown-blog-engine/seed.ts113 lines
/** Sample posts for the editor. Fictional site; the third post deliberately has problems for the lint panel. */

export type Post = { id: string; source: string };

export const SITE = { name: "Example Dev Blog", host: "blog.example.com" };

export const SEED: Post[] = [
  {
    id: "p1",
    source: `---
title: "Fixing layout shift from web fonts in Next.js"
description: "Why custom fonts move your page after load, how to measure it, and the three settings in next/font that stopped it on a small marketing site."
date: 2026-10-02
tags: [nextjs, performance]
---

Layout shift from fonts is one of the most common **Core Web Vitals** problems I see. The page renders with a fallback font, the web font arrives, and every line of text *reflows*.

## Measure it first

Open DevTools, record a performance trace, and look for \`layout-shift\` entries. You can also log them:

\`\`\`ts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("CLS entry", entry.value); // anything above 0.1 in total is "poor"
  }
}).observe({ type: "layout-shift", buffered: true });
\`\`\`

## Three settings that fix it

1. Load the font with \`next/font\`, so it is self-hosted and preloaded.
2. Keep \`display: "swap"\` but let Next.js generate a matched fallback.
3. Only load the weights you use.

> A matched fallback font has its size and line height adjusted so the swap is almost invisible.

### What changed

| Setting | Before | After |
| :-- | :--: | --: |
| Font hosting | Google CDN | self-hosted |
| Weights | 6 | 2 |

## Checklist

- [x] Fonts self-hosted
- [x] Unused weights removed
- [ ] Check again after the next design change

Read the [next/font docs](https://nextjs.org/docs/app/api-reference/components/font) for the full API.
`,
  },
  {
    id: "p2",
    source: `---
title: "Writing SQL indexes for the queries you actually run"
description: "A practical way to choose PostgreSQL indexes: start from slow queries, read EXPLAIN output, and add composite indexes in the right column order."
date: 2026-09-18
tags: [postgresql, performance]
slug: sql-indexes-for-real-queries
---

Most indexes I remove in reviews were added "just in case". Start from the query instead.

## Read the plan

\`\`\`sql
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 1042 AND created_at > now() - interval '30 days'
ORDER BY created_at DESC;
\`\`\`

A \`Seq Scan\` on a large table is the signal. A composite index on \`(customer_id, created_at)\` serves both the filter and the sort.

## Column order matters

- Equality columns first
- Then the range or sort column
  - \`created_at\` here
  - an index on \`(created_at, customer_id)\` would be much less useful

---

*Next post:* partial indexes for soft-deleted rows.
`,
  },
  {
    id: "p3",
    source: `---
title: "Draft"
date: 2026-13-01
tags: [react, vue]
author: someone
---

# A second H1

#### Skipped two levels

Here is a [suspicious link](javascript:alert('hi')) and a [click here](https://example.com) link.

![](/images/diagram.png)

\`\`\`
code block without a language
\`\`\`
`,
  },
];