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:
cloudneutral 2025-12-22 12:21:12 +08:00 committed by GitHub
commit e54ab3eb01
33 changed files with 3474 additions and 1878 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ node_modules/
out/
public/_build/
public/dl-index/
.contentlayer/
# Contentlayer cache
ui/docs/.contentlayer/

24
contentlayer.config.ts Normal file
View 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
View File

@ -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.

View File

@ -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);

View File

@ -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",

View File

@ -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>
)
}

View File

@ -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} />
}

View File

@ -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

View File

@ -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} />
</>
)
}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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>
)

View File

@ -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()
}

View File

@ -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`)

View File

@ -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 }
}

View File

@ -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
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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.

View 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.

View 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
View 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
View 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 })),
)
}

2287
yarn.lock

File diff suppressed because it is too large Load Diff