Files
blog/build.js
ozan d83fdb137c feat: blog build system + all HTML generated by Pi agent
build.js + templates copied from docs, 11 posts built to 14 HTML files.
Generated by local Pi orchestrator task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 11:12:08 +01:00

224 lines
7.0 KiB
JavaScript

#!/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 = [];
function closeUl() {
if (inUl) { html += "</ul>\n"; inUl = false; }
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Fenced code blocks
if (line.startsWith("```")) {
if (!inCode) {
closeUl();
inCode = true;
codeLang = line.slice(3).trim();
codeLines = [];
continue;
} else {
html += `<pre><code${codeLang ? ` class="language-${codeLang}"` : ""}>${codeLines.join("\n")}</code></pre>\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 += `<h${level}>${inline(hMatch[2])}</h${level}>\n`;
continue;
}
// Horizontal rule
if (/^---+$/.test(line.trim())) {
closeUl();
html += "<hr>\n";
continue;
}
// Figure (![alt](src) on its own line)
const figMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (figMatch) {
closeUl();
html += `<figure>\n <img src="${figMatch[2]}" alt="${escapeHtml(figMatch[1])}">\n <figcaption>${inline(figMatch[1])}</figcaption>\n</figure>\n`;
continue;
}
// Unordered list
const liMatch = line.match(/^[-*]\s+(.*)$/);
if (liMatch) {
if (!inUl) { html += "<ul>\n"; inUl = true; }
html += ` <li>${inline(liMatch[1])}</li>\n`;
continue;
}
// Paragraph
closeUl();
html += `<p>${inline(line)}</p>\n`;
}
closeUl();
return html;
}
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function inline(s) {
// Bold
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Italic
s = s.replace(/\*(.+?)\*/g, "<em>$1</em>");
// Inline code
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
// Links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: var(--c-accent-l);">$1</a>');
// Em dash
s = s.replace(/---/g, "&mdash;");
s = s.replace(/--/g, "&ndash;");
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) => `
<a href="${p.slug}" class="blog-card">
<span class="blog-card__date">${formatDate(p.date)}</span>
<h2 class="blog-card__title">${p.title}</h2>
<p class="blog-card__excerpt">${p.excerpt || p.description}</p>
<span class="blog-card__read">Read &rarr;</span>
</a>`
)
.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.`);