feat: enhance internationalization with comprehensive locale support

- Add German and Chinese language translations
- Update routing configuration to support multiple locales (en, de, zh)
- Create new components for locale switching and navigation
- Implement dynamic error and not-found pages with internationalized content
- Refactor global styles and MDX styling
- Update middleware and navigation configuration for improved i18n routing
This commit is contained in:
javayhu 2025-03-02 01:11:44 +08:00
parent 9ff8fca3f4
commit a4e7a59e17
28 changed files with 404 additions and 174 deletions

8
global.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import en from './messages/en.json';
type Messages = typeof en;
declare global {
// Use type safe message keys with `next-intl`
interface IntlMessages extends Messages {}
}

View File

@ -1,12 +0,0 @@
{
"HomePage": {
"title": "你好,世界!",
"about": "去关于页面"
},
"AboutPage": {
"title": "关于"
},
"BlogPage": {
"title": "博客"
}
}

46
messages/de.json Normal file
View File

@ -0,0 +1,46 @@
{
"Error": {
"description": "<p>Es ist leider ein Problem aufgetreten.</p><p>Du kannst versuchen <retry>diese Seite neu zu laden</retry>.</p>",
"title": "Etwas ist schief gelaufen!"
},
"IndexPage": {
"description": "Dies ist ein Beispiel, das die Verwendung von <code>next-intl</code> mit dem Next.js App Router demonstriert. Bei Ändern der Sprache rechts oben ändert sich der Inhalt dieser Seite.",
"title": "next-intl Beispiel"
},
"LocaleLayout": {
"title": "next-intl Beispiel"
},
"LocaleSwitcher": {
"label": "Sprache ändern",
"locale": "{locale, select, de {🇩🇪 Deutsch} en {🇺🇸 English} zh {🇨🇳 中文} other {Unbekannt}}"
},
"Manifest": {
"name": ""
},
"Navigation": {
"home": "Start",
"pathnames": "Pfadnamen"
},
"NotFoundPage": {
"description": "Bitte überprüfe die Addressleiste deines Browsers oder verwende die Navigation um zu einer bekannten Seite zu wechseln.",
"title": "Seite nicht gefunden"
},
"PageLayout": {
"links": {
"docs": {
"description": "Erfahre mehr über next-intl in der offiziellen Dokumentation.",
"href": "https://next-intl.dev",
"title": "Dokumentation"
},
"source": {
"description": "Sieh dir den Quellcode dieses Beispiels auf GitHub an.",
"href": "https://github.com/amannn/next-intl/tree/main/examples/example-app-router",
"title": "Quellcode"
}
}
},
"PathnamesPage": {
"description": "<p>Auch die Pfadnamen sind internationalisiert.</p><p>Wenn du die Standardsprache Englisch verwendest, siehst du <code>/en/pathnames</code> in der Adressleiste des Browsers auf dieser Seite.</p><p>Wenn du die Sprache auf Deutsch änderst, wird die URL entsprechend lokalisiert (<code>/de/pfadnamen</code>).</p>",
"title": "Pfadnamen"
}
}

View File

