#!/usr/bin/env node /** * Blog build script — converts markdown posts to static HTML. * * Usage: node build.js * Input: posts/*.md (markdown with YAML frontmatter) * Output: *.html (from _template.html + _index_template.html) * * Zero external dependencies — uses only Node.js built-ins. * Markdown conversion is intentionally minimal (handles the subset * our posts actually use). For richer formatting, swap in marked/markdown-it. */ const fs = require("fs"); const path = require("path"); const BLOG_DIR = __dirname; const POSTS_DIR = path.join(BLOG_DIR, "posts"); const TEMPLATE = fs.readFileSync(path.join(BLOG_DIR, "_template.html"), "utf8"); const INDEX_TEMPLATE = fs.readFileSync( path.join(BLOG_DIR, "_index_template.html"), "utf8" ); // ── Frontmatter parser ────────────────────────────────────────────── function parseFrontmatter(src) { const match = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) throw new Error("Missing YAML frontmatter"); const meta = {}; for (const line of match[1].split("\n")) { const idx = line.indexOf(":"); if (idx === -1) continue; const key = line.slice(0, idx).trim(); let val = line.slice(idx + 1).trim(); // strip surrounding quotes if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } meta[key] = val; } return { meta, body: match[2] }; } // ── Minimal Markdown → HTML ───────────────────────────────────────── function md(src) { const lines = src.split("\n"); let html = ""; let inUl = false; let inCode = false; let codeLang = ""; let codeLines = []; let inRaw = false; function closeUl() { if (inUl) { html += "\n"; inUl = false; } } for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Raw HTML passthrough — everything between and // is emitted verbatim (no escaping, no
wrap). For inline SVG diagrams, // tables, or any hand-authored markup the minimal converter can't express. if (line.trim() === "") { closeUl(); inRaw = true; continue; } if (line.trim() === "") { inRaw = false; continue; } if (inRaw) { html += line + "\n"; continue; } // Fenced code blocks if (line.startsWith("```")) { if (!inCode) { closeUl(); inCode = true; codeLang = line.slice(3).trim(); codeLines = []; continue; } else { html += `
${codeLines.join("\n")}\n`;
inCode = false;
continue;
}
}
if (inCode) {
codeLines.push(escapeHtml(line));
continue;
}
// Blank line
if (line.trim() === "") {
closeUl();
continue;
}
// Headings
const hMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (hMatch) {
closeUl();
const level = hMatch[1].length;
html += `${inline(line)}
\n`; } closeUl(); return html; } function escapeHtml(s) { return s.replace(/&/g, "&").replace(//g, ">"); } function inline(s) { // Bold s = s.replace(/\*\*(.+?)\*\*/g, "$1"); // Italic s = s.replace(/\*(.+?)\*/g, "$1"); // Inline code s = s.replace(/`([^`]+)`/g, "$1");
// Em dash / en dash — before links so CSS var(--x) in style attr isn't mangled
s = s.replace(/---/g, "—");
s = s.replace(/--/g, "–");
// Links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
return s;
}
// ── Date formatting ─────────────────────────────────────────────────
function formatDate(isoDate) {
const d = new Date(isoDate + "T00:00:00Z");
const months = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
return `${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}
// ── Build ───────────────────────────────────────────────────────────
function buildPost(file) {
const src = fs.readFileSync(path.join(POSTS_DIR, file), "utf8");
const { meta, body } = parseFrontmatter(src);
// Split lead (first paragraph) from rest
const parts = body.split(/\n\n/);
const lead = parts[0].trim();
const rest = parts.slice(1).join("\n\n");
const html = TEMPLATE
.replace(/\{\{TITLE\}\}/g, meta.title)
.replace(/\{\{DESCRIPTION\}\}/g, meta.description || meta.title)
.replace(/\{\{OG_DESCRIPTION\}\}/g, meta.og_description || meta.description || meta.title)
.replace(/\{\{OG_IMAGE\}\}/g, meta.og_image || "https://www.tinqs.com/img/og-cover.jpg")
.replace(/\{\{SLUG\}\}/g, meta.slug)
.replace(/\{\{DATE_ISO\}\}/g, meta.date)
.replace(/\{\{DATE_DISPLAY\}\}/g, formatDate(meta.date))
.replace(/\{\{LEAD\}\}/g, inline(lead))
.replace(/\{\{BODY\}\}/g, md(rest))
.replace(/\{\{AUTHOR_NAME\}\}/g, meta.author || "Ozan Bozkurt")
.replace(/\{\{AUTHOR_INITIALS\}\}/g, meta.author_initials || "OB")
.replace(/\{\{AUTHOR_ROLE\}\}/g, meta.author_role || "CTO & Developer, Tinqs");
const outPath = path.join(BLOG_DIR, `${meta.slug}.html`);
fs.writeFileSync(outPath, html);
console.log(` ${file} → ${meta.slug}.html`);
return meta;
}
function buildIndex(posts) {
// Sort newest first
posts.sort((a, b) => b.date.localeCompare(a.date));
const cards = posts
.map(
(p) => `
${formatDate(p.date)}
${p.excerpt || p.description}
Read → ` ) .join("\n"); const html = INDEX_TEMPLATE.replace("{{CARDS}}", cards); fs.writeFileSync(path.join(BLOG_DIR, "index.html"), html); console.log(" index.html (listing)"); } // ── Main ──────────────────────────────────────────────────────────── console.log("Building blog..."); if (!fs.existsSync(POSTS_DIR)) { console.error("No posts/ directory found."); process.exit(1); } const files = fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith(".md")).sort(); const posts = files.map(buildPost); buildIndex(posts); console.log(`Done — ${posts.length} posts built.`);