2026-07-04 22:53:10 +00:00
#!/usr/bin/env node
// Read-only admin dashboard for the Comiida content pipeline.
// Serves at /admin (proxied by Apache). Auth via secret token (?key= or cookie).
// Node built-ins only — no external deps.
import { createServer } from "node:http" ;
import { randomUUID } from "node:crypto" ;
import { readFileSync , writeFileSync , existsSync , readdirSync } from "node:fs" ;
import { dirname , resolve , join , extname } from "node:path" ;
import { fileURLToPath } from "node:url" ;
import { readFrontmatter , fmString , fmArray , loadJson , todayInTz , addDays , slugify } from "../scripts/lib/util.mjs" ;
import { addEntry } from "../scripts/lib/calendar.mjs" ;
const HERE = dirname ( fileURLToPath ( import . meta . url ) ) ;
const PIPELINE = resolve ( HERE , ".." ) ;
// --- env (.env) ---
function loadEnv ( ) {
const p = join ( PIPELINE , ".env" ) ;
if ( ! existsSync ( p ) ) return ;
for ( const line of readFileSync ( p , "utf8" ) . split ( "\n" ) ) {
const m = line . match ( /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/ ) ;
if ( m && ! ( m [ 1 ] in process . env ) ) process . env [ m [ 1 ] ] = m [ 2 ] ;
}
}
loadEnv ( ) ;
const cfg = loadJson ( join ( PIPELINE , "config.json" ) ) ;
const root = cfg . paths . projectRoot ;
const TOKEN = process . env . ADMIN _TOKEN || "" ;
const PORT = parseInt ( process . env . ADMIN _PORT , 10 ) || 3010 ;
if ( ! TOKEN ) {
console . error ( "[admin] ADMIN_TOKEN not set in .env — refusing to start." ) ;
process . exit ( 1 ) ;
}
// --- data gathering ---
const draftsDir = ( ) => join ( root , cfg . paths . draftsDir ) ;
const blogDir = ( ) => join ( root , cfg . paths . blogContentDir ) ;
function calendar ( ) {
const p = join ( root , cfg . paths . calendar ) ;
return existsSync ( p ) ? loadJson ( p ) : [ ] ;
}
function isPublished ( slug ) {
return existsSync ( join ( blogDir ( ) , slug , "index.mdx" ) ) ;
}
function draftSlugs ( ) {
const d = draftsDir ( ) ;
return existsSync ( d )
? readdirSync ( d , { withFileTypes : true } ) . filter ( ( x ) => x . isDirectory ( ) ) . map ( ( x ) => x . name )
: [ ] ;
}
function draftMeta ( slug ) {
const mdx = join ( draftsDir ( ) , slug , "index.mdx" ) ;
if ( ! existsSync ( mdx ) ) return null ;
const fm = readFrontmatter ( readFileSync ( mdx , "utf8" ) ) ;
let seo = null ;
const seoPath = join ( draftsDir ( ) , slug , "seo-review.json" ) ;
if ( existsSync ( seoPath ) ) {
try {
seo = JSON . parse ( readFileSync ( seoPath , "utf8" ) ) ;
} catch {
seo = null ;
}
}
return {
slug ,
title : fmString ( fm , "title" ) || slug ,
category : fmString ( fm , "category" ) || "" ,
tags : fmArray ( fm , "tags" ) ,
date : fmString ( fm , "date" ) || "" ,
hasCover : existsSync ( join ( draftsDir ( ) , slug , "cover.jpg" ) ) ,
seo ,
} ;
}
// --- helpers ---
const esc = ( s ) =>
String ( s ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
const STATUS _COLORS = {
planned : "#8a8f98" ,
drafting : "#d98324" ,
drafted : "#2f6feb" ,
"drafted-no-image" : "#b58900" ,
"draft-failed" : "#cb2431" ,
published : "#1a7f37" ,
} ;
const badge = ( status ) =>
` <span style="background: ${ STATUS _COLORS [ status ] || "#666" } ;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap"> ${ esc ( status ) } </span> ` ;
const newsBadge = ` <span style="background:#b5179e;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">NEWS</span> ` ;
const suggestedBadge = ` <span style="background:#7048e8;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SUGGESTED</span> ` ;
function freshNewsCount ( ) {
feat: content-pipeline/ → agents/ — formalize the agent system in the seed
Adopt the agents/ architecture proven on medellin.co (reference impl):
- Move the content engine to a top-level agents/ dir: orchestrators, prompts,
config, run.sh, admin console, shared libs. All content-pipeline literals
repointed (config paths, scripts, admin, LLM-facing prompts/image.md string,
configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block).
- Every script carries a parseable @agent-manifest header: name, title, class
(content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP),
tools, reads/writes tables. 5 content agents + 3 plumbing scripts.
- New agents/catalog.mjs generates the catalog from the headers:
agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine
manifest — a clone diffs it against a source to find missing tools/tables/MCP
before running). configure.mjs regenerates the catalog on every identity
stamp. No DB table, no watcher.
- config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and
news-radar read them instead of hardcoding.
- Full cut: content-pipeline/ deleted (the seed has no live crons, so no
hybrid period needed). Docs updated (AGENTS.md structure + pipeline section,
README paths).
Clones migrating from content-pipeline/: see medellin.co's
.memory/handoffs/agents-directory-migration.md for the cutover playbook
(one cron set active at a time; migrate drafts/state after repointing cron).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
2026-07-11 20:33:48 +00:00
const p = join ( root , "agents" , "news-queue.json" ) ;
2026-07-04 22:53:10 +00:00
if ( ! existsSync ( p ) ) return 0 ;
try {
return JSON . parse ( readFileSync ( p , "utf8" ) ) . filter ( ( i ) => i . status === "fresh" ) . length ;
} catch {
return 0 ;
}
}
const SEO _COLORS = { pass : "#1a7f37" , revise : "#d98324" , fail : "#cb2431" } ;
const seoBadge = ( seo ) =>
seo
? ` <span style="background: ${ SEO _COLORS [ seo . verdict ] || "#666" } ;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">SEO ${ esc ( seo . verdict ) } ${ esc ( seo . overall ) } </span> `
: ` <span style="background:#aaa;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SEO —</span> ` ;
feat: content-pipeline/ → agents/ — formalize the agent system in the seed
Adopt the agents/ architecture proven on medellin.co (reference impl):
- Move the content engine to a top-level agents/ dir: orchestrators, prompts,
config, run.sh, admin console, shared libs. All content-pipeline literals
repointed (config paths, scripts, admin, LLM-facing prompts/image.md string,
configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block).
- Every script carries a parseable @agent-manifest header: name, title, class
(content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP),
tools, reads/writes tables. 5 content agents + 3 plumbing scripts.
- New agents/catalog.mjs generates the catalog from the headers:
agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine
manifest — a clone diffs it against a source to find missing tools/tables/MCP
before running). configure.mjs regenerates the catalog on every identity
stamp. No DB table, no watcher.
- config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and
news-radar read them instead of hardcoding.
- Full cut: content-pipeline/ deleted (the seed has no live crons, so no
hybrid period needed). Docs updated (AGENTS.md structure + pipeline section,
README paths).
Clones migrating from content-pipeline/: see medellin.co's
.memory/handoffs/agents-directory-migration.md for the cutover playbook
(one cron set active at a time; migrate drafts/state after repointing cron).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
2026-07-11 20:33:48 +00:00
const messagesPath = ( ) => join ( root , "agents" , "messages.json" ) ;
2026-07-04 22:53:10 +00:00
function messages ( ) {
const p = messagesPath ( ) ;
if ( ! existsSync ( p ) ) return [ ] ;
try {
return JSON . parse ( readFileSync ( p , "utf8" ) ) ;
} catch {
return [ ] ;
}
}
function authed ( req ) {
const url = new URL ( req . url , "http://x" ) ;
const qkey = url . searchParams . get ( "key" ) ;
if ( qkey && qkey === TOKEN ) return { ok : true , setCookie : true } ;
const cookie = ( req . headers . cookie || "" ) . match ( /(?:^|;\s*)admin_key=([^;]+)/ ) ;
if ( cookie && decodeURIComponent ( cookie [ 1 ] ) === TOKEN ) return { ok : true } ;
return { ok : false } ;
}
// --- pages ---
function dashboardHtml ( ) {
const today = todayInTz ( cfg . editorial . timezone ) ;
const weekEnd = addDays ( today , 6 ) ;
const cal = calendar ( ) . slice ( ) . sort ( ( a , b ) => ( a . date || "" ) . localeCompare ( b . date || "" ) ) ;
const drafts = draftSlugs ( ) . map ( draftMeta ) . filter ( Boolean ) ;
const inWeek = cal . filter ( ( e ) => e . date >= today && e . date <= weekEnd ) ;
const counts = cal . reduce ( ( m , e ) => ( ( m [ e . status ] = ( m [ e . status ] || 0 ) + 1 ) , m ) , { } ) ;
const newsSlugs = new Set ( cal . filter ( ( e ) => e . news ) . map ( ( e ) => e . slug ) ) ;
drafts . forEach ( ( d ) => ( d . news = newsSlugs . has ( d . slug ) ) ) ;
const row = ( e ) => `
< tr >
< td style = "white-space:nowrap;color:#555" > $ { esc ( e . date ) } < / t d >
< td > < strong > $ { esc ( e . workingTitle || e . slug ) } < / s t r o n g > $ { e . s u g g e s t e d ? s u g g e s t e d B a d g e : " " } $ { e . n e w s ? " " + n e w s B a d g e : " " } < b r > < s p a n s t y l e = " c o l o r : # 8 8 8 ; f o n t - s i z e : 1 2 p x " > $ { e s c ( e . p r i m a r y K e y w o r d | | " " ) } < / s p a n > < / t d >
< td > < span style = "color:#555;font-size:13px" > $ { esc ( e . type ) } < / s p a n > < / t d >
< td > $ { badge ( isPublished ( e . slug ) ? "published" : e . status ) } < / t d >
< td > $ {
existsSync ( join ( draftsDir ( ) , e . slug , "index.mdx" ) )
? ` <a href="/admin/draft/ ${ esc ( e . slug ) } ">preview</a> `
: isPublished ( e . slug )
? ` <a href=" ${ esc ( cfg . site . url ) } /blog/ ${ esc ( e . slug ) } /" target="_blank">live ↗</a> `
: "—"
} < / t d >
< / t r > ` ;
const draftCards = drafts . length
? drafts
. map (
( d ) => `
< div style = "border:1px solid #e2e4e8;border-radius:10px;padding:14px;display:flex;gap:14px;align-items:center" >
$ { d . hasCover ? ` <img src="/admin/cover/ ${ esc ( d . slug ) } " style="width:96px;height:64px;object-fit:cover;border-radius:6px;flex:none"> ` : ` <div style="width:96px;height:64px;background:#f0f1f3;border-radius:6px;flex:none"></div> ` }
< div style = "flex:1" >
< div style = "display:flex;align-items:center;gap:8px" > < strong > $ { esc ( d . title ) } < / s t r o n g > $ { d . n e w s ? n e w s B a d g e : " " } $ { s e o B a d g e ( d . s e o ) } < / d i v >
< div style = "color:#888;font-size:12px" > $ { esc ( d . date ) } · $ { esc ( d . category ) } · $ { esc ( d . tags . join ( ", " ) ) } < / d i v >
$ { d . seo ? . blocking ? . length ? ` <div style="color:#cb2431;font-size:12px;margin-top:4px">⚠ ${ esc ( d . seo . blocking . length ) } blocking: ${ esc ( d . seo . blocking [ 0 ] ) } ${ d . seo . blocking . length > 1 ? " …" : "" } </div> ` : "" }
< div style = "margin-top:6px" > < a href = "/admin/draft/${esc(d.slug)}" > preview < / a > · < c o d e s t y l e = " f o n t - s i z e : 1 2 p x " > a p p r o v e . m j s $ { e s c ( d . s l u g ) } < / c o d e > < / d i v >
< / d i v >
< / d i v > `
)
. join ( "" )
: ` <p style="color:#888">No drafts awaiting review.</p> ` ;
const countPills = Object . entries ( counts )
. map ( ( [ k , v ] ) => ` ${ badge ( k ) } <span style="color:#555"> ${ v } </span> ` )
. join ( " " ) ;
const msgs = messages ( ) . slice ( ) . reverse ( ) ;
const msgHtml = msgs . length
? msgs
. map (
( m ) => `
< div style = "border:1px solid #e2e4e8;border-radius:10px;padding:12px 14px;position:relative" >
< button onclick = "deleteMsg('${esc(m.id || m.at)}')" title = "Delete message" style = "position:absolute;top:10px;right:10px;border:0;background:#f0f1f3;border-radius:6px;padding:3px 9px;cursor:pointer;color:#999;line-height:1" > ✕ < / b u t t o n >
< div style = "font-size:13px;padding-right:36px" > < strong > $ { esc ( m . name ) } < / s t r o n g > < s p a n s t y l e = " c o l o r : # 8 8 8 " > & l t ; $ { e s c ( m . e m a i l ) } & g t ; < / s p a n > < s p a n s t y l e = " c o l o r : # a a a " > · $ { e s c ( ( m . a t | | " " ) . s l i c e ( 0 , 1 6 ) . r e p l a c e ( " T " , " " ) ) } < / s p a n > < / d i v >
< div style = "margin-top:6px;white-space:pre-wrap" > $ { esc ( m . message ) } < / d i v >
< / d i v > `
)
. join ( "" )
: ` <p class="muted">No messages yet.</p> ` ;
return ` <!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
< title > Comiida — Content Admin < / t i t l e >
< style >
body { font : 15 px / 1.5 - apple - system , Segoe UI , Roboto , Helvetica , Arial , sans - serif ; color : # 1 a1a1a ; margin : 0 ; background : # fafbfc }
. wrap { max - width : 960 px ; margin : 0 auto ; padding : 28 px 20 px 60 px }
h1 { font - size : 22 px ; margin : 0 0 4 px } h2 { font - size : 16 px ; margin : 30 px 0 12 px }
table { width : 100 % ; border - collapse : collapse } td , th { text - align : left ; padding : 10 px 8 px ; border - bottom : 1 px solid # eceef1 ; vertical - align : top }
th { font - size : 12 px ; text - transform : uppercase ; letter - spacing : . 04 em ; color : # 888 }
a { color : # 2 f6feb ; text - decoration : none } a : hover { text - decoration : underline }
. muted { color : # 888 ; font - size : 13 px } . grid { display : flex ; flex - direction : column ; gap : 10 px }
. btn { display : inline - block ; background : # 7048e8 ; color : # fff ; border : 0 ; border - radius : 8 px ; padding : 9 px 16 px ; font - size : 14 px ; font - weight : 600 ; cursor : pointer }
. btn : hover { opacity : . 92 }
. overlay { position : fixed ; inset : 0 ; background : rgba ( 0 , 0 , 0 , . 4 ) ; display : none ; align - items : flex - start ; justify - content : center ; z - index : 10 }
. overlay . open { display : flex }
. modal { background : # fff ; border - radius : 12 px ; max - width : 520 px ; width : calc ( 100 % - 32 px ) ; margin - top : 8 vh ; padding : 22 px ; box - shadow : 0 12 px 40 px rgba ( 0 , 0 , 0 , . 2 ) }
. modal h3 { margin : 0 0 4 px ; font - size : 18 px } . modal label { display : block ; font - size : 13 px ; font - weight : 600 ; margin : 14 px 0 5 px }
. modal input , . modal textarea { width : 100 % ; box - sizing : border - box ; border : 1 px solid # d6d9de ; border - radius : 8 px ; padding : 9 px 11 px ; font - size : 14 px ; font - family : inherit }
. modal . row2 { display : flex ; gap : 10 px ; justify - content : flex - end ; margin - top : 18 px }
. modal . cancel { background : # eceef1 ; color : # 333 }
< / s t y l e > < / h e a d > < b o d y > < d i v c l a s s = " w r a p " >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px" >
< h1 > Comiida — Content Admin < / h 1 >
< button class = "btn" onclick = "document.getElementById('suggestOverlay').classList.add('open')" > + Suggest an article < / b u t t o n >
< / d i v >
< div class = "muted" > Today $ { esc ( today ) } ( $ { esc ( cfg . editorial . timezone ) } ) · $ { countPills } · 📰 news queue : $ { freshNewsCount ( ) } fresh < / d i v >
< div class = "overlay" id = "suggestOverlay" >
< div class = "modal" >
< h3 > Suggest an article < / h 3 >
< div class = "muted" > Added as a top - priority topic — the writer researches & drafts it next . < / d i v >
< form id = "suggestForm" >
< label > Title * < / l a b e l >
< input name = "title" required maxlength = "160" placeholder = "e.g. The best late-night eats in Laureles" / >
< label > Brief description < / l a b e l >
< textarea name = "description" rows = "3" maxlength = "600" placeholder = "What angle / what to cover?" > < / t e x t a r e a >
< label > Prompt / instructions for the agent ( optional ) < / l a b e l >
< textarea name = "instructions" rows = "3" maxlength = "4000" placeholder = "e.g. Focus on vegan spots in Laureles; research the new Provenza openings; compare prices and hours" > < / t e x t a r e a >
< label > Draft ( optional ) < / l a b e l >
< textarea name = "draft" rows = "6" maxlength = "20000" placeholder = "Paste a draft or rough notes — the agent will build on, fact-check, and expand it." > < / t e x t a r e a >
< label > Source link ( optional ) < / l a b e l >
< input name = "source" type = "url" placeholder = "https://… where you saw the idea" / >
< div class = "row2" >
< button type = "button" class = "btn cancel" onclick = "document.getElementById('suggestOverlay').classList.remove('open')" > Cancel < / b u t t o n >
< button type = "submit" class = "btn" id = "suggestSubmit" > Add suggestion < / b u t t o n >
< / d i v >
< / f o r m >
< / d i v >
< / d i v >
< script >
document . getElementById ( 'suggestForm' ) . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ( ) ;
const btn = document . getElementById ( 'suggestSubmit' ) ;
const f = e . target ;
const payload = { title : f . title . value . trim ( ) , description : f . description . value . trim ( ) , instructions : f . instructions . value . trim ( ) , draft : f . draft . value . trim ( ) , source : f . source . value . trim ( ) } ;
if ( ! payload . title ) return ;
btn . disabled = true ; btn . textContent = 'Adding…' ;
try {
const r = await fetch ( '/admin/suggest' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( payload ) } ) ;
if ( ! r . ok ) throw new Error ( ( await r . json ( ) ) . error || 'failed' ) ;
location . reload ( ) ;
} catch ( err ) {
btn . disabled = false ; btn . textContent = 'Add suggestion' ;
alert ( 'Could not add suggestion: ' + err . message ) ;
}
} ) ;
< / s c r i p t >
< h2 > 📬 Messages ( $ { msgs . length } ) < / h 2 >
< div class = "grid" > $ { msgHtml } < / d i v >
< script >
window . deleteMsg = async ( id ) => {
if ( ! confirm ( 'Delete this message?' ) ) return ;
try {
const r = await fetch ( '/admin/message-delete' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { id } ) } ) ;
if ( ! r . ok ) throw new Error ( ) ;
location . reload ( ) ;
} catch { alert ( 'Could not delete the message.' ) ; }
} ;
< / s c r i p t >
< h2 > This week ( $ { esc ( today ) } → $ { esc ( weekEnd ) } ) < / h 2 >
$ { inWeek . length ? ` <table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr> ${ inWeek . map ( row ) . join ( "" ) } </table> ` : ` <p class="muted">Nothing scheduled this week. Run research.mjs to plan more.</p> ` }
< h2 > Drafts awaiting review ( $ { drafts . length } ) < / h 2 >
< div class = "grid" > $ { draftCards } < / d i v >
< h2 > Full calendar ( $ { cal . length } ) < / h 2 >
< table > < tr > < th > Date < / t h > < t h > T o p i c < / t h > < t h > T y p e < / t h > < t h > S t a t u s < / t h > < t h > < / t h > < / t r > $ { c a l . m a p ( r o w ) . j o i n ( " " ) } < / t a b l e >
< / d i v > < / b o d y > < / h t m l > ` ;
}
function draftDetailHtml ( slug ) {
const dir = join ( draftsDir ( ) , slug ) ;
const mdxPath = join ( dir , "index.mdx" ) ;
if ( ! existsSync ( mdxPath ) ) return null ;
const raw = readFileSync ( mdxPath , "utf8" ) ;
const fm = readFrontmatter ( raw ) ;
const body = raw . replace ( /^---\n[\s\S]*?\n---\n?/ , "" ) ;
const sources = existsSync ( join ( dir , "sources.json" ) )
? readFileSync ( join ( dir , "sources.json" ) , "utf8" )
: "(none)" ;
let seo = null ;
if ( existsSync ( join ( dir , "seo-review.json" ) ) ) {
try {
seo = JSON . parse ( readFileSync ( join ( dir , "seo-review.json" ) , "utf8" ) ) ;
} catch {
seo = null ;
}
}
const seoSection = seo
? ` <h2>SEO audit ${ seoBadge ( seo ) } </h2>
< p class = "muted" > $ { esc ( seo . summary || "" ) } < / p >
< ul > $ { Object . entries ( seo . dimensions || { } )
. map ( ( [ k , v ] ) => ` <li><strong> ${ esc ( k ) } </strong>: ${ esc ( v . score ) } ${ v . issues ? . length ? " — " + esc ( v . issues . join ( "; " ) ) : "" } </li> ` )
. join ( "" ) } < / u l >
$ { seo . blocking ? . length ? ` <p style="color:#cb2431"><strong>Blocking:</strong></p><ul> ${ seo . blocking . map ( ( b ) => ` <li> ${ esc ( b ) } </li> ` ) . join ( "" ) } </ul> ` : "" }
$ { seo . topFixes ? . length ? ` <p><strong>Top fixes:</strong></p><ul> ${ seo . topFixes . map ( ( f ) => ` <li>[ ${ esc ( f . severity ) } / ${ esc ( f . area ) } ] ${ esc ( f . fix ) } </li> ` ) . join ( "" ) } </ul> ` : "" } `
: ` <h2>SEO audit ${ seoBadge ( null ) } </h2><p class="muted">No audit yet. Run <code>./run.sh write-daily.mjs</code> (auto-audits) or <code>node scripts/seo-review.mjs ${ esc ( slug ) } </code>.</p> ` ;
const hasCover = existsSync ( join ( dir , "cover.jpg" ) ) ;
return ` <!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
< title > $ { esc ( fmString ( fm , "title" ) || slug ) } — draft < / t i t l e >
< style >
body { font : 15 px / 1.6 - apple - system , Segoe UI , Roboto , Helvetica , Arial , sans - serif ; color : # 1 a1a1a ; margin : 0 ; background : # fafbfc }
. wrap { max - width : 760 px ; margin : 0 auto ; padding : 24 px 20 px 60 px }
pre { background : # f4f5f7 ; border : 1 px solid # e6e8eb ; border - radius : 8 px ; padding : 12 px ; overflow : auto ; font - size : 12.5 px ; white - space : pre - wrap }
a { color : # 2 f6feb } img { max - width : 100 % ; border - radius : 8 px }
h1 { font - size : 21 px }
< / s t y l e > < / h e a d > < b o d y > < d i v c l a s s = " w r a p " >
< p > < a href = "/admin" > ← back to dashboard < / a > < / p >
$ { hasCover ? ` <img src="/admin/cover/ ${ esc ( slug ) } "> ` : "" }
$ { seoSection }
< h2 > Frontmatter < / h 2 > < p r e > $ { e s c ( f m ) } < / p r e >
< h2 > Body ( raw MDX ) < / h 2 > < p r e > $ { e s c ( b o d y ) } < / p r e >
< h2 > sources . json < / h 2 > < p r e > $ { e s c ( s o u r c e s ) } < / p r e >
< p class = "muted" > To publish : < code > . / run . sh approve . mjs $ { esc ( slug ) } < / c o d e > < / p >
< / d i v > < / b o d y > < / h t m l > ` ;
}
// --- server ---
const send = ( res , code , body , type = "text/html; charset=utf-8" , extra = { } ) => {
res . writeHead ( code , { "content-type" : type , "cache-control" : "no-store" , ... extra } ) ;
res . end ( body ) ;
} ;
const server = createServer ( ( req , res ) => {
const url = new URL ( req . url , "http://x" ) ;
const path = url . pathname . replace ( /\/+$/ , "" ) || "/admin" ;
// PUBLIC (no auth): contact form submission → saved to messages.json, viewed in /admin.
if ( req . method === "POST" && path === "/contact-submit" ) {
let body = "" ;
req . on ( "data" , ( c ) => {
body += c ;
if ( body . length > 20000 ) req . destroy ( ) ; // basic size guard
} ) ;
req . on ( "end" , ( ) => {
try {
const d = JSON . parse ( body || "{}" ) ;
if ( d . website ) return send ( res , 200 , JSON . stringify ( { ok : true } ) , "application/json" ) ; // honeypot → silently drop
const name = String ( d . name || "" ) . trim ( ) . slice ( 0 , 120 ) ;
const email = String ( d . email || "" ) . trim ( ) . slice ( 0 , 160 ) ;
const message = String ( d . message || "" ) . trim ( ) . slice ( 0 , 4000 ) ;
if ( ! name || ! message || ! /^[^@\s]+@[^@\s]+\.[^@\s]+$/ . test ( email ) ) {
return send ( res , 400 , JSON . stringify ( { error : "Please fill in your name, a valid email, and a message." } ) , "application/json" ) ;
}
const arr = messages ( ) ;
arr . push ( { id : randomUUID ( ) , at : new Date ( ) . toISOString ( ) , name , email , message } ) ;
writeFileSync ( messagesPath ( ) , JSON . stringify ( arr , null , 2 ) ) ;
send ( res , 200 , JSON . stringify ( { ok : true } ) , "application/json" ) ;
} catch ( e ) {
send ( res , 500 , JSON . stringify ( { error : e . message } ) , "application/json" ) ;
}
} ) ;
return ;
}
const auth = authed ( req ) ;
if ( ! auth . ok ) {
return send ( res , 401 , "<h1>401</h1><p>Add ?key=YOUR_TOKEN to the URL.</p>" ) ;
}
const cookieHeader = auth . setCookie
? { "set-cookie" : ` admin_key= ${ encodeURIComponent ( TOKEN ) } ; Path=/admin; HttpOnly; SameSite=Lax; Max-Age=2592000 ` }
: { } ;
// suggest an article (POST) — inject a top-priority calendar entry
if ( req . method === "POST" && path === "/admin/suggest" ) {
let body = "" ;
req . on ( "data" , ( c ) => ( body += c ) ) ;
req . on ( "end" , async ( ) => {
try {
const data = JSON . parse ( body || "{}" ) ;
const title = String ( data . title || "" ) . trim ( ) ;
if ( ! title ) return send ( res , 400 , JSON . stringify ( { error : "Title is required" } ) , "application/json" ) ;
const description = String ( data . description || "" ) . trim ( ) ;
const source = String ( data . source || "" ) . trim ( ) ;
const instructions = String ( data . instructions || "" ) . trim ( ) . slice ( 0 , 4000 ) ;
const draft = String ( data . draft || "" ) . trim ( ) . slice ( 0 , 20000 ) ;
const today = todayInTz ( cfg . editorial . timezone ) ;
const entry = {
date : today ,
slug : slugify ( title ) ,
workingTitle : title ,
type : "guide" ,
primaryKeyword : title ,
secondaryKeywords : [ ] ,
searchIntent : "informational" ,
audienceAngle : description ,
sourceHints : source ? [ source ] : [ ] ,
eeatAngle : "User-suggested topic; research thoroughly and cite every claim." ,
suggested : true ,
status : "planned" ,
} ;
if ( instructions ) entry . instructions = instructions ;
if ( draft ) entry . draft = draft ;
await addEntry ( join ( root , cfg . paths . calendar ) , entry ) ;
send ( res , 200 , JSON . stringify ( { ok : true , slug : entry . slug } ) , "application/json" , cookieHeader ) ;
} catch ( e ) {
send ( res , 500 , JSON . stringify ( { error : e . message } ) , "application/json" ) ;
}
} ) ;
return ;
}
// delete a message (token-gated)
if ( req . method === "POST" && path === "/admin/message-delete" ) {
let body = "" ;
req . on ( "data" , ( c ) => ( body += c ) ) ;
req . on ( "end" , ( ) => {
try {
const { id } = JSON . parse ( body || "{}" ) ;
const arr = messages ( ) . filter ( ( m ) => ( m . id || m . at ) !== id ) ;
writeFileSync ( messagesPath ( ) , JSON . stringify ( arr , null , 2 ) ) ;
send ( res , 200 , JSON . stringify ( { ok : true } ) , "application/json" , cookieHeader ) ;
} catch ( e ) {
send ( res , 500 , JSON . stringify ( { error : e . message } ) , "application/json" ) ;
}
} ) ;
return ;
}
// cover image
const cover = path . match ( /^\/admin\/cover\/([a-z0-9-]+)$/i ) ;
if ( cover ) {
const f = join ( draftsDir ( ) , cover [ 1 ] , "cover.jpg" ) ;
if ( ! existsSync ( f ) ) return send ( res , 404 , "not found" , "text/plain" ) ;
return send ( res , 200 , readFileSync ( f ) , "image/jpeg" , cookieHeader ) ;
}
// draft detail
const draft = path . match ( /^\/admin\/draft\/([a-z0-9-]+)$/i ) ;
if ( draft ) {
const html = draftDetailHtml ( draft [ 1 ] ) ;
return html ? send ( res , 200 , html , "text/html; charset=utf-8" , cookieHeader ) : send ( res , 404 , "<h1>404</h1>" ) ;
}
// dashboard
if ( path === "/admin" || path === "/admin/" ) {
return send ( res , 200 , dashboardHtml ( ) , "text/html; charset=utf-8" , cookieHeader ) ;
}
return send ( res , 404 , "<h1>404</h1>" ) ;
} ) ;
server . listen ( PORT , "127.0.0.1" , ( ) => console . log ( ` [admin] listening on 127.0.0.1: ${ PORT } ` ) ) ;