From a7b79fdda22c6f9421f07a0f6ea096a33c22e42b Mon Sep 17 00:00:00 2001 From: cloudneutral Date: Thu, 1 Jan 2026 18:20:53 +0800 Subject: [PATCH 1/2] Make blog categories dynamic --- src/app/blog/page.tsx | 7 +++-- src/components/blog/BlogList.tsx | 34 +++++++++++----------- src/lib/blogContent.ts | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 5fefcb4..c9cb199 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -5,7 +5,8 @@ import type { Metadata } from 'next' import { Suspense } from 'react' import BlogList from '@components/blog/BlogList' -import { getBlogPosts } from '@lib/blogContent' +import type { BlogCategory, BlogPostSummary } from '@lib/blogContent' +import { getBlogCategories, getBlogPosts } from '@lib/blogContent' export const metadata: Metadata = { title: 'Blog | Cloud-Neutral', @@ -14,9 +15,11 @@ export const metadata: Metadata = { export default async function BlogPage() { const posts = await getBlogPosts() + const categories: BlogCategory[] = await getBlogCategories() + const postsWithoutContent: BlogPostSummary[] = posts.map(({ content: _content, ...post }) => post) return ( Loading blog content...}> - + ) } diff --git a/src/components/blog/BlogList.tsx b/src/components/blog/BlogList.tsx index 1e4031a..4424a5c 100644 --- a/src/components/blog/BlogList.tsx +++ b/src/components/blog/BlogList.tsx @@ -6,17 +6,7 @@ import { useSearchParams } from 'next/navigation' import BrandCTA from '@components/BrandCTA' import SearchComponent from '@components/search' -import type { BlogPost } from '@lib/blogContent' - -const CATEGORY_TABS: { key: string; label: string }[] = [ - { key: 'infra-cloud', label: 'Infra & Cloud' }, - { key: 'observability', label: 'Observability' }, - { key: 'identity', label: 'ID & Security' }, - { key: 'iac-devops', label: 'IaC & DevOps' }, - { key: 'data-ai', label: 'Data & AI' }, - { key: 'insight', label: '资讯' }, - { key: 'essays', label: '随笔&观察' }, -] +import type { BlogCategory, BlogPostSummary } from '@lib/blogContent' function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string { if (!dateStr) return '' @@ -39,10 +29,11 @@ function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string } interface BlogListProps { - posts: BlogPost[] + posts: BlogPostSummary[] + categories: BlogCategory[] } -function buildCategoryCounts(posts: BlogPost[]) { +function buildCategoryCounts(posts: BlogPostSummary[]) { return posts.reduce>((acc, post) => { const categoryKey = post.category?.key if (!categoryKey) return acc @@ -51,7 +42,7 @@ function buildCategoryCounts(posts: BlogPost[]) { }, {}) } -function detectLanguage(posts: BlogPost[]): 'zh' | 'en' { +function detectLanguage(posts: BlogPostSummary[]): 'zh' | 'en' { for (const post of posts) { if (/[\u4e00-\u9fff]/.test(`${post.title} ${post.excerpt}`)) { return 'zh' @@ -60,11 +51,22 @@ function detectLanguage(posts: BlogPost[]): 'zh' | 'en' { return 'en' } -export default function BlogList({ posts }: BlogListProps) { +export default function BlogList({ posts, categories }: BlogListProps) { const searchParams = useSearchParams() const selectedCategory = searchParams.get('category') const page = searchParams.get('page') + const categoryTabs = useMemo(() => { + const categoriesFromPosts = posts + .map((post) => post.category) + .filter((category): category is NonNullable => Boolean(category)) + .map((category) => ({ key: category.key, label: category.label ?? category.key })) + + return [...categories, ...categoriesFromPosts].filter( + (category, index, self) => self.findIndex((item) => item.key === category.key) === index, + ) + }, [categories, posts]) + const categoryCounts = useMemo(() => buildCategoryCounts(posts), [posts]) const filteredPosts = useMemo(() => { if (!selectedCategory) return posts @@ -109,7 +111,7 @@ export default function BlogList({ posts }: BlogListProps) {
- {CATEGORY_TABS.map((tab) => { + {categoryTabs.map((tab) => { const isActive = tab.key === selectedCategory const labelWithCount = categoryCounts[tab.key] diff --git a/src/lib/blogContent.ts b/src/lib/blogContent.ts index 14e4f11..69506a3 100644 --- a/src/lib/blogContent.ts +++ b/src/lib/blogContent.ts @@ -1,3 +1,4 @@ +import fs from 'fs/promises' import { cache } from 'react' import { readMdxDirectory, readMdxFile } from './mdx' @@ -17,6 +18,13 @@ export interface BlogPost { } } +export interface BlogCategory { + key: string + label: string +} + +export type BlogPostSummary = Omit + const BLOG_EXTENSIONS = ['.md', '.mdx'] const CATEGORY_MAP: { key: string; label: string; match: (segments: string[]) => boolean }[] = [ @@ -25,6 +33,7 @@ const CATEGORY_MAP: { key: string; label: string; match: (segments: string[]) => { key: 'identity', label: 'ID & Security', match: (segments) => segments[0] === '01-id-security' }, { key: 'iac-devops', label: 'IaC & DevOps', match: (segments) => segments[0] === '02-iac-devops' }, { key: 'data-ai', label: 'Data & AI', match: (segments) => segments[0] === '05-data-ai' }, + { key: 'workshops', label: 'Workshops', match: (segments) => segments[0] === '06-workshops' }, { key: 'insight', label: '资讯', @@ -37,6 +46,16 @@ const CATEGORY_MAP: { key: string; label: string; match: (segments: string[]) => }, ] +const CATEGORY_DIRECTORIES: Record = { + '04-infra-platform': { key: 'infra-cloud', label: 'Infra & Cloud' }, + '03-observability': { key: 'observability', label: 'Observability' }, + '01-id-security': { key: 'identity', label: 'ID & Security' }, + '02-iac-devops': { key: 'iac-devops', label: 'IaC & DevOps' }, + '05-data-ai': { key: 'data-ai', label: 'Data & AI' }, + '00-global': { key: 'insight', label: '资讯' }, + '06-workshops': { key: 'workshops', label: 'Workshops' }, +} + const readBlogFiles = cache(async () => readMdxDirectory('', { baseDir: resolveBlogContentRoot(), @@ -45,6 +64,36 @@ const readBlogFiles = cache(async () => }), ) +export const getBlogCategories = cache(async (): Promise => { + try { + const contentRoot = resolveBlogContentRoot() + const entries = await fs.readdir(contentRoot, { withFileTypes: true }) + const directories = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + + return directories + .sort() + .map((dirName) => { + const mapped = CATEGORY_DIRECTORIES[dirName] + if (mapped) return mapped + + const withoutPrefix = dirName.replace(/^\d+-/, '') + const normalized = withoutPrefix + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + + return { key: dirName, label: normalized || dirName } + }) + .filter((category, index, self) => self.findIndex((item) => item.key === category.key) === index) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + return [] + } +}) + function resolveCategory(slug: string): { key: string; label: string } | undefined { const segments = slug.split('/') const matched = CATEGORY_MAP.find((category) => category.match(segments)) From 8ca8723ed1521605d31357041fafe179d2058224 Mon Sep 17 00:00:00 2001 From: cloudneutral Date: Thu, 1 Jan 2026 18:25:12 +0800 Subject: [PATCH 2/2] Preserve blog content during builds --- Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4f61bee..26b2c45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,6 @@ RUN apt-get update \ && corepack prepare yarn@4.12.0 --activate COPY . . -RUN rm -rf src/content/blog/* \ - && mkdir -p src/content/blog RUN find . -name "package-lock.json" -delete RUN yarn install --immutable && \ yarn prebuild && \