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>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
#!/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.`);
|
||||
Reference in New Issue
Block a user