Enhance blog to support knowledge categories
This commit is contained in:
parent
18e849d338
commit
78aa67f5f7
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user