mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
feat(blog): implement blog structure with post listing, tagging, and layout enhancements (#1962)
* feat(blog): implement blog structure with post listing and tagging functionality * feat(blog): enhance blog layout and post metadata display with new components * fix(blog): address PR #1962 review feedback and fix lint issues (#14) * fix: format --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
809b341350
commit
7dc0c7d01f
@ -1,13 +1,12 @@
|
||||
import type { PageMapItem } from "nextra";
|
||||
import { getPageMap } from "nextra/page-map";
|
||||
import { Footer, Layout } from "nextra-theme-docs";
|
||||
import { Layout } from "nextra-theme-docs";
|
||||
|
||||
import { Footer } from "@/components/landing/footer";
|
||||
import { Header } from "@/components/landing/header";
|
||||
import { getLocaleByLang } from "@/core/i18n/locale";
|
||||
import "nextra-theme-docs/style.css";
|
||||
|
||||
const footer = <Footer>MIT {new Date().getFullYear()} © Nextra.</Footer>;
|
||||
|
||||
const i18n = [
|
||||
{ locale: "en", name: "English" },
|
||||
{ locale: "zh", name: "中文" },
|
||||
@ -15,7 +14,7 @@ const i18n = [
|
||||
|
||||
function formatPageRoute(base: string, items: PageMapItem[]): PageMapItem[] {
|
||||
return items.map((item) => {
|
||||
if ("route" in item) {
|
||||
if ("route" in item && !item.route.startsWith(base)) {
|
||||
item.route = `${base}${item.route}`;
|
||||
}
|
||||
if ("children" in item && item.children) {
|
||||
@ -29,6 +28,7 @@ export default async function DocLayout({ children, params }) {
|
||||
const { lang } = await params;
|
||||
const locale = getLocaleByLang(lang);
|
||||
const pages = await getPageMap(`/${lang}`);
|
||||
const pageMap = formatPageRoute(`/${lang}/docs`, pages);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@ -39,9 +39,9 @@ export default async function DocLayout({ children, params }) {
|
||||
locale={locale}
|
||||
/>
|
||||
}
|
||||
pageMap={formatPageRoute(`/${lang}/docs`, pages)}
|
||||
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/app/content"
|
||||
footer={footer}
|
||||
pageMap={pageMap}
|
||||
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/content"
|
||||
footer={<Footer />}
|
||||
i18n={i18n}
|
||||
// ... Your additional layout options
|
||||
>
|
||||
|
||||
178
frontend/src/app/blog/[[...mdxPath]]/page.tsx
Normal file
178
frontend/src/app/blog/[[...mdxPath]]/page.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { importPage } from "nextra/pages";
|
||||
import { cache } from "react";
|
||||
|
||||
import { PostList, PostMeta } from "@/components/landing/post-list";
|
||||
import {
|
||||
BLOG_LANGS,
|
||||
type BlogLang,
|
||||
formatTagName,
|
||||
getAllPosts,
|
||||
getBlogIndexData,
|
||||
getPreferredBlogLang,
|
||||
} from "@/core/blog";
|
||||
import { getI18n } from "@/core/i18n/server";
|
||||
|
||||
import { useMDXComponents as getMDXComponents } from "../../../mdx-components";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const Wrapper = getMDXComponents().wrapper;
|
||||
|
||||
function isBlogLang(value: string): value is BlogLang {
|
||||
return BLOG_LANGS.includes(value as BlogLang);
|
||||
}
|
||||
|
||||
const loadBlogPage = cache(async function loadBlogPage(
|
||||
mdxPath: string[] | undefined,
|
||||
preferredLang?: (typeof BLOG_LANGS)[number],
|
||||
) {
|
||||
const slug = mdxPath ?? [];
|
||||
const matches = await Promise.all(
|
||||
BLOG_LANGS.map(async (lang) => {
|
||||
try {
|
||||
// Try every localized source for the same public /blog slug,
|
||||
// then pick the best match for the current locale.
|
||||
const page = await importPage([...slug], lang);
|
||||
return { lang, page };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const availableMatches = matches.filter(
|
||||
(match): match is NonNullable<(typeof matches)[number]> => match !== null,
|
||||
);
|
||||
|
||||
if (availableMatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected =
|
||||
(preferredLang
|
||||
? availableMatches.find(({ lang }) => lang === preferredLang)
|
||||
: undefined) ?? availableMatches[0];
|
||||
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...selected.page,
|
||||
lang: selected.lang,
|
||||
metadata: {
|
||||
...selected.page.metadata,
|
||||
languages: availableMatches.map(({ lang }) => lang),
|
||||
},
|
||||
slug,
|
||||
};
|
||||
});
|
||||
|
||||
export async function generateMetadata(props) {
|
||||
const params = await props.params;
|
||||
const mdxPath = params.mdxPath ?? [];
|
||||
const { locale } = await getI18n();
|
||||
const preferredLang = getPreferredBlogLang(locale);
|
||||
|
||||
if (mdxPath.length === 0) {
|
||||
return {
|
||||
title: "Blog",
|
||||
};
|
||||
}
|
||||
|
||||
if (mdxPath[0] === "tags" && mdxPath[1]) {
|
||||
return {
|
||||
title: formatTagName(mdxPath[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const page = await loadBlogPage(mdxPath, preferredLang);
|
||||
|
||||
if (!page) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return page.metadata;
|
||||
}
|
||||
|
||||
export default async function Page(props) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const mdxPath = params.mdxPath ?? [];
|
||||
const { locale } = await getI18n();
|
||||
const localePreferredLang = getPreferredBlogLang(locale);
|
||||
const queryLang = searchParams?.lang;
|
||||
const preferredLang =
|
||||
typeof queryLang === "string" && isBlogLang(queryLang)
|
||||
? queryLang
|
||||
: localePreferredLang;
|
||||
|
||||
if (mdxPath.length === 0) {
|
||||
const posts = await getAllPosts(preferredLang);
|
||||
return (
|
||||
<Wrapper
|
||||
toc={[]}
|
||||
metadata={{ title: "All Posts", filePath: "blog/index.mdx" }}
|
||||
sourceCode=""
|
||||
>
|
||||
<PostList title="All Posts" posts={posts} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (mdxPath[0] === "tags" && mdxPath[1]) {
|
||||
let tag: string;
|
||||
try {
|
||||
tag = decodeURIComponent(mdxPath[1]);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
const title = formatTagName(tag);
|
||||
const { posts } = await getBlogIndexData(preferredLang, { tag });
|
||||
|
||||
if (posts.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
toc={[]}
|
||||
metadata={{ title, filePath: "blog/index.mdx" }}
|
||||
sourceCode=""
|
||||
>
|
||||
<PostList
|
||||
title={title}
|
||||
description={`${posts.length} posts with the tag “${title}”`}
|
||||
posts={posts}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const page = await loadBlogPage(mdxPath, preferredLang);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { default: MDXContent, toc, metadata, sourceCode, lang, slug } = page;
|
||||
const postMetaData = metadata as {
|
||||
date?: string;
|
||||
languages?: string[];
|
||||
tags?: unknown;
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
|
||||
<PostMeta
|
||||
currentLang={lang}
|
||||
date={
|
||||
typeof postMetaData.date === "string" ? postMetaData.date : undefined
|
||||
}
|
||||
languages={postMetaData.languages}
|
||||
pathname={slug.length === 0 ? "/blog" : `/blog/${slug.join("/")}`}
|
||||
/>
|
||||
<MDXContent {...props} params={{ ...params, lang, mdxPath: slug }} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
22
frontend/src/app/blog/layout.tsx
Normal file
22
frontend/src/app/blog/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Layout } from "nextra-theme-docs";
|
||||
|
||||
import { Footer } from "@/components/landing/footer";
|
||||
import { Header } from "@/components/landing/header";
|
||||
import { getBlogIndexData } from "@/core/blog";
|
||||
import "nextra-theme-docs/style.css";
|
||||
|
||||
export default async function BlogLayout({ children }) {
|
||||
const { pageMap } = await getBlogIndexData();
|
||||
|
||||
return (
|
||||
<Layout
|
||||
navbar={<Header className="relative max-w-full px-10" homeURL="/" />}
|
||||
pageMap={pageMap}
|
||||
sidebar={{ defaultOpen: true }}
|
||||
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/content"
|
||||
footer={<Footer />}
|
||||
>
|
||||
{children}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
24
frontend/src/app/blog/posts/page.tsx
Normal file
24
frontend/src/app/blog/posts/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { PostList } from "@/components/landing/post-list";
|
||||
import { getAllPosts, getPreferredBlogLang } from "@/core/blog";
|
||||
import { getI18n } from "@/core/i18n/server";
|
||||
|
||||
import { useMDXComponents as getMDXComponents } from "../../../mdx-components";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const Wrapper = getMDXComponents().wrapper;
|
||||
|
||||
export const metadata = {
|
||||
title: "All Posts",
|
||||
filePath: "blog/index.mdx",
|
||||
};
|
||||
|
||||
export default async function PostsPage() {
|
||||
const { locale } = await getI18n();
|
||||
const posts = await getAllPosts(getPreferredBlogLang(locale));
|
||||
|
||||
return (
|
||||
<Wrapper toc={[]} metadata={metadata} sourceCode="">
|
||||
<PostList title={metadata.title} posts={posts} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
51
frontend/src/app/blog/tags/[tag]/page.tsx
Normal file
51
frontend/src/app/blog/tags/[tag]/page.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { PostList } from "@/components/landing/post-list";
|
||||
import {
|
||||
formatTagName,
|
||||
getBlogIndexData,
|
||||
getPreferredBlogLang,
|
||||
} from "@/core/blog";
|
||||
import { getI18n } from "@/core/i18n/server";
|
||||
|
||||
import { useMDXComponents as getMDXComponents } from "../../../../mdx-components";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const Wrapper = getMDXComponents().wrapper;
|
||||
|
||||
export async function generateMetadata(props) {
|
||||
const params = await props.params;
|
||||
return {
|
||||
title: formatTagName(params.tag),
|
||||
filePath: "blog/index.mdx",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TagPage(props) {
|
||||
const params = await props.params;
|
||||
const tag = params.tag;
|
||||
const { locale } = await getI18n();
|
||||
const { posts } = await getBlogIndexData(getPreferredBlogLang(locale), {
|
||||
tag,
|
||||
});
|
||||
|
||||
if (posts.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const title = formatTagName(tag);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
toc={[]}
|
||||
metadata={{ title, filePath: "blog/index.mdx" }}
|
||||
sourceCode=""
|
||||
>
|
||||
<PostList
|
||||
title={title}
|
||||
description={`${posts.length} posts with the tag “${title}”`}
|
||||
posts={posts}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@ -41,13 +41,12 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
>
|
||||
{t.home.docs}
|
||||
</Link>
|
||||
<a
|
||||
href={`/${lang}/blog`}
|
||||
target="_self"
|
||||
<Link
|
||||
href="/blog/posts"
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t.home.blog}
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="relative">
|
||||
<div
|
||||
|
||||
163
frontend/src/components/landing/post-list.tsx
Normal file
163
frontend/src/components/landing/post-list.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getBlogRoute, normalizeTagSlug, type BlogPost } from "@/core/blog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PostListProps = {
|
||||
description?: string;
|
||||
posts: BlogPost[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PostMetaProps = {
|
||||
currentLang?: string;
|
||||
date?: string | null;
|
||||
languages?: string[];
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
function formatDate(date?: string): string | null {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = new Date(date);
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function PostMeta({
|
||||
currentLang,
|
||||
date,
|
||||
languages,
|
||||
pathname,
|
||||
}: PostMetaProps) {
|
||||
const formattedDate = formatDate(date ?? undefined);
|
||||
const availableLanguages = Array.isArray(languages)
|
||||
? languages.filter((lang): lang is string => typeof lang === "string")
|
||||
: [];
|
||||
|
||||
if (!formattedDate && availableLanguages.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-8 text-sm">
|
||||
{formattedDate ? (
|
||||
<p className="text-muted-foreground">{formattedDate}</p>
|
||||
) : null}
|
||||
|
||||
{pathname && availableLanguages.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-secondary-foreground text-sm">Language:</span>
|
||||
{availableLanguages.map((lang) => {
|
||||
const isActive = lang === currentLang;
|
||||
return (
|
||||
<Link
|
||||
key={lang}
|
||||
href={`${pathname}?lang=${lang}`}
|
||||
className={
|
||||
isActive
|
||||
? "text-foreground text-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||
}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostTags({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags?: unknown;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validTags = tags.filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
|
||||
if (validTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-3", className)}>
|
||||
<span className="text-secondary-foreground text-sm">Tags:</span>
|
||||
{validTags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/blog/tags/${normalizeTagSlug(tag)}`}
|
||||
className="border-border text-secondary-foreground hover:text-foreground rounded-xl border px-2 py-1 text-sm transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostList({ description, posts, title }: PostListProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-12 px-6">
|
||||
<header className="space-y-4">
|
||||
<h2 className="text-foreground text-4xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="text-secondary-foreground">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="space-y-12">
|
||||
{posts.map((post) => {
|
||||
return (
|
||||
<article
|
||||
key={post.slug.join("/")}
|
||||
className="border-border space-y-5 border-b pb-12 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PostMeta
|
||||
currentLang={post.lang}
|
||||
date={post.metadata.date}
|
||||
languages={post.languages}
|
||||
pathname={getBlogRoute(post.slug)}
|
||||
/>
|
||||
<Link
|
||||
href={getBlogRoute(post.slug)}
|
||||
className="text-foreground hover:text-primary block text-2xl font-semibold tracking-tight transition-colors"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{post.metadata.description ? (
|
||||
<p className="text-secondary-foreground leading-10">
|
||||
{post.metadata.description}
|
||||
</p>
|
||||
) : null}
|
||||
<PostTags tags={post.metadata.tags} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,9 @@ const meta: MetaRecord = {
|
||||
workspace: {
|
||||
type: "page",
|
||||
},
|
||||
blog: {
|
||||
type: "page",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
9
frontend/src/content/en/posts/_meta.ts
Normal file
9
frontend/src/content/en/posts/_meta.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
weekly: {
|
||||
title: "Weekly",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
11
frontend/src/content/en/posts/weekly/2026-04-06.mdx
Normal file
11
frontend/src/content/en/posts/weekly/2026-04-06.mdx
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Weekly - 2026-04-06
|
||||
description: Weekly updates in English.
|
||||
date: 2026-04-06
|
||||
tags:
|
||||
- DeerFlow Weekly
|
||||
---
|
||||
|
||||
## Weekly - 2026-04-06
|
||||
|
||||
In this week, enenenen
|
||||
@ -7,6 +7,9 @@ const meta: MetaRecord = {
|
||||
workspace: {
|
||||
type: "page",
|
||||
},
|
||||
blog: {
|
||||
type: "page",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
9
frontend/src/content/zh/posts/_meta.ts
Normal file
9
frontend/src/content/zh/posts/_meta.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { MetaRecord } from "nextra";
|
||||
|
||||
const meta: MetaRecord = {
|
||||
weekly: {
|
||||
title: "周报",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
9
frontend/src/content/zh/posts/releases/2_0_rc.mdx
Normal file
9
frontend/src/content/zh/posts/releases/2_0_rc.mdx
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: DeerFlow 2.0 RC
|
||||
description: DeerFlow 2.0 RC is officially in RC. Here's what you need to know.
|
||||
date: 2026-04-08
|
||||
tags:
|
||||
- Release
|
||||
---
|
||||
|
||||
## DeerFlow 2.0 RC 发布
|
||||
11
frontend/src/content/zh/posts/weekly/2026-04-06.mdx
Normal file
11
frontend/src/content/zh/posts/weekly/2026-04-06.mdx
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
title: 周报 - 2026-04-06
|
||||
description: DeerFlow 中文周报。
|
||||
date: 2026-04-06
|
||||
tags:
|
||||
- DeerFlow Weekly
|
||||
---
|
||||
|
||||
## Weekly - 2026-04-06
|
||||
|
||||
In this week, xxx
|
||||
357
frontend/src/core/blog/index.ts
Normal file
357
frontend/src/core/blog/index.ts
Normal file
@ -0,0 +1,357 @@
|
||||
import type { Folder, MdxFile, PageMapItem } from "nextra";
|
||||
import { getPageMap } from "nextra/page-map";
|
||||
import { cache } from "react";
|
||||
|
||||
import { getLangByLocale, type Locale } from "@/core/i18n/locale";
|
||||
|
||||
export const BLOG_LANGS = ["zh", "en"] as const;
|
||||
const RECENT_POST_LIMIT = 5;
|
||||
|
||||
export type BlogLang = (typeof BLOG_LANGS)[number];
|
||||
|
||||
export type BlogMetadata = {
|
||||
date?: string;
|
||||
description?: string;
|
||||
item: MdxFile;
|
||||
tags: string[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
type BlogMdxFile = MdxFile & {
|
||||
frontMatter?: {
|
||||
date?: string;
|
||||
description?: string;
|
||||
tags?: unknown;
|
||||
title?: string;
|
||||
};
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type BlogPost = {
|
||||
lang: BlogLang;
|
||||
languages: BlogLang[];
|
||||
metadata: BlogMetadata;
|
||||
slug: string[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
type LocalizedBlogPost = {
|
||||
lang: BlogLang;
|
||||
metadata: BlogMetadata;
|
||||
slug: string[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type BlogIndexData = {
|
||||
pageMap: PageMapItem[];
|
||||
posts: BlogPost[];
|
||||
recentPosts: BlogPost[];
|
||||
tags: Array<{ name: string; count: number; posts: BlogPost[] }>;
|
||||
};
|
||||
|
||||
function isFolder(item: PageMapItem): item is Folder {
|
||||
return "children" in item && Array.isArray(item.children);
|
||||
}
|
||||
|
||||
function isMdxFile(item: PageMapItem): item is BlogMdxFile {
|
||||
return "name" in item && "route" in item && !isFolder(item);
|
||||
}
|
||||
|
||||
function normalizeBlogRoute(route: string): string {
|
||||
// Posts are sourced from locale-specific content trees but exposed
|
||||
// under the single public /blog route.
|
||||
return route.replace(/^\/(en|zh)\/(?:posts|blog)(?=\/|$)/, "/blog");
|
||||
}
|
||||
|
||||
export function getBlogRoute(slug: string[]): string {
|
||||
return slug.length === 0 ? "/blog" : `/blog/${slug.join("/")}`;
|
||||
}
|
||||
|
||||
function getSlugFromRoute(route: string): string[] {
|
||||
return route
|
||||
.replace(/^\/blog\/?/, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getSlugKey(slug: string[]): string {
|
||||
return slug.join("/");
|
||||
}
|
||||
|
||||
function parseTags(tags: unknown): string[] {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tags.filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
function selectPreferredLanguage(
|
||||
languages: BlogLang[],
|
||||
preferredLang?: BlogLang,
|
||||
): BlogLang | null {
|
||||
if (preferredLang && languages.includes(preferredLang)) {
|
||||
return preferredLang;
|
||||
}
|
||||
|
||||
// Keep fallback order stable so merged posts resolve predictably
|
||||
// when the preferred locale is unavailable.
|
||||
for (const lang of BLOG_LANGS) {
|
||||
if (languages.includes(lang)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectLocalizedBlogPosts(
|
||||
items: PageMapItem[],
|
||||
lang: BlogLang,
|
||||
): LocalizedBlogPost[] {
|
||||
const posts: LocalizedBlogPost[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (isFolder(item)) {
|
||||
posts.push(...collectLocalizedBlogPosts(item.children, lang));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isMdxFile(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const route = normalizeBlogRoute(item.route);
|
||||
const slug = getSlugFromRoute(route);
|
||||
|
||||
if (slug.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = item.frontMatter?.title ?? item.title ?? item.name;
|
||||
|
||||
posts.push({
|
||||
lang,
|
||||
metadata: {
|
||||
date: item.frontMatter?.date,
|
||||
description:
|
||||
typeof item.frontMatter?.description === "string"
|
||||
? item.frontMatter.description
|
||||
: undefined,
|
||||
item: {
|
||||
...item,
|
||||
route,
|
||||
},
|
||||
tags: parseTags(item.frontMatter?.tags),
|
||||
title,
|
||||
},
|
||||
slug,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
function mergePostsBySlug(
|
||||
posts: LocalizedBlogPost[],
|
||||
preferredLang?: BlogLang,
|
||||
): BlogPost[] {
|
||||
const postsBySlug = new Map<string, LocalizedBlogPost[]>();
|
||||
|
||||
for (const post of posts) {
|
||||
const key = getSlugKey(post.slug);
|
||||
const group = postsBySlug.get(key) ?? [];
|
||||
group.push(post);
|
||||
postsBySlug.set(key, group);
|
||||
}
|
||||
|
||||
return [...postsBySlug.values()]
|
||||
.flatMap((group): BlogPost[] => {
|
||||
const languages = group.map((post) => post.lang);
|
||||
const selectedLang = selectPreferredLanguage(languages, preferredLang);
|
||||
const primary =
|
||||
group.find((post) => post.lang === selectedLang) ?? group[0];
|
||||
|
||||
if (!primary) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mergedTags = new Set<string>();
|
||||
for (const post of group) {
|
||||
for (const tag of post.metadata.tags) {
|
||||
mergedTags.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...primary,
|
||||
languages,
|
||||
metadata: {
|
||||
...primary.metadata,
|
||||
tags: [...mergedTags],
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => parseDate(b.metadata.date) - parseDate(a.metadata.date));
|
||||
}
|
||||
|
||||
function createFolder(
|
||||
name: string,
|
||||
route: string,
|
||||
title: string,
|
||||
children: PageMapItem[],
|
||||
): Folder {
|
||||
return {
|
||||
children,
|
||||
name,
|
||||
route,
|
||||
title,
|
||||
} as Folder;
|
||||
}
|
||||
|
||||
function createPostItem(post: BlogPost): MdxFile {
|
||||
return {
|
||||
...post.metadata.item,
|
||||
name: post.title,
|
||||
route: getBlogRoute(post.slug),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTagSlug(tag: string): string {
|
||||
return tag.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
export function formatTagName(tag: string): string {
|
||||
return tag
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function getPreferredBlogLang(locale: Locale): BlogLang | undefined {
|
||||
const lang = getLangByLocale(locale);
|
||||
return BLOG_LANGS.find((value) => value === lang);
|
||||
}
|
||||
|
||||
function matchTags(tags: string[], slug: string): boolean {
|
||||
for (const tag of tags) {
|
||||
if (normalizeTagSlug(tag) === slug) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const getAllPosts = cache(async function getAllPosts(
|
||||
preferredLang?: BlogLang,
|
||||
): Promise<BlogPost[]> {
|
||||
const localizedPageMaps = await Promise.all(
|
||||
BLOG_LANGS.map(async (lang) => ({
|
||||
items: await getPageMap(`/${lang}/posts`),
|
||||
lang,
|
||||
})),
|
||||
);
|
||||
|
||||
const localizedPosts = localizedPageMaps.flatMap(({ items, lang }) =>
|
||||
collectLocalizedBlogPosts(items, lang),
|
||||
);
|
||||
|
||||
return mergePostsBySlug(localizedPosts, preferredLang);
|
||||
});
|
||||
|
||||
export async function getBlogIndexData(
|
||||
preferredLang?: BlogLang,
|
||||
filters?: {
|
||||
tag?: string;
|
||||
},
|
||||
): Promise<BlogIndexData> {
|
||||
const posts = await getAllPosts(preferredLang);
|
||||
const tagFilter = filters?.tag;
|
||||
const filteredPosts = tagFilter
|
||||
? posts.filter((post) => matchTags(post.metadata.tags, tagFilter))
|
||||
: posts;
|
||||
const recentPosts = posts.slice(0, RECENT_POST_LIMIT);
|
||||
const postsByTag = new Map<string, BlogPost[]>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const tag of post.metadata.tags) {
|
||||
const group = postsByTag.get(tag) ?? [];
|
||||
group.push(post);
|
||||
postsByTag.set(tag, group);
|
||||
}
|
||||
}
|
||||
|
||||
const tags = [...postsByTag.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, tagPosts]) => ({
|
||||
count: tagPosts.length,
|
||||
name,
|
||||
posts: [...tagPosts].sort(
|
||||
(a, b) => parseDate(b.metadata.date) - parseDate(a.metadata.date),
|
||||
),
|
||||
}));
|
||||
|
||||
const pageMap: PageMapItem[] = [
|
||||
{
|
||||
data: {
|
||||
posts: { title: "All Posts", type: "Page" },
|
||||
recent_posts: { title: "Recent Posts" },
|
||||
tags: { title: "Tags" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Posts",
|
||||
route: "/blog/posts",
|
||||
title: "All Posts",
|
||||
frontMatter: {
|
||||
title: "All Posts",
|
||||
filePath: "blog/index.mdx",
|
||||
},
|
||||
} as MdxFile,
|
||||
createFolder(
|
||||
"recent_posts",
|
||||
"/blog/recent-posts",
|
||||
"Recent Posts",
|
||||
recentPosts.map(createPostItem),
|
||||
),
|
||||
];
|
||||
|
||||
if (tags.length > 0) {
|
||||
pageMap.push(
|
||||
createFolder(
|
||||
"tags",
|
||||
"/blog/tags",
|
||||
"Tags",
|
||||
tags.map((tag) => {
|
||||
return {
|
||||
name: tag.name,
|
||||
title: `${tag.name} (${tag.count})`,
|
||||
route: `/blog/tags/${normalizeTagSlug(tag.name)}`,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pageMap,
|
||||
posts: filteredPosts,
|
||||
recentPosts,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
@ -16,6 +16,14 @@ export function getLocaleByLang(lang: string): Locale {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function getLangByLocale(locale: Locale): string {
|
||||
const parts = locale.split("-");
|
||||
if (parts.length > 0 && typeof parts[0] === "string") {
|
||||
return parts[0];
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
export function normalizeLocale(locale: string | null | undefined): Locale {
|
||||
if (!locale) {
|
||||
return DEFAULT_LOCALE;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user