From 4c6997a012acc6d91835ba0ee26fa14e24b7f350 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 1 Mar 2025 21:59:22 +0800 Subject: [PATCH] feat: implement internationalization with next-intl - Add multi-language support using next-intl - Configure routing and localization for English and Chinese - Update project structure to support i18n routing - Add middleware and navigation helpers for localized routing - Create message files for translations - Modify layout and page components to support internationalization --- messages/cn.json | 12 +++ messages/en.json | 12 +++ next.config.ts | 32 +++--- package.json | 1 + pnpm-lock.yaml | 97 +++++++++++++++++++ src/app/(public)/(home)/page.tsx | 94 ------------------ .../(protected)/dashboard/page.tsx | 0 src/app/[locale]/(public)/(home)/page.tsx | 65 +++++++++++++ src/app/[locale]/(public)/about/page.tsx | 20 ++++ .../blog/(blog)/category/[slug]/loading.tsx | 0 .../(public)/blog/(blog)/layout.tsx | 0 .../(public)/blog/(blog)/loading.tsx | 0 .../(public)/blog/(blog)/page.tsx | 0 .../(public)/blog/[...slug]/layout.tsx | 0 .../(public)/blog/[...slug]/loading.tsx | 0 .../(public)/blog/[...slug]/page.tsx | 0 src/app/{ => [locale]}/(public)/layout.tsx | 11 ++- src/app/[locale]/[...rest]/page.tsx | 5 + src/app/{ => [locale]}/auth/error/page.tsx | 0 .../auth/forgot-password/page.tsx | 0 src/app/{ => [locale]}/auth/layout.tsx | 0 src/app/{ => [locale]}/auth/loading.tsx | 0 src/app/{ => [locale]}/auth/login/page.tsx | 0 .../{ => [locale]}/auth/new-password/page.tsx | 0 src/app/{ => [locale]}/auth/register/page.tsx | 0 src/app/[locale]/layout.tsx | 82 ++++++++++++++++ src/app/[locale]/providers.tsx | 22 +++++ src/app/layout.tsx | 57 +---------- src/app/manifest.ts | 2 + src/app/page.tsx | 6 ++ src/i18n/navigation.ts | 8 ++ src/i18n/request.ts | 22 +++++ src/i18n/routing.ts | 19 ++++ src/middleware.ts | 19 ++++ src/sitemap.ts | 29 ++++++ 35 files changed, 454 insertions(+), 161 deletions(-) create mode 100644 messages/cn.json create mode 100644 messages/en.json delete mode 100644 src/app/(public)/(home)/page.tsx rename src/app/{ => [locale]}/(protected)/dashboard/page.tsx (100%) create mode 100644 src/app/[locale]/(public)/(home)/page.tsx create mode 100644 src/app/[locale]/(public)/about/page.tsx rename src/app/{ => [locale]}/(public)/blog/(blog)/category/[slug]/loading.tsx (100%) rename src/app/{ => [locale]}/(public)/blog/(blog)/layout.tsx (100%) rename src/app/{ => [locale]}/(public)/blog/(blog)/loading.tsx (100%) rename src/app/{ => [locale]}/(public)/blog/(blog)/page.tsx (100%) rename src/app/{ => [locale]}/(public)/blog/[...slug]/layout.tsx (100%) rename src/app/{ => [locale]}/(public)/blog/[...slug]/loading.tsx (100%) rename src/app/{ => [locale]}/(public)/blog/[...slug]/page.tsx (100%) rename src/app/{ => [locale]}/(public)/layout.tsx (66%) create mode 100644 src/app/[locale]/[...rest]/page.tsx rename src/app/{ => [locale]}/auth/error/page.tsx (100%) rename src/app/{ => [locale]}/auth/forgot-password/page.tsx (100%) rename src/app/{ => [locale]}/auth/layout.tsx (100%) rename src/app/{ => [locale]}/auth/loading.tsx (100%) rename src/app/{ => [locale]}/auth/login/page.tsx (100%) rename src/app/{ => [locale]}/auth/new-password/page.tsx (100%) rename src/app/{ => [locale]}/auth/register/page.tsx (100%) create mode 100644 src/app/[locale]/layout.tsx create mode 100644 src/app/[locale]/providers.tsx create mode 100644 src/app/page.tsx create mode 100644 src/i18n/navigation.ts create mode 100644 src/i18n/request.ts create mode 100644 src/i18n/routing.ts create mode 100644 src/middleware.ts create mode 100644 src/sitemap.ts diff --git a/messages/cn.json b/messages/cn.json new file mode 100644 index 0000000..b2c1aa7 --- /dev/null +++ b/messages/cn.json @@ -0,0 +1,12 @@ +{ + "HomePage": { + "title": "你好,世界!", + "about": "去关于页面" + }, + "AboutPage": { + "title": "关于" + }, + "BlogPage": { + "title": "博客" + } +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..5509ccf --- /dev/null +++ b/messages/en.json @@ -0,0 +1,12 @@ +{ + "HomePage": { + "title": "Hello world!", + "about": "Go to the about page" + }, + "AboutPage": { + "title": "About" + }, + "BlogPage": { + "title": "Blog" + } +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 06bb143..28bee29 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,15 @@ import type { NextConfig } from "next"; +import createNextIntlPlugin from 'next-intl/plugin'; import { withContentCollections } from "@content-collections/next"; +/** + * https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#next-config + */ +const withNextIntl = createNextIntlPlugin(); + +/** + * https://nextjs.org/docs/app/api-reference/config/next-config-js + */ const nextConfig: NextConfig = { /* config options here */ @@ -13,11 +22,13 @@ const nextConfig: NextConfig = { images: { // https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs // vercel has limits on image optimization, 1000 images per month - unoptimized: true, + // unoptimized: true, + // https://medium.com/@niniroula/nextjs-upgrade-next-image-and-dangerouslyallowsvg-c934060d79f8 // The requested resource "https://cdn.sanity.io/images/58a2mkbj/preview/xxx.svg?fit=max&auto=format" has type "image/svg+xml" // but dangerouslyAllowSVG is disabled - dangerouslyAllowSVG: true, + // dangerouslyAllowSVG: true, + remotePatterns: [ { protocol: "https", @@ -31,18 +42,13 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "randomuser.me", }, - { - protocol: "https", - hostname: "cdn.sanity.io", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end - }, - { - protocol: "https", - hostname: "via.placeholder.com", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end - }, ], }, }; -// https://www.content-collections.dev/docs/quickstart/next -// withContentCollections must be the outermost plugin -export default withContentCollections(nextConfig); +/** + * withContentCollections must be the outermost plugin + * + * https://www.content-collections.dev/docs/quickstart/next + */ +export default withContentCollections(withNextIntl(nextConfig)); diff --git a/package.json b/package.json index bf4fae5..faad8ed 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "mdast-util-toc": "^7.1.0", "motion": "^12.4.3", "next": "15.1.7", + "next-intl": "^3.26.5", "next-plausible": "^3.12.4", "next-safe-action": "^7.10.4", "next-themes": "^0.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8b652b..ed8707b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: next: specifier: 15.1.7 version: 15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next-intl: + specifier: ^3.26.5 + version: 3.26.5(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) next-plausible: specifier: ^3.12.4 version: 3.12.4(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -976,6 +979,24 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@formatjs/ecma402-abstract@2.3.3': + resolution: {integrity: sha512-pJT1OkhplSmvvr6i3CWTPvC/FGC06MbN5TNBfRO6Ox62AEz90eMq+dVvtX9Bl3jxCEkS0tATzDarRZuOLw7oFg==} + + '@formatjs/fast-memoize@2.2.6': + resolution: {integrity: sha512-luIXeE2LJbQnnzotY1f2U2m7xuQNj2DA8Vq4ce1BY9ebRZaoPB1+8eZ6nXpLzsxuW5spQxr7LdCg+CApZwkqkw==} + + '@formatjs/icu-messageformat-parser@2.11.1': + resolution: {integrity: sha512-o0AhSNaOfKoic0Sn1GkFCK4MxdRsw7mPJ5/rBpIqdvcC7MIuyUSW8WChUEvrK78HhNpYOgqCQbINxCTumJLzZA==} + + '@formatjs/icu-skeleton-parser@1.8.13': + resolution: {integrity: sha512-N/LIdTvVc1TpJmMt2jVg0Fr1F7Q1qJPdZSCs19unMskCmVQ/sa0H9L8PWt13vq+gLdLg1+pPsvBLydL1Apahjg==} + + '@formatjs/intl-localematcher@0.5.10': + resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} + + '@formatjs/intl-localematcher@0.6.0': + resolution: {integrity: sha512-4rB4g+3hESy1bHSBG3tDFaMY2CH67iT7yne1e+0CLTsGLDcmoEWWpJjjpWVaYgYfYuohIRuo0E+N536gd2ZHZA==} + '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} @@ -2063,6 +2084,9 @@ packages: supports-color: optional: true + decimal.js@10.5.0: + resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -2494,6 +2518,9 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + intl-messageformat@10.7.15: + resolution: {integrity: sha512-LRyExsEsefQSBjU2p47oAheoKz+EOJxSLDdjOaEjdriajfHsMXOmV/EhMvYSg9bAgCUHasuAC+mcUBe/95PfIg==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2855,6 +2882,16 @@ packages: resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==} engines: {node: ^18.0.0 || >=20.0.0} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@3.26.5: + resolution: {integrity: sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==} + peerDependencies: + next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + next-plausible@3.12.4: resolution: {integrity: sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg==} peerDependencies: @@ -3595,6 +3632,11 @@ packages: '@types/react': optional: true + use-intl@3.26.5: + resolution: {integrity: sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -4183,6 +4225,36 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@formatjs/ecma402-abstract@2.3.3': + dependencies: + '@formatjs/fast-memoize': 2.2.6 + '@formatjs/intl-localematcher': 0.6.0 + decimal.js: 10.5.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.6': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.1': + dependencies: + '@formatjs/ecma402-abstract': 2.3.3 + '@formatjs/icu-skeleton-parser': 1.8.13 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.13': + dependencies: + '@formatjs/ecma402-abstract': 2.3.3 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.10': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.0': + dependencies: + tslib: 2.8.1 + '@hexagon/base64@1.1.28': {} '@hookform/resolvers@4.1.0(react-hook-form@7.54.2(react@19.0.0))': @@ -5262,6 +5334,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.5.0: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 @@ -5775,6 +5849,13 @@ snapshots: inline-style-parser@0.2.4: {} + intl-messageformat@10.7.15: + dependencies: + '@formatjs/ecma402-abstract': 2.3.3 + '@formatjs/fast-memoize': 2.2.6 + '@formatjs/icu-messageformat-parser': 2.11.1 + tslib: 2.8.1 + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -6396,6 +6477,16 @@ snapshots: nanostores@0.11.4: {} + negotiator@1.0.0: {} + + next-intl@3.26.5(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + '@formatjs/intl-localematcher': 0.5.10 + negotiator: 1.0.0 + next: 15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + use-intl: 3.26.5(react@19.0.0) + next-plausible@3.12.4(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: next: 15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -7249,6 +7340,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.9 + use-intl@3.26.5(react@19.0.0): + dependencies: + '@formatjs/fast-memoize': 2.2.6 + intl-messageformat: 10.7.15 + react: 19.0.0 + use-sidecar@1.1.3(@types/react@19.0.9)(react@19.0.0): dependencies: detect-node-es: 1.1.0 diff --git a/src/app/(public)/(home)/page.tsx b/src/app/(public)/(home)/page.tsx deleted file mode 100644 index 01d56ee..0000000 --- a/src/app/(public)/(home)/page.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// import PromotekitScript from "@/components/affiliate/promotekit-stripe-checkout"; -// import { HomeCallToAction } from "@/components/home/home-cta"; -// import { HomeFAQ } from "@/components/home/home-faq"; -// import HomeFeatures from "@/components/home/home-features"; -// import { HomeFeaturesHeader } from "@/components/home/home-features-header"; -// import { HomeFeaturesMore } from "@/components/home/home-features-more"; -// import HomeHero from "@/components/home/home-hero"; -// import { HomeHowItWorks } from "@/components/home/home-how-it-works"; -// import { HomeIntroduction } from "@/components/home/home-introduction"; -// import HomeMonetization from "@/components/home/home-monetization"; -// import { HomeNewsletter } from "@/components/home/home-newsletter"; -// import HomePowered from "@/components/home/home-powered"; -// import HomePricing from "@/components/home/home-pricing"; -// import { HomeShowcase } from "@/components/home/home-showcase"; -// import { HomeTestimonials } from "@/components/home/home-testimonials"; -// import HomeVideo from "@/components/home/home-video"; -import CallToAction from "@/components/nsui/call-to-action"; -import ContentSection from "@/components/nsui/content-2"; -import FAQs from "@/components/nsui/faqs"; -import Features from "@/components/nsui/features-2"; -import FeaturesSection from "@/components/nsui/features-8"; -import HeroSection from "@/components/nsui/hero-section"; -import LogoCloud from "@/components/nsui/logo-cloud"; -import Pricing from "@/components/nsui/pricing"; -import StatsSection from "@/components/nsui/stats"; -import WallOfLoveSection from "@/components/nsui/testimonials"; -import { siteConfig } from "@/config/site"; -import { constructMetadata } from "@/lib/metadata"; - -export const metadata = constructMetadata({ - title: "", - canonicalUrl: `${siteConfig.url}/`, -}); - -export default async function HomePage() { - return ( - <> - {/* */} - -
- - - - - - {/* */} - - - - - - - - - - - - - - - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} - - {/* */} -
- - ); -} diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx similarity index 100% rename from src/app/(protected)/dashboard/page.tsx rename to src/app/[locale]/(protected)/dashboard/page.tsx diff --git a/src/app/[locale]/(public)/(home)/page.tsx b/src/app/[locale]/(public)/(home)/page.tsx new file mode 100644 index 0000000..c666993 --- /dev/null +++ b/src/app/[locale]/(public)/(home)/page.tsx @@ -0,0 +1,65 @@ +import CallToAction from "@/components/nsui/call-to-action"; +import ContentSection from "@/components/nsui/content-2"; +import FAQs from "@/components/nsui/faqs"; +import FeaturesSection from "@/components/nsui/features-8"; +import HeroSection from "@/components/nsui/hero-section"; +import LogoCloud from "@/components/nsui/logo-cloud"; +import Pricing from "@/components/nsui/pricing"; +import StatsSection from "@/components/nsui/stats"; +import WallOfLoveSection from "@/components/nsui/testimonials"; +import { siteConfig } from "@/config/site"; +import { constructMetadata } from "@/lib/metadata"; +import { getTranslations, setRequestLocale } from 'next-intl/server'; + +export const metadata = constructMetadata({ + title: "", + canonicalUrl: `${siteConfig.url}/`, +}); + +interface HomePageProps { + params: { locale: string }; +}; + +export default async function HomePage({ params }: HomePageProps) { + const { locale } = params; + + // Enable static rendering + setRequestLocale(locale); + + // Use getTranslations instead of useTranslations for async server components + const t = await getTranslations('HomePage'); + + return ( + <> + {/* */} + +
+ +
+

{t('title')}

+ {/* {t('about')} */} +
+ + + + + + {/* */} + + + + + + + + + + + + + + +
+ + ); +} diff --git a/src/app/[locale]/(public)/about/page.tsx b/src/app/[locale]/(public)/about/page.tsx new file mode 100644 index 0000000..1193679 --- /dev/null +++ b/src/app/[locale]/(public)/about/page.tsx @@ -0,0 +1,20 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; + +interface AboutPageProps { + params: { locale: string }; +}; + +export default async function AboutPage({ params }: AboutPageProps) { + const { locale } = params; + + // Enable static rendering + setRequestLocale(locale); + + const t = await getTranslations('AboutPage'); + + return ( +
+

{t('title')}

+
+ ); +} diff --git a/src/app/(public)/blog/(blog)/category/[slug]/loading.tsx b/src/app/[locale]/(public)/blog/(blog)/category/[slug]/loading.tsx similarity index 100% rename from src/app/(public)/blog/(blog)/category/[slug]/loading.tsx rename to src/app/[locale]/(public)/blog/(blog)/category/[slug]/loading.tsx diff --git a/src/app/(public)/blog/(blog)/layout.tsx b/src/app/[locale]/(public)/blog/(blog)/layout.tsx similarity index 100% rename from src/app/(public)/blog/(blog)/layout.tsx rename to src/app/[locale]/(public)/blog/(blog)/layout.tsx diff --git a/src/app/(public)/blog/(blog)/loading.tsx b/src/app/[locale]/(public)/blog/(blog)/loading.tsx similarity index 100% rename from src/app/(public)/blog/(blog)/loading.tsx rename to src/app/[locale]/(public)/blog/(blog)/loading.tsx diff --git a/src/app/(public)/blog/(blog)/page.tsx b/src/app/[locale]/(public)/blog/(blog)/page.tsx similarity index 100% rename from src/app/(public)/blog/(blog)/page.tsx rename to src/app/[locale]/(public)/blog/(blog)/page.tsx diff --git a/src/app/(public)/blog/[...slug]/layout.tsx b/src/app/[locale]/(public)/blog/[...slug]/layout.tsx similarity index 100% rename from src/app/(public)/blog/[...slug]/layout.tsx rename to src/app/[locale]/(public)/blog/[...slug]/layout.tsx diff --git a/src/app/(public)/blog/[...slug]/loading.tsx b/src/app/[locale]/(public)/blog/[...slug]/loading.tsx similarity index 100% rename from src/app/(public)/blog/[...slug]/loading.tsx rename to src/app/[locale]/(public)/blog/[...slug]/loading.tsx diff --git a/src/app/(public)/blog/[...slug]/page.tsx b/src/app/[locale]/(public)/blog/[...slug]/page.tsx similarity index 100% rename from src/app/(public)/blog/[...slug]/page.tsx rename to src/app/[locale]/(public)/blog/[...slug]/page.tsx diff --git a/src/app/(public)/layout.tsx b/src/app/[locale]/(public)/layout.tsx similarity index 66% rename from src/app/(public)/layout.tsx rename to src/app/[locale]/(public)/layout.tsx index d454882..a5a6e99 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/[locale]/(public)/layout.tsx @@ -1,18 +1,25 @@ import { Footer } from "@/components/layout/footer"; import { Navbar } from "@/components/marketing/navbar"; import { marketingConfig } from "@/config/marketing"; +import { setRequestLocale } from "next-intl/server"; interface MarketingLayoutProps { children: React.ReactNode; + params: { locale: string }; } export default async function MarketingLayout({ children, + params }: MarketingLayoutProps) { + const { locale } = params; + + // Enable static rendering + setRequestLocale(locale); + return (
- {/* */} - +
{children}
diff --git a/src/app/[locale]/[...rest]/page.tsx b/src/app/[locale]/[...rest]/page.tsx new file mode 100644 index 0000000..cc88c3a --- /dev/null +++ b/src/app/[locale]/[...rest]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function CatchAllPage() { + notFound(); +} \ No newline at end of file diff --git a/src/app/auth/error/page.tsx b/src/app/[locale]/auth/error/page.tsx similarity index 100% rename from src/app/auth/error/page.tsx rename to src/app/[locale]/auth/error/page.tsx diff --git a/src/app/auth/forgot-password/page.tsx b/src/app/[locale]/auth/forgot-password/page.tsx similarity index 100% rename from src/app/auth/forgot-password/page.tsx rename to src/app/[locale]/auth/forgot-password/page.tsx diff --git a/src/app/auth/layout.tsx b/src/app/[locale]/auth/layout.tsx similarity index 100% rename from src/app/auth/layout.tsx rename to src/app/[locale]/auth/layout.tsx diff --git a/src/app/auth/loading.tsx b/src/app/[locale]/auth/loading.tsx similarity index 100% rename from src/app/auth/loading.tsx rename to src/app/[locale]/auth/loading.tsx diff --git a/src/app/auth/login/page.tsx b/src/app/[locale]/auth/login/page.tsx similarity index 100% rename from src/app/auth/login/page.tsx rename to src/app/[locale]/auth/login/page.tsx diff --git a/src/app/auth/new-password/page.tsx b/src/app/[locale]/auth/new-password/page.tsx similarity index 100% rename from src/app/auth/new-password/page.tsx rename to src/app/[locale]/auth/new-password/page.tsx diff --git a/src/app/auth/register/page.tsx b/src/app/[locale]/auth/register/page.tsx similarity index 100% rename from src/app/auth/register/page.tsx rename to src/app/[locale]/auth/register/page.tsx diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..8944279 --- /dev/null +++ b/src/app/[locale]/layout.tsx @@ -0,0 +1,82 @@ +import "@/app/globals.css"; +import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts"; +import { Toaster } from "@/components/ui/sonner"; +import { routing } from '@/i18n/routing'; +import { constructMetadata } from "@/lib/metadata"; +import { cn } from "@/lib/utils"; +import { GeistMono } from "geist/font/mono"; +import { GeistSans } from "geist/font/sans"; +import type { Metadata, Viewport } from "next"; +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 { TailwindIndicator } from "@/components/tailwind-indicator"; + +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 })); +} + +interface LocaleLayoutProps { + children: ReactNode; + params: { locale: string }; +}; + +/** + * https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#layout + */ +export default async function LocaleLayout({ children, params }: LocaleLayoutProps) { + const { locale } = params; + + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(locale as any)) { + notFound(); + } + + // Enable static rendering + setRequestLocale(locale); + + // Providing all messages to the client + // side is the easiest way to get started + const messages = await getMessages(); + + // TODO: body min-h-screen -> size-full, check if this is correct on mobile + return ( + + + + + {children} + + + + + + + + + ); +} diff --git a/src/app/[locale]/providers.tsx b/src/app/[locale]/providers.tsx new file mode 100644 index 0000000..91439a2 --- /dev/null +++ b/src/app/[locale]/providers.tsx @@ -0,0 +1,22 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider } from "next-themes"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +export function Providers({ + children +}: React.PropsWithChildren) { + return ( + + + {children} + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bfdb3ab..923bc20 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,55 +1,8 @@ -import "@/app/globals.css"; -import type { Metadata, Viewport } from "next"; -import type { PropsWithChildren } from "react"; -import { constructMetadata } from "@/lib/metadata"; -import { cn } from "@/lib/utils"; -import { ThemeProvider } from "next-themes"; -import { Toaster } from "@/components/ui/sonner"; -import { TailwindIndicator } from "@/components/tailwind-indicator"; -import { GeistSans } from "geist/font/sans"; -import { GeistMono } from "geist/font/mono"; -import { fontSourceSans, fontSourceSerif4 } from "@/assets/fonts"; - -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' } - ] -}; +import { PropsWithChildren } from 'react'; +// 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 ( - - - - - {children} - - - - {/* */} - - - - ); + // Simply pass children through - the [locale] layout will handle the HTML structure + return children; } diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 712f430..0a82b09 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -4,6 +4,8 @@ import { siteConfig } from '@/config/site'; /** * Generates the Web App Manifest for the application * + * TODO: https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/app/manifest.ts + * * The manifest.json provides metadata used when the web app is installed on a * user's mobile device or desktop. See https://web.dev/add-manifest/ * diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..0ad8a7d --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +// This page only renders when the app is built statically (output: 'export') +export default function RootPage() { + redirect('/en'); +} \ No newline at end of file diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts new file mode 100644 index 0000000..57bd1c3 --- /dev/null +++ b/src/i18n/navigation.ts @@ -0,0 +1,8 @@ +import { createNavigation } from 'next-intl/navigation'; +import { routing } from '@/i18n/routing'; + +/** + * https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-navigation + */ +export const { Link, redirect, usePathname, useRouter, getPathname } = + createNavigation(routing); diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..f323835 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,22 @@ +import { getRequestConfig } from 'next-intl/server'; +import { routing } from './routing'; + +export default getRequestConfig(async ({ requestLocale }) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that the incoming `locale` is valid + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } + + return { + locale, + messages: ( + await (locale === 'en' + ? // When using Turbopack, this will enable HMR for `en` + import('../../messages/en.json') + : import(`../../messages/${locale}.json`)) + ).default + }; +}); \ No newline at end of file diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts new file mode 100644 index 0000000..fbfa2e2 --- /dev/null +++ b/src/i18n/routing.ts @@ -0,0 +1,19 @@ +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', + + pathnames: { + '/': '/' + } +}); + +export type Pathnames = keyof typeof routing.pathnames; +export type Locale = (typeof routing.locales)[number]; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..8299c5c --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,19 @@ +import createMiddleware from 'next-intl/middleware'; +import { routing } from '@/i18n/routing'; + +export default createMiddleware(routing); + +export const config = { + matcher: [ + // Enable a redirect to a matching locale at the root + '/', + + // Set a cookie to remember the previous locale for + // all requests that have a locale prefix + '/(cn|en)/:path*', + + // Enable redirects that add missing locales + // (e.g. `/pathnames` -> `/en/pathnames`) + '/((?!_next|_vercel|.*\\..*).*)' + ] +}; \ No newline at end of file diff --git a/src/sitemap.ts b/src/sitemap.ts new file mode 100644 index 0000000..dc1dc5c --- /dev/null +++ b/src/sitemap.ts @@ -0,0 +1,29 @@ +import { MetadataRoute } from 'next'; +import { routing, Locale } from '@/i18n/routing'; +import { getPathname } from '@/i18n/navigation'; +import { siteConfig } from './config/site'; + +export default function sitemap(): MetadataRoute.Sitemap { + return [...getEntries('/')]; +} + +type Href = Parameters[0]['href']; + +/** + * https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/app/sitemap.ts + */ +function getEntries(href: Href) { + return routing.locales.map((locale) => ({ + url: getUrl(href, locale), + alternates: { + languages: Object.fromEntries( + routing.locales.map((cur) => [cur, getUrl(href, cur)]) + ) + } + })); +} + +function getUrl(href: Href, locale: Locale) { + const pathname = getPathname({ locale, href }); + return siteConfig.url + pathname; +} \ No newline at end of file