Skip to content

Commit 20e06ce

Browse files
author
CloudLobster
committed
fix: auto-discover blog posts from .md files (no more manual POSTS registry)
- Uses import.meta.glob to scan content/blog/*.md at build time - Parses pseudo-frontmatter (Published, Tags, Description) from each .md - Auto-sorts by date descending - Adding a new post = just add a .md file, no code changes needed
1 parent eede5f7 commit 20e06ce

1 file changed

Lines changed: 102 additions & 142 deletions

File tree

web/src/pages/Blog.tsx

Lines changed: 102 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,170 +1,124 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useState, useMemo } from 'react';
22
import { Link, useParams } from 'react-router-dom';
33
import { marked } from 'marked';
44

5-
// Static blog index — slug → metadata (newest first)
6-
const POSTS: Record<string, { title: string; date: string; tags: string; description: string; heroImage?: string }> = {
7-
'world-id-human-verification': {
8-
title: 'World ID Integration: Proving You\'re Human on BaseMail',
9-
date: '2026-03-02',
10-
tags: 'World ID, human verification, identity, trust',
11-
description: 'BaseMail now supports World ID v4 verification — cryptographic proof that you\'re a unique human. No passwords, no KYC, just math.',
12-
heroImage: '/blog/world-id-human-verification.png',
13-
},
14-
'the-diplomat-chainlink-hackathon': {
15-
title: 'The Diplomat: AI-Powered Email Arbitration on Chainlink CRE',
16-
date: '2026-03-02',
17-
tags: 'The Diplomat, Chainlink, CRE, hackathon, AI arbitration',
18-
description: 'BaseMail enters the Chainlink Convergence Hackathon with The Diplomat — an LLM arbitration layer that uses Chainlink CRE and Gemini AI to price every email based on intent.',
19-
heroImage: '/blog/the-diplomat-chainlink-hackathon.webp',
20-
},
21-
'usdc-escrow-claim-external-email': {
22-
title: 'Send USDC to Anyone — Even Without a Wallet',
23-
date: '2026-03-02',
24-
tags: 'USDC, escrow, payments, external email',
25-
description: 'BaseMail now lets you send USDC to any email address — Gmail, Outlook, anything. The recipient gets a claim link and doesn\'t need a crypto wallet.',
26-
heroImage: '/blog/usdc-escrow-claim-external-email.webp',
27-
},
28-
'attn-v3-announcement': {
29-
title: 'BaseMail v3: Your Inbox Is Now a Savings Account',
30-
date: '2026-02-28',
31-
tags: '$ATTN, v3, attention economy, announcement',
32-
description: 'Introducing $ATTN — free tokens that make spam economically irrational and good conversations literally free. All positive feedback, no punishment.',
33-
heroImage: '/blog/attn-v3-announcement.webp',
34-
},
35-
'who-needs-agentic-email': {
36-
title: 'Who Needs Agentic Email? (More People Than You Think)',
37-
date: '2026-02-28',
38-
tags: 'agentic email, use cases, AI agents, OpenClaw',
39-
description: 'From solo developers running OpenClaw agents to enterprises deploying agent fleets — here\'s who needs agentic email and why.',
40-
heroImage: '/blog/who-needs-agentic-email.webp',
41-
},
42-
'why-agents-need-email': {
43-
title: 'Why Your AI Agent Needs Its Own Email Address',
44-
date: '2026-02-28',
45-
tags: 'AI agents, email, identity, pain points',
46-
description: 'Gmail blocks bots. Sharing your inbox is a security risk. Without its own email, your agent can\'t sign up for anything.',
47-
heroImage: '/blog/why-agents-need-email.webp',
48-
},
49-
'attention-bonds-quadratic-funding-spam': {
50-
title: 'Attention Bonds: How Quadratic Funding Kills Spam',
51-
date: '2026-02-22',
52-
tags: 'attention bonds, quadratic funding, CO-QAF',
53-
description: 'Learn how Attention Bonds use Quadratic Funding to eliminate spam while rewarding genuine communication.',
54-
heroImage: '/blog/attention-bonds-quadratic-funding-spam.webp',
55-
},
56-
'basemail-vs-agentmail': {
57-
title: 'BaseMail vs AgentMail: Onchain Identity vs SaaS',
58-
date: '2026-02-22',
59-
tags: 'comparison, agent email, onchain identity',
60-
description: 'A detailed comparison of BaseMail and AgentMail approaches to AI agent email.',
61-
heroImage: '/blog/basemail-vs-agentmail.webp',
62-
},
63-
'erc-8004-agent-email-resolution': {
64-
title: 'ERC-8004: The Standard for Agent Email Resolution',
65-
date: '2026-02-22',
66-
tags: 'ERC-8004, standard, agent discovery',
67-
description: 'How ERC-8004 enables verifiable agent email resolution on the blockchain.',
68-
heroImage: '/blog/erc-8004-agent-email-resolution.webp',
69-
},
70-
'lens-protocol-agent-social-graph': {
71-
title: 'Lens Protocol + Agent Identity: Social Graph for AI',
72-
date: '2026-02-22',
73-
tags: 'Lens Protocol, social graph, agent identity',
74-
description: 'Integrating Lens Protocol social graph with AI agent identity on BaseMail.',
75-
heroImage: '/blog/lens-protocol-agent-social-graph.webp',
76-
},
77-
'openclaw-agent-email-tutorial': {
78-
title: 'How to Give Your OpenClaw Agent an Email in 2 Minutes',
79-
date: '2026-02-22',
80-
tags: 'tutorial, OpenClaw, getting started',
81-
description: 'Quick tutorial to set up email for your OpenClaw AI agent with BaseMail.',
82-
heroImage: '/blog/openclaw-agent-email-tutorial.webp',
83-
},
84-
'why-agents-need-onchain-identity': {
85-
title: 'Why AI Agents Need Onchain Identity (Not Just an Inbox)',
86-
date: '2026-02-22',
87-
tags: 'identity, AI agents, onchain',
88-
description: 'The case for onchain identity as the foundation of AI agent communication.',
89-
heroImage: '/blog/why-agents-need-onchain-identity.webp',
90-
},
91-
};
5+
// Auto-import all blog posts at build time (raw strings)
6+
const modules = import.meta.glob('../../content/blog/*.md', { as: 'raw', eager: true }) as Record<string, string>;
927

93-
const SLUGS = Object.keys(POSTS);
8+
interface PostMeta {
9+
slug: string;
10+
title: string;
11+
date: string;
12+
author: string;
13+
tags: string;
14+
description: string;
15+
heroImage?: string;
16+
}
17+
18+
/** Parse pseudo-frontmatter from our .md blog format */
19+
function parseMeta(slug: string, raw: string): PostMeta {
20+
const lines = raw.split('\n');
21+
const title = (lines[0] || '').replace(/^#\s+/, '');
22+
let date = '', author = '', tags = '', description = '', heroImage: string | undefined;
23+
24+
for (let i = 1; i < Math.min(lines.length, 12); i++) {
25+
const line = lines[i];
26+
if (line.startsWith('**Published')) date = line.replace(/.*\*\*Published:\*\*\s*/, '').trim();
27+
else if (line.startsWith('**Author')) author = line.replace(/.*\*\*Author:\*\*\s*/, '').trim();
28+
else if (line.startsWith('**Tags')) tags = line.replace(/.*\*\*Tags:\*\*\s*/, '').trim();
29+
else if (line.startsWith('**Description')) description = line.replace(/.*\*\*Description:\*\*\s*/, '').trim();
30+
else if (line.startsWith('**Hero')) heroImage = line.replace(/.*\*\*Hero(?:Image|Image):\*\*\s*/, '').trim();
31+
}
32+
33+
// Auto-detect hero image: check /blog/<slug>.webp or .png convention
34+
if (!heroImage) {
35+
heroImage = `/blog/${slug}.webp`;
36+
}
37+
38+
return { slug, title, date, author, tags, description, heroImage };
39+
}
40+
41+
/** Build sorted post list from glob imports */
42+
function buildPosts(): PostMeta[] {
43+
const posts: PostMeta[] = [];
44+
for (const [path, raw] of Object.entries(modules)) {
45+
const slug = path.split('/').pop()?.replace(/\.md$/, '') || '';
46+
posts.push(parseMeta(slug, raw));
47+
}
48+
// Sort by date descending, then title
49+
posts.sort((a, b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title));
50+
return posts;
51+
}
9452

9553
// Blog list page
96-
function BlogIndex() {
54+
function BlogIndex({ posts }: { posts: PostMeta[] }) {
9755
return (
9856
<div className="min-h-screen bg-gray-950 text-white">
9957
<div className="max-w-3xl mx-auto px-6 py-16">
10058
<Link to="/" className="text-gray-500 hover:text-white text-sm mb-8 inline-block">← Back to BaseMail</Link>
10159
<h1 className="text-4xl font-bold mb-2">Blog</h1>
10260
<p className="text-gray-400 mb-12">Insights on agentic email, onchain identity, and mechanism design.</p>
10361
<div className="space-y-8">
104-
{SLUGS.map(slug => {
105-
const p = POSTS[slug];
106-
return (
107-
<Link key={slug} to={`/blog/${slug}`} className="block group">
108-
<article className="border border-gray-800 rounded-xl overflow-hidden hover:border-gray-600 transition">
109-
{p.heroImage && (
110-
<div className="aspect-[3/2] overflow-hidden bg-gray-900">
111-
<img
112-
src={p.heroImage}
113-
alt={p.title}
114-
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
115-
loading="lazy"
116-
/>
117-
</div>
118-
)}
119-
<div className="p-6">
120-
<time className="text-gray-500 text-sm">{p.date}</time>
121-
<h2 className="text-xl font-semibold mt-1 group-hover:text-blue-400 transition">{p.title}</h2>
122-
<p className="text-gray-400 mt-2 text-sm">{p.description}</p>
123-
<div className="mt-3 flex gap-2 flex-wrap">
124-
{p.tags.split(', ').slice(0, 3).map(t => (
125-
<span key={t} className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded">{t}</span>
126-
))}
127-
</div>
62+
{posts.map(p => (
63+
<Link key={p.slug} to={`/blog/${p.slug}`} className="block group">
64+
<article className="border border-gray-800 rounded-xl overflow-hidden hover:border-gray-600 transition">
65+
{p.heroImage && (
66+
<div className="aspect-[3/2] overflow-hidden bg-gray-900">
67+
<img
68+
src={p.heroImage}
69+
alt={p.title}
70+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
71+
loading="lazy"
72+
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
73+
/>
74+
</div>
75+
)}
76+
<div className="p-6">
77+
<time className="text-gray-500 text-sm">{p.date}</time>
78+
<h2 className="text-xl font-semibold mt-1 group-hover:text-blue-400 transition">{p.title}</h2>
79+
<p className="text-gray-400 mt-2 text-sm">{p.description}</p>
80+
<div className="mt-3 flex gap-2 flex-wrap">
81+
{p.tags.split(', ').slice(0, 3).map(t => (
82+
<span key={t} className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded">{t}</span>
83+
))}
12884
</div>
129-
</article>
130-
</Link>
131-
);
132-
})}
85+
</div>
86+
</article>
87+
</Link>
88+
))}
13389
</div>
13490
</div>
13591
</div>
13692
);
13793
}
13894

13995
// Blog post page
140-
function BlogPost() {
96+
function BlogPost({ posts }: { posts: PostMeta[] }) {
14197
const { slug } = useParams<{ slug: string }>();
14298
const [html, setHtml] = useState('');
14399
const [loading, setLoading] = useState(true);
144-
const meta = slug ? POSTS[slug] : null;
100+
const meta = posts.find(p => p.slug === slug);
145101

146102
useEffect(() => {
147103
if (!slug || !meta) { setLoading(false); return; }
148-
import(`../../content/blog/${slug}.md?raw`)
149-
.then(mod => {
150-
// Strip the frontmatter-like header (title, date, tags lines)
151-
let md = (mod.default || mod) as string;
152-
// Remove first lines that are metadata
153-
const lines = md.split('\n');
154-
let start = 0;
155-
for (let i = 0; i < Math.min(lines.length, 10); i++) {
156-
if (lines[i].startsWith('**Published') || lines[i].startsWith('**Author') || lines[i].startsWith('**Tags') || lines[i].trim() === '') {
157-
start = i + 1;
158-
} else if (i > 0 && lines[i].startsWith('#')) {
159-
// Keep the title
160-
start = i;
161-
break;
162-
}
163-
}
164-
setHtml(marked.parse(lines.slice(start).join('\n')) as string);
165-
setLoading(false);
166-
})
167-
.catch(() => { setHtml(''); setLoading(false); });
104+
const key = Object.keys(modules).find(k => k.endsWith(`/${slug}.md`));
105+
if (!key) { setLoading(false); return; }
106+
107+
const raw = modules[key];
108+
// Strip header lines (title, metadata, ---) to get body
109+
const lines = raw.split('\n');
110+
let start = 0;
111+
for (let i = 0; i < Math.min(lines.length, 15); i++) {
112+
const line = lines[i].trim();
113+
if (line === '---') { start = i + 1; break; }
114+
if (line.startsWith('**Published') || line.startsWith('**Author') ||
115+
line.startsWith('**Tags') || line.startsWith('**Description') ||
116+
line.startsWith('**Hero') || line === '' || line.startsWith('#')) {
117+
start = i + 1;
118+
}
119+
}
120+
setHtml(marked.parse(lines.slice(start).join('\n')) as string);
121+
setLoading(false);
168122
}, [slug, meta]);
169123

170124
if (!meta) {
@@ -186,7 +140,12 @@ function BlogPost() {
186140
<article>
187141
{meta.heroImage && (
188142
<div className="aspect-[3/2] overflow-hidden rounded-xl mb-8 bg-gray-900">
189-
<img src={meta.heroImage} alt={meta.title} className="w-full h-full object-cover" />
143+
<img
144+
src={meta.heroImage}
145+
alt={meta.title}
146+
className="w-full h-full object-cover"
147+
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
148+
/>
190149
</div>
191150
)}
192151
<time className="text-gray-500 text-sm">{meta.date}</time>
@@ -212,6 +171,7 @@ function BlogPost() {
212171
}
213172

214173
export default function Blog() {
174+
const posts = useMemo(() => buildPosts(), []);
215175
const { slug } = useParams<{ slug: string }>();
216-
return slug ? <BlogPost /> : <BlogIndex />;
176+
return slug ? <BlogPost posts={posts} /> : <BlogIndex posts={posts} />;
217177
}

0 commit comments

Comments
 (0)