Back to overview

A follow-up to my first post.

Sat, 4 Oct 2025

So I just learned I can directly commit .md files like I am using for the blog to my git, let Netlify convert them to html with the styling I want and auto generate an index/overview over all my blog posts.
That means I only ever see the .md files in my ./blog/ folder in git but deleting them there also deletes the post and updates the index. Very fancy.

This is the ./scripts/generate-blog-index.mjs:

import { readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { join, basename } from "node:path";
import { marked } from "marked";

const BLOG_DIR = "blog";
const OUT = join(BLOG_DIR, "index.html");

const HEADER = `<!doctype html><html lang=en>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel=icon href=data:,>
<style>:root{color-scheme:light dark}body{margin:0 auto;line-height:1.5;max-width:65ch;padding:1rem}a{display:block}small a{color:gray;text-decoration:none}small a:hover{text-decoration:underline}pre{background:#eee;padding:.5em;overflow-x:auto;border-radius:.3em}@media(prefers-color-scheme:dark){pre{background:#222}}code{font-family:monospace}</style>
<small><a href="/blog/">Back to overview</a></small>
`;

for (const file of readdirSync(BLOG_DIR)) {
  if (!file.endsWith(".md")) continue;

  const mdPath = join(BLOG_DIR, file);
  const htmlName = basename(file, ".md") + ".html";
  const htmlPath = join(BLOG_DIR, htmlName);

  const md = readFileSync(mdPath, "utf8");
  const htmlBody = marked.parse(md);
  writeFileSync(htmlPath, HEADER + htmlBody);
  unlinkSync(mdPath);  // remove .md after conversion

  console.log(`Converted ${file} → ${htmlName} and removed original`);
}

const files = readdirSync(BLOG_DIR)
  .filter(f => f.endsWith(".html") && f !== "index.html");

function decodeEntities(s) {
  return s
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'");
}

function extractH2H6(html) {
  const h2 = html.match(/<h2\b[^>]*>([\s\S]*?)<\/h2>/i);
  const h6 = html.match(/<h6\b[^>]*>([\s\S]*?)<\/h6>/i);
  const title = h2 ? decodeEntities(h2[1].trim()) : "";
  const date  = h6 ? decodeEntities(h6[1].trim()) : "";
  return { title, date };
}

function labelFromName(name) {
  const m = name.match(/^(\d{6})/);
  if (!m) return name;
  const yy = parseInt(m[1].slice(0,2),10);
  const mm = m[1].slice(2,4);
  const dd = m[1].slice(4,6);
  const yyyy = 2000 + yy;
  return `${yyyy}-${mm}-${dd}`;
}

const posts = files.map(f => {
  const html = readFileSync(join(BLOG_DIR, f), "utf8");
  const { title, date } = extractH2H6(html);

  const match = f.match(/^(\d{6})(?:_(\d+))?/);
  const base = match ? match[1] : "000000";
  const sub  = match && match[2] ? parseInt(match[2], 10) : 0;

  const slug = f.replace(/\.html$/i, "");
  return { file: f, slug, title: title || base, date, base, sub };
}).sort((a, b) => {
  if (b.base !== a.base) return b.base.localeCompare(a.base);
  return b.sub - a.sub;
});

const doc = `<!doctype html><html lang=en>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Blog</title>
<link rel=icon href=data:,>
<style>:root{color-scheme:light dark}body{margin:0 auto;max-width:65ch;padding:1rem;line-height:1.5}ul{padding-left:1.25rem}small{color:gray}small a{color:gray;text-decoration:none}small a:hover{text-decoration:underline}</style>
<small><a href="/">Back to home</a></small>
<h1>Blog</h1>
<ul>
${posts.map(p => `<li><a href="/blog/${p.slug}">${p.title}</a>${p.date ? `<br><small>${p.date}</small>` : ""}`).join("\n")}
</ul>`;

writeFileSync(OUT, doc);
console.log(`Wrote ${OUT} with ${posts.length} posts`);