Blog Manager Implementation Plan
Blog Manager Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a local web app at localhost:9000 for authoring, managing, and publishing posts to a Jekyll GitHub Pages blog.
Architecture: Express server reads/writes directly to the Jekyll directory structure (_posts/, _drafts/, assets/). Single-page vanilla frontend uses EasyMDE for markdown editing and Pico CSS for styling. No build step, no database — the filesystem is the source of truth.
Tech Stack: Node.js, Express, multer, gray-matter, slugify, Pico CSS (CDN), EasyMDE (CDN)
File Structure
blog-manager/
├── server.js # Express server: static serving + all API routes
├── package.json # Project metadata + 4 dependencies
├── public/
│ ├── index.html # App shell: nav, view containers, editor markup
│ ├── style.css # Custom styles on top of Pico CSS
│ └── app.js # Vanilla JS: routing, API calls, editor init, image upload
└── launch/
└── BlogManager.app/ # macOS app bundle for dock launch
└── Contents/
├── Info.plist
├── MacOS/
│ └── launch.sh
└── Resources/
└── AppIcon.icns
Responsibilities:
server.js: All file I/O, front matter parsing, image handling, API routes. ~250 lines.public/index.html: Static markup for all three views (dashboard, editor, pages). Tab-style navigation hides/shows views. ~150 lines.public/style.css: Minimal overrides — Pico CSS does the heavy lifting. Category pills, toast, layout tweaks. ~100 lines.public/app.js: View switching, fetch wrappers, EasyMDE initialization, image drop handler, category picker logic, toast notifications. ~400 lines.
Task 1: Project Scaffolding
Files:
- Create:
blog-manager/package.json - Create:
blog-manager/server.js(minimal — just static serving) -
Create:
blog-manager/public/index.html(bare shell) - Step 1: Create package.json
mkdir -p /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager/public
Create blog-manager/package.json:
{
"name": "blog-manager",
"version": "1.0.0",
"description": "Local blog manager for gclinton.com",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"gray-matter": "^4.0.3",
"multer": "^1.4.5-lts.1",
"slugify": "^1.6.6"
}
}
- Step 2: Install dependencies
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && npm install
Expected: node_modules/ created, package-lock.json generated, 4 packages installed.
- Step 3: Create minimal server.js
Create blog-manager/server.js:
const express = require('express');
const path = require('path');
const app = express();
const PORT = 9000;
// The blog root is one directory up from blog-manager/
const BLOG_ROOT = path.resolve(__dirname, '..');
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, () => {
console.log(`Blog Manager running at http://localhost:${PORT}`);
});
- Step 4: Create bare index.html
Create blog-manager/public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
</head>
<body>
<main class="container">
<h1>Blog Manager</h1>
<p>It works.</p>
</main>
</body>
</html>
- Step 5: Verify server starts and serves the page
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1 && curl -s http://localhost:9000 | head -5
kill %1
Expected: HTML output containing <title>Blog Manager</title>.
- Step 6: Add blog-manager to Jekyll excludes and commit
Add blog-manager/ to the exclude: list in _config.yml so Jekyll doesn’t try to process it.
git add blog-manager/package.json blog-manager/package-lock.json blog-manager/server.js blog-manager/public/index.html _config.yml
git commit -m "feat: scaffold blog-manager app with Express + Pico CSS"
Task 2: Posts API — List and Read
Files:
-
Modify:
blog-manager/server.js(add GET /api/posts and GET /api/posts/:filename) -
Step 1: Add posts list endpoint
Add to server.js before app.listen():
const matter = require('gray-matter');
const fs = require('fs');
const POSTS_DIR = path.join(BLOG_ROOT, '_posts');
const DRAFTS_DIR = path.join(BLOG_ROOT, '_drafts');
// Ensure _drafts exists
if (!fs.existsSync(DRAFTS_DIR)) {
fs.mkdirSync(DRAFTS_DIR, { recursive: true });
}
app.get('/api/posts', (req, res) => {
try {
const posts = [];
// Read published posts
if (fs.existsSync(POSTS_DIR)) {
for (const file of fs.readdirSync(POSTS_DIR)) {
if (!file.endsWith('.markdown') && !file.endsWith('.md')) continue;
const raw = fs.readFileSync(path.join(POSTS_DIR, file), 'utf8');
const { data } = matter(raw);
posts.push({
filename: file,
title: data.title || file,
date: data.date || null,
categories: data.categories || '',
layout: data.layout || 'post',
status: 'published',
});
}
}
// Read drafts
if (fs.existsSync(DRAFTS_DIR)) {
for (const file of fs.readdirSync(DRAFTS_DIR)) {
if (!file.endsWith('.markdown') && !file.endsWith('.md')) continue;
const raw = fs.readFileSync(path.join(DRAFTS_DIR, file), 'utf8');
const { data } = matter(raw);
posts.push({
filename: file,
title: data.title || file,
date: data.date || null,
categories: data.categories || '',
layout: data.layout || 'post',
status: 'draft',
});
}
}
// Sort newest first
posts.sort((a, b) => {
const da = a.date ? new Date(a.date) : new Date(0);
const db = b.date ? new Date(b.date) : new Date(0);
return db - da;
});
res.json(posts);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 2: Add single post read endpoint
Add to server.js:
app.get('/api/posts/:filename', (req, res) => {
const { filename } = req.params;
// Check _posts first, then _drafts
let filePath = path.join(POSTS_DIR, filename);
let status = 'published';
if (!fs.existsSync(filePath)) {
filePath = path.join(DRAFTS_DIR, filename);
status = 'draft';
}
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Post not found' });
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(raw);
res.json({
filename,
title: data.title || '',
date: data.date || null,
categories: data.categories || '',
layout: data.layout || 'post',
status,
content,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 3: Verify endpoints work
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
curl -s http://localhost:9000/api/posts | python3 -m json.tool | head -20
curl -s http://localhost:9000/api/posts/2024-09-02-AI-UI-problem.markdown | python3 -m json.tool | head -10
kill %1
Expected: JSON array of 5+ posts with title/date/categories/status fields. Single post returns content field with markdown body.
- Step 4: Commit
git add blog-manager/server.js
git commit -m "feat: add GET /api/posts list and read endpoints"
Task 3: Posts API — Create, Update, Delete, Publish/Unpublish
Files:
-
Modify:
blog-manager/server.js -
Step 1: Add helper to generate filenames
Add to server.js after the requires:
const slugify = require('slugify');
function makeFilename(title, date) {
const slug = slugify(title, { lower: true, strict: true });
const dateStr = date ? new Date(date).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
return `${dateStr}-${slug}.markdown`;
}
function formatDate(date) {
const d = date ? new Date(date) : new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} -0400`;
}
function buildFileContent(title, date, categories, layout, content) {
const frontMatter = [
'---',
`layout: ${layout || 'post'}`,
`title: "${title.replace(/"/g, '\\"')}"`,
`date: ${formatDate(date)}`,
`categories: ${categories || ''}`,
'---',
].join('\n');
return frontMatter + '\n' + (content || '');
}
function resolveUniqueFilename(dir, filename) {
if (!fs.existsSync(path.join(dir, filename))) return filename;
const ext = path.extname(filename);
const base = filename.slice(0, -ext.length);
let i = 2;
while (fs.existsSync(path.join(dir, `${base}-${i}${ext}`))) i++;
return `${base}-${i}${ext}`;
}
- Step 2: Add create endpoint
app.post('/api/posts', (req, res) => {
try {
const { title, date, categories, layout, content, status } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const dir = status === 'published' ? POSTS_DIR : DRAFTS_DIR;
const filename = resolveUniqueFilename(dir, makeFilename(title, date));
const fileContent = buildFileContent(title, date, categories, layout, content);
fs.writeFileSync(path.join(dir, filename), fileContent, 'utf8');
res.json({ filename, status: status || 'draft' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 3: Add update endpoint
app.put('/api/posts/:filename', (req, res) => {
const { filename } = req.params;
const { title, date, categories, layout, content } = req.body;
// Find where the file currently lives
let filePath = path.join(POSTS_DIR, filename);
let currentStatus = 'published';
if (!fs.existsSync(filePath)) {
filePath = path.join(DRAFTS_DIR, filename);
currentStatus = 'draft';
}
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Post not found' });
}
try {
const fileContent = buildFileContent(title, date, categories, layout, content);
// If title or date changed, the filename changes too
const newFilename = makeFilename(title, date);
const dir = currentStatus === 'published' ? POSTS_DIR : DRAFTS_DIR;
// Delete old file
fs.unlinkSync(filePath);
// Write new file (resolve conflicts)
const finalFilename = resolveUniqueFilename(dir, newFilename);
fs.writeFileSync(path.join(dir, finalFilename), fileContent, 'utf8');
res.json({ filename: finalFilename, status: currentStatus });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 4: Add delete endpoint
app.delete('/api/posts/:filename', (req, res) => {
const { filename } = req.params;
let filePath = path.join(POSTS_DIR, filename);
if (!fs.existsSync(filePath)) {
filePath = path.join(DRAFTS_DIR, filename);
}
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Post not found' });
}
try {
fs.unlinkSync(filePath);
res.json({ deleted: filename });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 5: Add publish/unpublish endpoints
app.post('/api/posts/:filename/publish', (req, res) => {
const { filename } = req.params;
const src = path.join(DRAFTS_DIR, filename);
if (!fs.existsSync(src)) {
return res.status(404).json({ error: 'Draft not found' });
}
try {
// Re-read to update the date to now if it's a draft without a date prefix
const raw = fs.readFileSync(src, 'utf8');
const { data, content } = matter(raw);
const newFilename = makeFilename(data.title || filename, data.date || new Date());
const dest = path.join(POSTS_DIR, resolveUniqueFilename(POSTS_DIR, newFilename));
fs.writeFileSync(dest, raw, 'utf8');
fs.unlinkSync(src);
res.json({ filename: path.basename(dest), status: 'published' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/posts/:filename/unpublish', (req, res) => {
const { filename } = req.params;
const src = path.join(POSTS_DIR, filename);
if (!fs.existsSync(src)) {
return res.status(404).json({ error: 'Published post not found' });
}
try {
const raw = fs.readFileSync(src, 'utf8');
const dest = path.join(DRAFTS_DIR, resolveUniqueFilename(DRAFTS_DIR, filename));
fs.writeFileSync(dest, raw, 'utf8');
fs.unlinkSync(src);
res.json({ filename: path.basename(dest), status: 'draft' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 6: Add categories endpoint
app.get('/api/categories', (req, res) => {
try {
const cats = new Set();
for (const dir of [POSTS_DIR, DRAFTS_DIR]) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.markdown') && !file.endsWith('.md')) continue;
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
const { data } = matter(raw);
const c = data.categories;
if (typeof c === 'string') {
c.split(/\s+/).filter(Boolean).forEach((cat) => cats.add(cat));
} else if (Array.isArray(c)) {
c.forEach((cat) => cats.add(String(cat)));
}
}
}
res.json([...cats].sort());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 7: Verify create and categories
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
curl -s -X POST http://localhost:9000/api/posts -H 'Content-Type: application/json' -d '{"title":"Test Post","date":"2026-03-26","categories":"testing","content":"Hello world","status":"draft"}' | python3 -m json.tool
curl -s http://localhost:9000/api/categories | python3 -m json.tool
# Clean up test draft
rm -f ../\_drafts/2026-03-26-test-post.markdown
kill %1
Expected: Create returns { "filename": "2026-03-26-test-post.markdown", "status": "draft" }. Categories returns array including “testing”, “AI”, “speculation”, “leadership”, “code”.
- Step 8: Commit
git add blog-manager/server.js
git commit -m "feat: add create, update, delete, publish/unpublish post endpoints"
Task 4: Pages API
Files:
-
Modify:
blog-manager/server.js -
Step 1: Add pages list and read/update endpoints
Add to server.js:
const PAGES_EXTENSIONS = ['.markdown', '.md', '.html'];
const PAGES_EXCLUDE = ['index.markdown', 'index.md', 'index.html', '404.html'];
app.get('/api/pages', (req, res) => {
try {
const pages = [];
for (const file of fs.readdirSync(BLOG_ROOT)) {
if (!PAGES_EXTENSIONS.some((ext) => file.endsWith(ext))) continue;
if (PAGES_EXCLUDE.includes(file)) continue;
const raw = fs.readFileSync(path.join(BLOG_ROOT, file), 'utf8');
const { data } = matter(raw);
pages.push({
filename: file,
title: data.title || file,
layout: data.layout || 'page',
permalink: data.permalink || null,
});
}
res.json(pages);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/pages/:filename', (req, res) => {
const filePath = path.join(BLOG_ROOT, req.params.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Page not found' });
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(raw);
res.json({
filename: req.params.filename,
title: data.title || '',
layout: data.layout || 'page',
permalink: data.permalink || '',
content,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/pages/:filename', (req, res) => {
const filePath = path.join(BLOG_ROOT, req.params.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Page not found' });
}
try {
const { title, layout, permalink, content } = req.body;
const frontMatter = ['---', `layout: ${layout || 'page'}`, `title: ${title}`];
if (permalink) frontMatter.push(`permalink: ${permalink}`);
frontMatter.push('---');
fs.writeFileSync(filePath, frontMatter.join('\n') + '\n' + (content || ''), 'utf8');
res.json({ filename: req.params.filename });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
- Step 2: Verify
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
curl -s http://localhost:9000/api/pages | python3 -m json.tool
curl -s http://localhost:9000/api/pages/about.markdown | python3 -m json.tool | head -10
kill %1
Expected: Pages list returns about.markdown and resources.markdown. Single page returns content with front matter fields.
- Step 3: Commit
git add blog-manager/server.js
git commit -m "feat: add pages list, read, and update endpoints"
Task 5: Image Upload API
Files:
-
Modify:
blog-manager/server.js -
Step 1: Add multer upload endpoint
Add to the top of server.js with other requires:
const multer = require('multer');
Add the upload route:
const ASSETS_DIR = path.join(BLOG_ROOT, 'assets');
// Ensure assets dir exists
if (!fs.existsSync(ASSETS_DIR)) {
fs.mkdirSync(ASSETS_DIR, { recursive: true });
}
const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
const ext = path.extname(file.originalname).toLowerCase();
cb(null, allowed.includes(ext));
},
});
app.post('/api/upload', upload.single('image'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No valid image file provided' });
}
try {
const ext = path.extname(req.file.originalname).toLowerCase();
const name = path.basename(req.file.originalname, ext);
const slug = slugify(name, { lower: true, strict: true });
let filename = `${slug}${ext}`;
// Avoid overwriting
let i = 2;
while (fs.existsSync(path.join(ASSETS_DIR, filename))) {
filename = `${slug}-${i}${ext}`;
i++;
}
fs.writeFileSync(path.join(ASSETS_DIR, filename), req.file.buffer);
res.json({ url: `/assets/${filename}` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Update the multer config to use memory storage (add storage option):
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
const ext = path.extname(file.originalname).toLowerCase();
cb(null, allowed.includes(ext));
},
});
- Step 2: Verify upload works
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
# Create a tiny test image
printf '\x89PNG\r\n\x1a\n' > /tmp/test-upload.png
curl -s -X POST http://localhost:9000/api/upload -F "image=@/tmp/test-upload.png" | python3 -m json.tool
# Clean up
rm -f ../assets/test-upload.png /tmp/test-upload.png
kill %1
Expected: Returns { "url": "/assets/test-upload.png" }.
- Step 3: Commit
git add blog-manager/server.js
git commit -m "feat: add image upload endpoint with slugified filenames"
Task 6: Frontend — HTML Shell and Styles
Files:
- Modify:
blog-manager/public/index.html(replace placeholder content) -
Create:
blog-manager/public/style.css - Step 1: Write the full HTML shell
Replace blog-manager/public/index.html with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav class="container">
<ul>
<li><strong>Blog Manager</strong></li>
</ul>
<ul>
<li><a href="#" data-view="dashboard" class="nav-link active">Posts</a></li>
<li><a href="#" data-view="pages" class="nav-link">Pages</a></li>
</ul>
</nav>
<main class="container">
<!-- Dashboard View -->
<section id="view-dashboard">
<header>
<hgroup>
<h2>Posts</h2>
<p>All published posts and drafts</p>
</hgroup>
<button id="btn-new-post" class="btn-new">+ New Post</button>
</header>
<table id="posts-table">
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Categories</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="posts-body"></tbody>
</table>
</section>
<!-- Editor View -->
<section id="view-editor" hidden>
<header>
<a href="#" id="btn-back-dashboard">← Back to Posts</a>
<h2 id="editor-heading">New Post</h2>
</header>
<div class="grid">
<label>
Title
<input type="text" id="editor-title" placeholder="Post title" required>
</label>
<label>
Date
<input type="date" id="editor-date">
</label>
</div>
<div class="grid">
<label>
Layout
<select id="editor-layout">
<option value="post">post</option>
<option value="page">page</option>
</select>
</label>
<div>
<label>Categories</label>
<div id="categories-picker">
<div id="categories-pills"></div>
<div class="cat-input-row">
<input type="text" id="cat-input" placeholder="Add category...">
<button id="btn-add-cat" class="outline" type="button">Add</button>
</div>
</div>
</div>
</div>
<label>
Content
<textarea id="editor-content"></textarea>
</label>
<div class="editor-actions">
<button id="btn-save-draft" class="secondary">Save Draft</button>
<button id="btn-publish">Publish</button>
</div>
</section>
<!-- Pages View -->
<section id="view-pages" hidden>
<hgroup>
<h2>Pages</h2>
<p>Edit existing site pages</p>
</hgroup>
<table id="pages-table">
<thead>
<tr>
<th>Title</th>
<th>Permalink</th>
<th></th>
</tr>
</thead>
<tbody id="pages-body"></tbody>
</table>
</section>
<!-- Page Editor View -->
<section id="view-page-editor" hidden>
<header>
<a href="#" id="btn-back-pages">← Back to Pages</a>
<h2 id="page-editor-heading">Edit Page</h2>
</header>
<div class="grid">
<label>
Title
<input type="text" id="page-editor-title">
</label>
<label>
Permalink
<input type="text" id="page-editor-permalink" placeholder="/about/">
</label>
</div>
<label>
Content
<textarea id="page-editor-content"></textarea>
</label>
<div class="editor-actions">
<button id="btn-save-page">Save Page</button>
</div>
</section>
</main>
<!-- Toast -->
<div id="toast" class="toast" hidden></div>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="/app.js"></script>
</body>
</html>
- Step 2: Write custom styles
Create blog-manager/public/style.css:
/* Dashboard header layout */
#view-dashboard > header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.btn-new {
white-space: nowrap;
}
/* Category pills */
#categories-pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.cat-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
font-size: 0.85rem;
cursor: default;
}
.cat-pill .remove {
cursor: pointer;
font-weight: bold;
opacity: 0.7;
}
.cat-pill .remove:hover {
opacity: 1;
}
.cat-input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.cat-input-row input {
margin-bottom: 0;
}
.cat-input-row button {
margin-bottom: 0;
padding: 0.4rem 0.8rem;
}
/* Editor actions bar */
.editor-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
/* Status badges */
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.8rem;
font-weight: 600;
}
.badge-published {
background: #d4edda;
color: #155724;
}
.badge-draft {
background: #fff3cd;
color: #856404;
}
/* Toast */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
font-size: 0.9rem;
z-index: 1000;
transition: opacity 0.3s;
}
.toast.error {
background: #f8d7da;
color: #721c24;
}
/* Nav active state */
.nav-link.active {
font-weight: bold;
text-decoration: underline;
}
/* Action buttons in table */
.action-btn {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
margin-right: 0.3rem;
cursor: pointer;
}
/* Make EasyMDE taller */
.EasyMDEContainer .CodeMirror {
min-height: 350px;
}
- Step 3: Verify HTML loads with styles
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
curl -s http://localhost:9000 | grep '<title>'
curl -s http://localhost:9000/style.css | head -3
kill %1
Expected: Title tag present, CSS file served.
- Step 4: Commit
git add blog-manager/public/index.html blog-manager/public/style.css
git commit -m "feat: add blog manager HTML shell and styles"
Task 7: Frontend — JavaScript Application
Files:
- Create:
blog-manager/public/app.js
This is the largest file. It handles view switching, API calls, EasyMDE initialization, category picking, image drag-and-drop, and toast notifications.
- Step 1: Write app.js — utilities and view routing
Create blog-manager/public/app.js:
// ── Utilities ────────────────────────────────────────────
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.toggle('error', isError);
toast.hidden = false;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.hidden = true; }, 3000);
}
async function api(path, options = {}) {
try {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
} catch (err) {
showToast(err.message, true);
throw err;
}
}
// ── View Routing ─────────────────────────────────────────
const views = ['dashboard', 'editor', 'pages', 'page-editor'];
function showView(name) {
views.forEach((v) => {
document.getElementById(`view-${v}`).hidden = v !== name;
});
// Update nav active state
document.querySelectorAll('.nav-link').forEach((link) => {
const target = link.dataset.view;
link.classList.toggle('active', target === name || (target === 'dashboard' && name === 'editor') || (target === 'pages' && name === 'page-editor'));
});
}
document.querySelectorAll('.nav-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
if (view === 'dashboard') loadDashboard();
if (view === 'pages') loadPages();
});
});
- Step 2: Write app.js — dashboard
Append to app.js:
// ── Dashboard ────────────────────────────────────────────
async function loadDashboard() {
showView('dashboard');
const posts = await api('/api/posts');
const tbody = document.getElementById('posts-body');
tbody.innerHTML = '';
for (const post of posts) {
const tr = document.createElement('tr');
const dateStr = post.date ? new Date(post.date).toLocaleDateString() : '—';
const cats = typeof post.categories === 'string' ? post.categories : (post.categories || []).join(' ');
tr.innerHTML = `
<td><a href="#" class="edit-link" data-filename="${post.filename}">${escapeHtml(post.title)}</a></td>
<td>${dateStr}</td>
<td>${escapeHtml(cats)}</td>
<td><span class="badge badge-${post.status}">${post.status}</span></td>
<td>
${post.status === 'draft'
? `<button class="action-btn outline" data-action="publish" data-filename="${post.filename}">Publish</button>`
: `<button class="action-btn outline" data-action="unpublish" data-filename="${post.filename}">Unpublish</button>`
}
<button class="action-btn outline secondary" data-action="delete" data-filename="${post.filename}">Delete</button>
</td>
`;
tbody.appendChild(tr);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Dashboard event delegation
document.getElementById('posts-table').addEventListener('click', async (e) => {
const link = e.target.closest('.edit-link');
if (link) {
e.preventDefault();
openEditor(link.dataset.filename);
return;
}
const btn = e.target.closest('.action-btn');
if (!btn) return;
const { action, filename } = btn.dataset;
if (action === 'delete') {
if (!confirm(`Delete "${filename}"?`)) return;
await api(`/api/posts/${encodeURIComponent(filename)}`, { method: 'DELETE' });
showToast('Deleted');
loadDashboard();
}
if (action === 'publish') {
await api(`/api/posts/${encodeURIComponent(filename)}/publish`, { method: 'POST' });
showToast('Published');
loadDashboard();
}
if (action === 'unpublish') {
await api(`/api/posts/${encodeURIComponent(filename)}/unpublish`, { method: 'POST' });
showToast('Moved to drafts');
loadDashboard();
}
});
document.getElementById('btn-new-post').addEventListener('click', () => openEditor(null));
- Step 3: Write app.js — post editor with EasyMDE and image upload
Append to app.js:
// ── Post Editor ──────────────────────────────────────────
let editorMDE = null;
let editingFilename = null;
let selectedCategories = [];
let allCategories = [];
async function openEditor(filename) {
showView('editor');
editingFilename = filename;
// Load categories
allCategories = await api('/api/categories');
if (filename) {
document.getElementById('editor-heading').textContent = 'Edit Post';
const post = await api(`/api/posts/${encodeURIComponent(filename)}`);
document.getElementById('editor-title').value = post.title;
document.getElementById('editor-date').value = post.date ? new Date(post.date).toISOString().slice(0, 10) : '';
document.getElementById('editor-layout').value = post.layout || 'post';
const cats = typeof post.categories === 'string' ? post.categories.split(/\s+/).filter(Boolean) : (post.categories || []);
selectedCategories = [...cats];
initMDE(post.content || '');
} else {
document.getElementById('editor-heading').textContent = 'New Post';
document.getElementById('editor-title').value = '';
document.getElementById('editor-date').value = new Date().toISOString().slice(0, 10);
document.getElementById('editor-layout').value = 'post';
selectedCategories = [];
initMDE('');
}
renderCategoryPills();
}
function initMDE(content) {
if (editorMDE) {
editorMDE.toTextArea();
editorMDE = null;
}
const textarea = document.getElementById('editor-content');
textarea.value = content;
editorMDE = new EasyMDE({
element: textarea,
spellChecker: false,
autosave: { enabled: false },
placeholder: 'Write your post in markdown...',
toolbar: ['bold', 'italic', 'heading', '|', 'quote', 'unordered-list', 'ordered-list', '|', 'link', 'image', '|', 'preview', 'side-by-side', 'fullscreen', '|', 'guide'],
sideBySideFullscreen: false,
});
// Drag-and-drop image upload
editorMDE.codemirror.on('drop', async (cm, e) => {
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
if (!file.type.startsWith('image/')) return;
e.preventDefault();
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const cursor = cm.getCursor();
cm.replaceRange(``, cursor);
showToast('Image uploaded');
} catch (err) {
showToast('Upload failed: ' + err.message, true);
}
});
}
// Category picker
function renderCategoryPills() {
const container = document.getElementById('categories-pills');
container.innerHTML = '';
for (const cat of selectedCategories) {
const pill = document.createElement('span');
pill.className = 'cat-pill';
pill.innerHTML = `${escapeHtml(cat)} <span class="remove" data-cat="${escapeHtml(cat)}">×</span>`;
container.appendChild(pill);
}
// Show suggestions from existing categories not yet selected
const input = document.getElementById('cat-input');
input.setAttribute('list', 'cat-suggestions');
let datalist = document.getElementById('cat-suggestions');
if (!datalist) {
datalist = document.createElement('datalist');
datalist.id = 'cat-suggestions';
input.parentNode.appendChild(datalist);
}
datalist.innerHTML = allCategories
.filter((c) => !selectedCategories.includes(c))
.map((c) => `<option value="${escapeHtml(c)}">`)
.join('');
}
document.getElementById('categories-pills').addEventListener('click', (e) => {
const remove = e.target.closest('.remove');
if (!remove) return;
selectedCategories = selectedCategories.filter((c) => c !== remove.dataset.cat);
renderCategoryPills();
});
document.getElementById('btn-add-cat').addEventListener('click', addCategory);
document.getElementById('cat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addCategory(); }
});
function addCategory() {
const input = document.getElementById('cat-input');
const val = input.value.trim();
if (val && !selectedCategories.includes(val)) {
selectedCategories.push(val);
renderCategoryPills();
}
input.value = '';
input.focus();
}
- Step 4: Write app.js — save/publish actions
Append to app.js:
// ── Save / Publish ───────────────────────────────────────
function getEditorData() {
return {
title: document.getElementById('editor-title').value.trim(),
date: document.getElementById('editor-date').value,
layout: document.getElementById('editor-layout').value,
categories: selectedCategories.join(' '),
content: editorMDE ? editorMDE.value() : '',
};
}
document.getElementById('btn-save-draft').addEventListener('click', async () => {
const data = getEditorData();
if (!data.title) return showToast('Title is required', true);
if (editingFilename) {
await api(`/api/posts/${encodeURIComponent(editingFilename)}`, {
method: 'PUT',
body: JSON.stringify({ ...data, status: 'draft' }),
});
} else {
await api('/api/posts', {
method: 'POST',
body: JSON.stringify({ ...data, status: 'draft' }),
});
}
showToast('Draft saved');
loadDashboard();
});
document.getElementById('btn-publish').addEventListener('click', async () => {
const data = getEditorData();
if (!data.title) return showToast('Title is required', true);
if (editingFilename) {
await api(`/api/posts/${encodeURIComponent(editingFilename)}`, {
method: 'PUT',
body: JSON.stringify(data),
});
// If it was a draft, also publish it
// We need to figure out the new filename after the update
// Simplify: just save with published status
const result = await api(`/api/posts/${encodeURIComponent(editingFilename)}`, {
method: 'PUT',
body: JSON.stringify(data),
});
} else {
await api('/api/posts', {
method: 'POST',
body: JSON.stringify({ ...data, status: 'published' }),
});
}
showToast('Published');
loadDashboard();
});
document.getElementById('btn-back-dashboard').addEventListener('click', (e) => {
e.preventDefault();
loadDashboard();
});
Wait — the publish logic for editing an existing post is awkward. Let me simplify. When the user clicks “Publish” on an existing post, we should update it and ensure it’s in _posts/. Let me rethink this.
The cleaner approach: the PUT endpoint already knows which directory the file is in. We need a way to say “save AND move to published.” Let’s add a status field to the PUT body that the server respects.
Revised Step 4 — update server.js PUT to handle status changes, then simplify client:
First, update the PUT endpoint in server.js to accept a status field:
In Task 3’s app.put('/api/posts/:filename'), replace the directory logic:
app.put('/api/posts/:filename', (req, res) => {
const { filename } = req.params;
const { title, date, categories, layout, content, status } = req.body;
// Find where the file currently lives
let filePath = path.join(POSTS_DIR, filename);
let currentStatus = 'published';
if (!fs.existsSync(filePath)) {
filePath = path.join(DRAFTS_DIR, filename);
currentStatus = 'draft';
}
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Post not found' });
}
try {
const fileContent = buildFileContent(title, date, categories, layout, content);
const newFilename = makeFilename(title, date);
// Determine target directory — if status is provided, use it; otherwise keep current
const targetStatus = status || currentStatus;
const targetDir = targetStatus === 'published' ? POSTS_DIR : DRAFTS_DIR;
// Delete old file
fs.unlinkSync(filePath);
// Write new file
const finalFilename = resolveUniqueFilename(targetDir, newFilename);
fs.writeFileSync(path.join(targetDir, finalFilename), fileContent, 'utf8');
res.json({ filename: finalFilename, status: targetStatus });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Then the client publish button becomes simply:
document.getElementById('btn-save-draft').addEventListener('click', async () => {
const data = getEditorData();
if (!data.title) return showToast('Title is required', true);
if (editingFilename) {
await api(`/api/posts/${encodeURIComponent(editingFilename)}`, {
method: 'PUT',
body: JSON.stringify({ ...data, status: 'draft' }),
});
} else {
await api('/api/posts', {
method: 'POST',
body: JSON.stringify({ ...data, status: 'draft' }),
});
}
showToast('Draft saved');
loadDashboard();
});
document.getElementById('btn-publish').addEventListener('click', async () => {
const data = getEditorData();
if (!data.title) return showToast('Title is required', true);
if (editingFilename) {
await api(`/api/posts/${encodeURIComponent(editingFilename)}`, {
method: 'PUT',
body: JSON.stringify({ ...data, status: 'published' }),
});
} else {
await api('/api/posts', {
method: 'POST',
body: JSON.stringify({ ...data, status: 'published' }),
});
}
showToast('Published');
loadDashboard();
});
document.getElementById('btn-back-dashboard').addEventListener('click', (e) => {
e.preventDefault();
loadDashboard();
});
- Step 5: Write app.js — pages view and page editor
Append to app.js:
// ── Pages ────────────────────────────────────────────────
let pageMDE = null;
let editingPage = null;
async function loadPages() {
showView('pages');
const pages = await api('/api/pages');
const tbody = document.getElementById('pages-body');
tbody.innerHTML = '';
for (const page of pages) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><a href="#" class="page-edit-link" data-filename="${page.filename}">${escapeHtml(page.title)}</a></td>
<td>${escapeHtml(page.permalink || '—')}</td>
<td><button class="action-btn outline page-edit-link" data-filename="${page.filename}">Edit</button></td>
`;
tbody.appendChild(tr);
}
}
document.getElementById('pages-table').addEventListener('click', async (e) => {
const link = e.target.closest('.page-edit-link');
if (!link) return;
e.preventDefault();
await openPageEditor(link.dataset.filename);
});
async function openPageEditor(filename) {
showView('page-editor');
editingPage = filename;
const page = await api(`/api/pages/${encodeURIComponent(filename)}`);
document.getElementById('page-editor-heading').textContent = `Edit: ${page.title}`;
document.getElementById('page-editor-title').value = page.title;
document.getElementById('page-editor-permalink').value = page.permalink || '';
if (pageMDE) {
pageMDE.toTextArea();
pageMDE = null;
}
const textarea = document.getElementById('page-editor-content');
textarea.value = page.content || '';
pageMDE = new EasyMDE({
element: textarea,
spellChecker: false,
placeholder: 'Page content...',
toolbar: ['bold', 'italic', 'heading', '|', 'quote', 'unordered-list', 'ordered-list', '|', 'link', 'image', '|', 'preview', 'side-by-side', 'fullscreen'],
sideBySideFullscreen: false,
});
}
document.getElementById('btn-save-page').addEventListener('click', async () => {
if (!editingPage) return;
await api(`/api/pages/${encodeURIComponent(editingPage)}`, {
method: 'PUT',
body: JSON.stringify({
title: document.getElementById('page-editor-title').value.trim(),
permalink: document.getElementById('page-editor-permalink').value.trim(),
content: pageMDE ? pageMDE.value() : '',
}),
});
showToast('Page saved');
loadPages();
});
document.getElementById('btn-back-pages').addEventListener('click', (e) => {
e.preventDefault();
loadPages();
});
// ── Init ─────────────────────────────────────────────────
loadDashboard();
- Step 6: Verify the full app loads and shows posts
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
curl -s http://localhost:9000/app.js | wc -l
curl -s http://localhost:9000/api/posts | python3 -c "import sys,json; posts=json.load(sys.stdin); print(f'{len(posts)} posts loaded')"
kill %1
Expected: app.js has ~300+ lines. API returns 5+ posts.
- Step 7: Commit
git add blog-manager/public/app.js
git commit -m "feat: add full frontend JS — dashboard, editor, pages, image upload"
Task 8: macOS Dock Launcher
Files:
- Create:
blog-manager/launch/BlogManager.app/Contents/Info.plist -
Create:
blog-manager/launch/BlogManager.app/Contents/MacOS/launch.sh - Step 1: Create the app bundle structure
mkdir -p /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager/launch/BlogManager.app/Contents/MacOS
mkdir -p /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager/launch/BlogManager.app/Contents/Resources
- Step 2: Write Info.plist
Create blog-manager/launch/BlogManager.app/Contents/Info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Blog Manager</string>
<key>CFBundleDisplayName</key>
<string>Blog Manager</string>
<key>CFBundleIdentifier</key>
<string>com.gclinton.blog-manager</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleExecutable</key>
<string>launch.sh</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSUIElement</key>
<false/>
</dict>
</plist>
- Step 3: Write launch script
Create blog-manager/launch/BlogManager.app/Contents/MacOS/launch.sh:
#!/bin/bash
# Resolve the blog-manager directory (two levels up from MacOS/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BLOG_MANAGER_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
cd "$BLOG_MANAGER_DIR" || exit 1
# Kill any existing instance on port 9000
lsof -ti:9000 | xargs kill -9 2>/dev/null
# Start the server
node server.js &
SERVER_PID=$!
# Wait for server to be ready
for i in {1..10}; do
curl -s http://localhost:9000 > /dev/null 2>&1 && break
sleep 0.5
done
# Open browser
open http://localhost:9000
# Wait for the server process — keeps the app "running" in the dock
wait $SERVER_PID
- Step 4: Make launch script executable
chmod +x /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager/launch/BlogManager.app/Contents/MacOS/launch.sh
- Step 5: Test the launcher
open /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager/launch/BlogManager.app
Expected: Browser opens to http://localhost:9000 showing the Blog Manager dashboard. The app appears in the dock while running.
After verifying, close the app (Cmd+Q on the dock icon or kill the process):
lsof -ti:9000 | xargs kill -9 2>/dev/null
- Step 6: Commit
git add blog-manager/launch/
git commit -m "feat: add macOS dock launcher app bundle"
Task 9: Final Integration — Exclude from Jekyll, Update .gitignore
Files:
- Modify:
_config.yml -
Modify:
.gitignore(create if needed) - Step 1: Add blog-manager to Jekyll excludes
This was done in Task 1 Step 6, but verify it’s there. The exclude: list in _config.yml should include:
- blog-manager/
- Step 2: Add node_modules to .gitignore
Check if .gitignore exists. If not, create it. Ensure it contains:
blog-manager/node_modules/
- Step 3: Verify everything works end-to-end
cd /Users/greg/Documents/GitHub/2gclinton.github.io/blog-manager && node server.js &
sleep 1
echo "=== Posts ==="
curl -s http://localhost:9000/api/posts | python3 -c "import sys,json; [print(f' {p[\"status\"]}: {p[\"title\"]}') for p in json.load(sys.stdin)]"
echo "=== Categories ==="
curl -s http://localhost:9000/api/categories | python3 -m json.tool
echo "=== Pages ==="
curl -s http://localhost:9000/api/pages | python3 -c "import sys,json; [print(f' {p[\"title\"]}') for p in json.load(sys.stdin)]"
echo "=== Create test draft ==="
curl -s -X POST http://localhost:9000/api/posts -H 'Content-Type: application/json' -d '{"title":"Integration Test","date":"2026-03-26","categories":"testing","content":"# Test\nHello","status":"draft"}' | python3 -m json.tool
echo "=== Publish it ==="
curl -s -X POST "http://localhost:9000/api/posts/2026-03-26-integration-test.markdown/publish" -X POST | python3 -m json.tool
echo "=== Delete it ==="
curl -s -X DELETE "http://localhost:9000/api/posts/2026-03-26-integration-test.markdown" | python3 -m json.tool
kill %1
Expected: All 5 existing posts listed, categories include AI/code/speculation/leadership, pages show about + resources, draft created then published then deleted successfully.
- Step 4: Final commit
git add _config.yml .gitignore
git commit -m "chore: exclude blog-manager from Jekyll build and gitignore node_modules"
Summary
| Task | What It Builds | Files |
|---|---|---|
| 1 | Project scaffolding | package.json, server.js (minimal), index.html (bare) |
| 2 | Posts API — list + read | server.js |
| 3 | Posts API — create, update, delete, publish/unpublish | server.js |
| 4 | Pages API — list, read, update | server.js |
| 5 | Image upload API | server.js |
| 6 | Frontend HTML + CSS | index.html, style.css |
| 7 | Frontend JavaScript | app.js |
| 8 | macOS dock launcher | BlogManager.app bundle |
| 9 | Final integration + gitignore | _config.yml, .gitignore |