From f3b6603db74a3b66d1de8a5296d9689b07ec1490 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 9 Jun 2025 00:21:28 +0800 Subject: [PATCH] Revert "cf: test remove docs and blog pages and content" This reverts commit 9cb559a48da160ee6bb6a5812820d24e4c7025fa. --- content/author/fox.mdx | 5 + content/author/fox.zh.mdx | 5 + content/author/mkdirs.mdx | 5 + content/author/mkdirs.zh.mdx | 5 + content/author/mksaas.mdx | 5 + content/author/mksaas.zh.mdx | 5 + content/category/company.mdx | 5 + content/category/company.zh.mdx | 5 + content/category/news.mdx | 5 + content/category/news.zh.mdx | 5 + content/category/product.mdx | 5 + content/category/product.zh.mdx | 5 + .../blog/(blog)/category/[slug]/page.tsx | 73 +++++ .../category/[slug]/page/[page]/page.tsx | 84 ++++++ .../(marketing)/blog/(blog)/layout.tsx | 42 +++ .../(marketing)/blog/(blog)/loading.tsx | 5 + .../[locale]/(marketing)/blog/(blog)/page.tsx | 45 +++ .../blog/(blog)/page/[page]/page.tsx | 65 +++++ .../(marketing)/blog/[...slug]/layout.tsx | 10 + .../(marketing)/blog/[...slug]/page.tsx | 256 ++++++++++++++++++ 20 files changed, 640 insertions(+) create mode 100644 content/author/fox.mdx create mode 100644 content/author/fox.zh.mdx create mode 100644 content/author/mkdirs.mdx create mode 100644 content/author/mkdirs.zh.mdx create mode 100644 content/author/mksaas.mdx create mode 100644 content/author/mksaas.zh.mdx create mode 100644 content/category/company.mdx create mode 100644 content/category/company.zh.mdx create mode 100644 content/category/news.mdx create mode 100644 content/category/news.zh.mdx create mode 100644 content/category/product.mdx create mode 100644 content/category/product.zh.mdx create mode 100644 src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page.tsx create mode 100644 src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page/[page]/page.tsx create mode 100644 src/app/[locale]/(marketing)/blog/(blog)/layout.tsx create mode 100644 src/app/[locale]/(marketing)/blog/(blog)/loading.tsx create mode 100644 src/app/[locale]/(marketing)/blog/(blog)/page.tsx create mode 100644 src/app/[locale]/(marketing)/blog/(blog)/page/[page]/page.tsx create mode 100644 src/app/[locale]/(marketing)/blog/[...slug]/layout.tsx create mode 100644 src/app/[locale]/(marketing)/blog/[...slug]/page.tsx diff --git a/content/author/fox.mdx b/content/author/fox.mdx new file mode 100644 index 0000000..6151ce7 --- /dev/null +++ b/content/author/fox.mdx @@ -0,0 +1,5 @@ +--- +slug: fox +name: Fox +avatar: /images/avatars/fox.png +--- diff --git a/content/author/fox.zh.mdx b/content/author/fox.zh.mdx new file mode 100644 index 0000000..6151ce7 --- /dev/null +++ b/content/author/fox.zh.mdx @@ -0,0 +1,5 @@ +--- +slug: fox +name: Fox +avatar: /images/avatars/fox.png +--- diff --git a/content/author/mkdirs.mdx b/content/author/mkdirs.mdx new file mode 100644 index 0000000..e5cf61f --- /dev/null +++ b/content/author/mkdirs.mdx @@ -0,0 +1,5 @@ +--- +slug: mkdirs +name: Mkdirs +avatar: /images/avatars/mkdirs.png +--- diff --git a/content/author/mkdirs.zh.mdx b/content/author/mkdirs.zh.mdx new file mode 100644 index 0000000..61ba558 --- /dev/null +++ b/content/author/mkdirs.zh.mdx @@ -0,0 +1,5 @@ +--- +slug: mkdirs +name: Mkdirs模板 +avatar: /images/avatars/mkdirs.png +--- diff --git a/content/author/mksaas.mdx b/content/author/mksaas.mdx new file mode 100644 index 0000000..77581c6 --- /dev/null +++ b/content/author/mksaas.mdx @@ -0,0 +1,5 @@ +--- +slug: mksaas +name: MkSaaS +avatar: /images/avatars/mksaas.png +--- diff --git a/content/author/mksaas.zh.mdx b/content/author/mksaas.zh.mdx new file mode 100644 index 0000000..294ec63 --- /dev/null +++ b/content/author/mksaas.zh.mdx @@ -0,0 +1,5 @@ +--- +slug: mksaas +name: MkSaaS模板 +avatar: /images/avatars/mksaas.png +--- diff --git a/content/category/company.mdx b/content/category/company.mdx new file mode 100644 index 0000000..c3ac928 --- /dev/null +++ b/content/category/company.mdx @@ -0,0 +1,5 @@ +--- +slug: company +name: Company +description: Company news and updates +--- diff --git a/content/category/company.zh.mdx b/content/category/company.zh.mdx new file mode 100644 index 0000000..5217933 --- /dev/null +++ b/content/category/company.zh.mdx @@ -0,0 +1,5 @@ +--- +slug: company +name: 公司 +description: 公司新闻和更新 +--- diff --git a/content/category/news.mdx b/content/category/news.mdx new file mode 100644 index 0000000..550a4cb --- /dev/null +++ b/content/category/news.mdx @@ -0,0 +1,5 @@ +--- +slug: news +name: News +description: News and updates about MkSaaS +--- diff --git a/content/category/news.zh.mdx b/content/category/news.zh.mdx new file mode 100644 index 0000000..ffc2670 --- /dev/null +++ b/content/category/news.zh.mdx @@ -0,0 +1,5 @@ +--- +slug: news +name: 新闻 +description: 最新新闻和更新 +--- diff --git a/content/category/product.mdx b/content/category/product.mdx new file mode 100644 index 0000000..aa07989 --- /dev/null +++ b/content/category/product.mdx @@ -0,0 +1,5 @@ +--- +slug: product +name: Product +description: Products and services powered by MkSaaS +--- diff --git a/content/category/product.zh.mdx b/content/category/product.zh.mdx new file mode 100644 index 0000000..3027a7d --- /dev/null +++ b/content/category/product.zh.mdx @@ -0,0 +1,5 @@ +--- +slug: product +name: 产品 +description: 产品和服务 +--- diff --git a/src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page.tsx new file mode 100644 index 0000000..b8d0a41 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page.tsx @@ -0,0 +1,73 @@ +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 { allCategories } 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) +export function generateStaticParams() { + const params: { locale: string; slug: string }[] = []; + for (const locale of LOCALES) { + const localeCategories = allCategories.filter( + (category) => category.locale === locale + ); + 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, + canonicalUrl: getUrlWithLocale(canonicalPath, locale), + }); +} + +interface BlogCategoryPageProps { + params: Promise<{ + locale: Locale; + slug: string; + }>; +} + +export default async function BlogCategoryPage({ + params, +}: BlogCategoryPageProps) { + const { locale, slug } = await params; + const category = allCategories.find( + (category) => category.locale === locale && category.slug === slug + ); + if (!category) { + notFound(); + } + const currentPage = 1; + const { paginatedPosts, totalPages } = getPaginatedBlogPosts({ + locale, + page: currentPage, + category: slug, + }); + return ( + + ); +} diff --git a/src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page/[page]/page.tsx b/src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page/[page]/page.tsx new file mode 100644 index 0000000..7c09807 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/(blog)/category/[slug]/page/[page]/page.tsx @@ -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: string; + }>; +} + +export default async function BlogCategoryPage({ + params, +}: BlogCategoryPageProps) { + const { locale, slug, page } = await params; + const currentPage = Number(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 ( + + ); +} diff --git a/src/app/[locale]/(marketing)/blog/(blog)/layout.tsx b/src/app/[locale]/(marketing)/blog/(blog)/layout.tsx new file mode 100644 index 0000000..1302a1a --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/(blog)/layout.tsx @@ -0,0 +1,42 @@ +import { BlogCategoryFilter } from '@/components/blog/blog-category-filter'; +import Container from '@/components/layout/container'; +import type { NextPageProps } from '@/types/next-page-props'; +import { allCategories } from 'content-collections'; +import { getTranslations } from 'next-intl/server'; +import type { PropsWithChildren } from 'react'; + +interface BlogListLayoutProps extends PropsWithChildren, NextPageProps {} + +export default async function BlogListLayout({ + children, + params, +}: BlogListLayoutProps) { + const resolvedParams = await params; + const { locale } = resolvedParams; + const t = await getTranslations('BlogPage'); + + // Filter categories by locale + const categoryList = allCategories.filter( + (category) => category.locale === locale + ); + + return ( +
+
+ {/* Header */} +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ + +
+ + {children} +
+ ); +} diff --git a/src/app/[locale]/(marketing)/blog/(blog)/loading.tsx b/src/app/[locale]/(marketing)/blog/(blog)/loading.tsx new file mode 100644 index 0000000..bc312b4 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/(blog)/loading.tsx @@ -0,0 +1,5 @@ +import { BlogGridSkeleton } from '@/components/blog/blog-grid'; + +export default function Loading() { + return ; +} diff --git a/src/app/[locale]/(marketing)/blog/(blog)/page.tsx b/src/app/[locale]/(marketing)/blog/(blog)/page.tsx new file mode 100644 index 0000000..aaf6394 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/(blog)/page.tsx @@ -0,0 +1,45 @@ +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 { Locale } from 'next-intl'; +import { getTranslations } from 'next-intl/server'; + +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'), + canonicalUrl: getUrlWithLocale(canonicalPath, locale), + }); +} + +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 ( + + ); +} diff --git a/src/app/[locale]/(marketing)/blog/(blog)/page/[page]/page.tsx b/src/app/[locale]/(marketing)/blog/(blog)/page/[page]/page.tsx new file mode 100644 index 0000000..b0c42b1 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/(blog)/page/[page]/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/layout.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/layout.tsx new file mode 100644 index 0000000..ee15fe6 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/[...slug]/layout.tsx @@ -0,0 +1,10 @@ +import Container from '@/components/layout/container'; +import type { PropsWithChildren } from 'react'; + +export default function BlogPostLayout({ children }: PropsWithChildren) { + return ( + +
{children}
+
+ ); +} diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx new file mode 100644 index 0000000..83233c0 --- /dev/null +++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx @@ -0,0 +1,256 @@ +import AllPostsButton from '@/components/blog/all-posts-button'; +import BlogGrid from '@/components/blog/blog-grid'; +import { BlogToc } from '@/components/blog/blog-toc'; +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 Post, allPosts } from 'content-collections'; +import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react'; +import type { Metadata } from 'next'; +import type { Locale } from 'next-intl'; +import { getTranslations } from 'next-intl/server'; +import Image from 'next/image'; +import { notFound } from 'next/navigation'; + +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, + * max size is websiteConfig.blog.relatedPostsSize + */ +async function getRelatedPosts(post: Post) { + const relatedPosts = allPosts + .filter((p) => p.locale === post.locale) + .filter((p) => p.slugAsParams !== post.slugAsParams) + .sort(() => Math.random() - 0.5) + .slice(0, websiteConfig.blog.relatedPostsSize); + + return relatedPosts; +} + +export function generateStaticParams() { + return LOCALES.flatMap((locale) => { + const posts = allPosts.filter((post) => post.locale === locale); + return posts.map((post) => ({ + locale, + slug: [post.slugAsParams], + })); + }); +} + +export async function generateMetadata({ + params, +}: BlogPostPageProps): Promise { + const { locale, slug } = await params; + const post = await getBlogPostFromParams(locale, slug.join('/')); + if (!post) { + notFound(); + } + + const t = await getTranslations({ locale, namespace: 'Metadata' }); + + return constructMetadata({ + title: `${post.title} | ${t('title')}`, + description: post.description, + canonicalUrl: getUrlWithLocale(post.slug, locale), + image: post.image, + }); +} + +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(); + } + + const publishDate = post.date; + const date = formatDate(new Date(publishDate)); + const toc = await getTableOfContents(post.content); + + // getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static + const t = await getTranslations('BlogPage'); + + // get related posts + const relatedPosts = await getRelatedPosts(post); + + return ( +
+ {/* content section */} +
+ {/* left column (blog post content) */} +
+ {/* Basic information */} +
+ {/* blog post image */} +
+ {post.image && ( + {post.title + )} +
+ + {/* blog post date and reading time */} +
+
+ + + {date} + +
+
+ + + {t('readTime', { minutes: post.estimatedTime })} + +
+
+ + {/* blog post title */} +

{post.title}

+ + {/* blog post description */} +

{post.description}

+
+ + {/* blog post content */} + {/* in order to make the mdx.css work, we need to add the className prose to the div */} + {/* https://github.com/tailwindlabs/tailwindcss-typography */} +
+ +
+ +
+ +
+
+ + {/* right column (sidebar) */} +
+
+ {/* author info */} +
+

{t('author')}

+
+
+ {post.author?.avatar && ( + {`avatar + )} +
+ {post.author?.name} +
+
+ + {/* categories */} +
+

{t('categories')}

+
    + {post.categories?.filter(Boolean).map( + (category) => + category && ( +
  • + + {category.name} + +
  • + ) + )} +
+
+ + {/* table of contents */} +
+

+ {t('tableOfContents')} +

+
+ +
+
+
+
+
+ + {/* Footer section shows related posts */} + {relatedPosts && relatedPosts.length > 0 && ( +
+
+ +

+ {t('morePosts')} +

+
+ + +
+ )} + + {/* newsletter */} +
+ +
+
+ ); +}