From 6008203af5fd00a249de94c740b3d98ef4aff4b5 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:34:31 +0000 Subject: [PATCH] feat: support multiple blog categories --- blogs/AGENTS.md | 4 ++- ...uman-authorization-in-agentic-workflows.md | 2 +- src/marketing/app/blog/[slug]/page.tsx | 11 +++++--- .../app/blog/_components/PostExplorer.tsx | 13 +++++++--- src/marketing/app/blog/_lib/categories.ts | 1 + src/marketing/app/blog/_lib/posts.ts | 1 + src/marketing/app/blog/page.tsx | 1 + src/marketing/blogPlugin.ts | 26 ++++++++++++++----- src/marketing/next.d.ts | 1 + 9 files changed, 45 insertions(+), 15 deletions(-) diff --git a/blogs/AGENTS.md b/blogs/AGENTS.md index 74ce3c5da..a10f6e6e2 100644 --- a/blogs/AGENTS.md +++ b/blogs/AGENTS.md @@ -14,11 +14,13 @@ title: "Tuning Reth for payments: how we hit 21,200 TPS" excerpt: "One or two sentences shown on the index and as the post's lede." date: 2026-06-02 category: technical # network-upgrades | events | technical | case-studies +# Use an inline list when a post belongs to multiple categories: +# category: [technical, case-studies] featured: true # optional — pins the post to the hero card on /blog --- ``` -`category` must be one of the four slugs above (the build fails loudly otherwise). At most one post should be `featured`; if none is, the newest post takes the hero card. +`category` must be one of the four slugs above or an inline list of those slugs (the build fails loudly otherwise). At most one post should be `featured`; if none is, the newest post takes the hero card. ## Body diff --git a/blogs/human-authorization-in-agentic-workflows.md b/blogs/human-authorization-in-agentic-workflows.md index 83d4f8451..f3d992366 100644 --- a/blogs/human-authorization-in-agentic-workflows.md +++ b/blogs/human-authorization-in-agentic-workflows.md @@ -2,7 +2,7 @@ title: "Human Authorization in Agentic Workflows" excerpt: "How Tempo enables AI-accelerated development securely" date: 2026-08-17 -category: case-studies +category: [technical, case-studies] --- At Tempo, we are building more workflows where software can move quickly on a person's behalf. That is useful, but it can also be risky. diff --git a/src/marketing/app/blog/[slug]/page.tsx b/src/marketing/app/blog/[slug]/page.tsx index 3c5ed6a31..0af87806b 100644 --- a/src/marketing/app/blog/[slug]/page.tsx +++ b/src/marketing/app/blog/[slug]/page.tsx @@ -48,9 +48,14 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
- - {categoryBySlug(post.category).badge} - + {post.categories.map((category) => ( + + {categoryBySlug(category).badge} + + ))} {formatDate(post.date)} diff --git a/src/marketing/app/blog/_components/PostExplorer.tsx b/src/marketing/app/blog/_components/PostExplorer.tsx index 160794d42..84b1d204f 100644 --- a/src/marketing/app/blog/_components/PostExplorer.tsx +++ b/src/marketing/app/blog/_components/PostExplorer.tsx @@ -12,7 +12,7 @@ type Filter = (typeof filters)[number]['slug'] export default function PostExplorer({ posts }: { posts: PostMeta[] }) { const [active, setActive] = useState('all') - const visible = active === 'all' ? posts : posts.filter((p) => p.category === active) + const visible = active === 'all' ? posts : posts.filter((p) => p.categories.includes(active)) return (
@@ -48,9 +48,14 @@ export default function PostExplorer({ posts }: { posts: PostMeta[] }) { className="group flex flex-col gap-2.5 border-line border-b px-5 py-6 transition-colors hover:bg-surface-block lg:px-8" > - - {categoryBySlug(post.category).badge} - + {post.categories.map((category) => ( + + {categoryBySlug(category).badge} + + ))} {isNew(post.date) && ( New diff --git a/src/marketing/app/blog/_lib/categories.ts b/src/marketing/app/blog/_lib/categories.ts index 618f7972a..e4cbd2d2f 100644 --- a/src/marketing/app/blog/_lib/categories.ts +++ b/src/marketing/app/blog/_lib/categories.ts @@ -17,6 +17,7 @@ export type PostMeta = { excerpt: string date: string category: CategorySlug + categories: CategorySlug[] authors: string featured: boolean } diff --git a/src/marketing/app/blog/_lib/posts.ts b/src/marketing/app/blog/_lib/posts.ts index e85edb741..23a3239c5 100644 --- a/src/marketing/app/blog/_lib/posts.ts +++ b/src/marketing/app/blog/_lib/posts.ts @@ -10,6 +10,7 @@ export type Post = PostMeta & { html: string } const posts: Post[] = renderedPosts.map((post) => ({ ...post, category: post.category as CategorySlug, + categories: post.categories as CategorySlug[], })) export function getAllPosts(): Post[] { diff --git a/src/marketing/app/blog/page.tsx b/src/marketing/app/blog/page.tsx index 62bdc19c9..bbfde132a 100644 --- a/src/marketing/app/blog/page.tsx +++ b/src/marketing/app/blog/page.tsx @@ -17,6 +17,7 @@ export default function BlogPage() { excerpt: post.excerpt, date: post.date, category: post.category, + categories: post.categories, authors: post.authors, featured: post.featured, })) diff --git a/src/marketing/blogPlugin.ts b/src/marketing/blogPlugin.ts index f66229093..b6598e39f 100644 --- a/src/marketing/blogPlugin.ts +++ b/src/marketing/blogPlugin.ts @@ -10,7 +10,8 @@ import { unified } from 'unified' import type { Plugin } from 'vite' // Blog content lives as dev-managed markdown files in /blogs at the repo root. -// Frontmatter schema: title, excerpt, date (YYYY-MM-DD), category, optional +// Frontmatter schema: title, excerpt, date (YYYY-MM-DD), category (a slug or +// inline list of slugs), optional // authors, and an optional `featured: true` to pin a post to the hero card. // // Markdown is rendered to HTML here, in Node, at build/dev time, so the heavy @@ -66,6 +67,7 @@ export type RenderedPost = { excerpt: string date: string category: string + categories: string[] authors: string featured: boolean html: string @@ -97,8 +99,9 @@ const processor = unified() .use(rehypeStringify) // Minimal frontmatter parser for our simple schema (quoted strings, an -// unquoted YYYY-MM-DD date, a category slug, and an optional boolean). Avoids -// pulling in gray-matter + js-yaml for a handful of known keys. +// unquoted YYYY-MM-DD date, a category slug or inline list of slugs, and an +// optional boolean). Avoids pulling in gray-matter + js-yaml for a handful of +// known keys. function parseFrontmatter(raw: string): { data: Record; content: string } { const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw) if (!match) return { data: {}, content: raw } @@ -123,14 +126,24 @@ function parseFrontmatter(raw: string): { data: Record; content: return { data, content: match[2] } } +function parseCategories(value: string): string[] { + const list = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value + return list + .split(',') + .map((category) => category.trim().replace(/^(['"])(.*)\1$/, '$2')) + .filter(Boolean) +} + async function renderPost(filename: string): Promise { const slug = filename.replace(/\.md$/, '') const raw = fs.readFileSync(path.join(BLOGS_DIR, filename), 'utf8') const { data, content } = parseFrontmatter(raw) - if (!CATEGORY_SLUGS.includes(data.category)) { + const categories = [...new Set(parseCategories(data.category ?? ''))] + const unknownCategories = categories.filter((category) => !CATEGORY_SLUGS.includes(category)) + if (categories.length === 0 || unknownCategories.length > 0) { throw new Error( - `blogs/${filename}: unknown category "${data.category}". ` + + `blogs/${filename}: unknown category "${unknownCategories[0] ?? data.category}". ` + `Expected one of: ${CATEGORY_SLUGS.join(', ')}`, ) } @@ -142,7 +155,8 @@ async function renderPost(filename: string): Promise { title: data.title, excerpt: data.excerpt, date: data.date, - category: data.category, + category: categories[0], + categories, authors: data.authors ?? '', featured: data.featured === 'true', html, diff --git a/src/marketing/next.d.ts b/src/marketing/next.d.ts index dff63aada..be7f48b3c 100644 --- a/src/marketing/next.d.ts +++ b/src/marketing/next.d.ts @@ -9,6 +9,7 @@ declare module 'virtual:blog-posts' { excerpt: string date: string category: string + categories: string[] authors: string featured: boolean html: string