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 && \ diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 73cd891..c9cb199 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -5,8 +5,8 @@ import type { Metadata } from 'next' import { Suspense } from 'react' import BlogList from '@components/blog/BlogList' -import type { BlogPostSummary } from '@lib/blogContent' -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', @@ -15,10 +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 c77b418..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 { BlogPostSummary } 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 '' @@ -40,6 +30,7 @@ function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string interface BlogListProps { posts: BlogPostSummary[] + categories: BlogCategory[] } function buildCategoryCounts(posts: BlogPostSummary[]) { @@ -60,11 +51,22 @@ function detectLanguage(posts: BlogPostSummary[]): '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 8fb312b..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,11 @@ export interface BlogPost { } } +export interface BlogCategory { + key: string + label: string +} + export type BlogPostSummary = Omit const BLOG_EXTENSIONS = ['.md', '.mdx'] @@ -27,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: '资讯', @@ -39,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(), @@ -47,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))