refactor(blog) blog page ready with toc
This commit is contained in:
parent
53ab869f07
commit
c6ad6d0ad5
@ -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(),
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
@ -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'}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>;
|
||||||
|
Loading…
Reference in New Issue
Block a user