|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Blog build script for brain.vraspar.com |
| 5 | + * |
| 6 | + * Converts markdown posts in website/blog-src/posts/ to HTML in website/blog/. |
| 7 | + * Uses gray-matter for frontmatter + marked for markdown-to-HTML. |
| 8 | + * |
| 9 | + * Usage: node scripts/build-blog.js |
| 10 | + */ |
| 11 | + |
| 12 | +import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs'; |
| 13 | +import { join, basename, dirname } from 'node:path'; |
| 14 | +import { fileURLToPath } from 'node:url'; |
| 15 | +import matter from 'gray-matter'; |
| 16 | +import { marked } from 'marked'; |
| 17 | + |
| 18 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 19 | +const ROOT = join(__dirname, '..'); |
| 20 | +const POSTS_DIR = join(ROOT, 'website', 'blog-src', 'posts'); |
| 21 | +const OUTPUT_DIR = join(ROOT, 'website', 'blog'); |
| 22 | +const SITE_URL = 'https://brain.vraspar.com'; |
| 23 | + |
| 24 | +function loadPosts() { |
| 25 | + if (!existsSync(POSTS_DIR)) return []; |
| 26 | + return readdirSync(POSTS_DIR) |
| 27 | + .filter(f => f.endsWith('.md')) |
| 28 | + .map(file => { |
| 29 | + const raw = readFileSync(join(POSTS_DIR, file), 'utf8'); |
| 30 | + const { data, content } = matter(raw); |
| 31 | + const slug = basename(file, '.md'); |
| 32 | + const html = marked.parse(content); |
| 33 | + return { slug, html, ...data }; |
| 34 | + }) |
| 35 | + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); |
| 36 | +} |
| 37 | + |
| 38 | +function formatDate(date) { |
| 39 | + return new Date(date).toLocaleDateString('en-US', { |
| 40 | + year: 'numeric', month: 'long', day: 'numeric', |
| 41 | + }); |
| 42 | +} |
| 43 | + |
| 44 | +function esc(str) { |
| 45 | + return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| 46 | +} |
| 47 | + |
| 48 | +const HEAD = ` <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='80' font-family='monospace' fill='%234ade80'>b</text></svg>"> |
| 49 | + <link rel="preconnect" href="https://fonts.googleapis.com"> |
| 50 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 51 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">`; |
| 52 | + |
| 53 | +function renderPost(post) { |
| 54 | + return `<!DOCTYPE html> |
| 55 | +<html lang="en"> |
| 56 | +<head> |
| 57 | + <meta charset="UTF-8"> |
| 58 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 59 | + <title>${esc(post.title)} \u2014 Brain CLI Blog</title> |
| 60 | + <meta name="description" content="${esc(post.summary || '')}"> |
| 61 | + <meta property="og:title" content="${esc(post.title)}"> |
| 62 | + <meta property="og:description" content="${esc(post.summary || '')}"> |
| 63 | + <meta property="og:type" content="article"> |
| 64 | + <meta property="og:url" content="${SITE_URL}/blog/${post.slug}/"> |
| 65 | + <meta name="twitter:card" content="summary"> |
| 66 | +${HEAD} |
| 67 | + <link rel="stylesheet" href="../../style.css"> |
| 68 | + <link rel="stylesheet" href="../blog.css"> |
| 69 | +</head> |
| 70 | +<body> |
| 71 | + <nav class="blog-nav"> |
| 72 | + <div class="container"> |
| 73 | + <a href="/" class="blog-nav-brand">brain</a> |
| 74 | + <a href="/blog/">Blog</a> |
| 75 | + </div> |
| 76 | + </nav> |
| 77 | + <main class="blog-main"> |
| 78 | + <article class="blog-post"> |
| 79 | + <header class="blog-post-header"> |
| 80 | + <h1>${esc(post.title)}</h1> |
| 81 | + <div class="blog-post-meta"> |
| 82 | + <time datetime="${new Date(post.date).toISOString()}">${formatDate(post.date)}</time> |
| 83 | + ${post.author ? `<span class="blog-post-author">by ${esc(post.author)}</span>` : ''} |
| 84 | + </div> |
| 85 | + </header> |
| 86 | + <div class="blog-post-content"> |
| 87 | + ${post.html} |
| 88 | + </div> |
| 89 | + </article> |
| 90 | + <div class="blog-post-footer"> |
| 91 | + <a href="/blog/">← All posts</a> |
| 92 | + <a href="https://github.com/vraspar/brain">GitHub →</a> |
| 93 | + </div> |
| 94 | + </main> |
| 95 | + <footer class="site-footer"> |
| 96 | + <div class="container"> |
| 97 | + <div>brain · MIT License</div> |
| 98 | + </div> |
| 99 | + </footer> |
| 100 | +</body> |
| 101 | +</html>`; |
| 102 | +} |
| 103 | + |
| 104 | +function renderIndex(posts) { |
| 105 | + const list = posts.map(p => ` |
| 106 | + <article class="blog-index-post"> |
| 107 | + <a href="/blog/${p.slug}/"> |
| 108 | + <h2>${esc(p.title)}</h2> |
| 109 | + <time datetime="${new Date(p.date).toISOString()}">${formatDate(p.date)}</time> |
| 110 | + ${p.summary ? `<p>${esc(p.summary)}</p>` : ''} |
| 111 | + </a> |
| 112 | + </article>`).join('\n'); |
| 113 | + |
| 114 | + return `<!DOCTYPE html> |
| 115 | +<html lang="en"> |
| 116 | +<head> |
| 117 | + <meta charset="UTF-8"> |
| 118 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 119 | + <title>Blog \u2014 Brain CLI</title> |
| 120 | + <meta name="description" content="Blog posts about building Brain CLI."> |
| 121 | + <meta property="og:title" content="Blog \u2014 Brain CLI"> |
| 122 | + <meta property="og:type" content="website"> |
| 123 | + <meta property="og:url" content="${SITE_URL}/blog/"> |
| 124 | + <meta name="twitter:card" content="summary"> |
| 125 | +${HEAD} |
| 126 | + <link rel="stylesheet" href="../style.css"> |
| 127 | + <link rel="stylesheet" href="blog.css"> |
| 128 | +</head> |
| 129 | +<body> |
| 130 | + <nav class="blog-nav"> |
| 131 | + <div class="container"> |
| 132 | + <a href="/" class="blog-nav-brand">brain</a> |
| 133 | + <a href="/blog/">Blog</a> |
| 134 | + </div> |
| 135 | + </nav> |
| 136 | + <main class="blog-main"> |
| 137 | + <header class="blog-index-header"> |
| 138 | + <h1>Blog</h1> |
| 139 | + <p>Notes on building Brain CLI.</p> |
| 140 | + </header> |
| 141 | + <div class="blog-index-list"> |
| 142 | +${list} |
| 143 | + </div> |
| 144 | + </main> |
| 145 | + <footer class="site-footer"> |
| 146 | + <div class="container"> |
| 147 | + <div>brain · MIT License</div> |
| 148 | + </div> |
| 149 | + </footer> |
| 150 | +</body> |
| 151 | +</html>`; |
| 152 | +} |
| 153 | + |
| 154 | +const posts = loadPosts(); |
| 155 | +if (posts.length === 0) { |
| 156 | + console.log('No blog posts found in website/blog-src/posts/'); |
| 157 | + process.exit(0); |
| 158 | +} |
| 159 | + |
| 160 | +for (const post of posts) { |
| 161 | + const postDir = join(OUTPUT_DIR, post.slug); |
| 162 | + mkdirSync(postDir, { recursive: true }); |
| 163 | + writeFileSync(join(postDir, 'index.html'), renderPost(post)); |
| 164 | + console.log(` Built: blog/${post.slug}/`); |
| 165 | +} |
| 166 | + |
| 167 | +writeFileSync(join(OUTPUT_DIR, 'index.html'), renderIndex(posts)); |
| 168 | +console.log(` Built: blog/index.html (${posts.length} post${posts.length === 1 ? '' : 's'})`); |
0 commit comments