Enhance blog to support knowledge categories

This commit is contained in:
cloudneutral 2025-12-21 23:48:08 +08:00
parent 18e849d338
commit 78aa67f5f7
4 changed files with 143 additions and 19 deletions

View File

@ -1,10 +1,11 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { readMarkdownFile } from '@lib/markdown'
import { resolveBlogContentRoot } from '@lib/marketingContent'
import type { Metadata } from 'next'
type PageProps = {
params: { slug: string }
params: { slug: string | string[] }
}
function formatDate(dateStr: string, language: 'zh' | 'en'): string {
@ -27,9 +28,10 @@ function formatDate(dateStr: string, language: 'zh' | 'en'): string {
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const slugPath = Array.isArray(slug) ? slug.join('/') : slug
try {
const blogContentRoot = process.cwd() + '/src/content/blog'
const file = await readMarkdownFile(`${slug}.md`, { baseDir: blogContentRoot })
const blogContentRoot = resolveBlogContentRoot()
const file = await readMarkdownFile(`${slugPath}.md`, { baseDir: blogContentRoot })
const title = file.metadata.title as string
const excerpt = (file.metadata.excerpt as string) || ''
@ -47,11 +49,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params
const slugPath = Array.isArray(slug) ? slug.join('/') : slug
try {
const blogContentRoot = process.cwd() + '/src/content/blog'
const file = await readMarkdownFile(`${slug}.md`, { baseDir: blogContentRoot })
const blogContentRoot = resolveBlogContentRoot()
const file = await readMarkdownFile(`${slugPath}.md`, { baseDir: blogContentRoot })
const title = (file.metadata.title as string) || slug
const title = (file.metadata.title as string) || slugPath
const author = file.metadata.author as string | undefined
const date = file.metadata.date as string | undefined
const tags = file.metadata.tags as string[] | undefined

View File

@ -32,24 +32,46 @@ function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string
}
type PageProps = {
searchParams?: { page?: string } | Promise<{ page?: string }>
searchParams?: { page?: string; category?: string } | Promise<{ page?: string; category?: string }>
}
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: '随笔&观察' },
]
function buildCategoryCounts(posts: Awaited<ReturnType<typeof getHomepagePosts>>) {
return posts.reduce<Record<string, number>>((acc, post) => {
if (post.category?.key) {
acc[post.category.key] = (acc[post.category.key] || 0) + 1
}
return acc
}, {})
}
export default async function BlogPage({ searchParams }: PageProps) {
const posts = await getHomepagePosts()
const resolvedSearchParams = await Promise.resolve(searchParams ?? {})
const { page } = resolvedSearchParams ?? {}
const { page, category } = resolvedSearchParams ?? {}
const categoryCounts = buildCategoryCounts(posts)
const selectedCategory = CATEGORY_TABS.find((tab) => tab.key === category)?.key
const filteredPosts = selectedCategory ? posts.filter((post) => post.category?.key === selectedCategory) : posts
const postsPerPage = 10
const currentPage = parseInt(page || '1', 10)
const totalPages = Math.max(1, Math.ceil(posts.length / postsPerPage))
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / postsPerPage))
if ((posts.length > 0 && currentPage > totalPages) || currentPage < 1) {
if ((filteredPosts.length > 0 && currentPage > totalPages) || currentPage < 1) {
notFound()
}
const startIndex = (currentPage - 1) * postsPerPage
const endIndex = startIndex + postsPerPage
const paginatedPosts = posts.slice(startIndex, endIndex)
const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
return (
<div className="bg-white text-slate-900">
@ -76,7 +98,55 @@ export default async function BlogPage({ searchParams }: PageProps) {
</p>
</div>
{posts.length === 0 ? (
<div className="mb-10 flex flex-wrap items-center gap-3">
{CATEGORY_TABS.map((tab) => {
const isActive = tab.key === selectedCategory
const labelWithCount = categoryCounts[tab.key]
return (
<Link
key={tab.key}
href={`/blog${isActive ? '' : `?category=${tab.key}`}`}
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${
isActive
? 'border-brand bg-brand text-white shadow-sm'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand/60 hover:text-brand'
}`}
aria-current={isActive ? 'page' : undefined}
>
<span>{tab.label}</span>
{labelWithCount ? (
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
isActive ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-700'
}`}
>
{labelWithCount}
</span>
) : null}
</Link>
)
})}
<Link
href="/blog"
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${
!selectedCategory
? 'border-brand bg-brand text-white shadow-sm'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand/60 hover:text-brand'
}`}
>
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
!selectedCategory ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-700'
}`}
>
{posts.length}
</span>
</Link>
</div>
{filteredPosts.length === 0 ? (
<div className="text-center py-20">
<p className="text-slate-500"></p>
</div>
@ -128,7 +198,7 @@ export default async function BlogPage({ searchParams }: PageProps) {
{totalPages > 1 && (
<nav className="mt-12 flex items-center justify-center gap-2">
<Link
href={`/blog?page=${currentPage - 1}`}
href={`/blog?page=${currentPage - 1}${selectedCategory ? `&category=${selectedCategory}` : ''}`}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
currentPage === 1
? 'cursor-not-allowed text-slate-400'
@ -142,7 +212,7 @@ export default async function BlogPage({ searchParams }: PageProps) {
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Link
key={page}
href={`/blog?page=${page}`}
href={`/blog?page=${page}${selectedCategory ? `&category=${selectedCategory}` : ''}`}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
page === currentPage
? 'bg-brand text-white'
@ -154,7 +224,7 @@ export default async function BlogPage({ searchParams }: PageProps) {
))}
<Link
href={`/blog?page=${currentPage + 1}`}
href={`/blog?page=${currentPage + 1}${selectedCategory ? `&category=${selectedCategory}` : ''}`}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
currentPage === totalPages
? 'cursor-not-allowed text-slate-400'

View File

@ -85,14 +85,15 @@ export async function readMarkdownFile(
const { metadata, content } = parseFrontMatter(raw)
const htmlResult = await marked.parse(content)
const html = typeof htmlResult === 'string' ? htmlResult : await htmlResult
const slug = path.basename(relativePath, path.extname(relativePath))
const withoutExtension = relativePath.replace(new RegExp(`${path.extname(relativePath)}$`), '')
const slug = withoutExtension.split(path.sep).join('/')
return { metadata, content, html, slug }
}
export async function readMarkdownDirectory(
relativeDir: string,
options?: { baseDir?: string }
options?: { baseDir?: string; recursive?: boolean }
): Promise<MarkdownFile[]> {
const baseDir = options?.baseDir ?? CONTENT_ROOT
const dirPath = path.join(baseDir, relativeDir)
@ -104,5 +105,14 @@ export async function readMarkdownDirectory(
files.map((file) => readMarkdownFile(path.join(relativeDir, file.name), { baseDir }))
)
return results
if (!options?.recursive) {
return results
}
const nestedDirectories = entries.filter((entry) => entry.isDirectory())
const nestedResults = await Promise.all(
nestedDirectories.map((dir) => readMarkdownDirectory(path.join(relativeDir, dir.name), { baseDir, recursive: true }))
)
return results.concat(...nestedResults)
}

View File

@ -1,3 +1,4 @@
import fs from 'fs'
import path from 'path'
import { readMarkdownDirectory } from './markdown'
@ -38,6 +39,10 @@ export interface HomepagePost {
tags: string[]
excerpt: string
contentHtml: string
category?: {
key: string
label: string
}
}
export interface SidebarSection {
@ -185,6 +190,39 @@ const CONTACT_PANEL: ContactPanelContent = {
}
const BLOG_CONTENT_ROOT = path.join(process.cwd(), 'src', 'content', 'blog')
const KNOWLEDGE_CONTENT_ROOT = path.join(process.cwd(), 'content')
const CATEGORY_MAP: { key: string; label: string; match: (segments: string[]) => boolean }[] = [
{ key: 'infra-cloud', label: 'Infra & Cloud', match: (segments) => segments[0] === '04-infra-platform' },
{ key: 'observability', label: 'Observability', match: (segments) => segments[0] === '03-observability' },
{ 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: 'insight',
label: '资讯',
match: (segments) => segments[0] === '00-global' && (!segments[1] || segments[1] === 'news' || segments[1] === 'workshops'),
},
{
key: 'essays',
label: '随笔&观察',
match: (segments) => segments[0] === '00-global' && segments[1] === 'essays',
},
]
export function resolveBlogContentRoot(): string {
if (fs.existsSync(KNOWLEDGE_CONTENT_ROOT)) {
return KNOWLEDGE_CONTENT_ROOT
}
return BLOG_CONTENT_ROOT
}
function resolveCategory(slug: string): { key: string; label: string } | undefined {
const segments = slug.split('/')
const matched = CATEGORY_MAP.find((category) => category.match(segments))
return matched ? { key: matched.key, label: matched.label } : undefined
}
function extractExcerpt(markdown: string): string {
const blocks = markdown.split(/\r?\n\s*\r?\n/)
@ -213,7 +251,8 @@ export async function getHeroSolutions(): Promise<HeroSolution[]> {
export async function getHomepagePosts(): Promise<HomepagePost[]> {
let posts: HomepagePost[] = []
try {
const files = await readMarkdownDirectory('', { baseDir: BLOG_CONTENT_ROOT })
const contentRoot = resolveBlogContentRoot()
const files = await readMarkdownDirectory('', { baseDir: contentRoot, recursive: true })
posts = files.map((file) => {
const title = typeof file.metadata.title === 'string' ? file.metadata.title : file.slug
@ -226,6 +265,7 @@ export async function getHomepagePosts(): Promise<HomepagePost[]> {
: []
const excerptMetadata = typeof file.metadata.excerpt === 'string' ? file.metadata.excerpt : undefined
const excerpt = excerptMetadata ?? extractExcerpt(file.content)
const category = resolveCategory(file.slug)
return {
slug: file.slug,
@ -236,6 +276,7 @@ export async function getHomepagePosts(): Promise<HomepagePost[]> {
tags,
excerpt,
contentHtml: file.html,
category,
}
})
} catch (error) {