d83fdb137c
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>
224 lines
7.0 KiB
JavaScript
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 ( 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
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, "—");
|
|
s = s.replace(/--/g, "–");
|
|
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 →</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.`);
|