refactor(blog) blog page ready with toc

This commit is contained in:
javayhu 2025-06-17 17:29:15 +08:00
parent 53ab869f07
commit c6ad6d0ad5
6 changed files with 136 additions and 116 deletions

View File

@ -90,14 +90,11 @@ export const category = defineCollections({
export const blog = defineCollections({ export const blog = defineCollections({
type: 'doc', type: 'doc',
dir: 'content/blog', dir: 'content/blog',
schema: (ctx) => { schema: frontmatterSchema.extend({
// console.log('ctx', ctx); // {source, path}
return frontmatterSchema.extend({
image: z.string(), image: z.string(),
date: z.string().date(), date: z.string().date(),
published: z.boolean().default(true), published: z.boolean().default(true),
categories: z.array(z.string()), categories: z.array(z.string()),
author: z.string(), author: z.string(),
}); }),
},
}); });

View File

@ -1,6 +1,7 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination'; import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing'; import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data'; import { blogSource } from '@/lib/docs/source';
import { constructMetadata } from '@/lib/metadata'; import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls'; import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Locale } from 'next-intl'; import type { Locale } from 'next-intl';
@ -14,11 +15,11 @@ export async function generateMetadata({ params }: BlogPageProps) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' }); const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' }); const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = '/blog';
return constructMetadata({ return constructMetadata({
title: `${pt('title')} | ${t('title')}`, title: `${pt('title')} | ${t('title')}`,
description: pt('description'), description: pt('description'),
canonicalUrl: getUrlWithLocale(canonicalPath, locale), canonicalUrl: getUrlWithLocale('/blog', locale),
}); });
} }
@ -30,14 +31,23 @@ interface BlogPageProps {
export default async function BlogPage({ params }: BlogPageProps) { export default async function BlogPage({ params }: BlogPageProps) {
const { locale } = await params; const { locale } = await params;
const currentPage = 1; const localePosts = blogSource.getPages(locale);
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({ const publishedPosts = localePosts.filter((post) => post.data.published);
locale, const sortedPosts = publishedPosts.sort((a, b) => {
page: currentPage, return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
}); });
const currentPage = 1;
const blogPageSize = websiteConfig.blog.paginationSize;
const paginatedLocalePosts = sortedPosts.slice(
(currentPage - 1) * blogPageSize,
currentPage * blogPageSize
);
const totalPages = Math.ceil(sortedPosts.length / blogPageSize);
return ( return (
<BlogGridWithPagination <BlogGridWithPagination
posts={paginatedPosts} locale={locale}
posts={paginatedLocalePosts}
totalPages={totalPages} totalPages={totalPages}
routePrefix={'/blog'} routePrefix={'/blog'}
/> />

View File

@ -1,16 +1,19 @@
import AllPostsButton from '@/components/blog/all-posts-button'; import AllPostsButton from '@/components/blog/all-posts-button';
import BlogGrid from '@/components/blog/blog-grid'; import BlogGrid from '@/components/blog/blog-grid';
import { BlogToc } from '@/components/blog/blog-toc'; import { getMDXComponents } from '@/components/custom/mdx-components';
import { NewsletterCard } from '@/components/newsletter/newsletter-card'; import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { CustomMDXContent } from '@/components/shared/custom-mdx-content';
import { websiteConfig } from '@/config/website'; import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation'; import { LocaleLink } from '@/i18n/navigation';
import { LOCALES } from '@/i18n/routing'; import {
import { getTableOfContents } from '@/lib/blog/toc'; type BlogType,
authorSource,
blogSource,
categorySource,
} from '@/lib/docs/source';
import { formatDate } from '@/lib/formatter'; import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata'; import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls'; import { getUrlWithLocale } from '@/lib/urls/urls';
import { type Post, allPosts } from 'content-collections'; import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react'; import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import type { Locale } from 'next-intl'; import type { Locale } from 'next-intl';
@ -20,49 +23,15 @@ import { notFound } from 'next/navigation';
import '@/styles/mdx.css'; import '@/styles/mdx.css';
/**
* Gets the blog post from the params
* @param slug - The slug of the blog post
* @param locale - The locale of the blog post
* @returns The blog post
*
* How it works:
* /[locale]/blog/first-post:
* params.slug = ["first-post"]
* slug becomes "first-post" after join('/')
* Matches post where slugAsParams === "first-post" AND locale === params.locale
*/
async function getBlogPostFromParams(locale: Locale, slug: string) {
// console.log('getBlogPostFromParams', locale, slug);
// Find post with matching slug and locale
const post = allPosts.find(
(post) =>
(post.slugAsParams === slug ||
(!slug && post.slugAsParams === 'index')) &&
post.locale === locale
);
if (!post) {
// If no post found with the current locale, try to find one with the default locale
const defaultPost = allPosts.find(
(post) =>
post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')
);
return defaultPost;
}
return post;
}
/** /**
* get related posts, random pick from all posts with same locale, different slug, * get related posts, random pick from all posts with same locale, different slug,
* max size is websiteConfig.blog.relatedPostsSize * max size is websiteConfig.blog.relatedPostsSize
*/ */
async function getRelatedPosts(post: Post) { async function getRelatedPosts(post: BlogType) {
const relatedPosts = allPosts const relatedPosts = blogSource
.filter((p) => p.locale === post.locale) .getPages(post.locale)
.filter((p) => p.slugAsParams !== post.slugAsParams) .filter((p) => p.data.published)
.filter((p) => p.slugs.join('/') !== post.slugs.join('/'))
.sort(() => Math.random() - 0.5) .sort(() => Math.random() - 0.5)
.slice(0, websiteConfig.blog.relatedPostsSize); .slice(0, websiteConfig.blog.relatedPostsSize);
@ -70,12 +39,14 @@ async function getRelatedPosts(post: Post) {
} }
export function generateStaticParams() { export function generateStaticParams() {
return LOCALES.flatMap((locale) => { return blogSource
const posts = allPosts.filter((post) => post.locale === locale); .getPages()
return posts.map((post) => ({ .filter((post) => post.data.published)
locale, .flatMap((post) => {
slug: [post.slugAsParams], return {
})); locale: post.locale,
slug: post.slugs,
};
}); });
} }
@ -83,7 +54,7 @@ export async function generateMetadata({
params, params,
}: BlogPostPageProps): Promise<Metadata | undefined> { }: BlogPostPageProps): Promise<Metadata | undefined> {
const { locale, slug } = await params; const { locale, slug } = await params;
const post = await getBlogPostFromParams(locale, slug.join('/')); const post = blogSource.getPage(slug, locale);
if (!post) { if (!post) {
notFound(); notFound();
} }
@ -91,10 +62,10 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'Metadata' }); const t = await getTranslations({ locale, namespace: 'Metadata' });
return constructMetadata({ return constructMetadata({
title: `${post.title} | ${t('title')}`, title: `${post.data.title} | ${t('title')}`,
description: post.description, description: post.data.description,
canonicalUrl: getUrlWithLocale(post.slug, locale), canonicalUrl: getUrlWithLocale(`/blog/${slug}`, locale),
image: post.image, image: post.data.image,
}); });
} }
@ -107,14 +78,27 @@ interface BlogPostPageProps {
export default async function BlogPostPage(props: BlogPostPageProps) { export default async function BlogPostPage(props: BlogPostPageProps) {
const { locale, slug } = await props.params; const { locale, slug } = await props.params;
const post = await getBlogPostFromParams(locale, slug.join('/')); const post = blogSource.getPage(slug, locale);
if (!post) { if (!post) {
notFound(); notFound();
} }
const publishDate = post.date; const { date, title, description, image, author, categories } = post.data;
const date = formatDate(new Date(publishDate)); const publishDate = formatDate(new Date(date));
const toc = await getTableOfContents(post.content); // const toc = await getTableOfContents(post.data.body);
// console.log('post.data.toc', post.data.toc);
// NOTICE: we can not call post.data.content here in Cloudflare Worker environment
// const wordCount = post.data.content.split(/\s+/).length;
// const wordsPerMinute = 200; // average reading speed: 200 words per minute
// const estimatedTime = Math.max(Math.ceil(wordCount / wordsPerMinute), 1);
const blogAuthor = authorSource.getPage([author], locale);
const blogCategories = categorySource
.getPages(locale)
.filter((category) => categories.includes(category.slugs[0] ?? ''));
const MDX = post.data.body;
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static // getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
const t = await getTranslations('BlogPage'); const t = await getTranslations('BlogPage');
@ -132,11 +116,11 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
<div className="space-y-8"> <div className="space-y-8">
{/* blog post image */} {/* blog post image */}
<div className="group overflow-hidden relative aspect-16/9 rounded-lg transition-all border"> <div className="group overflow-hidden relative aspect-16/9 rounded-lg transition-all border">
{post.image && ( {image && (
<Image <Image
src={post.image} src={image}
alt={post.title || 'image for blog post'} alt={title || 'image for blog post'}
title={post.title || 'image for blog post'} title={title || 'image for blog post'}
loading="eager" loading="eager"
fill fill
className="object-cover" className="object-cover"
@ -146,32 +130,50 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{/* blog post date and reading time */} {/* blog post date and reading time */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{blogAuthor && (
<div className="flex items-center gap-2">
<div className="relative h-6 w-6 shrink-0">
{blogAuthor.data.avatar && (
<Image
src={blogAuthor.data.avatar}
alt={`avatar for ${blogAuthor.data.name}`}
className="rounded-full object-cover border"
fill
/>
)}
</div>
<span className="text-sm text-muted-foreground leading-none my-auto">
{blogAuthor.data.name}
</span>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" /> <CalendarIcon className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground leading-none my-auto"> <span className="text-sm text-muted-foreground leading-none my-auto">
{date} {publishDate}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> {/* <div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" /> <ClockIcon className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground leading-none my-auto"> <span className="text-sm text-muted-foreground leading-none my-auto">
{t('readTime', { minutes: post.estimatedTime })} {t('readTime', { minutes: estimatedTime })}
</span> </span>
</div> </div> */}
</div> </div>
{/* blog post title */} {/* blog post title */}
<h1 className="text-3xl font-bold">{post.title}</h1> <h1 className="text-3xl font-bold">{title}</h1>
{/* blog post description */} {/* blog post description */}
<p className="text-lg text-muted-foreground">{post.description}</p> <p className="text-lg text-muted-foreground">{description}</p>
</div> </div>
{/* blog post content */} {/* blog post content */}
{/* in order to make the mdx.css work, we need to add the className prose to the div */} {/* in order to make the mdx.css work, we need to add the className prose to the div */}
{/* https://github.com/tailwindlabs/tailwindcss-typography */} {/* https://github.com/tailwindlabs/tailwindcss-typography */}
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg"> <div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<CustomMDXContent code={post.body} /> <MDX components={getMDXComponents()} />
</div> </div>
<div className="flex items-center justify-start my-16"> <div className="flex items-center justify-start my-16">
@ -183,36 +185,38 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
<div> <div>
<div className="space-y-4 lg:sticky lg:top-24"> <div className="space-y-4 lg:sticky lg:top-24">
{/* author info */} {/* author info */}
{/* {blogAuthor && (
<div className="bg-muted/50 rounded-lg p-6"> <div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('author')}</h2> <h2 className="text-lg font-semibold mb-4">{t('author')}</h2>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative h-8 w-8 shrink-0"> <div className="relative h-8 w-8 shrink-0">
{post.author?.avatar && ( {blogAuthor.data.avatar && (
<Image <Image
src={post.author.avatar} src={blogAuthor.data.avatar}
alt={`avatar for ${post.author.name}`} alt={`avatar for ${blogAuthor.data.name}`}
className="rounded-full object-cover border" className="rounded-full object-cover border"
fill fill
/> />
)} )}
</div> </div>
<span className="line-clamp-1">{post.author?.name}</span> <span className="line-clamp-1">{blogAuthor.data.name}</span>
</div> </div>
</div> </div>
)} */}
{/* categories */} {/* categories */}
<div className="bg-muted/50 rounded-lg p-6"> <div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('categories')}</h2> <h2 className="text-lg font-semibold mb-4">{t('categories')}</h2>
<ul className="flex flex-wrap gap-4"> <ul className="flex flex-wrap gap-4">
{post.categories?.filter(Boolean).map( {blogCategories.map(
(category) => (category) =>
category && ( category && (
<li key={category.slug}> <li key={category.slugs[0]}>
<LocaleLink <LocaleLink
href={`/blog/category/${category.slug}`} href={`/blog/category/${category.slugs[0]}`}
className="text-sm font-medium text-muted-foreground hover:text-primary" className="text-sm font-medium text-muted-foreground hover:text-primary"
> >
{category.name} {category.data.name}
</LocaleLink> </LocaleLink>
</li> </li>
) )
@ -220,13 +224,20 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
</ul> </ul>
</div> </div>
{/* table of contents */} {/* table of contents, https://fumadocs.dev/docs/ui/components/inline-toc */}
<div className="bg-muted/50 rounded-lg p-6 hidden lg:block"> <div className="bg-muted/50 rounded-lg p-6 hidden lg:block">
<h2 className="text-lg font-semibold mb-4"> <h2 className="text-lg font-semibold mb-4">
{t('tableOfContents')} {t('tableOfContents')}
</h2> </h2>
<div className="max-h-[calc(100vh-18rem)] overflow-y-auto"> <div className="max-h-[calc(100vh-18rem)] overflow-y-auto">
<BlogToc toc={toc} /> {post.data.toc && (
<InlineTOC
items={post.data.toc}
open={true}
defaultOpen={true}
className="prose"
/>
)}
</div> </div>
</div> </div>
</div> </div>
@ -243,7 +254,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
</h2> </h2>
</div> </div>
<BlogGrid posts={relatedPosts} /> <BlogGrid posts={relatedPosts} locale={locale} />
</div> </div>
)} )}

View File

@ -18,12 +18,8 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
.getPages(locale) .getPages(locale)
.filter((category) => categories.includes(category.slugs[0] ?? '')); .filter((category) => categories.includes(category.slugs[0] ?? ''));
// Extract the slug parts for the Link component
const slugParts = post.slugs[0].split('/');
// console.log('BlogCard, slugParts', slugParts);
return ( return (
<LocaleLink href={`/blog/${slugParts.join('/')}`} className="block h-full"> <LocaleLink href={`/blog/${post.slugs}`} className="block h-full">
<div className="group flex flex-col border rounded-lg overflow-hidden h-full"> <div className="group flex flex-col border rounded-lg overflow-hidden h-full">
{/* Image container - fixed aspect ratio */} {/* Image container - fixed aspect ratio */}
<div className="group overflow-hidden relative aspect-16/9 w-full"> <div className="group overflow-hidden relative aspect-16/9 w-full">

View File

@ -14,7 +14,7 @@ export default function BlogGrid({ locale, posts }: BlogGridProps) {
{posts?.length > 0 && ( {posts?.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => ( {posts.map((post) => (
<BlogCard key={post.slugs[0]} locale={locale} post={post} /> <BlogCard key={post.slugs.join('/')} locale={locale} post={post} />
))} ))}
</div> </div>
)} )}

View File

@ -82,6 +82,12 @@ export const blogSource = loader({
baseUrl: '/blog', baseUrl: '/blog',
i18n: docsI18nConfig, i18n: docsI18nConfig,
source: createMDXSource(blog), source: createMDXSource(blog),
transformers: [
(page) => {
// console.log('page', page);
return page;
},
],
}); });
export type ChangelogType = InferPageType<typeof changelogSource>; export type ChangelogType = InferPageType<typeof changelogSource>;