refactor: update blog url path & components & sitemap

This commit is contained in:
javayhu 2025-05-31 00:07:25 +08:00
parent 707399057e
commit f589fa2d00
9 changed files with 416 additions and 184 deletions

View File

@ -1,36 +1,38 @@
import BlogGrid from '@/components/blog/blog-grid';
import EmptyGrid from '@/components/shared/empty-grid';
import CustomPagination from '@/components/shared/pagination';
import { websiteConfig } from '@/config/website';
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { allCategories, allPosts } from 'content-collections';
import type { Metadata } from 'next';
import { allCategories } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: Locale }>;
}): Promise<Metadata | undefined> {
const { slug, locale } = await params;
// Find category with matching slug and locale
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
if (!category) {
console.warn(
`generateMetadata, category not found for slug: ${slug}, locale: ${locale}`
// Generate all static params for SSG (locale + category)
export function generateStaticParams() {
const params: { locale: string; slug: string }[] = [];
for (const locale of LOCALES) {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
return {};
for (const category of localeCategories) {
params.push({ locale, slug: category.slug });
}
}
return params;
}
// Generate metadata for each static category page (locale + category)
export async function generateMetadata({ params }: BlogCategoryPageProps) {
const { locale, slug } = await params;
const category = allCategories.find(
(category) => category.locale === locale && category.slug === slug
);
if (!category) {
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
const canonicalPath = `/blog/category/${slug}`;
return constructMetadata({
title: `${category.name} | ${t('title')}`,
description: category.description,
@ -38,66 +40,34 @@ export async function generateMetadata({
});
}
interface BlogCategoryPageProps {
params: Promise<{
locale: Locale;
slug: string;
}>;
}
export default async function BlogCategoryPage({
params,
searchParams,
}: NextPageProps) {
const paginationSize = websiteConfig.blog.paginationSize;
const resolvedParams = await params;
const { slug, locale } = resolvedParams;
const resolvedSearchParams = await searchParams;
const { page } = (resolvedSearchParams as { [key: string]: string }) || {};
const currentPage = page ? Number(page) : 1;
const startIndex = (currentPage - 1) * paginationSize;
const endIndex = startIndex + paginationSize;
// Find category with matching slug and locale
}: BlogCategoryPageProps) {
const { locale, slug } = await params;
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
(category) => category.locale === locale && category.slug === slug
);
// Filter posts by category and locale
const filteredPosts = allPosts.filter((post) => {
if (!post.published || post.locale !== locale) {
return false;
}
// Check if any of the post's categories match the current category slug
return post.categories.some(
(category) => category && category.slug === slug
);
if (!category) {
notFound();
}
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
category: slug,
});
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / paginationSize);
// console.log("BlogCategoryPage, totalCount", totalCount, ", totalPages", totalPages);
return (
<div>
{/* when no posts are found */}
{paginatedPosts.length === 0 && <EmptyGrid />}
{/* when posts are found */}
{paginatedPosts.length > 0 && (
<div>
<BlogGrid posts={paginatedPosts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix={`/${locale}/blog/category/${resolvedParams.slug}`}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
/>
);
}

View File

@ -0,0 +1,84 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allCategories, allPosts } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
// Generate all static params for SSG (locale + category + pagination)
export function generateStaticParams() {
const params: { locale: string; slug: string; page: string }[] = [];
for (const locale of LOCALES) {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
for (const category of localeCategories) {
const totalPages = Math.ceil(
allPosts.filter(
(post) =>
post.locale === locale &&
post.categories.some((cat) => cat && cat.slug !== category.slug)
).length / websiteConfig.blog.paginationSize
);
for (let page = 2; page <= totalPages; page++) {
params.push({ locale, slug: category.slug, page: String(page) });
}
}
}
return params;
}
// Generate metadata for each static category page (locale + category + pagination)
export async function generateMetadata({ params }: BlogCategoryPageProps) {
const { locale, slug, page } = await params;
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
if (!category) {
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
const canonicalPath = `/blog/category/${slug}/page/${page}`;
return constructMetadata({
title: `${category.name} | ${t('title')}`,
description: category.description,
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
interface BlogCategoryPageProps {
params: Promise<{
locale: Locale;
slug: string;
page: number;
}>;
}
export default async function BlogCategoryPage({
params,
}: BlogCategoryPageProps) {
const { locale, slug, page } = await params;
const currentPage = page;
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
if (!category) {
notFound();
}
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
category: slug,
});
return (
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
/>
);
}

View File

@ -1,23 +1,20 @@
import BlogGrid from '@/components/blog/blog-grid';
import EmptyGrid from '@/components/shared/empty-grid';
import CustomPagination from '@/components/shared/pagination';
import { websiteConfig } from '@/config/website';
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { allPosts } from 'content-collections';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> {
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: BlogPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = '/blog';
return constructMetadata({
title: pt('title') + ' | ' + t('title'),
description: pt('description'),
@ -25,60 +22,24 @@ export async function generateMetadata({
});
}
export default async function BlogPage({
params,
searchParams,
}: NextPageProps) {
const paginationSize = websiteConfig.blog.paginationSize;
const resolvedParams = await params;
const { locale } = resolvedParams;
const resolvedSearchParams = await searchParams;
const { page } = (resolvedSearchParams as { [key: string]: string }) || {};
const currentPage = page ? Number(page) : 1;
const startIndex = (currentPage - 1) * paginationSize;
const endIndex = startIndex + paginationSize;
// Filter posts by locale
const localePosts = allPosts.filter(
(post) => post.locale === locale && post.published
);
// If no posts found for the current locale, show all published posts
const filteredPosts =
localePosts.length > 0
? localePosts
: allPosts.filter((post) => post.published);
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / paginationSize);
// console.log("BlogPage, totalCount", totalCount, ", totalPages", totalPages,);
interface BlogPageProps {
params: Promise<{
locale: Locale;
}>;
}
export default async function BlogPage({ params }: BlogPageProps) {
const { locale } = await params;
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
});
return (
<div>
{/* when no posts are found */}
{paginatedPosts.length === 0 && <EmptyGrid />}
{/* when posts are found */}
{paginatedPosts.length > 0 && (
<div>
<BlogGrid posts={paginatedPosts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix={`/${locale}/blog`}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={'/blog'}
/>
);
}

View File

@ -0,0 +1,65 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allPosts } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
export function generateStaticParams() {
const paginationSize = websiteConfig.blog.paginationSize;
const params: { locale: string; page: string }[] = [];
for (const locale of LOCALES) {
const publishedPosts = allPosts.filter(
(post) => post.published && post.locale === locale
);
const totalPages = Math.max(
1,
Math.ceil(publishedPosts.length / paginationSize)
);
for (let pageNumber = 2; pageNumber <= totalPages; pageNumber++) {
params.push({
locale,
page: String(pageNumber),
});
}
}
return params;
}
export async function generateMetadata({ params }: BlogListPageProps) {
const { locale, page } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = `/blog/page/${page}`;
return constructMetadata({
title: `${pt('title')} | ${t('title')}`,
description: pt('description'),
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
interface BlogListPageProps {
params: Promise<{
locale: Locale;
page: string;
}>;
}
export default async function BlogListPage({ params }: BlogListPageProps) {
const { page, locale } = await params;
const currentPage = Number(page);
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
});
return (
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={'/blog'}
/>
);
}

View File

@ -5,11 +5,11 @@ import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { CustomMDXContent } from '@/components/shared/custom-mdx-content';
import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { LOCALES } from '@/i18n/routing';
import { getTableOfContents } from '@/lib/blog/toc';
import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { type Post, allPosts } from 'content-collections';
import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next';
@ -22,7 +22,8 @@ import '@/styles/mdx.css';
/**
* Gets the blog post from the params
* @param props - The props of the page
* @param slug - The slug of the blog post
* @param locale - The locale of the blog post
* @returns The blog post
*
* How it works:
@ -31,16 +32,8 @@ import '@/styles/mdx.css';
* slug becomes "first-post" after join('/')
* Matches post where slugAsParams === "first-post" AND locale === params.locale
*/
async function getBlogPostFromParams(props: NextPageProps) {
const params = await props.params;
if (!params) {
return null;
}
const locale = params.locale as string;
const slug =
(Array.isArray(params.slug) ? params.slug?.join('/') : params.slug) || '';
async function getBlogPostFromParams(locale: Locale, slug: string) {
// console.log('getBlogPostFromParams', locale, slug);
// Find post with matching slug and locale
const post = allPosts.find(
(post) =>
@ -76,22 +69,23 @@ async function getRelatedPosts(post: Post) {
return relatedPosts;
}
export function generateStaticParams() {
return LOCALES.map((locale) => {
const posts = allPosts.filter((post) => post.locale === locale);
return posts.map((post) => ({
locale,
slug: post.slugAsParams,
}));
});
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: Locale }>;
}): Promise<Metadata | undefined> {
const { slug, locale } = await params;
const post = await getBlogPostFromParams({
params: Promise.resolve({ slug, locale }),
searchParams: Promise.resolve({}),
});
}: BlogPostPageProps): Promise<Metadata | undefined> {
const { locale, slug } = await params;
const post = await getBlogPostFromParams(locale, slug.join('/'));
if (!post) {
console.warn(
`generateMetadata, post not found for slug: ${slug}, locale: ${locale}`
);
return {};
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
@ -104,8 +98,16 @@ export async function generateMetadata({
});
}
export default async function BlogPostPage(props: NextPageProps) {
const post = await getBlogPostFromParams(props);
interface BlogPostPageProps {
params: Promise<{
locale: Locale;
slug: string[];
}>;
}
export default async function BlogPostPage(props: BlogPostPageProps) {
const { locale, slug } = await props.params;
const post = await getBlogPostFromParams(locale, slug.join('/'));
if (!post) {
notFound();
}

View File

@ -1,3 +1,4 @@
import { websiteConfig } from '@/config/website';
import { getLocalePathname } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';
import { source } from '@/lib/docs/source';
@ -60,15 +61,73 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
)
);
// add posts
sitemapList.push(
...allPosts.flatMap((post: { slugAsParams: string }) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/${post.slugAsParams}`, locale),
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = allPosts.filter(
(post) => post.locale === locale && post.published
);
const totalPages = Math.max(
1,
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
);
// /blog/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/page/${page}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
});
}
});
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = allPosts.filter(
(post) =>
post.locale === locale &&
post.published &&
post.categories.some((cat) => cat && cat.slug === category.slug)
);
const totalPages = Math.max(
1,
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
);
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slug}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
// /blog/category/[slug]/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/category/${category.slug}/page/${page}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
});
// add posts (single post pages)
sitemapList.push(
...allPosts.flatMap((post: { slugAsParams: string; locale: string }) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugAsParams}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
)
);

View File

@ -0,0 +1,32 @@
import EmptyGrid from '../shared/empty-grid';
import CustomPagination from '../shared/pagination';
import BlogGrid from './blog-grid';
interface BlogListWithPaginationProps {
posts: any[];
totalPages: number;
routePrefix: string;
}
export default function BlogGridWithPagination({
posts,
totalPages,
routePrefix,
}: BlogListWithPaginationProps) {
return (
<div>
{posts.length === 0 && <EmptyGrid />}
{posts.length > 0 && (
<div>
<BlogGrid posts={posts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix={routePrefix}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -9,8 +9,15 @@ import {
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import { useLocaleRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
function getCurrentPageFromPath(pathname: string): number {
const match = pathname.match(/\/page\/(\d+)$/);
if (match?.[1]) {
return Number(match[1]);
}
return 1;
}
type CustomPaginationProps = {
totalPages: number;
@ -22,13 +29,18 @@ export default function CustomPagination({
routePreix,
}: CustomPaginationProps) {
const router = useLocaleRouter();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const pathname = useLocalePathname();
const currentPage = getCurrentPageFromPath(pathname);
const handlePageChange = (page: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', page.toString());
router.push(`${routePreix}?${params.toString()}`);
const pageNum = Number(page);
if (pageNum === 1) {
// Go to /blog or /blog/category/[slug] for page 1
router.push(routePreix);
} else {
// Go to /blog/page/x or /blog/category/[slug]/page/x
router.push(`${routePreix}/page/${pageNum}`);
}
};
const allPages = generatePagination(currentPage, totalPages);
@ -122,3 +134,8 @@ const generatePagination = (currentPage: number, totalPages: number) => {
totalPages,
];
};
function normalizePath(path: string) {
// Remove duplicated locale prefix, e.g. /zh/zh/blog => /zh/blog
return path.replace(/^(\/\w{2,3})(?:\1)+/, '$1');
}

42
src/lib/blog/data.ts Normal file
View File

@ -0,0 +1,42 @@
import { websiteConfig } from '@/config/website';
import { allPosts } from 'content-collections';
export function getPaginatedBlogPosts({
locale,
page = 1,
category,
}: {
locale: string;
page?: number;
category?: string;
}) {
// Filter posts by locale
let filteredPosts = allPosts.filter(
(post) => post.locale === locale && post.published
);
// If no posts found for the current locale, show all published posts
if (filteredPosts.length === 0) {
filteredPosts = allPosts.filter((post) => post.published);
}
// Filter by category if category is provided
if (category) {
filteredPosts = filteredPosts.filter((post) =>
post.categories.some((cat) => cat && cat.slug === category)
);
}
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginationSize = websiteConfig.blog.paginationSize;
const startIndex = (page - 1) * paginationSize;
const endIndex = startIndex + paginationSize;
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalPages = Math.ceil(filteredPosts.length / paginationSize);
return {
paginatedPosts,
totalPages,
filteredPostsCount: filteredPosts.length,
};
}