@ -1,12 +1,46 @@
{
"HomePage": {
"title": "Hello world!",
"about": "Go to the about page"
"Error": {
"description": "<p>We've unfortunately encountered an error.</p><p>You can try to <retry>reload the page</retry> you were visiting.</p>",
"title": "Something went wrong!"
},
"AboutPage": {
"title": "About"
"IndexPage": {
"description": "This is a basic example that demonstrates the usage of <code>next-intl</code> with the Next.js App Router. Try changing the locale in the top right corner and see how the content changes.",
"title": "next-intl example"
},
"BlogPage": {
"title": "Blog"
"LocaleLayout": {
"title": "next-intl example"
},
"LocaleSwitcher": {
"label": "Change language",
"locale": "{locale, select, de {🇩🇪 Deutsch} en {🇺🇸 English} zh {🇨🇳 中文} other {Unknown}}"
},
"Manifest": {
"name": "next-intl example"
},
"Navigation": {
"home": "Home",
"pathnames": "Pathnames"
},
"NotFoundPage": {
"description": "Please double-check the browser address bar or use the navigation to go to a known page.",
"title": "Page not found"
},
"PageLayout": {
"links": {
"docs": {
"description": "Learn more about next-intl in the official docs.",
"href": "https://next-intl.dev",
"title": "Docs"
},
"source": {
"description": "Browse the source code of this example on GitHub.",
"href": "https://github.com/amannn/next-intl/tree/main/examples/example-app-router",
"title": "Source code"
}
}
},
"PathnamesPage": {
"description": "<p>The pathnames are internationalized too.</p><p>If you're using the default language English, you'll see <code>/en/pathnames</code> in the browser address bar on this page.</p><p>If you change the locale to German, the URL is localized accordingly (<code>/de/pfadnamen</code>).</p>",
"title": "Pathnames"
}
}
}

46
messages/zh.json Normal file
View File

@ -0,0 +1,46 @@
{
"Error": {
"description": "<p>很抱歉,我们遇到了一个错误。</p><p>您可以尝试<retry>重新加载</retry>您正在访问的页面。</p>",
"title": "出错了!"
},
"IndexPage": {
"description": "这是一个基础示例,展示了如何在 Next.js App Router 中使用 <code>next-intl</code>。尝试在右上角更改语言,看看内容如何变化。",
"title": "next-intl 示例"
},
"LocaleLayout": {
"title": "next-intl 示例"
},
"LocaleSwitcher": {
"label": "更改语言",
"locale": "{locale, select, de {🇩🇪 德语} en {🇺🇸 英语} zh {🇨🇳 中文} other {未知}}"
},
"Manifest": {
"name": "next-intl 示例"
},
"Navigation": {
"home": "首页",
"pathnames": "路径名称"
},
"NotFoundPage": {
"description": "请检查浏览器地址栏或使用导航前往已知页面。",
"title": "页面未找到"
},
"PageLayout": {
"links": {
"docs": {
"description": "在官方文档中了解更多关于 next-intl 的信息。",
"href": "https://next-intl.dev",
"title": "文档"
},
"source": {
"description": "在 GitHub 上浏览此示例的源代码。",
"href": "https://github.com/amannn/next-intl/tree/main/examples/example-app-router",
"title": "源代码"
}
}
},
"PathnamesPage": {
"description": "<p>路径名称也是国际化的。</p><p>如果您使用默认语言英语,您将在此页面的浏览器地址栏中看到 <code>/en/pathnames</code>。</p><p>如果您将语言更改为中文URL 将相应地本地化(例如 <code>/cn/路径名称</code>)。</p>",
"title": "路径名称"
}
}

View File

@ -7,6 +7,13 @@ import { withContentCollections } from "@content-collections/next";
*/
const withNextIntl = createNextIntlPlugin();
module.exports = {
experimental: {
// https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
missingSuspenseWithCSRBailout: false,
},
}
/**
* https://nextjs.org/docs/app/api-reference/config/next-config-js
*/

View File

@ -28,7 +28,7 @@ export default async function HomePage(props: HomePageProps) {
setRequestLocale(locale);
// Use getTranslations instead of useTranslations for async server components
const t = await getTranslations('HomePage');
const t = await getTranslations('IndexPage');
return (
<>
@ -36,7 +36,7 @@ export default async function HomePage(props: HomePageProps) {
<div className="mt-12 flex flex-col gap-16">
<div>
<div className="text-center">
<h1>{t('title')}</h1>
{/* <Link href="/about">{t('about')}</Link> */}
</div>

View File

@ -1,21 +0,0 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
interface AboutPageProps {
params: Promise<{ locale: string }>;
};
export default async function AboutPage(props: AboutPageProps) {
const params = await props.params;
const { locale } = params;
// Enable static rendering
setRequestLocale(locale);
const t = await getTranslations('AboutPage');
return (
<div>
<h1>{t('title')}</h1>
</div>
);
}

View File

@ -11,9 +11,8 @@ import Link from 'next/link';
import Image from 'next/image';
import { BlogToc } from '@/components/blog/blog-toc';
import { Mdx } from '@/components/marketing/blog/mdx-component';
import '@/app/mdx.css';
import AllPostsButton from '@/components/blog/all-posts-button';
import '@/app/styles/mdx.css';
/**
* Gets the blog post from the params

View File

@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
export default function CatchAllPage() {
notFound();
notFound();
}

View File

@ -1,48 +1,41 @@
import "@/app/globals.css";
import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/marketing/navbar";
import { TailwindIndicator } from "@/components/tailwind-indicator";
import { LangAttributeSetter } from "@/components/layout/lang-attribute-setter";
import { Toaster } from "@/components/ui/sonner";
import { marketingConfig } from "@/config/marketing";
import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts";
import { Footer } from '@/components/layout/footer';
import { Navbar } from '@/components/marketing/navbar';
import { TailwindIndicator } from '@/components/tailwind-indicator';
import { marketingConfig } from '@/config/marketing';
import { routing } from '@/i18n/routing';
import { constructMetadata } from "@/lib/metadata";
import type { Metadata, Viewport } from "next";
import { cn } from "@/lib/utils";
import { GeistMono } from "geist/font/mono";
import { GeistSans } from "geist/font/sans";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { Providers } from "./providers";
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import { Providers } from './providers';
export const metadata: Metadata = constructMetadata();
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
minimumScale: 1,
maximumScale: 1,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' }
]
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
import '@/app/styles/globals.css';
interface LocaleLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
};
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#layout
*/
export default async function LocaleLayout(props: LocaleLayoutProps) {
const { children } = props;
const params = await props.params;
const { locale } = params;
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata(props: Omit<LocaleLayoutProps, 'children'>) {
const { locale } = await props.params;
const t = await getTranslations({ locale, namespace: 'LocaleLayout' });
return {
title: t('title')
};
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
// Ensure that the incoming `locale` is valid
if (!routing.locales.includes(locale as any)) {
@ -56,26 +49,30 @@ export default async function LocaleLayout(props: LocaleLayoutProps) {
// side is the easiest way to get started
const messages = await getMessages();
// Apply all the classes and providers without the html/body tags
// as those are now handled by the root layout
return (
<>
{/* Client component that sets the lang attribute on the html element */}
<LangAttributeSetter locale={locale} />
<NextIntlClientProvider messages={messages}>
<Providers>
<div className="flex flex-col min-h-screen">
<Navbar scroll={true} config={marketingConfig} />
<main className="flex-1">{children}</main>
<Footer />
</div>
<html lang={locale} suppressHydrationWarning>
<body className={cn(
"size-full antialiased",
GeistSans.className,
fontSourceSerif4.variable,
fontSourceSans.variable,
GeistSans.variable,
GeistMono.variable,
)}>
<NextIntlClientProvider messages={messages}>
<Providers>
<div className="flex flex-col min-h-screen">
<Navbar scroll={true} config={marketingConfig} />
<main className="flex-1">{children}</main>
<Footer />
</div>
<Toaster richColors position="top-right" offset={64} />
<Toaster richColors position="top-right" offset={64} />
<TailwindIndicator />
</Providers>
</NextIntlClientProvider>
</>
<TailwindIndicator />
</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,25 @@
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import Link from "next/link";
/**
* Note that `app/[locale]/[...rest]/page.tsx`
* is necessary for this page to render.
*/
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<Logo className="size-12" />
<h1 className="text-4xl font-bold">404</h1>
<p className="text-balance text-center text-xl font-medium px-4">
Sorry, the page you are looking for does not exist.
</p>
<Button asChild size="lg" variant="default">
<Link href="/">Back to home</Link>
</Button>
</div>
);
}

View File

@ -1,28 +1,13 @@
import { PropsWithChildren } from 'react';
import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts";
import { cn } from "@/lib/utils";
import { GeistMono } from "geist/font/mono";
import { GeistSans } from "geist/font/sans";
import {ReactNode} from 'react';
interface Props {
children: ReactNode;
};
/**
* 1. Root layout must include html and body tags.
* 2. We can't directly access the locale here.
* 3. The locale layout will set the correct lang attribute on the html element
* via data attributes (@/components/layout/lang-attribute-setter.tsx).
* Since we have a `not-found.tsx` page on the root, a layout file
* is required, even if it's just passing children through.
*/
export default function RootLayout({ children }: PropsWithChildren) {
return (
<html suppressHydrationWarning>
<body className={cn(
"size-full antialiased",
GeistSans.className,
fontSourceSerif4.variable,
fontSourceSans.variable,
GeistSans.variable,
GeistMono.variable,
)}>
{children}
</body>
</html>
);
export default function RootLayout({children}: Props) {
return children;
}

View File

@ -1,5 +0,0 @@
import { Loader2Icon } from "lucide-react";
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -1,21 +1,18 @@
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import Link from "next/link";
"use client";
export default function NotFound() {
import Error from "next/error";
/**
* This page renders when a route like `/unknown.txt` is requested.
* In this case, the layout at `app/[locale]/layout.tsx` receives
* an invalid value as the `[locale]` param and calls `notFound()`.
*/
export default function GlobalNotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<Logo className="size-12" />
<h1 className="text-4xl font-bold">404</h1>
<p className="text-balance text-center text-xl font-medium px-4">
Sorry, the page you are looking for does not exist.
</p>
<Button asChild size="lg" variant="default">
<Link href="/">Back to home</Link>
</Button>
</div>
<html lang="en">
<body>
<Error statusCode={404} />;
</body>
</html>
);
}

View File

@ -1,6 +1,9 @@
import { redirect } from 'next/navigation';
// This page only renders when the app is built statically (output: 'export')
/**
* This page only renders when the app is built statically (output: 'export')
* and the `redirect` function is used to redirect to the default locale.
*/
export default function RootPage() {
redirect('/en');
}
redirect("/en");
}

View File

@ -0,0 +1,21 @@
import { useLocale, useTranslations } from "next-intl";
import { routing } from "@/i18n/routing";
import LocaleSwitcherSelect from "./LocaleSwitcherSelect";
/**
* This component is used to render a locale switcher.
*/
export default function LocaleSwitcher() {
const t = useTranslations("LocaleSwitcher");
const locale = useLocale();
return (
<LocaleSwitcherSelect defaultValue={locale} label={t("label")}>
{routing.locales.map((item) => (
<option key={item} value={item}>
{t("locale", { locale: item })}
</option>
))}
</LocaleSwitcherSelect>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import clsx from "clsx";
import { useParams } from "next/navigation";
import { ChangeEvent, ReactNode, useTransition } from "react";
import { Locale } from "@/i18n/routing";
import { usePathname, useRouter } from "@/i18n/navigation";
interface LocaleSwitcherSelectProps {
children: ReactNode;
defaultValue: string;
label: string;
};
/**
* This component is used to render a select element for the locale switcher.
*/
export default function LocaleSwitcherSelect({
children,
defaultValue,
label
}: LocaleSwitcherSelectProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
const params = useParams();
function onSelectChange(event: ChangeEvent<HTMLSelectElement>) {
const nextLocale = event.target.value as Locale;
startTransition(() => {
router.replace(
// @ts-expect-error -- TypeScript will validate that only known `params`
// are used in combination with a given `pathname`. Since the two will
// always match for the current route, we can skip runtime checks.
{ pathname, params },
{ locale: nextLocale }
);
});
}
return (
<label
className={clsx(
"relative text-gray-400",
isPending && "transition-opacity [&:disabled]:opacity-30"
)}
>
<p className="sr-only">{label}</p>
<select
className="inline-flex appearance-none bg-transparent py-3 pl-2 pr-6"
defaultValue={defaultValue}
disabled={isPending}
onChange={onSelectChange}
>
{children}
</select>
<span className="pointer-events-none absolute right-2 top-[8px]"></span>
</label>
);
}

View File

@ -0,0 +1,18 @@
import {useTranslations} from 'next-intl';
import LocaleSwitcher from './LocaleSwitcher';
import NavigationLink from './NavigationLink';
export default function Navigation() {
const t = useTranslations('Navigation');
return (
<div className="bg-slate-850">
<nav className="container flex justify-between p-2 text-white">
<div>
<NavigationLink href="/">{t('home')}</NavigationLink>
</div>
<LocaleSwitcher />
</nav>
</div>
);
}

View File

@ -0,0 +1,30 @@
"use client";
import { Link } from "@/i18n/navigation";
import clsx from "clsx";
import { useSelectedLayoutSegment } from "next/navigation";
import { ComponentProps } from "react";
/**
* This component is used to render a link in the navigation bar.
*/
export default function NavigationLink({
href,
...rest
}: ComponentProps<typeof Link>) {
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : "/";
const isActive = pathname === href;
return (
<Link
aria-current={isActive ? "page" : undefined}
className={clsx(
"inline-block px-2 py-3 transition-colors",
isActive ? "text-white" : "text-gray-400 hover:text-gray-200"
)}
href={href}
{...rest}
/>
);
}

View File

@ -26,6 +26,7 @@ import { MarketingConfig } from '@/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ArrowUpRightIcon } from 'lucide-react';
import LocaleSwitcher from '../LocaleSwitcher';
interface NavBarProps {
scroll?: boolean;
@ -171,6 +172,7 @@ export function Navbar({ scroll, config }: NavBarProps) {
)}
<ThemeSwitcher />
<LocaleSwitcher />
</div>
</nav>

View File

@ -1,8 +1,5 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from '@/i18n/routing';
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-navigation
*/
export const { Link, redirect, usePathname, useRouter, getPathname } =
export const { Link, getPathname, redirect, usePathname, useRouter } =
createNavigation(routing);

View File

@ -1,5 +1,5 @@
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
// This typically corresponds to the `[locale]` segment
@ -13,10 +13,10 @@ export default getRequestConfig(async ({ requestLocale }) => {
return {
locale,
messages: (
await (locale === 'en'
await (locale === "en"
? // When using Turbopack, this will enable HMR for `en`
import('../../messages/en.json')
import("../../messages/en.json")
: import(`../../messages/${locale}.json`))
).default
};
});
});

View File

@ -1,17 +1,10 @@
import { defineRouting } from 'next-intl/routing';
import { defineRouting } from "next-intl/routing";
/**
* https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-routing
*/
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'cn'],
// Used when no locale matches
defaultLocale: 'en',
locales: ["en", "de", "zh"],
defaultLocale: "en",
pathnames: {
'/': '/'
"/": "/",
}
});

View File

@ -1,5 +1,5 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from '@/i18n/routing';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
@ -10,10 +10,10 @@ export const config = {
// Set a cookie to remember the previous locale for
// all requests that have a locale prefix
'/(cn|en)/:path*',
'/(de|en)/:path*',
// Enable redirects that add missing locales
// (e.g. `/pathnames` -> `/en/pathnames`)
'/((?!_next|_vercel|.*\\..*).*)'
]
};
};