feat(i18n): add multilingual support for documentation with Chinese and English content

This commit is contained in:
HouYunFei
2026-08-05 14:41:42 +08:00
parent 5a7116f924
commit ec82958f7c
82 changed files with 2400 additions and 1473 deletions
-6
View File
@@ -1,6 +0,0 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/'>) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
+7
View File
@@ -0,0 +1,7 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
return <HomeLayout {...baseOptions(lang)}>{children}</HomeLayout>;
}
@@ -1,6 +1,8 @@
import Link from 'next/link';
import { ArrowUpRight, BookOpen, Rocket } from 'lucide-react';
import { appName, gitConfig } from '@/lib/shared';
import { appNames, gitConfig } from '@/lib/shared';
import { localizePath, type Locale } from '@/lib/i18n';
import type { Metadata } from 'next';
const githubUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}`;
const demoUrl = 'https://canvas.best/';
@@ -11,45 +13,79 @@ const darkStarHistoryChart = `${starHistoryChart}&theme=dark`;
const previewImages = [
{
src: 'https://i.ibb.co/TDFvGWDT/image.png',
title: '画布编排',
title: { en: 'Canvas composition', 'zh-CN': '画布编排' },
},
{
src: 'https://i.ibb.co/zVwJq3YS/image.png',
title: '图片生成',
title: { en: 'Image generation', 'zh-CN': '图片生成' },
},
{
src: 'https://i.ibb.co/PvY3qhhK/image.png',
title: '参考图编辑',
title: { en: 'Reference editing', 'zh-CN': '参考图编辑' },
},
{
src: 'https://i.ibb.co/7D04LwN/image.png',
title: '节点工作流',
title: { en: 'Node workflow', 'zh-CN': '节点工作流' },
},
];
export default function HomePage() {
const messages = {
en: {
eyebrow: 'Open-source AI image creation workspace',
center: 'Documentation',
description: 'An infinite canvas for image creation that brings canvas composition, AI generation, reference editing, prompt libraries, and reusable assets into one workflow.',
quickStart: 'Quick Start',
demo: 'Live Demo',
gallery: 'Gallery',
features: 'Explore Features',
previewAlt: 'Infinite Canvas preview',
contributors: 'Contributors',
contributorsDescription: 'Thank you to everyone who has contributed to this project',
contributorsAlt: 'Contributor avatars',
},
'zh-CN': {
eyebrow: '开源 AI 图片创作工作台',
center: '文档中心',
description: '面向图片创作的无限画布,把画布编排、AI 生成、参考图编辑、提示词库和素材沉淀放在同一个工作流里。',
quickStart: '快速开始',
demo: '在线体验',
gallery: '效果展示',
features: '功能介绍',
previewAlt: '无限画布效果图',
contributors: '开发贡献者',
contributorsDescription: '感谢所有为本项目做出贡献的开发者',
contributorsAlt: '开发贡献者头像',
},
};
export default async function HomePage({ params }: PageProps<'/[lang]'>) {
const { lang } = await params;
const locale = lang as Locale;
const text = messages[locale];
const appName = appNames[locale];
return (
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-5 pb-16 pt-8 md:px-10 md:pt-14">
<section className="grid min-h-[520px] items-center gap-10 border-b border-zinc-200 pb-12 dark:border-zinc-800 lg:grid-cols-[0.88fr_1.12fr]">
<div>
<div className="inline-flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">
<Rocket className="size-3.5 text-emerald-600 dark:text-emerald-400" />
AI
{text.eyebrow}
</div>
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-zinc-950 dark:text-zinc-50 md:text-6xl [font-family:var(--font-display)]">
{appName}
<span className="block text-zinc-500 dark:text-zinc-400"></span>
<span className="block text-zinc-500 dark:text-zinc-400">{text.center}</span>
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-zinc-600 dark:text-zinc-400">
AI
{text.description}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link
href="/docs/overview/quick-start"
href={localizePath(locale, '/docs/overview/quick-start')}
className="inline-flex items-center justify-center gap-2 rounded-full bg-zinc-950 px-5 py-3 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-zinc-200"
>
<BookOpen className="size-4" />
{text.quickStart}
</Link>
<a
href={githubUrl}
@@ -66,7 +102,7 @@ export default function HomePage() {
rel="noreferrer noopener"
className="inline-flex items-center justify-center gap-2 rounded-full border border-zinc-300 px-5 py-3 text-sm font-medium text-zinc-900 transition hover:border-zinc-900 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-100 dark:hover:border-zinc-500 dark:hover:bg-zinc-900"
>
线
{text.demo}
<ArrowUpRight className="size-4" />
</a>
</div>
@@ -75,7 +111,7 @@ export default function HomePage() {
<div className="overflow-hidden rounded-2xl lg:w-[108%] lg:max-w-none">
<img
src={previewImages[3].src}
alt="无限画布效果图"
alt={text.previewAlt}
className="aspect-[16/10] w-full rounded-xl object-cover"
/>
</div>
@@ -85,14 +121,14 @@ export default function HomePage() {
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-2xl font-semibold text-zinc-950 dark:text-zinc-50 md:text-3xl">
{text.gallery}
</h2>
</div>
<Link
href="/docs/overview/features"
href={localizePath(locale, '/docs/overview/features')}
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-zinc-800 transition hover:text-zinc-950 dark:text-zinc-200 dark:hover:text-white"
>
{text.features}
<ArrowUpRight className="size-4" />
</Link>
</div>
@@ -101,7 +137,7 @@ export default function HomePage() {
<img
key={item.src}
src={item.src}
alt={`${item.title}效果图`}
alt={item.title[locale]}
loading="lazy"
decoding="async"
className="aspect-[16/10] w-full rounded-2xl object-cover"
@@ -112,10 +148,10 @@ export default function HomePage() {
<section className="mx-auto mt-16 w-full max-w-4xl text-center">
<h2 className="text-2xl font-semibold text-zinc-950 dark:text-zinc-50 md:text-3xl">
{text.contributors}
</h2>
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
{text.contributorsDescription}
</p>
<div className="mt-7 flex justify-center">
<a
@@ -126,7 +162,7 @@ export default function HomePage() {
>
<img
src={`https://contrib.rocks/image?repo=${gitConfig.user}/${gitConfig.repo}`}
alt="开发贡献者头像"
alt={text.contributorsAlt}
loading="lazy"
decoding="async"
className="max-w-full"
@@ -169,3 +205,20 @@ export default function HomePage() {
</main>
);
}
export async function generateMetadata({ params }: PageProps<'/[lang]'>): Promise<Metadata> {
const { lang } = await params;
const locale = lang as Locale;
const text = messages[locale];
return {
title: `${appNames[locale]} ${text.center}`,
description: text.description,
alternates: {
languages: {
en: '/',
'zh-CN': '/zh-CN',
},
},
};
}
@@ -3,18 +3,22 @@ import { source } from '@/lib/source';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
export default async function Page(props: PageProps<'/docs/[...slug]'>) {
export default async function Page(props: PageProps<'/[lang]/docs/[...slug]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return <DocPageContent page={page} />;
}
export async function generateMetadata(props: PageProps<'/docs/[...slug]'>): Promise<Metadata> {
export async function generateMetadata(props: PageProps<'/[lang]/docs/[...slug]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return getDocPageMetadata(page);
}
export function generateStaticParams() {
return source.generateParams();
}
@@ -3,9 +3,11 @@ import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { DocsTopTabs } from '@/components/docs-top-tabs';
export default function Layout({ children }: LayoutProps<'/docs'>) {
export default async function Layout({ params, children }: LayoutProps<'/[lang]/docs'>) {
const { lang } = await params;
return (
<DocsLayout {...baseOptions()} tree={source.getPageTree()} tabs={false}>
<DocsLayout {...baseOptions(lang)} tree={source.getPageTree(lang)} tabs={false}>
<DocsTopTabs />
{children}
</DocsLayout>
+55
View File
@@ -0,0 +1,55 @@
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { Markdown } from 'fumadocs-core/content/md';
import { getTableOfContents } from 'fumadocs-core/content/toc';
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Metadata } from 'next';
import { getMDXComponents } from '@/components/mdx';
const messages = {
en: {
title: 'Infinite Canvas Documentation',
description: 'Features, canvas guides, deployment, development, business, and support',
index: 'index.md',
},
'zh-CN': {
title: '无限画布文档',
description: '功能说明、操作手册、部署方式、开发文档、商务合作与赞助支持',
index: 'index.zh-CN.md',
},
};
async function readDocsIndex(locale: keyof typeof messages) {
return readFile(join(process.cwd(), messages[locale].index), 'utf8');
}
export default async function Page({ params }: PageProps<'/[lang]/docs'>) {
const { lang } = await params;
const locale = lang as keyof typeof messages;
const content = await readDocsIndex(locale);
const text = messages[locale];
const toc = getTableOfContents(content);
return (
<DocsPage toc={toc}>
<DocsTitle>{text.title}</DocsTitle>
<DocsDescription>{text.description}</DocsDescription>
<DocsBody>
<Markdown components={getMDXComponents()} remarkPlugins={[remarkHeading]}>
{content}
</Markdown>
</DocsBody>
</DocsPage>
);
}
export async function generateMetadata({ params }: PageProps<'/[lang]/docs'>): Promise<Metadata> {
const { lang } = await params;
const text = messages[lang as keyof typeof messages];
return {
title: text.title,
description: text.description,
};
}
@@ -0,0 +1,53 @@
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { Markdown } from 'fumadocs-core/content/md';
import { getTableOfContents } from 'fumadocs-core/content/toc';
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Metadata } from 'next';
import { getMDXComponents } from '@/components/mdx';
const messages = {
en: {
title: 'Changelog',
description: 'Project release history',
content: '# Changelog\n\nThe detailed changelog is currently maintained in Chinese. See the [source changelog](https://github.com/basketikun/infinite-canvas/blob/main/CHANGELOG.md) for all releases.',
},
'zh-CN': {
title: '更新日志',
description: '项目版本变更记录',
},
};
async function readChangelog() {
return readFile(join(process.cwd(), '..', 'CHANGELOG.md'), 'utf8');
}
export default async function ChangelogPage({ params }: PageProps<'/[lang]/docs/progress/changelog'>) {
const { lang } = await params;
const text = messages[lang as keyof typeof messages];
const changelog = lang === 'zh-CN' ? await readChangelog() : messages.en.content;
const toc = getTableOfContents(changelog);
return (
<DocsPage toc={toc}>
<DocsTitle>{text.title}</DocsTitle>
<DocsDescription>{text.description}</DocsDescription>
<DocsBody>
<Markdown components={getMDXComponents()} remarkPlugins={[remarkHeading]}>
{changelog}
</Markdown>
</DocsBody>
</DocsPage>
);
}
export async function generateMetadata({ params }: PageProps<'/[lang]/docs/progress/changelog'>): Promise<Metadata> {
const { lang } = await params;
const text = messages[lang as keyof typeof messages];
return {
title: text.title,
description: text.description,
};
}
+19
View File
@@ -0,0 +1,19 @@
import { Provider } from '@/components/provider';
import '../global.css';
import { i18n } from '@/lib/i18n';
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
return (
<html lang={lang} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<Provider locale={lang}>{children}</Provider>
</body>
</html>
);
}
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
+9 -2
View File
@@ -5,7 +5,14 @@ import { createFromSource } from 'fumadocs-core/search/server';
export const revalidate = false;
export const { staticGET: GET } = createFromSource(source, {
components: {
tokenizer: createDocsSearchTokenizer(),
localeMap: {
en: {
language: 'english',
},
'zh-CN': {
components: {
tokenizer: createDocsSearchTokenizer(),
},
},
},
});
-39
View File
@@ -1,39 +0,0 @@
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { Markdown } from 'fumadocs-core/content/md';
import { getTableOfContents } from 'fumadocs-core/content/toc';
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Metadata } from 'next';
import { getMDXComponents } from '@/components/mdx';
const title = '无限画布文档';
const description = '功能说明、操作手册、部署方式、开发文档、商务合作与赞助支持';
async function readDocsIndex() {
return readFile(join(process.cwd(), 'index.md'), 'utf8');
}
export default async function Page() {
const content = await readDocsIndex();
const toc = getTableOfContents(content);
return (
<DocsPage toc={toc}>
<DocsTitle>{title}</DocsTitle>
<DocsDescription>{description}</DocsDescription>
<DocsBody>
<Markdown components={getMDXComponents()} remarkPlugins={[remarkHeading]}>
{content}
</Markdown>
</DocsBody>
</DocsPage>
);
}
export function generateMetadata(): Metadata {
return {
title,
description,
};
}
@@ -1,39 +0,0 @@
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { Markdown } from 'fumadocs-core/content/md';
import { getTableOfContents } from 'fumadocs-core/content/toc';
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Metadata } from 'next';
import { getMDXComponents } from '@/components/mdx';
const title = '更新日志';
const description = '项目版本变更记录';
async function readChangelog() {
return readFile(join(process.cwd(), '..', 'CHANGELOG.md'), 'utf8');
}
export default async function ChangelogPage() {
const changelog = await readChangelog();
const toc = getTableOfContents(changelog);
return (
<DocsPage toc={toc}>
<DocsTitle>{title}</DocsTitle>
<DocsDescription>{description}</DocsDescription>
<DocsBody>
<Markdown components={getMDXComponents()} remarkPlugins={[remarkHeading]}>
{changelog}
</Markdown>
</DocsBody>
</DocsPage>
);
}
export function generateMetadata(): Metadata {
return {
title,
description,
};
}
-12
View File
@@ -1,12 +0,0 @@
import { Provider } from '@/components/provider';
import './global.css';
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<Provider>{children}</Provider>
</body>
</html>
);
}
+4 -3
View File
@@ -4,9 +4,10 @@ import { join } from 'node:path';
export const revalidate = false;
export async function GET() {
const docsIndex = await readFile(join(process.cwd(), 'index.md'), 'utf8');
const scan = source.getPages().map(getLLMText);
export async function GET(request: Request) {
const locale = new URL(request.url).searchParams.get('locale') ?? 'en';
const docsIndex = await readFile(join(process.cwd(), locale === 'zh-CN' ? 'index.zh-CN.md' : 'index.md'), 'utf8');
const scan = source.getPages(locale).map(getLLMText);
const scanned = await Promise.all(scan);
return new Response([docsIndex, ...scanned].join('\n\n'));
@@ -3,10 +3,11 @@ import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
export async function GET(req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
const locale = new URL(req.url).searchParams.get('locale') ?? 'en';
// remove the appended "content.md"
const page = source.getPage(slug?.slice(0, -1));
const page = source.getPage(slug?.slice(0, -1), locale);
if (!page) notFound();
return new Response(await getLLMText(page), {
@@ -17,7 +18,7 @@ export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/doc
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
return source.getPages('en').map((page) => ({
slug: getPageMarkdownUrl(page).segments,
}));
}
+4 -3
View File
@@ -5,7 +5,8 @@ import { join } from 'node:path';
export const revalidate = false;
export async function GET() {
const docsIndex = await readFile(join(process.cwd(), 'index.md'), 'utf8');
return new Response([docsIndex, llms(source).index()].join('\n\n'));
export async function GET(request: Request) {
const locale = new URL(request.url).searchParams.get('locale') ?? 'en';
const docsIndex = await readFile(join(process.cwd(), locale === 'zh-CN' ? 'index.zh-CN.md' : 'index.md'), 'utf8');
return new Response([docsIndex, llms(source).index(locale)].join('\n\n'));
}