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 type { PageMapItem } from "nextra";
|
||||||
import { getPageMap } from "nextra/page-map";
|
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 { Header } from "@/components/landing/header";
|
||||||
import { getLocaleByLang } from "@/core/i18n/locale";
|
import { getLocaleByLang } from "@/core/i18n/locale";
|
||||||
import "nextra-theme-docs/style.css";
|
import "nextra-theme-docs/style.css";
|
||||||
|
|
||||||
const footer = <Footer>MIT {new Date().getFullYear()} © Nextra.</Footer>;
|
|
||||||
|
|
||||||
const i18n = [
|
const i18n = [
|
||||||
{ locale: "en", name: "English" },
|
{ locale: "en", name: "English" },
|
||||||
{ locale: "zh", name: "中文" },
|
{ locale: "zh", name: "中文" },
|
||||||
@ -15,7 +14,7 @@ const i18n = [
|
|||||||
|
|
||||||
function formatPageRoute(base: string, items: PageMapItem[]): PageMapItem[] {
|
function formatPageRoute(base: string, items: PageMapItem[]): PageMapItem[] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if ("route" in item) {
|
if ("route" in item && !item.route.startsWith(base)) {
|
||||||
item.route = `${base}${item.route}`;
|
item.route = `${base}${item.route}`;
|
||||||
}
|
}
|
||||||
if ("children" in item && item.children) {
|
if ("children" in item && item.children) {
|
||||||
@ -29,6 +28,7 @@ export default async function DocLayout({ children, params }) {
|
|||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
const locale = getLocaleByLang(lang);
|
const locale = getLocaleByLang(lang);
|
||||||
const pages = await getPageMap(`/${lang}`);
|
const pages = await getPageMap(`/${lang}`);
|
||||||
|
const pageMap = formatPageRoute(`/${lang}/docs`, pages);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
@ -39,9 +39,9 @@ export default async function DocLayout({ children, params }) {
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
pageMap={formatPageRoute(`/${lang}/docs`, pages)}
|
pageMap={pageMap}
|
||||||
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/app/content"
|
docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/content"
|
||||||
footer={footer}
|
footer={<Footer />}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
// ... Your additional layout options
|
// ... 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}
|
{t.home.docs}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href={`/${lang}/blog`}
|
href="/blog/posts"
|
||||||
target="_self"
|
|
||||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{t.home.blog}
|
{t.home.blog}
|
||||||
</a>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<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: {
|
workspace: {
|
||||||
type: "page",
|
type: "page",
|
||||||
},
|
},
|
||||||
|
blog: {
|
||||||
|
type: "page",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
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: {
|
workspace: {
|
||||||
type: "page",
|
type: "page",
|
||||||
},
|
},
|
||||||
|
blog: {
|
||||||
|
type: "page",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
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;
|
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 {
|
export function normalizeLocale(locale: string | null | undefined): Locale {
|
||||||
if (!locale) {
|
if (!locale) {
|
||||||
return DEFAULT_LOCALE;
|
return DEFAULT_LOCALE;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user