Back

generating thoughts from markdown

written 20th July 2026

I’ve been thinking about how i don’t want to write html anymore.

I got Claude to write me a Node script that takes a markdown file and generates an HTML file that matches the existing style of pages on here. Here’s the source:

#!/usr/bin/env node
  // Convert markdown posts in thoughts/ to HTML.
  //
  // Write a post as thoughts/YYYY-MM-DD.md with frontmatter:
  //
  //     ---
  //     title: my post title
  //     date: 2026-07-20
  //     ---
  //
  //     Post body in markdown...
  //
  // Then run `node build_thoughts.js`. Every .md file in thoughts/ is
  // converted to a .html file next to it (existing hand-written .html
  // posts without a .md source are never touched), and new posts are
  // linked from the Thoughts list in index.html, sorted newest-first.
  // Requires pandoc.

  const fs = require("fs");
  const path = require("path");
  const { execFileSync } = require("child_process");

  const THOUGHTS_DIR = path.join(__dirname, "thoughts");
  const INDEX = path.join(__dirname, "index.html");

  const MONTHS = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
  ];

  function ordinal(n) {
    if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
    return n + ({ 1: "st", 2: "nd", 3: "rd" }[n % 10] || "th");
  }

  // "2026-07-20" -> "20th July 2026"
  function formatDate(isoDate) {
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
    if (!m) throw new Error(`invalid date: ${isoDate}`);
    return `${ordinal(Number(m[3]))} ${MONTHS[Number(m[2]) - 1]} ${m[1]}`;
  }

  function escapeHtml(s) {
    return s
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  }

  function parseFrontmatter(text, filename) {
    const meta = {};
    let body = text;
    const match = /^---\s*\n([\s\S]*?)\n---\s*\n/.exec(text);
    if (match) {
      body = text.slice(match[0].length);
      for (const line of match[1].split("\n")) {
        const i = line.indexOf(":");
        if (i !== -1) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
      }
    }
    const stem = path.basename(filename, ".md");
    return {
      title: meta.title || stem,
      date: meta.date || stem, // filename is YYYY-MM-DD
      body,
    };
  }

  function template(title, date, body) {
    return `<!DOCTYPE html>
  <html>

  <head>
    <title>${title} | rjwebb.github.io</title>
    <link rel="stylesheet" type="text/css" href="../style/style.css" />
  </head>

  <body>
    <a href="../index.html">Back</a>
    <h1>${title}</h1>
    <em>written ${date}</em>

  ${body}
  </body>

  </html>
  `;
  }

  function convert(mdPath) {
    const { title, date, body } = parseFrontmatter(
      fs.readFileSync(mdPath, "utf8"),
      mdPath
    );
    const bodyHtml = execFileSync("pandoc", ["-f", "markdown", "-t", "html"], {
      input: body,
      encoding: "utf8",
    })
      .trimEnd()
      .split("\n")
      .map((line) => (line ? "  " + line : ""))
      .join("\n");

    const outPath = mdPath.replace(/\.md$/, ".html");
    fs.writeFileSync(
      outPath,
      template(escapeHtml(title), formatDate(date), bodyHtml)
    );
    return { outPath, title };
  }

  // Insert a link into the Thoughts <ul> in index.html, keeping the list
  // sorted newest-first. Posts are named YYYY-MM-DD so names sort as dates.
  function addToIndex(indexHtml, name, title) {
    const li = [
      "                    <li>",
      `                        <a href="thoughts/${name}">${escapeHtml(title)}</a>`,
      "                    </li>",
      "",
    ].join("\n");

    const linkRe = /<li>(?:(?!<\/li>)[\s\S])*?href="thoughts\/([^"]+)"[\s\S]*?<\/li>\s*\n/g;
    let lastEnd = -1;
    let match;
    while ((match = linkRe.exec(indexHtml)) !== null) {
      if (match[1] < name) {
        // first existing post older than this one: insert before it,
        // at the start of its line so indentation stays intact
        const at = indexHtml.lastIndexOf("\n", match.index) + 1;
        return indexHtml.slice(0, at) + li + indexHtml.slice(at);
      }
      lastEnd = match.index + match[0].length;
    }
    if (lastEnd !== -1) {
      // older than every existing post: append after the last one
      return indexHtml.slice(0, lastEnd) + li + indexHtml.slice(lastEnd);
    }
    return null;
  }

  function main() {
    const mdFiles = fs
      .readdirSync(THOUGHTS_DIR)
      .filter((f) => f.endsWith(".md"))
      .sort()
      .map((f) => path.join(THOUGHTS_DIR, f));

    if (mdFiles.length === 0) {
      console.log("No .md files found in thoughts/");
      return;
    }

    let indexHtml = fs.existsSync(INDEX) ? fs.readFileSync(INDEX, "utf8") : "";
    let indexChanged = false;
    for (const mdPath of mdFiles) {
      const { outPath, title } = convert(mdPath);
      const name = path.basename(outPath);
      console.log(`${path.basename(mdPath)} -> ${name}`);
      if (!indexHtml.includes(`thoughts/${name}`)) {
        const updated = addToIndex(indexHtml, name, title);
        if (updated) {
          indexHtml = updated;
          indexChanged = true;
          console.log(`  added to index.html: ${title}`);
        } else {
          console.log("  couldn't find the Thoughts list — add manually:");
          console.log(
            `    <li><a href="thoughts/${name}">${escapeHtml(title)}</a></li>`
          );
        }
      }
    }
    if (indexChanged) fs.writeFileSync(INDEX, indexHtml);
  }

  main();

I think this is so cool!!!