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:
JeffJiang 2026-04-10 20:24:52 +08:00 committed by GitHub
parent 809b341350
commit 7dc0c7d01f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 868 additions and 11 deletions

View File

@ -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
>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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

View 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>
);
}

View File

@ -22,6 +22,9 @@ const meta: MetaRecord = {
workspace: {
type: "page",
},
blog: {
type: "page",
},
};
export default meta;

View File

@ -0,0 +1,9 @@
import type { MetaRecord } from "nextra";
const meta: MetaRecord = {
weekly: {
title: "Weekly",
},
};
export default meta;

View 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

View File

@ -7,6 +7,9 @@ const meta: MetaRecord = {
workspace: {
type: "page",
},
blog: {
type: "page",
},
};
export default meta;

View File

@ -0,0 +1,9 @@
import type { MetaRecord } from "nextra";
const meta: MetaRecord = {
weekly: {
title: "周报",
},
};
export default meta;

View 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 发布

View 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

View 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,
};
}

View File

@ -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;