Merge pull request #16 from Cloud-Neutral-Toolkit/codex/refactor-dashboard-content-system
Refactor blog, doc, and workshop content lifecycle
This commit is contained in:
commit
e54ab3eb01
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ node_modules/
|
||||
out/
|
||||
public/_build/
|
||||
public/dl-index/
|
||||
.contentlayer/
|
||||
|
||||
# Contentlayer cache
|
||||
ui/docs/.contentlayer/
|
||||
|
||||
24
contentlayer.config.ts
Normal file
24
contentlayer.config.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
|
||||
|
||||
export const Workshop = defineDocumentType(() => ({
|
||||
name: 'Workshop',
|
||||
filePathPattern: '**/*.mdx',
|
||||
contentType: 'mdx',
|
||||
fields: {
|
||||
title: { type: 'string', required: true },
|
||||
summary: { type: 'string', required: true },
|
||||
level: { type: 'string', default: 'Intro' },
|
||||
duration: { type: 'string', required: false },
|
||||
tags: { type: 'list', of: { type: 'string' }, default: [] },
|
||||
updatedAt: { type: 'date', required: false },
|
||||
},
|
||||
computedFields: {
|
||||
slug: { type: 'string', resolve: (doc) => doc._raw.flattenedPath },
|
||||
url: { type: 'string', resolve: (doc) => `/workshop/${doc._raw.flattenedPath}` },
|
||||
},
|
||||
}))
|
||||
|
||||
export default makeSource({
|
||||
contentDirPath: 'src/content/workshop',
|
||||
documentTypes: [Workshop],
|
||||
})
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@ -1,6 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
/// <reference types="contentlayer/generated" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { withContentlayer } from "next-contentlayer";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@ -106,4 +107,4 @@ export async function rewrites() {
|
||||
];
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default withContentlayer(nextConfig);
|
||||
|
||||
@ -74,6 +74,7 @@
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "24.0.3",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.3.26",
|
||||
@ -81,9 +82,11 @@
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"contentlayer": "^0.3.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"jsdom": "^24.0.0",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.3",
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
export const dynamic = 'error'
|
||||
export const revalidate = false
|
||||
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { compileMDX } from 'next-mdx-remote/rsc'
|
||||
import { readMdxFile } from '@lib/mdx'
|
||||
import { resolveBlogContentRoot } from '@lib/marketingContent'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { getBlogPostBySlug, getBlogSlugs } from '@lib/blogContent'
|
||||
|
||||
type PageProps = {
|
||||
params: { slug: string | string[] }
|
||||
}
|
||||
@ -18,114 +21,91 @@ function formatDate(dateStr: string, language: 'zh' | 'en'): string {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getBlogSlugs()
|
||||
return slugs.map((slug) => ({ slug: slug.split('/') }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const slugPath = Array.isArray(slug) ? slug.join('/') : slug
|
||||
try {
|
||||
const blogContentRoot = resolveBlogContentRoot()
|
||||
const file = await readMdxFile(slugPath, { baseDir: blogContentRoot })
|
||||
const slugParam = await params
|
||||
const slugPath = Array.isArray(slugParam.slug) ? slugParam.slug.join('/') : slugParam.slug
|
||||
const post = await getBlogPostBySlug(slugPath)
|
||||
|
||||
const title = file.metadata.title as string
|
||||
const excerpt = (file.metadata.excerpt as string) || ''
|
||||
if (!post) {
|
||||
return { title: 'Blog Post | Cloud-Neutral' }
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${title} | Cloud-Neutral Blog`,
|
||||
description: excerpt,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
title: 'Blog Post | Cloud-Neutral',
|
||||
}
|
||||
return {
|
||||
title: `${post.title} | Cloud-Neutral Blog`,
|
||||
description: post.excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const { slug } = await params
|
||||
const slugPath = Array.isArray(slug) ? slug.join('/') : slug
|
||||
try {
|
||||
const blogContentRoot = resolveBlogContentRoot()
|
||||
const file = await readMdxFile(slugPath, { baseDir: blogContentRoot })
|
||||
const mdx = await compileMDX({
|
||||
source: file.content,
|
||||
})
|
||||
const slugParam = await params
|
||||
const slugPath = Array.isArray(slugParam.slug) ? slugParam.slug.join('/') : slugParam.slug
|
||||
const post = await getBlogPostBySlug(slugPath)
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
<div className="mx-auto w-full max-w-4xl px-4 py-16">
|
||||
{/* Back to blog link */}
|
||||
<Link
|
||||
href="/blog"
|
||||
className="mb-8 inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
← {date ? 'Back to Blog' : '返回博客'}
|
||||
</Link>
|
||||
|
||||
{/* Article header */}
|
||||
<header className="mb-12">
|
||||
<h1 className="mb-4 text-4xl font-bold text-slate-900 sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{author && (
|
||||
<p className="mb-2 text-sm text-slate-600">
|
||||
{date ? 'By' : '作者'} {author}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{date && (
|
||||
<time className="text-sm text-slate-500">
|
||||
{formatDate(date, 'en')}
|
||||
</time>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Article content */}
|
||||
<article
|
||||
className="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline"
|
||||
>
|
||||
{mdx.content}
|
||||
</article>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-16 border-t border-slate-200 pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
← Back to Blog
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
} catch (error) {
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const mdx = await compileMDX({
|
||||
source: post.content,
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
<div className="mx-auto w-full max-w-4xl px-4 py-16">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="mb-8 inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
← {post.date ? 'Back to Blog' : '返回博客'}
|
||||
</Link>
|
||||
|
||||
<header className="mb-12">
|
||||
<h1 className="mb-4 text-4xl font-bold text-slate-900 sm:text-5xl">{post.title}</h1>
|
||||
|
||||
{post.author && <p className="mb-2 text-sm text-slate-600">{post.date ? 'By' : '作者'} {post.author}</p>}
|
||||
|
||||
{post.date && (
|
||||
<time className="text-sm text-slate-500">{formatDate(post.date, 'en')}</time>
|
||||
)}
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<article className="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline">
|
||||
{mdx.content}
|
||||
</article>
|
||||
|
||||
<footer className="mt-16 border-t border-slate-200 pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
← Back to Blog
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,244 +1,17 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamic = 'error'
|
||||
export const revalidate = false
|
||||
|
||||
import Link from 'next/link'
|
||||
import SearchComponent from '@components/search'
|
||||
import { getHomepagePosts } from '@lib/marketingContent'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import BlogList from '@components/blog/BlogList'
|
||||
import { getBlogPosts } from '@lib/blogContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Cloud-Neutral',
|
||||
description: 'Latest updates, releases, and insights from the Cloud-Neutral community.',
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string {
|
||||
if (!dateStr) return ''
|
||||
|
||||
const date = new Date(dateStr)
|
||||
|
||||
if (language === 'zh') {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: { 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 { page, category } = searchParams ?? {}
|
||||
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(filteredPosts.length / postsPerPage))
|
||||
|
||||
if ((filteredPosts.length > 0 && currentPage > totalPages) || currentPage < 1) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * postsPerPage
|
||||
const endIndex = startIndex + postsPerPage
|
||||
const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
|
||||
|
||||
return (
|
||||
<div className="bg-white text-slate-900">
|
||||
{/* Sticky Navigation Header */}
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-slate-500">SVC.plus</span>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-brand-dark">blog</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<SearchComponent className="relative w-full max-w-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-16">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">Blog</h1>
|
||||
<p className="text-lg text-slate-600">
|
||||
Latest updates, releases, and insights from the Cloud-Neutral community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-8">
|
||||
{paginatedPosts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-brand">Blog</span>
|
||||
{post.date && (
|
||||
<time className="text-sm text-slate-500">
|
||||
{formatDate(post.date, 'en')}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-slate-900">{post.title}</h2>
|
||||
{post.author && (
|
||||
<p className="mb-4 text-sm text-slate-500">By {post.author}</p>
|
||||
)}
|
||||
<p className="mb-6 text-slate-600">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="ml-auto text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="mt-12 flex items-center justify-center gap-2">
|
||||
<Link
|
||||
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'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Link
|
||||
key={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'
|
||||
: 'text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Link
|
||||
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'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
export default async function BlogPage() {
|
||||
const posts = await getBlogPosts()
|
||||
return <BlogList posts={posts} />
|
||||
}
|
||||
|
||||
@ -6,52 +6,46 @@ import Link from 'next/link'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
|
||||
import ClientTime from '../components/ClientTime'
|
||||
import type { DocCollection, DocVersionOption } from './types'
|
||||
import type { DocCollection } from './types'
|
||||
|
||||
interface DocCollectionCardProps {
|
||||
collection: DocCollection
|
||||
meta?: string
|
||||
}
|
||||
|
||||
function resolveVersionLabel(version?: DocVersionOption | null) {
|
||||
return version?.label ?? 'Latest'
|
||||
}
|
||||
|
||||
export default function DocCollectionCard({ collection, meta }: DocCollectionCardProps) {
|
||||
const { versions } = collection
|
||||
const defaultVersionId = collection.defaultVersionId ?? versions[0]?.id ?? ''
|
||||
const [selectedVersionId, setSelectedVersionId] = useState(defaultVersionId)
|
||||
const defaultVersionSlug = collection.defaultVersionSlug ?? versions[0]?.slug ?? ''
|
||||
const [selectedVersionSlug, setSelectedVersionSlug] = useState(defaultVersionSlug)
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultVersionId) {
|
||||
if (!defaultVersionSlug) {
|
||||
return
|
||||
}
|
||||
setSelectedVersionId(defaultVersionId)
|
||||
}, [defaultVersionId, collection.slug])
|
||||
setSelectedVersionSlug(defaultVersionSlug)
|
||||
}, [defaultVersionSlug, collection.slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (!versions.length) {
|
||||
return
|
||||
}
|
||||
if (!versions.some((version) => version.id === selectedVersionId)) {
|
||||
const fallback = versions.find((version) => version.id === defaultVersionId) ?? versions[0]
|
||||
if (!versions.some((version) => version.slug === selectedVersionSlug)) {
|
||||
const fallback = versions.find((version) => version.slug === defaultVersionSlug) ?? versions[0]
|
||||
if (fallback) {
|
||||
setSelectedVersionId(fallback.id)
|
||||
setSelectedVersionSlug(fallback.slug)
|
||||
}
|
||||
}
|
||||
}, [versions, selectedVersionId, defaultVersionId])
|
||||
}, [versions, selectedVersionSlug, defaultVersionSlug])
|
||||
|
||||
const activeVersion = useMemo(() => {
|
||||
if (!versions.length) return undefined
|
||||
return versions.find((version) => version.id === selectedVersionId) ?? versions[0]
|
||||
}, [versions, selectedVersionId])
|
||||
return versions.find((version) => version.slug === selectedVersionSlug) ?? versions[0]
|
||||
}, [versions, selectedVersionSlug])
|
||||
|
||||
const activeResource = activeVersion?.resource
|
||||
const description = activeResource?.description ?? collection.description
|
||||
const description = activeVersion?.description ?? collection.description
|
||||
const href = activeVersion ? `/docs/${collection.slug}/${activeVersion.slug}` : `/docs/${collection.slug}`
|
||||
const updatedAt = activeResource?.updatedAt ?? collection.updatedAt
|
||||
const estimatedMinutes = activeResource?.estimatedMinutes ?? collection.estimatedMinutes
|
||||
const tags = activeResource?.tags?.length ? activeResource.tags : collection.tags
|
||||
const updatedAt = activeVersion?.updatedAt ?? collection.updatedAt
|
||||
const tags = activeVersion?.tags?.length ? activeVersion.tags : collection.tags
|
||||
|
||||
return (
|
||||
<article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-brand-border bg-white shadow-[0_4px_20px_rgba(0,0,0,0.04)] transition duration-200 hover:-translate-y-1 hover:border-brand hover:shadow-[0_8px_28px_rgba(51,102,255,0.18)]">
|
||||
@ -70,7 +64,6 @@ export default function DocCollectionCard({ collection, meta }: DocCollectionCar
|
||||
Updated <ClientTime isoString={updatedAt} />
|
||||
</span>
|
||||
)}
|
||||
{estimatedMinutes && <span>{estimatedMinutes} min read</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -85,12 +78,12 @@ export default function DocCollectionCard({ collection, meta }: DocCollectionCar
|
||||
<label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-[0.2em] text-brand-heading/70">
|
||||
<span>Version</span>
|
||||
<select
|
||||
value={selectedVersionId}
|
||||
onChange={(event) => setSelectedVersionId(event.target.value)}
|
||||
value={selectedVersionSlug}
|
||||
onChange={(event) => setSelectedVersionSlug(event.target.value)}
|
||||
className="rounded-full border border-brand-border px-3 py-1 text-sm font-medium text-brand-heading focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand/30"
|
||||
>
|
||||
{versions.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
<option key={version.slug} value={version.slug}>
|
||||
{version.label}
|
||||
</option>
|
||||
))}
|
||||
@ -112,7 +105,7 @@ export default function DocCollectionCard({ collection, meta }: DocCollectionCar
|
||||
<div className="flex flex-col text-brand-heading">
|
||||
<span>Open reader</span>
|
||||
{activeVersion && (
|
||||
<span className="text-xs text-brand-heading/70">{resolveVersionLabel(activeVersion)}</span>
|
||||
<span className="text-xs text-brand-heading/70">{activeVersion.label}</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import ClientTime from '../../../components/ClientTime'
|
||||
import DocViewSection, { type DocViewOption, type ViewMode } from './DocViewSection'
|
||||
import { buildAbsoluteDocUrl } from '../../utils'
|
||||
import type { DocCollection, DocResource } from '../../types'
|
||||
|
||||
interface DocCollectionViewProps {
|
||||
collection: DocCollection
|
||||
initialVersionId?: string
|
||||
}
|
||||
|
||||
const buildViewerUrl = (
|
||||
rawUrl: string | undefined,
|
||||
_mode: ViewMode,
|
||||
): { sourceUrl: string; viewerUrl: string } | null => {
|
||||
if (!rawUrl) {
|
||||
return null
|
||||
}
|
||||
const absoluteUrl = buildAbsoluteDocUrl(rawUrl) ?? rawUrl
|
||||
if (!absoluteUrl) {
|
||||
return null
|
||||
}
|
||||
return { sourceUrl: absoluteUrl, viewerUrl: absoluteUrl }
|
||||
}
|
||||
|
||||
function buildViewOptions(resource?: DocResource): DocViewOption[] {
|
||||
if (!resource) {
|
||||
return []
|
||||
}
|
||||
const options: DocViewOption[] = []
|
||||
if (resource.pdfUrl) {
|
||||
const resolved = buildViewerUrl(resource.pdfUrl, 'pdf')
|
||||
if (resolved) {
|
||||
options.push({
|
||||
id: 'pdf',
|
||||
label: 'PDF',
|
||||
description: 'Best for printing and full fidelity diagrams.',
|
||||
url: resolved.sourceUrl,
|
||||
viewerUrl: resolved.viewerUrl,
|
||||
icon: 'pdf',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (resource.htmlUrl) {
|
||||
const resolved = buildViewerUrl(resource.htmlUrl, 'html')
|
||||
if (resolved) {
|
||||
options.push({
|
||||
id: 'html',
|
||||
label: 'HTML',
|
||||
description: 'Responsive reader mode optimised for browsers.',
|
||||
url: resolved.sourceUrl,
|
||||
viewerUrl: resolved.viewerUrl,
|
||||
icon: 'html',
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
export default function DocCollectionView({ collection, initialVersionId }: DocCollectionViewProps) {
|
||||
const { versions } = collection
|
||||
const router = useRouter()
|
||||
const defaultVersionId = collection.defaultVersionId ?? versions[0]?.id ?? ''
|
||||
const [activeVersionId, setActiveVersionId] = useState(initialVersionId ?? defaultVersionId)
|
||||
|
||||
useEffect(() => {
|
||||
const nextId = initialVersionId ?? defaultVersionId
|
||||
setActiveVersionId((current) => {
|
||||
if (!nextId) {
|
||||
return ''
|
||||
}
|
||||
if (current === nextId) {
|
||||
return current
|
||||
}
|
||||
return nextId
|
||||
})
|
||||
}, [initialVersionId, defaultVersionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!versions.length) {
|
||||
return
|
||||
}
|
||||
if (!versions.some((version) => version.id === activeVersionId)) {
|
||||
const fallback = versions.find((version) => version.id === defaultVersionId) ?? versions[0]
|
||||
if (fallback) {
|
||||
setActiveVersionId(fallback.id)
|
||||
}
|
||||
}
|
||||
}, [versions, activeVersionId, defaultVersionId])
|
||||
|
||||
const activeVersion = useMemo(() => {
|
||||
if (!versions.length) return undefined
|
||||
return versions.find((version) => version.id === activeVersionId) ?? versions[0]
|
||||
}, [versions, activeVersionId])
|
||||
|
||||
const activeResource = activeVersion?.resource
|
||||
const description = activeResource?.description || collection.description
|
||||
const tags = activeResource?.tags?.length ? activeResource.tags : collection.tags
|
||||
const viewOptions = useMemo(() => buildViewOptions(activeResource), [activeResource])
|
||||
const [isIntroCollapsed, setIsIntroCollapsed] = useState(false)
|
||||
|
||||
if (!activeResource) {
|
||||
return (
|
||||
<section className="rounded-3xl border border-dashed border-gray-300 bg-white/70 p-10 text-center text-sm text-gray-500">
|
||||
Documentation details are not available for this resource yet.
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">
|
||||
{collection.category || 'Documentation'}
|
||||
{activeResource.version
|
||||
? ` • ${activeResource.version}`
|
||||
: activeResource.variant
|
||||
? ` • ${activeResource.variant}`
|
||||
: ''}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 md:text-4xl">{collection.title}</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsIntroCollapsed((value) => !value)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 transition hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
{isIntroCollapsed ? (
|
||||
<>
|
||||
展开简介 <ChevronDown className="h-3.5 w-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
收起简介 <ChevronUp className="h-3.5 w-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isIntroCollapsed && (
|
||||
<div className="space-y-4 text-sm text-gray-600 md:text-base">
|
||||
<p className="max-w-3xl leading-relaxed">{description}</p>
|
||||
{(activeResource.variant || activeResource.language) && (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
|
||||
{activeResource.variant && (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1">Release {activeResource.variant}</span>
|
||||
)}
|
||||
{activeResource.language && (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1">Language {activeResource.language}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-purple-50 px-3 py-1 text-xs font-medium text-purple-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 text-sm text-gray-500 md:items-end">
|
||||
{versions.length > 1 && (
|
||||
<label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 md:items-end">
|
||||
<span>Version</span>
|
||||
<select
|
||||
value={activeVersionId}
|
||||
onChange={(event) => {
|
||||
const nextId = event.target.value
|
||||
setActiveVersionId(nextId)
|
||||
const target = versions.find((version) => version.id === nextId)
|
||||
if (target) {
|
||||
const targetSlug = target.slug || target.id
|
||||
const currentSlug = activeVersion?.slug || activeVersion?.id
|
||||
if (!currentSlug || currentSlug !== targetSlug) {
|
||||
router.replace(`/docs/${collection.slug}/${targetSlug}`)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-full border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 md:w-auto"
|
||||
>
|
||||
{versions.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
{version.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{activeResource.updatedAt && (
|
||||
<span suppressHydrationWarning>
|
||||
Updated <ClientTime isoString={activeResource.updatedAt} />
|
||||
</span>
|
||||
)}
|
||||
{activeResource.estimatedMinutes && <span>Approx. {activeResource.estimatedMinutes} minute read</span>}
|
||||
{activeVersion?.pathSegment && (
|
||||
<span className="text-xs text-gray-400">{activeVersion.pathSegment}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DocViewSection docTitle={collection.title} options={viewOptions} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,567 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ComponentProps,
|
||||
ReactElement,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type { DocViewOption } from './DocViewSection'
|
||||
|
||||
export interface DocTocItem {
|
||||
id: string
|
||||
title: string
|
||||
level: number
|
||||
type: 'html' | 'pdf'
|
||||
targetId?: string
|
||||
pageNumber?: number
|
||||
}
|
||||
|
||||
export interface DocReaderHandle {
|
||||
scrollToAnchor: (item: DocTocItem) => void
|
||||
}
|
||||
|
||||
interface DocReaderProps {
|
||||
docTitle: string
|
||||
view?: DocViewOption
|
||||
onTocChange: (items: DocTocItem[]) => void
|
||||
onProgressChange: (value: number) => void
|
||||
onActiveAnchorChange: (id: string | null) => void
|
||||
}
|
||||
|
||||
type LoadingState = 'idle' | 'loading' | 'error'
|
||||
|
||||
interface PdfModule {
|
||||
Document: typeof import('react-pdf').Document
|
||||
Page: typeof import('react-pdf').Page
|
||||
}
|
||||
|
||||
type DocumentComponent = PdfModule['Document']
|
||||
type OnLoadSuccess = NonNullable<ComponentProps<DocumentComponent>['onLoadSuccess']>
|
||||
type PdfDocument = Parameters<OnLoadSuccess>[0]
|
||||
|
||||
const HTML_ENCODINGS = ['utf-8', 'utf-16le', 'utf-16be', 'gb18030']
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'section'
|
||||
|
||||
const decodeBuffer = (buffer: ArrayBuffer, encodings: string[]): string => {
|
||||
for (const encoding of encodings) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding, { fatal: true })
|
||||
return decoder.decode(buffer)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(buffer)
|
||||
}
|
||||
|
||||
const buildHtmlToc = (container: HTMLElement): DocTocItem[] => {
|
||||
const headings = Array.from(container.querySelectorAll('h1, h2, h3, h4, h5, h6'))
|
||||
return headings.map((heading, index) => {
|
||||
const rawTitle = heading.textContent?.trim() || `Section ${index + 1}`
|
||||
const baseId = slugify(rawTitle)
|
||||
const headingId = heading.getAttribute('id') || `${baseId}-${index}`
|
||||
heading.setAttribute('id', headingId)
|
||||
heading.setAttribute('data-doc-heading', 'true')
|
||||
const level = Number(heading.tagName.replace('H', ''))
|
||||
return {
|
||||
id: headingId,
|
||||
title: rawTitle,
|
||||
level: Number.isNaN(level) ? 2 : level,
|
||||
type: 'html' as const,
|
||||
targetId: headingId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type PdfOutline = {
|
||||
title: string
|
||||
dest?: unknown
|
||||
items?: PdfOutline[]
|
||||
}
|
||||
|
||||
type PdfRef = { num: number; gen: number }
|
||||
|
||||
const isPdfRef = (value: unknown): value is PdfRef =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'num' in value &&
|
||||
'gen' in value &&
|
||||
typeof (value as { num: unknown }).num === 'number' &&
|
||||
typeof (value as { gen: unknown }).gen === 'number'
|
||||
|
||||
const buildPdfToc = async (pdf: PdfDocument): Promise<DocTocItem[]> => {
|
||||
const outline = ((await pdf.getOutline()) as PdfOutline[]) ?? []
|
||||
|
||||
const flattenOutline = async (items: PdfOutline[] | undefined, level = 1): Promise<DocTocItem[]> => {
|
||||
if (!items || !items.length) {
|
||||
return []
|
||||
}
|
||||
const result: DocTocItem[] = []
|
||||
for (const item of items) {
|
||||
if (!item) continue
|
||||
let pageNumber: number | undefined
|
||||
if (item.dest) {
|
||||
try {
|
||||
let ref: unknown
|
||||
if (typeof item.dest === 'string') {
|
||||
const destination = await pdf.getDestination(item.dest)
|
||||
ref = Array.isArray(destination) ? destination[0] : destination
|
||||
} else if (Array.isArray(item.dest)) {
|
||||
const [first] = item.dest
|
||||
ref = first
|
||||
} else {
|
||||
ref = item.dest
|
||||
}
|
||||
if (isPdfRef(ref)) {
|
||||
pageNumber = (await pdf.getPageIndex(ref)) + 1
|
||||
} else if (typeof ref === 'number') {
|
||||
pageNumber = ref + 1
|
||||
}
|
||||
} catch {
|
||||
// ignore resolution errors
|
||||
}
|
||||
}
|
||||
const id = `${slugify(item.title || 'page')}-${level}-${result.length}`
|
||||
result.push({
|
||||
id,
|
||||
title: item.title || 'Untitled section',
|
||||
level,
|
||||
type: 'pdf',
|
||||
pageNumber,
|
||||
})
|
||||
if (item.items?.length) {
|
||||
const children = await flattenOutline(item.items, Math.min(level + 1, 6))
|
||||
result.push(...children)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const toc = await flattenOutline(outline, 1)
|
||||
if (toc.length) {
|
||||
return toc
|
||||
}
|
||||
|
||||
const fallback: DocTocItem[] = []
|
||||
const numPages = pdf.numPages
|
||||
for (let page = 1; page <= numPages; page += 1) {
|
||||
fallback.push({
|
||||
id: `pdf-page-${page}`,
|
||||
title: `Page ${page}`,
|
||||
level: 2,
|
||||
type: 'pdf',
|
||||
pageNumber: page,
|
||||
})
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const DocReader = forwardRef<DocReaderHandle, DocReaderProps>(
|
||||
({ docTitle, view, onTocChange, onProgressChange, onActiveAnchorChange }, ref) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const htmlContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [htmlContent, setHtmlContent] = useState<string>('')
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [pdfModule, setPdfModule] = useState<PdfModule | null>(null)
|
||||
const [numPages, setNumPages] = useState<number>(0)
|
||||
const [containerWidth, setContainerWidth] = useState<number | null>(null)
|
||||
const pdfDocumentRef = useRef<PdfDocument | null>(null)
|
||||
const pageRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const tocItemsRef = useRef<DocTocItem[]>([])
|
||||
|
||||
const currentViewId = view?.id
|
||||
const resolvedViewUrl = view?.viewerUrl ?? view?.url ?? null
|
||||
|
||||
useEffect(() => {
|
||||
onTocChange([])
|
||||
onActiveAnchorChange(null)
|
||||
onProgressChange(0)
|
||||
setHtmlContent('')
|
||||
setErrorMessage(null)
|
||||
setNumPages(0)
|
||||
pageRefs.current.clear()
|
||||
pdfDocumentRef.current = null
|
||||
tocItemsRef.current = []
|
||||
if (view) {
|
||||
if (!resolvedViewUrl) {
|
||||
setErrorMessage('Document source is unavailable for this format.')
|
||||
setLoadingState('error')
|
||||
return
|
||||
}
|
||||
setLoadingState('loading')
|
||||
} else {
|
||||
setLoadingState('idle')
|
||||
}
|
||||
}, [view, resolvedViewUrl, onTocChange, onActiveAnchorChange, onProgressChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || view.id !== 'html') {
|
||||
return
|
||||
}
|
||||
const targetUrl = view.viewerUrl ?? view.url
|
||||
if (!targetUrl) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const controller = new AbortController()
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch(targetUrl, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`)
|
||||
}
|
||||
const buffer = await response.arrayBuffer()
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const charsetMatch = /charset=([^;]+)/i.exec(contentType)
|
||||
const encodings = [...HTML_ENCODINGS]
|
||||
if (charsetMatch) {
|
||||
const charset = charsetMatch[1].trim().toLowerCase()
|
||||
if (charset && !encodings.includes(charset)) {
|
||||
encodings.unshift(charset)
|
||||
}
|
||||
}
|
||||
const html = decodeBuffer(buffer, encodings)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const body = doc.body || doc.createElement('body')
|
||||
const tocItems = buildHtmlToc(body)
|
||||
if (cancelled) return
|
||||
const sanitized = DOMPurify.sanitize(body.innerHTML, {
|
||||
ADD_ATTR: ['data-doc-heading'],
|
||||
USE_PROFILES: { html: true },
|
||||
})
|
||||
setHtmlContent(sanitized)
|
||||
tocItemsRef.current = tocItems
|
||||
onTocChange(tocItems)
|
||||
setLoadingState('idle')
|
||||
if (tocItems.length) {
|
||||
onActiveAnchorChange(tocItems[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled || (error instanceof DOMException && error.name === 'AbortError')) {
|
||||
return
|
||||
}
|
||||
setErrorMessage('Unable to load HTML content for this document.')
|
||||
setLoadingState('error')
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [view, resolvedViewUrl, onTocChange, onActiveAnchorChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || view.id !== 'pdf') {
|
||||
setPdfModule(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const mod = await import('react-pdf')
|
||||
mod.pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${mod.pdfjs.version}/pdf.worker.min.js`
|
||||
if (!cancelled) {
|
||||
setPdfModule({ Document: mod.Document, Page: mod.Page })
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
setErrorMessage('Unable to initialise the PDF renderer.')
|
||||
setLoadingState('error')
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [view])
|
||||
|
||||
const handlePdfLoadSuccess = useCallback<OnLoadSuccess>(
|
||||
(pdf) => {
|
||||
pdfDocumentRef.current = pdf
|
||||
setNumPages(pdf.numPages)
|
||||
setLoadingState('idle')
|
||||
void (async () => {
|
||||
try {
|
||||
const tocItems = await buildPdfToc(pdf)
|
||||
tocItemsRef.current = tocItems
|
||||
onTocChange(tocItems)
|
||||
if (tocItems.length) {
|
||||
onActiveAnchorChange(tocItems[0].id)
|
||||
}
|
||||
} catch {
|
||||
const fallback: DocTocItem[] = []
|
||||
for (let page = 1; page <= pdf.numPages; page += 1) {
|
||||
fallback.push({
|
||||
id: `pdf-page-${page}`,
|
||||
title: `Page ${page}`,
|
||||
level: 2,
|
||||
type: 'pdf',
|
||||
pageNumber: page,
|
||||
})
|
||||
}
|
||||
tocItemsRef.current = fallback
|
||||
onTocChange(fallback)
|
||||
if (fallback.length) {
|
||||
onActiveAnchorChange(fallback[0].id)
|
||||
}
|
||||
}
|
||||
})()
|
||||
},
|
||||
[onTocChange, onActiveAnchorChange],
|
||||
)
|
||||
|
||||
const registerPageRef = useCallback((pageNumber: number, element: HTMLDivElement | null) => {
|
||||
if (!element) {
|
||||
pageRefs.current.delete(pageNumber)
|
||||
return
|
||||
}
|
||||
pageRefs.current.set(pageNumber, element)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!container) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const denominator = scrollHeight - clientHeight
|
||||
if (denominator <= 0) {
|
||||
onProgressChange(1)
|
||||
return
|
||||
}
|
||||
const nextValue = Math.min(Math.max(scrollTop / denominator, 0), 1)
|
||||
onProgressChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}
|
||||
|
||||
updateProgress()
|
||||
container.addEventListener('scroll', updateProgress)
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateProgress)
|
||||
}
|
||||
}, [onProgressChange, currentViewId])
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const observer = new ResizeObserver(() => {
|
||||
const { scrollHeight, clientHeight } = container
|
||||
if (scrollHeight <= clientHeight) {
|
||||
onProgressChange(1)
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [onProgressChange, currentViewId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || view.id !== 'html') return
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const headings = Array.from(container.querySelectorAll<HTMLElement>('[data-doc-heading="true"]'))
|
||||
if (!headings.length) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
|
||||
if (visible[0]) {
|
||||
onActiveAnchorChange(visible[0].target.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
root: container,
|
||||
threshold: [0.1, 0.25, 0.4],
|
||||
rootMargin: '0px 0px -40% 0px',
|
||||
},
|
||||
)
|
||||
headings.forEach((heading) => observer.observe(heading))
|
||||
return () => {
|
||||
headings.forEach((heading) => observer.unobserve(heading))
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [view, htmlContent, onActiveAnchorChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || view.id !== 'pdf') return
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const pages = Array.from(pageRefs.current.values())
|
||||
if (!pages.length) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const topEntry = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]
|
||||
if (topEntry) {
|
||||
const pageNumber = Number((topEntry.target as HTMLElement).dataset.pageNumber)
|
||||
if (Number.isFinite(pageNumber)) {
|
||||
const tocMatch = tocItemsRef.current.find((item) => item.pageNumber === pageNumber)
|
||||
onActiveAnchorChange(tocMatch?.id ?? `pdf-page-${pageNumber}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: container,
|
||||
threshold: [0.2, 0.4],
|
||||
rootMargin: '0px 0px -45% 0px',
|
||||
},
|
||||
)
|
||||
pages.forEach((page) => observer.observe(page))
|
||||
return () => {
|
||||
pages.forEach((page) => observer.unobserve(page))
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [view, numPages, onActiveAnchorChange])
|
||||
|
||||
useEffect(() => {
|
||||
const container = htmlContainerRef.current
|
||||
if (!container) return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
setContainerWidth(entry.contentRect.width)
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [view, htmlContent])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToAnchor: (item: DocTocItem) => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
if (item.type === 'html' && item.targetId) {
|
||||
const escapedId =
|
||||
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(item.targetId) : item.targetId.replace(/\s+/g, '\\ ')
|
||||
const target = container.querySelector<HTMLElement>(`#${escapedId}`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
if (item.type === 'pdf' && item.pageNumber) {
|
||||
const page = pageRefs.current.get(item.pageNumber)
|
||||
if (page) {
|
||||
page.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const renderHtmlContent = useMemo(() => {
|
||||
if (!htmlContent) return null
|
||||
return (
|
||||
<div
|
||||
className="doc-html-content space-y-6 text-sm leading-relaxed text-gray-700 [&_a]:text-purple-600 [&_a:hover]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-purple-200 [&_blockquote]:pl-4 [&_blockquote]:text-gray-600 [&_code]:rounded [&_code]:bg-gray-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_h1]:mt-10 [&_h1]:text-3xl [&_h1]:font-semibold [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:text-lg [&_h4]:font-semibold [&_h5]:mt-3 [&_h5]:text-base [&_h5]:font-semibold [&_h6]:mt-3 [&_h6]:text-sm [&_h6]:font-semibold [&_li]:pl-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:text-gray-700 [&_pre]:overflow-x-auto [&_pre]:rounded-2xl [&_pre]:bg-gray-900 [&_pre]:p-4 [&_pre]:text-xs [&_pre]:text-gray-100 [&_strong]:font-semibold [&_table]:w-full [&_table]:table-auto [&_td]:border [&_td]:border-gray-200 [&_td]:px-3 [&_td]:py-2 [&_th]:border [&_th]:border-gray-200 [&_th]:bg-gray-50 [&_th]:px-3 [&_th]:py-2 [&_ul]:list-disc [&_ul]:pl-6"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
)
|
||||
}, [htmlContent])
|
||||
|
||||
const renderPdfContent = () => {
|
||||
if (!pdfModule || !view || view.id !== 'pdf') {
|
||||
return null
|
||||
}
|
||||
const targetUrl = view.viewerUrl ?? view.url
|
||||
if (!targetUrl) {
|
||||
return (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
We could not locate the PDF source for this document.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { Document, Page } = pdfModule
|
||||
const width = containerWidth ? Math.min(containerWidth - 32, 960) : undefined
|
||||
return (
|
||||
<Document
|
||||
file={{ url: targetUrl }}
|
||||
onLoadSuccess={handlePdfLoadSuccess}
|
||||
onLoadError={() => {
|
||||
setErrorMessage('Unable to load the PDF document.')
|
||||
setLoadingState('error')
|
||||
}}
|
||||
loading={<div className="py-10 text-center text-sm text-gray-500">Loading PDF…</div>}
|
||||
error={
|
||||
<div className="py-10 text-center text-sm text-red-500">Unable to load the PDF document.</div>
|
||||
}
|
||||
>
|
||||
{Array.from({ length: numPages }).map((_, index) => (
|
||||
<div
|
||||
key={index + 1}
|
||||
ref={(element) => registerPageRef(index + 1, element)}
|
||||
data-page-number={index + 1}
|
||||
className="mb-8 flex justify-center"
|
||||
>
|
||||
<Page pageNumber={index + 1} width={width} renderAnnotationLayer renderTextLayer />
|
||||
</div>
|
||||
))}
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
let content: ReactElement | null = null
|
||||
if (!view) {
|
||||
content = (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
Select a format on the left to begin reading this document.
|
||||
</div>
|
||||
)
|
||||
} else if (loadingState === 'loading') {
|
||||
content = (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
Preparing {view.label} view for “{docTitle}”…
|
||||
</div>
|
||||
)
|
||||
} else if (loadingState === 'error') {
|
||||
content = (
|
||||
<div className="py-10 text-center text-sm text-red-500">{errorMessage}</div>
|
||||
)
|
||||
} else if (view.id === 'html') {
|
||||
content = renderHtmlContent ?? (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
We could not extract readable HTML content from this resource.
|
||||
</div>
|
||||
)
|
||||
} else if (view.id === 'pdf') {
|
||||
content = renderPdfContent()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-6 py-8" aria-live="polite">
|
||||
<div ref={htmlContainerRef}>{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
DocReader.displayName = 'DocReader'
|
||||
|
||||
export default DocReader
|
||||
|
||||
@ -1,263 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Monitor,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
} from 'lucide-react'
|
||||
|
||||
import DocReader, { type DocReaderHandle, type DocTocItem } from './DocReader'
|
||||
|
||||
export type ViewMode = 'pdf' | 'html'
|
||||
|
||||
export interface DocViewOption {
|
||||
id: ViewMode
|
||||
label: string
|
||||
description: string
|
||||
url: string
|
||||
viewerUrl?: string
|
||||
icon: ViewMode
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<ViewMode, typeof FileText> = {
|
||||
pdf: FileText,
|
||||
html: Monitor,
|
||||
}
|
||||
|
||||
interface DocViewSectionProps {
|
||||
docTitle: string
|
||||
options: DocViewOption[]
|
||||
}
|
||||
|
||||
export default function DocViewSection({ docTitle, options }: DocViewSectionProps) {
|
||||
const [activeId, setActiveId] = useState<ViewMode | undefined>(options[0]?.id)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [formatsCollapsed, setFormatsCollapsed] = useState(false)
|
||||
const [tocCollapsed, setTocCollapsed] = useState(false)
|
||||
const [tocItems, setTocItems] = useState<DocTocItem[]>([])
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [activeAnchor, setActiveAnchor] = useState<string | null>(null)
|
||||
const readerRef = useRef<DocReaderHandle | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.length) {
|
||||
setActiveId(undefined)
|
||||
return
|
||||
}
|
||||
if (!activeId || !options.some((option) => option.id === activeId)) {
|
||||
setActiveId(options[0].id)
|
||||
}
|
||||
}, [options, activeId])
|
||||
|
||||
const activeView = useMemo(() => {
|
||||
if (!options.length) return undefined
|
||||
return options.find((option) => option.id === activeId) ?? options[0]
|
||||
}, [options, activeId])
|
||||
|
||||
const ActiveIcon = activeView ? ICON_MAP[activeView.icon] : undefined
|
||||
|
||||
const handleTocClick = (item: DocTocItem) => {
|
||||
readerRef.current?.scrollToAnchor(item)
|
||||
}
|
||||
|
||||
const activeAnchorLabel = useMemo(() => {
|
||||
if (!activeAnchor) return ''
|
||||
const match = tocItems.find((item) => item.id === activeAnchor)
|
||||
if (match) {
|
||||
if (match.type === 'pdf' && match.pageNumber) {
|
||||
return `${match.title} • 第 ${match.pageNumber} 页`
|
||||
}
|
||||
return match.title
|
||||
}
|
||||
if (activeView?.id === 'pdf' && activeAnchor.startsWith('pdf-page-')) {
|
||||
const page = Number(activeAnchor.replace('pdf-page-', ''))
|
||||
if (Number.isFinite(page)) {
|
||||
return `第 ${page} 页`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}, [activeAnchor, tocItems, activeView])
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 lg:grid-cols-[minmax(0,280px)_1fr]">
|
||||
<div className="space-y-3">
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" /> 显示阅读侧栏
|
||||
</button>
|
||||
) : (
|
||||
<aside className="rounded-3xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">阅读模式</p>
|
||||
<p className="text-sm font-semibold text-gray-800">格式与目录</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-transparent px-2 py-1 text-xs text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" /> 隐藏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormatsCollapsed((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-gray-800 hover:text-purple-600"
|
||||
>
|
||||
<span>阅读格式</span>
|
||||
{formatsCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
{!formatsCollapsed && (
|
||||
<div className="space-y-2 border-t border-gray-100 p-3">
|
||||
{options.length === 0 && (
|
||||
<p className="text-xs text-gray-500">暂无可用格式。</p>
|
||||
)}
|
||||
{options.map((view) => {
|
||||
const isActive = activeView?.id === view.id
|
||||
const Icon = ICON_MAP[view.icon]
|
||||
return (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
onClick={() => setActiveId(view.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-2xl border px-3 py-2 text-left transition ${
|
||||
isActive
|
||||
? 'border-purple-500 bg-purple-50/70 text-purple-700 shadow'
|
||||
: 'border-transparent hover:border-purple-200 hover:bg-purple-50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
|
||||
isActive ? 'bg-purple-600 text-white' : 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="space-y-1">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
{view.label}
|
||||
{isActive && <span className="text-xs font-medium text-purple-600">当前</span>}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{view.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTocCollapsed((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-gray-800 hover:text-purple-600"
|
||||
>
|
||||
<span>内容目录</span>
|
||||
{tocCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
{!tocCollapsed && (
|
||||
<div className="max-h-[50vh] space-y-1 overflow-y-auto border-t border-gray-100 p-3 text-sm">
|
||||
{tocItems.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">当前格式暂未提供目录。</p>
|
||||
) : (
|
||||
tocItems.map((item) => {
|
||||
const isActive = activeAnchor === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleTocClick(item)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left transition ${
|
||||
isActive
|
||||
? 'bg-purple-100/80 text-purple-700'
|
||||
: 'hover:bg-purple-50 hover:text-purple-700'
|
||||
}`}
|
||||
style={{ paddingLeft: `${Math.min(item.level - 1, 4) * 12 + 12}px` }}
|
||||
>
|
||||
<span className="truncate text-sm">{item.title}</span>
|
||||
{item.type === 'pdf' && item.pageNumber && (
|
||||
<span className="ml-2 text-xs text-gray-400">P{item.pageNumber}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[70vh] flex-col gap-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
{ActiveIcon && (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 text-purple-600">
|
||||
<ActiveIcon className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">当前阅读格式</p>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{activeView ? `${activeView.label}` : '请选择阅读格式'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeView && (
|
||||
<a
|
||||
href={activeView.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> 全屏沉浸式模式
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocReader
|
||||
ref={readerRef}
|
||||
docTitle={docTitle}
|
||||
view={activeView}
|
||||
onTocChange={setTocItems}
|
||||
onProgressChange={setProgress}
|
||||
onActiveAnchorChange={setActiveAnchor}
|
||||
/>
|
||||
|
||||
<div className="border-t border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>阅读进度</span>
|
||||
<span>{Math.round(progress * 100)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-purple-500 via-purple-400 to-purple-600 transition-all duration-300"
|
||||
style={{ width: `${Math.min(Math.max(progress, 0), 1) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{activeAnchorLabel && (
|
||||
<p className="mt-2 text-xs text-gray-500">当前章节:{activeAnchorLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,24 +1,27 @@
|
||||
export const dynamic = 'error'
|
||||
export const revalidate = false
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import Breadcrumbs, { type Crumb } from '../../../../components/download/Breadcrumbs'
|
||||
import { getDocCollections, getDocResource, getDocCollectionsForBuildTime } from '../../resources.server'
|
||||
import DocArticle from '@/components/doc/DocArticle'
|
||||
import DocMetaPanel from '@/components/doc/DocMetaPanel'
|
||||
import DocVersionSwitcher from '@/components/doc/DocVersionSwitcher'
|
||||
import { getDocVersionParams, getDocVersion } from '../../resources.server'
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
import DocCollectionView from './DocCollectionView'
|
||||
|
||||
function buildBreadcrumbs(
|
||||
slug: string,
|
||||
docTitle: string,
|
||||
version?: { label: string; slug?: string; id: string },
|
||||
version?: { label: string; slug: string },
|
||||
): Crumb[] {
|
||||
const crumbs: Crumb[] = [
|
||||
{ label: 'Docs', href: '/docs' },
|
||||
{ label: docTitle, href: `/docs/${slug}` },
|
||||
]
|
||||
if (version) {
|
||||
const versionSlug = version.slug ?? version.id
|
||||
const versionSlug = version.slug
|
||||
crumbs.push({ label: version.label, href: `/docs/${slug}/${versionSlug}` })
|
||||
}
|
||||
return crumbs
|
||||
@ -29,15 +32,7 @@ export const generateStaticParams = async () => {
|
||||
return []
|
||||
}
|
||||
|
||||
// 构建时优先使用本地 fallback 数据,避免外部API调用
|
||||
const collections = await getDocCollectionsForBuildTime()
|
||||
const params: { collection: string; version: string }[] = []
|
||||
for (const doc of collections) {
|
||||
for (const version of doc.versions) {
|
||||
params.push({ collection: doc.slug, version: version.slug })
|
||||
}
|
||||
}
|
||||
return params
|
||||
return getDocVersionParams()
|
||||
}
|
||||
|
||||
export const dynamicParams = false
|
||||
@ -55,23 +50,44 @@ export default async function DocVersionPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
const doc = await getDocResource(params.collection)
|
||||
const doc = await getDocVersion(params.collection, params.version)
|
||||
if (!doc) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const activeVersion = doc.versions.find((item) => item.slug === params.version || item.id === params.version)
|
||||
if (!activeVersion) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs(doc.slug, doc.title, activeVersion)
|
||||
const { collection, version } = doc
|
||||
const breadcrumbs = buildBreadcrumbs(collection.slug, collection.title, version)
|
||||
|
||||
return (
|
||||
<main className="px-4 py-8 md:px-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-6">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
<DocCollectionView collection={doc} initialVersionId={activeVersion.id} />
|
||||
<section className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{collection.title}</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 md:text-4xl">{version.title}</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">{collection.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 text-sm text-gray-500 md:items-end">
|
||||
<DocVersionSwitcher
|
||||
collectionSlug={collection.slug}
|
||||
versions={collection.versions.map((item) => ({ slug: item.slug, label: item.label }))}
|
||||
activeSlug={version.slug}
|
||||
/>
|
||||
{version.updatedAt && <span suppressHydrationWarning>Updated {version.updatedAt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,240px)_1fr]">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<DocMetaPanel description={version.description} updatedAt={version.updatedAt} tags={version.tags} />
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<DocArticle content={version.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -31,7 +31,7 @@ export default async function CollectionPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
const defaultVersion = doc.versions.find((version) => version.id === doc.defaultVersionId) ?? doc.versions[0]
|
||||
const defaultVersion = doc.versions.find((version) => version.slug === doc.defaultVersionSlug) ?? doc.versions[0]
|
||||
if (!defaultVersion) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamic = 'error'
|
||||
export const revalidate = false
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
@ -9,13 +10,8 @@ import DocCollectionCard from './DocCollectionCard'
|
||||
|
||||
function formatMeta(resource: DocCollection) {
|
||||
const parts: string[] = []
|
||||
if (resource.category) {
|
||||
parts.push(resource.category)
|
||||
}
|
||||
if (resource.latestVersionLabel) {
|
||||
parts.push(resource.latestVersionLabel)
|
||||
} else if (resource.latestVariant) {
|
||||
parts.push(resource.latestVariant)
|
||||
if (resource.updatedAt) {
|
||||
parts.push('Updated')
|
||||
}
|
||||
if (resource.versions.length > 1) {
|
||||
parts.push(`${resource.versions.length} versions`)
|
||||
|
||||
@ -1,401 +1,23 @@
|
||||
import 'server-only'
|
||||
|
||||
import { cache } from 'react'
|
||||
|
||||
import { getDocCollection, getDocCollections as loadDocCollections, getDocParams } from '@lib/docContent'
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
import fallbackDocsIndex from '../../../public/_build/docs_index.json'
|
||||
|
||||
import { buildAbsoluteDocUrl } from './utils'
|
||||
import type { DocCollection, DocResource, DocVersionOption } from './types'
|
||||
|
||||
const DOCS_MANIFEST_URL = 'https://dl.svc.plus/dl-index/docs-manifest.json'
|
||||
const REMOTE_DOCS_ENABLED = process.env.ALLOW_REMOTE_DOCS_FETCH === 'true'
|
||||
const FALLBACK_DOCS_INDEX = Array.isArray(fallbackDocsIndex) ? (fallbackDocsIndex as RawDocResource[]) : []
|
||||
|
||||
interface RawDocResource {
|
||||
slug?: unknown
|
||||
title?: unknown
|
||||
description?: unknown
|
||||
category?: unknown
|
||||
version?: unknown
|
||||
updatedAt?: unknown
|
||||
pdfUrl?: unknown
|
||||
htmlUrl?: unknown
|
||||
tags?: unknown
|
||||
estimatedMinutes?: unknown
|
||||
coverImage?: unknown
|
||||
language?: unknown
|
||||
variant?: unknown
|
||||
versionSlug?: unknown
|
||||
pathSegments?: unknown
|
||||
collection?: unknown
|
||||
collectionSlug?: unknown
|
||||
collectionLabel?: unknown
|
||||
}
|
||||
|
||||
async function fetchDocs(options?: { useCache?: boolean }): Promise<RawDocResource[]> {
|
||||
try {
|
||||
const response = await fetch(DOCS_MANIFEST_URL, {
|
||||
// 运行时使用缓存策略,减少API调用
|
||||
next: options?.useCache ? { revalidate: 3600 } : undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch docs manifest: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return Array.isArray(data) ? (data as RawDocResource[]) : []
|
||||
} catch (error) {
|
||||
if (REMOTE_DOCS_ENABLED) {
|
||||
console.warn('Error fetching docs manifest:', error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocs(options?: { useCache?: boolean }): Promise<RawDocResource[]> {
|
||||
if (!REMOTE_DOCS_ENABLED) {
|
||||
return FALLBACK_DOCS_INDEX
|
||||
}
|
||||
|
||||
const manifestDocs = await fetchDocs(options)
|
||||
|
||||
if (manifestDocs.length > 0) {
|
||||
return manifestDocs
|
||||
}
|
||||
|
||||
return FALLBACK_DOCS_INDEX
|
||||
}
|
||||
|
||||
// 构建时数据获取:优先使用本地 fallback,保证构建成功
|
||||
async function loadDocsForBuildTime(): Promise<RawDocResource[]> {
|
||||
// 构建时优先使用本地数据,避免外部API调用导致构建失败
|
||||
const fallbackDocs = FALLBACK_DOCS_INDEX
|
||||
|
||||
if (fallbackDocs.length > 0) {
|
||||
return fallbackDocs
|
||||
}
|
||||
|
||||
// fallback为空时,再尝试获取远程数据
|
||||
if (REMOTE_DOCS_ENABLED) {
|
||||
console.warn('Fallback docs not found, attempting to fetch remote docs manifest...')
|
||||
const manifestDocs = await fetchDocs({ useCache: true })
|
||||
return manifestDocs
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async function getRawDocs(): Promise<RawDocResource[]> {
|
||||
return loadDocs()
|
||||
}
|
||||
|
||||
async function buildDocsDataset(): Promise<DocResource[]> {
|
||||
const rawDocs = await getRawDocs()
|
||||
return rawDocs.map((item) => normalizeResource(item as RawDocResource)).filter(
|
||||
(item): item is DocResource => item !== null,
|
||||
)
|
||||
}
|
||||
|
||||
// 构建时数据集生成:优先使用本地 fallback 数据
|
||||
async function buildDocsDatasetForBuildTime(): Promise<DocResource[]> {
|
||||
const rawDocs = await loadDocsForBuildTime()
|
||||
return rawDocs.map((item) => normalizeResource(item as RawDocResource)).filter(
|
||||
(item): item is DocResource => item !== null,
|
||||
)
|
||||
}
|
||||
|
||||
export async function getDocsDataset(): Promise<DocResource[]> {
|
||||
return buildDocsDataset()
|
||||
}
|
||||
|
||||
export function clearDocsCache(): void {
|
||||
// Intentionally left blank. Runtime fetches always return fresh data.
|
||||
}
|
||||
|
||||
|
||||
function slugifySegment(value: string): string {
|
||||
const base = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return base || value.toLowerCase().replace(/\s+/g, '-') || 'doc'
|
||||
}
|
||||
|
||||
function humanizeSegment(value: string): string {
|
||||
if (!value) return ''
|
||||
const withSpaces = value
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
const normalized = withSpaces.replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return value
|
||||
return normalized
|
||||
.split(' ')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function parseUpdatedAt(value?: string): number {
|
||||
if (!value) return 0
|
||||
const ts = Date.parse(value)
|
||||
return Number.isNaN(ts) ? 0 : ts
|
||||
}
|
||||
|
||||
interface CollectionAccumulator {
|
||||
directory: string
|
||||
slug: string
|
||||
label: string
|
||||
docs: DocResource[]
|
||||
}
|
||||
|
||||
function buildCollections(docs: DocResource[]): DocCollection[] {
|
||||
const map = new Map<string, CollectionAccumulator>()
|
||||
const usedSlugs = new Set<string>()
|
||||
|
||||
const ensureUniqueSlug = (slug: string) => {
|
||||
let candidate = slug || 'doc'
|
||||
if (!usedSlugs.has(candidate)) {
|
||||
usedSlugs.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
let counter = 2
|
||||
while (usedSlugs.has(`${candidate}-${counter}`)) {
|
||||
counter += 1
|
||||
}
|
||||
const unique = `${candidate}-${counter}`
|
||||
usedSlugs.add(unique)
|
||||
return unique
|
||||
}
|
||||
|
||||
for (const doc of docs) {
|
||||
const directory = doc.collection ?? doc.pathSegments?.[0] ?? doc.category ?? doc.slug
|
||||
if (!directory) {
|
||||
continue
|
||||
}
|
||||
if (directory === 'all.json' || directory === 'dir.json') {
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = doc.collectionSlug ?? slugifySegment(directory)
|
||||
const label = doc.collectionLabel ?? doc.category ?? humanizeSegment(directory)
|
||||
const key = directory
|
||||
const existing = map.get(key)
|
||||
if (existing) {
|
||||
existing.docs.push(doc)
|
||||
continue
|
||||
}
|
||||
|
||||
map.set(key, {
|
||||
directory,
|
||||
slug: ensureUniqueSlug(slug),
|
||||
label,
|
||||
docs: [doc],
|
||||
})
|
||||
}
|
||||
|
||||
const collections: DocCollection[] = []
|
||||
for (const accumulator of map.values()) {
|
||||
const docsSorted = [...accumulator.docs].sort((a, b) => parseUpdatedAt(b.updatedAt) - parseUpdatedAt(a.updatedAt))
|
||||
const primary = docsSorted[0]
|
||||
if (!primary) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tagSet = new Set<string>()
|
||||
for (const doc of docsSorted) {
|
||||
if (!doc.tags) continue
|
||||
for (const tag of doc.tags) {
|
||||
if (tag) tagSet.add(tag)
|
||||
}
|
||||
}
|
||||
|
||||
const versionSlugSet = new Set<string>()
|
||||
const ensureUniqueVersionSlug = (slug: string) => {
|
||||
let candidate = slug || 'version'
|
||||
if (!versionSlugSet.has(candidate)) {
|
||||
versionSlugSet.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
let counter = 2
|
||||
while (versionSlugSet.has(`${candidate}-${counter}`)) {
|
||||
counter += 1
|
||||
}
|
||||
const unique = `${candidate}-${counter}`
|
||||
versionSlugSet.add(unique)
|
||||
return unique
|
||||
}
|
||||
|
||||
const versions: DocVersionOption[] = docsSorted.map((doc) => {
|
||||
const labelParts: string[] = []
|
||||
if (doc.version) {
|
||||
labelParts.push(doc.version)
|
||||
}
|
||||
if (!doc.version && doc.variant) {
|
||||
labelParts.push(doc.variant)
|
||||
}
|
||||
if (doc.language && !labelParts.includes(doc.language)) {
|
||||
labelParts.push(doc.language)
|
||||
}
|
||||
const label = labelParts.length > 0 ? labelParts.join(' • ') : doc.title
|
||||
let versionSlug = doc.versionSlug
|
||||
if (!versionSlug || !versionSlug.trim()) {
|
||||
const candidate = doc.variant ?? doc.version ?? doc.slug
|
||||
versionSlug = slugifySegment(candidate)
|
||||
}
|
||||
versionSlug = ensureUniqueVersionSlug(versionSlug)
|
||||
return {
|
||||
id: doc.slug,
|
||||
label,
|
||||
resource: doc,
|
||||
slug: versionSlug,
|
||||
pathSegment: doc.pathSegments?.[1],
|
||||
}
|
||||
})
|
||||
|
||||
const collection: DocCollection = {
|
||||
slug: accumulator.slug,
|
||||
title: primary.title,
|
||||
description: primary.description,
|
||||
category: primary.category ?? accumulator.label,
|
||||
updatedAt: primary.updatedAt,
|
||||
estimatedMinutes: primary.estimatedMinutes,
|
||||
tags: Array.from(tagSet).sort((a, b) => a.localeCompare(b)),
|
||||
latestVersionLabel: versions[0]?.label,
|
||||
latestVariant: primary.variant,
|
||||
versions,
|
||||
defaultVersionId: versions[0]?.id,
|
||||
defaultVersionSlug: versions[0]?.slug,
|
||||
directory: accumulator.directory,
|
||||
}
|
||||
|
||||
collections.push(collection)
|
||||
}
|
||||
|
||||
return collections.sort((a, b) => parseUpdatedAt(b.updatedAt) - parseUpdatedAt(a.updatedAt))
|
||||
}
|
||||
|
||||
async function buildDocsCollections(dataset?: DocResource[]): Promise<DocCollection[]> {
|
||||
const docs = dataset ?? await getDocsDataset()
|
||||
return buildCollections(docs)
|
||||
}
|
||||
|
||||
// 构建时集合生成:优先使用本地数据
|
||||
async function buildDocsCollectionsForBuildTime(): Promise<DocCollection[]> {
|
||||
const docs = await buildDocsDatasetForBuildTime()
|
||||
return buildCollections(docs)
|
||||
}
|
||||
|
||||
export async function getDocCollections(): Promise<DocCollection[]> {
|
||||
return buildDocsCollections()
|
||||
}
|
||||
|
||||
// 构建时获取集合:优先使用本地数据,保证构建成功
|
||||
export async function getDocCollectionsForBuildTime(): Promise<DocCollection[]> {
|
||||
return buildDocsCollectionsForBuildTime()
|
||||
}
|
||||
|
||||
export function clearCollectionsCache(): void {
|
||||
clearDocsCache()
|
||||
}
|
||||
|
||||
function normalizeResource(item: RawDocResource): DocResource | null {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const slug = typeof item.slug === 'string' ? item.slug : undefined
|
||||
const title = typeof item.title === 'string' ? item.title : undefined
|
||||
if (!slug || !title) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resource: DocResource = {
|
||||
slug,
|
||||
title,
|
||||
description: typeof item.description === 'string' ? item.description : '',
|
||||
}
|
||||
|
||||
if (typeof item.category === 'string' && item.category.trim()) {
|
||||
resource.category = item.category
|
||||
}
|
||||
if (typeof item.version === 'string' && item.version.trim()) {
|
||||
resource.version = item.version
|
||||
}
|
||||
if (typeof item.updatedAt === 'string' && item.updatedAt.trim()) {
|
||||
resource.updatedAt = item.updatedAt
|
||||
}
|
||||
if (typeof item.pdfUrl === 'string' && item.pdfUrl.trim()) {
|
||||
resource.pdfUrl = buildAbsoluteDocUrl(item.pdfUrl) ?? item.pdfUrl
|
||||
}
|
||||
if (typeof item.htmlUrl === 'string' && item.htmlUrl.trim()) {
|
||||
resource.htmlUrl = buildAbsoluteDocUrl(item.htmlUrl) ?? item.htmlUrl
|
||||
}
|
||||
if (typeof item.language === 'string' && item.language.trim()) {
|
||||
resource.language = item.language
|
||||
}
|
||||
if (typeof item.variant === 'string' && item.variant.trim()) {
|
||||
resource.variant = item.variant
|
||||
}
|
||||
if (typeof item.versionSlug === 'string' && item.versionSlug.trim()) {
|
||||
resource.versionSlug = item.versionSlug
|
||||
}
|
||||
if (typeof item.collection === 'string' && item.collection.trim()) {
|
||||
resource.collection = item.collection
|
||||
}
|
||||
if (typeof item.collectionSlug === 'string' && item.collectionSlug.trim()) {
|
||||
resource.collectionSlug = item.collectionSlug
|
||||
}
|
||||
if (typeof item.collectionLabel === 'string' && item.collectionLabel.trim()) {
|
||||
resource.collectionLabel = item.collectionLabel
|
||||
}
|
||||
if (typeof item.estimatedMinutes === 'number' && !Number.isNaN(item.estimatedMinutes)) {
|
||||
resource.estimatedMinutes = item.estimatedMinutes
|
||||
}
|
||||
if (typeof item.coverImage === 'string' && item.coverImage.trim()) {
|
||||
resource.coverImage = item.coverImage
|
||||
}
|
||||
if (Array.isArray(item.tags)) {
|
||||
const tags = item.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0)
|
||||
if (tags.length > 0) {
|
||||
resource.tags = [...new Set(tags)]
|
||||
}
|
||||
}
|
||||
if (Array.isArray(item.pathSegments)) {
|
||||
const segments = item.pathSegments.filter((segment): segment is string => typeof segment === 'string' && segment.trim().length > 0)
|
||||
if (segments.length > 0) {
|
||||
resource.pathSegments = segments
|
||||
}
|
||||
}
|
||||
|
||||
if (!resource.description.trim()) {
|
||||
const context: string[] = []
|
||||
if (resource.category) {
|
||||
context.push(resource.category)
|
||||
}
|
||||
if (resource.version) {
|
||||
context.push(`edition ${resource.version}`)
|
||||
} else if (resource.variant) {
|
||||
context.push(`release ${resource.variant}`)
|
||||
}
|
||||
const formats: string[] = []
|
||||
if (resource.pdfUrl) formats.push('PDF')
|
||||
if (resource.htmlUrl) formats.push('HTML')
|
||||
if (formats.length > 0) {
|
||||
context.push(`available as ${formats.join(' and ')}`)
|
||||
}
|
||||
const suffix = context.length > 0 ? ` (${context.join(', ')})` : ''
|
||||
resource.description = `${resource.title}${suffix}.`
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
import type { DocCollection } from './types'
|
||||
|
||||
const isDocsModuleEnabled = () => isFeatureEnabled('appModules', '/docs')
|
||||
|
||||
export async function getDocResources(): Promise<DocCollection[]> {
|
||||
export const getDocCollections = cache(async (): Promise<DocCollection[]> => {
|
||||
if (!isDocsModuleEnabled()) {
|
||||
return []
|
||||
}
|
||||
return loadDocCollections()
|
||||
})
|
||||
|
||||
export const getDocCollectionsForBuildTime = getDocCollections
|
||||
|
||||
export async function getDocResources(): Promise<DocCollection[]> {
|
||||
return getDocCollections()
|
||||
}
|
||||
|
||||
@ -407,3 +29,21 @@ export async function getDocResource(slug: string): Promise<DocCollection | unde
|
||||
const collections = await getDocCollections()
|
||||
return collections.find((doc) => doc.slug === slug)
|
||||
}
|
||||
|
||||
export async function getDocVersionParams() {
|
||||
if (!isDocsModuleEnabled()) {
|
||||
return []
|
||||
}
|
||||
return getDocParams()
|
||||
}
|
||||
|
||||
export async function getDocVersion(slug: string, version: string) {
|
||||
if (!isDocsModuleEnabled()) {
|
||||
return undefined
|
||||
}
|
||||
const collection = await getDocCollection(slug)
|
||||
if (!collection) return undefined
|
||||
const versionMatch = collection.versions.find((item) => item.slug === version)
|
||||
if (!versionMatch) return undefined
|
||||
return { collection, version: versionMatch }
|
||||
}
|
||||
|
||||
@ -1,44 +1,20 @@
|
||||
export interface DocResource {
|
||||
export interface DocVersionOption {
|
||||
slug: string
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
category?: string
|
||||
version?: string
|
||||
updatedAt?: string
|
||||
pdfUrl?: string
|
||||
htmlUrl?: string
|
||||
tags?: string[]
|
||||
estimatedMinutes?: number
|
||||
coverImage?: string
|
||||
language?: string
|
||||
variant?: string
|
||||
versionSlug?: string
|
||||
pathSegments?: string[]
|
||||
collection?: string
|
||||
collectionSlug?: string
|
||||
collectionLabel?: string
|
||||
}
|
||||
|
||||
export interface DocVersionOption {
|
||||
id: string
|
||||
label: string
|
||||
resource: DocResource
|
||||
slug: string
|
||||
pathSegment?: string
|
||||
content: string
|
||||
isMdx: boolean
|
||||
}
|
||||
|
||||
export interface DocCollection {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
category?: string
|
||||
updatedAt?: string
|
||||
estimatedMinutes?: number
|
||||
tags: string[]
|
||||
latestVersionLabel?: string
|
||||
latestVariant?: string
|
||||
versions: DocVersionOption[]
|
||||
defaultVersionId?: string
|
||||
defaultVersionSlug?: string
|
||||
directory?: string
|
||||
defaultVersionSlug: string
|
||||
}
|
||||
|
||||
53
src/app/workshop/[slug]/page.tsx
Normal file
53
src/app/workshop/[slug]/page.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
export const dynamic = 'error'
|
||||
export const revalidate = false
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import WorkshopArticle from '@/components/workshop/WorkshopArticle'
|
||||
import { allWorkshops } from 'contentlayer/generated'
|
||||
|
||||
export const generateStaticParams = async () => allWorkshops.map((workshop) => ({ slug: workshop.slug }))
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}): Promise<Metadata> {
|
||||
const workshop = allWorkshops.find((entry) => entry.slug === params.slug)
|
||||
if (!workshop) {
|
||||
return { title: 'Workshop | Cloud-Neutral' }
|
||||
}
|
||||
return {
|
||||
title: `${workshop.title} | Workshop`,
|
||||
description: workshop.summary,
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorkshopDetailPage({ params }: { params: { slug: string } }) {
|
||||
const workshop = allWorkshops.find((entry) => entry.slug === params.slug)
|
||||
if (!workshop) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="bg-brand-surface px-6 py-12 sm:px-8">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-8">
|
||||
<header className="space-y-3 rounded-3xl border border-brand-border bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-brand">Workshop</p>
|
||||
<h1 className="text-3xl font-bold text-brand-heading md:text-[36px]">{workshop.title}</h1>
|
||||
<p className="text-sm text-brand-heading/80">{workshop.summary}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-brand-heading/70">
|
||||
<span className="rounded-full bg-brand-surface px-3 py-1 font-semibold text-brand-heading">{workshop.level}</span>
|
||||
{workshop.duration && <span>{workshop.duration}</span>}
|
||||
{workshop.updatedAt && <span suppressHydrationWarning>Updated {workshop.updatedAt}</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rounded-3xl border border-brand-border bg-white p-6 shadow-sm">
|
||||
<WorkshopArticle code={workshop.body.code} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
47
src/app/workshop/page.tsx
Normal file
47
src/app/workshop/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import WorkshopCard from '@/components/workshop/WorkshopCard'
|
||||
import { allWorkshops } from 'contentlayer/generated'
|
||||
|
||||
export const dynamic = 'error'
|
||||
export const revalidate = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Workshops | Cloud-Neutral',
|
||||
description: 'Hands-on, short-lived experiments built with MDX and Contentlayer.',
|
||||
}
|
||||
|
||||
export default function WorkshopIndexPage() {
|
||||
const workshops = [...allWorkshops].sort((a, b) => {
|
||||
const aDate = a.updatedAt ? Date.parse(a.updatedAt) : 0
|
||||
const bDate = b.updatedAt ? Date.parse(b.updatedAt) : 0
|
||||
if (aDate && bDate && aDate !== bDate) return bDate - aDate
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-brand-surface px-6 py-12 sm:px-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-10">
|
||||
<header className="space-y-4 text-brand-heading">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-brand">Workshop</p>
|
||||
<h1 className="text-[32px] font-bold text-brand md:text-[36px]">Interactive Workflows</h1>
|
||||
<p className="max-w-3xl text-sm text-brand-heading/80 md:text-base">
|
||||
Short-lived, high-interaction guides compiled with Contentlayer. Experiment with toggles and demos without changing the core UI.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{workshops.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-brand-border bg-white p-10 text-center text-sm text-brand-heading/70">
|
||||
Workshops will appear here once content is published.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{workshops.map((workshop) => (
|
||||
<WorkshopCard key={workshop.slug} workshop={workshop} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
240
src/components/blog/BlogList.tsx
Normal file
240
src/components/blog/BlogList.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
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: '随笔&观察' },
|
||||
]
|
||||
|
||||
function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string {
|
||||
if (!dateStr) return ''
|
||||
|
||||
const date = new Date(dateStr)
|
||||
|
||||
if (language === 'zh') {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
interface BlogListProps {
|
||||
posts: BlogPost[]
|
||||
}
|
||||
|
||||
function buildCategoryCounts(posts: BlogPost[]) {
|
||||
return posts.reduce<Record<string, number>>((acc, post) => {
|
||||
const categoryKey = post.category?.key
|
||||
if (!categoryKey) return acc
|
||||
acc[categoryKey] = (acc[categoryKey] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export default function BlogList({ posts }: BlogListProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const selectedCategory = searchParams.get('category')
|
||||
const page = searchParams.get('page')
|
||||
|
||||
const categoryCounts = useMemo(() => buildCategoryCounts(posts), [posts])
|
||||
const filteredPosts = useMemo(() => {
|
||||
if (!selectedCategory) return posts
|
||||
return posts.filter((post) => post.category?.key === selectedCategory)
|
||||
}, [posts, selectedCategory])
|
||||
|
||||
const postsPerPage = 10
|
||||
const currentPage = useMemo(() => {
|
||||
const parsed = Number(page || '1')
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return 1
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / postsPerPage))
|
||||
return Math.min(parsed, totalPages)
|
||||
}, [page, filteredPosts.length])
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / postsPerPage))
|
||||
const startIndex = (currentPage - 1) * postsPerPage
|
||||
const endIndex = startIndex + postsPerPage
|
||||
const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
|
||||
|
||||
return (
|
||||
<div className="bg-white text-slate-900">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-slate-500">SVC.plus</span>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-brand-dark">blog</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<SearchComponent className="relative w-full max-w-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-16">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">Blog</h1>
|
||||
<p className="text-lg text-slate-600">
|
||||
Latest updates, releases, and insights from the Cloud-Neutral community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-8">
|
||||
{paginatedPosts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-brand">Blog</span>
|
||||
{post.date && <time className="text-sm text-slate-500">{formatDate(post.date, 'en')}</time>}
|
||||
</div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-slate-900">{post.title}</h2>
|
||||
{post.author && <p className="mb-4 text-sm text-slate-500">By {post.author}</p>}
|
||||
<p className="mb-6 text-slate-600">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="ml-auto text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="mt-12 flex items-center justify-center gap-2">
|
||||
<Link
|
||||
href={`/blog?page=${Math.max(1, 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'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNumber) => (
|
||||
<Link
|
||||
key={pageNumber}
|
||||
href={`/blog?page=${pageNumber}${selectedCategory ? `&category=${selectedCategory}` : ''}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
pageNumber === currentPage
|
||||
? 'bg-brand text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{pageNumber}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href={`/blog?page=${Math.min(totalPages, 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'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/components/doc/DocArticle.tsx
Normal file
24
src/components/doc/DocArticle.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { compileMDX } from 'next-mdx-remote/rsc'
|
||||
|
||||
import DocCallout from './DocCallout'
|
||||
import DocSteps from './DocSteps'
|
||||
|
||||
interface DocArticleProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
export default async function DocArticle({ content }: DocArticleProps) {
|
||||
const mdx = await compileMDX({
|
||||
source: content,
|
||||
components: {
|
||||
Callout: DocCallout,
|
||||
Steps: DocSteps,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<article className="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline">
|
||||
{mdx.content}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
22
src/components/doc/DocCallout.tsx
Normal file
22
src/components/doc/DocCallout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
interface DocCalloutProps {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
tone?: 'info' | 'warning' | 'success'
|
||||
}
|
||||
|
||||
const toneStyles: Record<NonNullable<DocCalloutProps['tone']>, string> = {
|
||||
info: 'border-blue-200 bg-blue-50 text-blue-800',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-800',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-800',
|
||||
}
|
||||
|
||||
export default function DocCallout({ title, children, tone = 'info' }: DocCalloutProps) {
|
||||
return (
|
||||
<div className={`rounded-2xl border px-4 py-3 text-sm shadow-sm ${toneStyles[tone]}`}>
|
||||
{title && <p className="mb-1 text-xs font-semibold uppercase tracking-wide">{title}</p>}
|
||||
<div className="space-y-2 leading-relaxed">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/components/doc/DocMetaPanel.tsx
Normal file
29
src/components/doc/DocMetaPanel.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import ClientTime from '@/app/components/ClientTime'
|
||||
|
||||
interface DocMetaPanelProps {
|
||||
description?: string
|
||||
updatedAt?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export default function DocMetaPanel({ description, updatedAt, tags }: DocMetaPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm text-brand-heading">
|
||||
{description && <p className="text-brand-heading/80">{description}</p>}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-brand-border bg-brand-surface px-3 py-1 text-xs font-medium">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{updatedAt && (
|
||||
<p className="text-xs text-brand-heading/70" suppressHydrationWarning>
|
||||
Updated <ClientTime isoString={updatedAt} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/components/doc/DocSteps.tsx
Normal file
15
src/components/doc/DocSteps.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
|
||||
interface DocStepsProps {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function DocSteps({ title, children }: DocStepsProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
{title && <p className="mb-2 text-sm font-semibold text-slate-800">{title}</p>}
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-slate-700">{children}</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/doc/DocVersionSwitcher.tsx
Normal file
33
src/components/doc/DocVersionSwitcher.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface DocVersionSwitcherProps {
|
||||
collectionSlug: string
|
||||
versions: { slug: string; label: string }[]
|
||||
activeSlug: string
|
||||
}
|
||||
|
||||
export default function DocVersionSwitcher({ collectionSlug, versions, activeSlug }: DocVersionSwitcherProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-[0.2em] text-brand-heading/70">
|
||||
<span>Version</span>
|
||||
<select
|
||||
value={activeSlug}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
router.replace(`/docs/${collectionSlug}/${next}`)
|
||||
}}
|
||||
className="rounded-full border border-brand-border px-3 py-1 text-sm font-medium text-brand-heading focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand/30"
|
||||
>
|
||||
{versions.map((version) => (
|
||||
<option key={version.slug} value={version.slug}>
|
||||
{version.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
18
src/components/workshop/WorkshopArticle.tsx
Normal file
18
src/components/workshop/WorkshopArticle.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks'
|
||||
|
||||
import WorkshopDemo from './WorkshopDemo'
|
||||
|
||||
interface WorkshopArticleProps {
|
||||
code: string
|
||||
}
|
||||
|
||||
export default function WorkshopArticle({ code }: WorkshopArticleProps) {
|
||||
const MDXContent = useMDXComponent(code)
|
||||
return (
|
||||
<article className="prose prose-slate max-w-none prose-a:text-brand">
|
||||
<MDXContent components={{ WorkshopDemo }} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
40
src/components/workshop/WorkshopCard.tsx
Normal file
40
src/components/workshop/WorkshopCard.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { Workshop } from 'contentlayer/generated'
|
||||
|
||||
interface WorkshopCardProps {
|
||||
workshop: Workshop
|
||||
}
|
||||
|
||||
export default function WorkshopCard({ workshop }: WorkshopCardProps) {
|
||||
return (
|
||||
<article className="flex h-full flex-col justify-between rounded-2xl border border-brand-border bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-md">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-brand">Workshop</p>
|
||||
<h2 className="text-lg font-semibold text-brand-heading">{workshop.title}</h2>
|
||||
<p className="text-sm text-brand-heading/80">{workshop.summary}</p>
|
||||
{workshop.tags?.length ? (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{workshop.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-brand-border bg-brand-surface px-3 py-1 text-xs font-medium">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between text-sm text-brand-heading/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-brand-surface px-3 py-1 text-xs font-semibold text-brand-heading">{workshop.level}</span>
|
||||
{workshop.duration && <span>{workshop.duration}</span>}
|
||||
</div>
|
||||
<Link
|
||||
href={workshop.url}
|
||||
className="rounded-full border border-brand-border bg-brand-surface px-3 py-1 text-sm font-semibold text-brand transition hover:border-brand hover:bg-white"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
51
src/components/workshop/WorkshopDemo.tsx
Normal file
51
src/components/workshop/WorkshopDemo.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function WorkshopDemo() {
|
||||
const [environment, setEnvironment] = useState<'staging' | 'production'>('staging')
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-brand-border bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-brand">Live Toggle</p>
|
||||
<p className="text-sm text-brand-heading/80">Switch environments to preview workshop actions.</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold text-brand-heading">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-brand-border text-brand focus:ring-brand"
|
||||
/>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
{(['staging', 'production'] as const).map((item) => {
|
||||
const isActive = environment === item
|
||||
return (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setEnvironment(item)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${
|
||||
isActive ? 'border-brand bg-brand text-white' : 'border-brand-border bg-brand-surface text-brand-heading'
|
||||
}`}
|
||||
>
|
||||
{item === 'staging' ? 'Staging' : 'Production'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl bg-brand-surface p-3 text-sm text-brand-heading/80">
|
||||
<p className="font-semibold text-brand-heading">
|
||||
{enabled ? 'Automation ready' : 'Preview mode'} · {environment}
|
||||
</p>
|
||||
<p className="text-xs text-brand-heading/70">Stateful interactions stay inside workshop scope.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/content/doc/observability/overview.md
Normal file
29
src/content/doc/observability/overview.md
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Observability Baseline
|
||||
description: Establish a consistent telemetry surface before onboarding workloads.
|
||||
updatedAt: 2024-11-05
|
||||
tags:
|
||||
- tracing
|
||||
- metrics
|
||||
- dashboards
|
||||
collection: observability
|
||||
collectionLabel: Observability
|
||||
version: "2024 Q4"
|
||||
versionSlug: overview
|
||||
---
|
||||
|
||||
## Why this matters
|
||||
|
||||
Reliable dashboards and alerts depend on predictable signals. This baseline locks in a minimal telemetry contract so new services inherit the same trace attributes, metric names, and log keys.
|
||||
|
||||
### Core checklist
|
||||
|
||||
- Emit request, dependency, and queue spans with shared trace IDs.
|
||||
- Forward deployment, region, and tenant labels with every metric.
|
||||
- Normalize structured logs with `severity`, `service`, and `component` fields.
|
||||
|
||||
### Rollout tips
|
||||
|
||||
1. Start with staging namespaces and mirror traffic where possible.
|
||||
2. Validate alerts on canary services before expanding coverage.
|
||||
3. Keep a changelog in the runbook so teams can replay the rollout.
|
||||
25
src/content/doc/observability/zero-downtime.mdx
Normal file
25
src/content/doc/observability/zero-downtime.mdx
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Zero Downtime Dashboards
|
||||
description: Use shadow pipelines to publish dashboards without interrupting operators.
|
||||
updatedAt: 2024-12-12
|
||||
tags:
|
||||
- dashboards
|
||||
- releases
|
||||
collection: observability
|
||||
collectionLabel: Observability
|
||||
version: Preview
|
||||
versionSlug: zero-downtime
|
||||
format: mdx
|
||||
---
|
||||
|
||||
<Callout title="Guardrails" tone="warning">
|
||||
Always keep the stable dashboard folder intact. Publish experimental panels into a shadow folder first.
|
||||
</Callout>
|
||||
|
||||
<Steps title="Release path">
|
||||
<li>Create a new folder such as `dashboards/shadow` and sync it to staging only.</li>
|
||||
<li>Attach the same data sources as production but pin to canary namespaces.</li>
|
||||
<li>After validation, promote the folder and archive the previous release.</li>
|
||||
</Steps>
|
||||
|
||||
Teams can safely add complex visualizations without losing historical parity. The same pattern works for alert rule experiments.
|
||||
25
src/content/workshop/fast-feedback-loops.mdx
Normal file
25
src/content/workshop/fast-feedback-loops.mdx
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Fast Feedback Loops
|
||||
summary: Prototype change workflows with interactive toggles before wiring production automation.
|
||||
level: Intermediate
|
||||
duration: 25 min
|
||||
tags:
|
||||
- gitops
|
||||
- automation
|
||||
- rollout
|
||||
updatedAt: 2024-12-01
|
||||
---
|
||||
|
||||
Live demos in this workshop stay close to how engineers actually deploy. Toggle environments, flip feature states, and observe how the rollout recipe responds in real time.
|
||||
|
||||
## What you will build
|
||||
|
||||
- A staged toggle flow that mirrors your GitOps pipelines.
|
||||
- A small guardrail to ensure approvals stay attached to risky rollouts.
|
||||
- A preview card that highlights the blast radius of each action.
|
||||
|
||||
## Try it now
|
||||
|
||||
<WorkshopDemo />
|
||||
|
||||
Because the MDX runs through Contentlayer, interactive components compile at build time while preserving state on the client.
|
||||
144
src/lib/blogContent.ts
Normal file
144
src/lib/blogContent.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { cache } from 'react'
|
||||
|
||||
import { readMdxDirectory, readMdxFile } from './mdx'
|
||||
import { resolveBlogContentRoot } from './marketingContent'
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string
|
||||
title: string
|
||||
author?: string
|
||||
date?: string
|
||||
tags: string[]
|
||||
excerpt: string
|
||||
content: string
|
||||
category?: {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
const BLOG_EXTENSIONS = ['.md', '.mdx']
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
const readBlogFiles = cache(async () =>
|
||||
readMdxDirectory('', {
|
||||
baseDir: resolveBlogContentRoot(),
|
||||
recursive: true,
|
||||
extensions: BLOG_EXTENSIONS,
|
||||
}),
|
||||
)
|
||||
|
||||
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 buildExcerpt(markdown: string): string {
|
||||
const cleaned = markdown
|
||||
.replace(/^\s*import\s+.*$/gm, '')
|
||||
.replace(/^\s*export\s+const\s+.*$/gm, '')
|
||||
.trim()
|
||||
|
||||
const blocks = cleaned.split(/\r?\n\s*\r?\n/)
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) continue
|
||||
const withoutFormatting = trimmed
|
||||
.replace(/^#+\s*/g, '')
|
||||
.replace(/[`*_>\[\]]/g, '')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
if (withoutFormatting.trim()) {
|
||||
return withoutFormatting.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizePost(file: Awaited<ReturnType<typeof readBlogFiles>>[number]): BlogPost {
|
||||
const title = typeof file.metadata.title === 'string' ? file.metadata.title : file.slug
|
||||
const author = typeof file.metadata.author === 'string' ? file.metadata.author : undefined
|
||||
const date = typeof file.metadata.date === 'string' ? file.metadata.date : undefined
|
||||
const tags = Array.isArray(file.metadata.tags)
|
||||
? file.metadata.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||
: []
|
||||
const excerpt = typeof file.metadata.excerpt === 'string' ? file.metadata.excerpt : buildExcerpt(file.content)
|
||||
const categoryKey = typeof file.metadata.category === 'string' ? file.metadata.category : undefined
|
||||
const categoryLabel = typeof file.metadata.categoryLabel === 'string' ? file.metadata.categoryLabel : categoryKey
|
||||
const category =
|
||||
categoryKey && categoryLabel
|
||||
? { key: categoryKey, label: categoryLabel }
|
||||
: resolveCategory(file.slug)
|
||||
|
||||
return {
|
||||
slug: file.slug,
|
||||
title,
|
||||
author,
|
||||
date,
|
||||
tags,
|
||||
excerpt,
|
||||
content: file.content,
|
||||
category,
|
||||
}
|
||||
}
|
||||
|
||||
export const getBlogPosts = cache(async (): Promise<BlogPost[]> => {
|
||||
try {
|
||||
const files = await readBlogFiles()
|
||||
const posts = files.map(normalizePost)
|
||||
|
||||
return posts
|
||||
.map((post) => ({
|
||||
...post,
|
||||
dateValue: post.date ? new Date(post.date) : undefined,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.dateValue && b.dateValue) {
|
||||
return b.dateValue.getTime() - a.dateValue.getTime()
|
||||
}
|
||||
if (a.dateValue) return -1
|
||||
if (b.dateValue) return 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
.map(({ dateValue: _dateValue, ...post }) => post)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export async function getBlogPostBySlug(slug: string): Promise<BlogPost | undefined> {
|
||||
const posts = await getBlogPosts()
|
||||
return posts.find((post) => post.slug === slug)
|
||||
}
|
||||
|
||||
export async function getBlogSlugs(): Promise<string[]> {
|
||||
const posts = await getBlogPosts()
|
||||
return posts.map((post) => post.slug)
|
||||
}
|
||||
|
||||
export async function loadBlogContent(slug: string): Promise<string> {
|
||||
const contentRoot = resolveBlogContentRoot()
|
||||
const file = await readMdxFile(slug, { baseDir: contentRoot, extensions: BLOG_EXTENSIONS })
|
||||
return file.content
|
||||
}
|
||||
202
src/lib/docContent.ts
Normal file
202
src/lib/docContent.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import path from 'path'
|
||||
import { cache } from 'react'
|
||||
|
||||
import { readMdxDirectory } from './mdx'
|
||||
|
||||
export interface DocVersion {
|
||||
slug: string
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
updatedAt?: string
|
||||
tags: string[]
|
||||
content: string
|
||||
isMdx: boolean
|
||||
}
|
||||
|
||||
export interface DocCollection {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
updatedAt?: string
|
||||
tags: string[]
|
||||
versions: DocVersion[]
|
||||
defaultVersionSlug: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
const DOC_CONTENT_ROOT = path.join(process.cwd(), 'src', 'content', 'doc')
|
||||
const DOC_EXTENSIONS = ['.md', '.mdx']
|
||||
|
||||
const readDocFiles = cache(async () =>
|
||||
readMdxDirectory('', {
|
||||
baseDir: DOC_CONTENT_ROOT,
|
||||
recursive: true,
|
||||
extensions: DOC_EXTENSIONS,
|
||||
}),
|
||||
)
|
||||
|
||||
function humanize(value: string) {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.split(' ')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildExcerpt(markdown: string): string {
|
||||
const cleaned = markdown
|
||||
.replace(/^\s*import\s+.*$/gm, '')
|
||||
.replace(/^\s*export\s+const\s+.*$/gm, '')
|
||||
.trim()
|
||||
|
||||
const blocks = cleaned.split(/\r?\n\s*\r?\n/)
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) continue
|
||||
const withoutFormatting = trimmed
|
||||
.replace(/^#+\s*/g, '')
|
||||
.replace(/[`*_>\[\]]/g, '')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
if (withoutFormatting.trim()) {
|
||||
return withoutFormatting.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeDoc(file: Awaited<ReturnType<typeof readDocFiles>>[number]): DocVersion & {
|
||||
collection: string
|
||||
collectionLabel: string
|
||||
} {
|
||||
const segments = file.slug.split('/')
|
||||
const collection = typeof file.metadata.collection === 'string' ? file.metadata.collection : segments[0] || 'docs'
|
||||
const collectionLabel =
|
||||
typeof file.metadata.collectionLabel === 'string'
|
||||
? file.metadata.collectionLabel
|
||||
: humanize(collection)
|
||||
|
||||
const versionSlug = typeof file.metadata.versionSlug === 'string' ? file.metadata.versionSlug : segments.at(-1) ?? 'latest'
|
||||
const label = typeof file.metadata.version === 'string' ? file.metadata.version : versionSlug
|
||||
const title = typeof file.metadata.title === 'string' ? file.metadata.title : label
|
||||
const description =
|
||||
typeof file.metadata.description === 'string' ? file.metadata.description : buildExcerpt(file.content)
|
||||
const updatedAt = typeof file.metadata.updatedAt === 'string' ? file.metadata.updatedAt : undefined
|
||||
const tags = Array.isArray(file.metadata.tags)
|
||||
? file.metadata.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0)
|
||||
: []
|
||||
const isMdx = String(file.metadata.format || '').toLowerCase() === 'mdx'
|
||||
|
||||
return {
|
||||
slug: versionSlug,
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
updatedAt,
|
||||
tags,
|
||||
content: file.content,
|
||||
isMdx,
|
||||
collection,
|
||||
collectionLabel,
|
||||
}
|
||||
}
|
||||
|
||||
export const getDocCollections = cache(async (): Promise<DocCollection[]> => {
|
||||
try {
|
||||
const files = await readDocFiles()
|
||||
const docs = files.map(normalizeDoc)
|
||||
|
||||
const collections = new Map<
|
||||
string,
|
||||
{
|
||||
collectionLabel: string
|
||||
versions: DocVersion[]
|
||||
}
|
||||
>()
|
||||
|
||||
for (const doc of docs) {
|
||||
const existing = collections.get(doc.collection)
|
||||
const version: DocVersion = {
|
||||
slug: doc.slug,
|
||||
label: doc.label,
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
updatedAt: doc.updatedAt,
|
||||
tags: doc.tags,
|
||||
content: doc.content,
|
||||
isMdx: doc.isMdx,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.versions.push(version)
|
||||
continue
|
||||
}
|
||||
collections.set(doc.collection, {
|
||||
collectionLabel: doc.collectionLabel,
|
||||
versions: [version],
|
||||
})
|
||||
}
|
||||
|
||||
const result: DocCollection[] = []
|
||||
for (const [slug, data] of collections.entries()) {
|
||||
const versions = [...data.versions].sort((a, b) => {
|
||||
const aDate = a.updatedAt ? Date.parse(a.updatedAt) : 0
|
||||
const bDate = b.updatedAt ? Date.parse(b.updatedAt) : 0
|
||||
if (aDate && bDate && aDate !== bDate) {
|
||||
return bDate - aDate
|
||||
}
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
const primary = versions[0]
|
||||
if (!primary) continue
|
||||
|
||||
result.push({
|
||||
slug,
|
||||
title: data.collectionLabel,
|
||||
description: primary.description,
|
||||
updatedAt: primary.updatedAt,
|
||||
tags: versions.flatMap((item) => item.tags ?? []),
|
||||
versions,
|
||||
defaultVersionSlug: primary.slug,
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => {
|
||||
const aDate = a.updatedAt ? Date.parse(a.updatedAt) : 0
|
||||
const bDate = b.updatedAt ? Date.parse(b.updatedAt) : 0
|
||||
if (aDate && bDate && aDate !== bDate) {
|
||||
return bDate - aDate
|
||||
}
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export async function getDocCollection(slug: string): Promise<DocCollection | undefined> {
|
||||
const collections = await getDocCollections()
|
||||
return collections.find((collection) => collection.slug === slug)
|
||||
}
|
||||
|
||||
export async function getDocVersion(
|
||||
collectionSlug: string,
|
||||
versionSlug: string,
|
||||
): Promise<{ collection: DocCollection; version: DocVersion } | undefined> {
|
||||
const collection = await getDocCollection(collectionSlug)
|
||||
if (!collection) return undefined
|
||||
const version = collection.versions.find((item) => item.slug === versionSlug)
|
||||
if (!version) return undefined
|
||||
return { collection, version }
|
||||
}
|
||||
|
||||
export async function getDocParams() {
|
||||
const collections = await getDocCollections()
|
||||
return collections.flatMap((collection) =>
|
||||
collection.versions.map((version) => ({ collection: collection.slug, version: version.slug })),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user