From c6923d7cf50b7fe8face7bce6051500911e1a8c5 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Tue, 28 May 2024 23:03:46 +0200 Subject: [PATCH] feat: add nextjs app router example --- .../src/app/[lang]/app-router-demo/page.tsx | 4 ++ examples/nextjs-swc/src/app/[lang]/layout.tsx | 38 ++++++++++++++ examples/nextjs-swc/src/app/[lang]/page.tsx | 9 ++++ examples/nextjs-swc/src/appRouterI18n.ts | 37 +++++++++++++ .../nextjs-swc/src/components/Developers.tsx | 3 ++ .../index.tsx => components/HomePage.tsx} | 34 ++++-------- .../src/components/LinguiClientProvider.tsx | 25 +++++++++ .../nextjs-swc/src/components/Switcher.tsx | 22 +++++--- examples/nextjs-swc/src/locales/en.po | 15 +++--- examples/nextjs-swc/src/locales/es.po | 15 +++--- examples/nextjs-swc/src/locales/pseudo.po | 15 +++--- examples/nextjs-swc/src/locales/sr.po | 15 +++--- examples/nextjs-swc/src/middleware.ts | 52 +++++++++++++++++++ .../pages/[lang]/pages-router-demo/index.tsx | 30 +++++++++++ examples/nextjs-swc/src/pages/_app.tsx | 10 ++-- .../src/{utils.ts => pagesRouterI18n.ts} | 6 +-- examples/nextjs-swc/src/withLingui.tsx | 45 ++++++++++++++++ examples/nextjs-swc/tsconfig.json | 4 +- 18 files changed, 307 insertions(+), 72 deletions(-) create mode 100644 examples/nextjs-swc/src/app/[lang]/app-router-demo/page.tsx create mode 100644 examples/nextjs-swc/src/app/[lang]/layout.tsx create mode 100644 examples/nextjs-swc/src/app/[lang]/page.tsx create mode 100644 examples/nextjs-swc/src/appRouterI18n.ts rename examples/nextjs-swc/src/{pages/index.tsx => components/HomePage.tsx} (59%) create mode 100644 examples/nextjs-swc/src/components/LinguiClientProvider.tsx create mode 100644 examples/nextjs-swc/src/middleware.ts create mode 100644 examples/nextjs-swc/src/pages/[lang]/pages-router-demo/index.tsx rename examples/nextjs-swc/src/{utils.ts => pagesRouterI18n.ts} (88%) create mode 100644 examples/nextjs-swc/src/withLingui.tsx diff --git a/examples/nextjs-swc/src/app/[lang]/app-router-demo/page.tsx b/examples/nextjs-swc/src/app/[lang]/app-router-demo/page.tsx new file mode 100644 index 000000000..842d5515e --- /dev/null +++ b/examples/nextjs-swc/src/app/[lang]/app-router-demo/page.tsx @@ -0,0 +1,4 @@ +import { HomePage } from '../../../components/HomePage' +import { withLinguiPage } from '../../../withLingui' + +export default withLinguiPage(HomePage) diff --git a/examples/nextjs-swc/src/app/[lang]/layout.tsx b/examples/nextjs-swc/src/app/[lang]/layout.tsx new file mode 100644 index 000000000..11f1efef5 --- /dev/null +++ b/examples/nextjs-swc/src/app/[lang]/layout.tsx @@ -0,0 +1,38 @@ +import linguiConfig from '../../../lingui.config' +import { allI18nInstances, allMessages } from '../../appRouterI18n' +import { LinguiClientProvider } from '../../components/LinguiClientProvider' +import { PageLangParam, withLinguiLayout } from '../../withLingui' +import React from 'react' +import { t } from '@lingui/macro' + +export async function generateStaticParams() { + return linguiConfig.locales.map((lang) => ({ lang })) +} + +export function generateMetadata({ params }: PageLangParam) { + const i18n = allI18nInstances[params.lang]! + + return { + title: t(i18n)`Translation Demo` + } +} + +export default withLinguiLayout(function RootLayout({ + children, + params: { lang } +}) { + return ( + + +
+ + {children} + +
+ + + ) +}) diff --git a/examples/nextjs-swc/src/app/[lang]/page.tsx b/examples/nextjs-swc/src/app/[lang]/page.tsx new file mode 100644 index 000000000..9498097d8 --- /dev/null +++ b/examples/nextjs-swc/src/app/[lang]/page.tsx @@ -0,0 +1,9 @@ +export default function Index() { + return ( + <> + This is the homepage of the demo app. This page is not localized. You can + go to the App router demo or the{' '} + Pages router demo. + + ) +} diff --git a/examples/nextjs-swc/src/appRouterI18n.ts b/examples/nextjs-swc/src/appRouterI18n.ts new file mode 100644 index 000000000..8bd7b24db --- /dev/null +++ b/examples/nextjs-swc/src/appRouterI18n.ts @@ -0,0 +1,37 @@ +import 'server-only' + +import linguiConfig from '../lingui.config' +import { I18n, Messages, setupI18n } from '@lingui/core' + +const { locales } = linguiConfig +// optionally use a stricter union type +type SupportedLocales = string + +async function loadCatalog(locale: SupportedLocales): Promise<{ + [k: string]: Messages +}> { + const { messages } = await import(`./locales/${locale}.po`) + return { + [locale]: messages + } +} +const catalogs = await Promise.all(locales.map(loadCatalog)) + +// transform array of catalogs into a single object +export const allMessages = catalogs.reduce((acc, oneCatalog) => { + return { ...acc, ...oneCatalog } +}, {}) + +type AllI18nInstances = { [K in SupportedLocales]: I18n } + +export const allI18nInstances: AllI18nInstances = locales.reduce( + (acc, locale) => { + const messages = allMessages[locale] ?? {} + const i18n = setupI18n({ + locale, + messages: { [locale]: messages } + }) + return { ...acc, [locale]: i18n } + }, + {} +) diff --git a/examples/nextjs-swc/src/components/Developers.tsx b/examples/nextjs-swc/src/components/Developers.tsx index 027f5bf4d..64ef1ed61 100644 --- a/examples/nextjs-swc/src/components/Developers.tsx +++ b/examples/nextjs-swc/src/components/Developers.tsx @@ -1,3 +1,6 @@ +'use client' +// this is a client component because it uses the `useState` hook + import { useState } from 'react' import { Trans, Plural } from '@lingui/macro' diff --git a/examples/nextjs-swc/src/pages/index.tsx b/examples/nextjs-swc/src/components/HomePage.tsx similarity index 59% rename from examples/nextjs-swc/src/pages/index.tsx rename to examples/nextjs-swc/src/components/HomePage.tsx index 21c3cc420..59a0a3482 100644 --- a/examples/nextjs-swc/src/pages/index.tsx +++ b/examples/nextjs-swc/src/components/HomePage.tsx @@ -1,28 +1,14 @@ -import { t, Trans } from '@lingui/macro' -import { GetStaticProps, NextPage } from 'next' +import React from 'react' +import { useLingui } from '@lingui/react' import Head from 'next/head' -import { AboutText } from '../components/AboutText' -import Developers from '../components/Developers' -import { Switcher } from '../components/Switcher' +import { t, Trans } from '@lingui/macro' +import { Switcher } from './Switcher' +import { AboutText } from './AboutText' +import Developers from './Developers' import styles from '../styles/Index.module.css' -import { loadCatalog } from '../utils' -import { useLingui } from '@lingui/react' -export const getStaticProps: GetStaticProps = async (ctx) => { - const translation = await loadCatalog(ctx.locale!) - return { - props: { - translation - } - } -} - -const Index: NextPage = () => { - /** - * This hook is needed to subscribe your - * component for changes if you use t`` macro - */ - useLingui() +export const HomePage = () => { + const { i18n } = useLingui() return (
@@ -32,7 +18,7 @@ const Index: NextPage = () => { component tree and React Context is not being passed down to the components placed in the . That means we cannot use the component here and instead have to use `t` macro. */} - {t`Translation Demo`} + {t(i18n)`Translation Demo`} @@ -51,5 +37,3 @@ const Index: NextPage = () => {
) } - -export default Index diff --git a/examples/nextjs-swc/src/components/LinguiClientProvider.tsx b/examples/nextjs-swc/src/components/LinguiClientProvider.tsx new file mode 100644 index 000000000..880f952bb --- /dev/null +++ b/examples/nextjs-swc/src/components/LinguiClientProvider.tsx @@ -0,0 +1,25 @@ +'use client' + +import { I18nProvider } from '@lingui/react' +import { type Messages, setupI18n } from '@lingui/core' +import { useState } from 'react' + +type Props = { + children: React.ReactNode + initialLocale: string + initialMessages: Messages +} + +export function LinguiClientProvider({ + children, + initialLocale, + initialMessages +}: Props) { + const [i18n] = useState(() => { + return setupI18n({ + locale: initialLocale, + messages: { [initialLocale]: initialMessages } + }) + }) + return {children} +} diff --git a/examples/nextjs-swc/src/components/Switcher.tsx b/examples/nextjs-swc/src/components/Switcher.tsx index 2b43020ba..eee524d57 100644 --- a/examples/nextjs-swc/src/components/Switcher.tsx +++ b/examples/nextjs-swc/src/components/Switcher.tsx @@ -1,23 +1,26 @@ -import { useRouter } from 'next/router' +'use client' +// this is a client component because it uses the `useState` hook + import { useState } from 'react' -import { t, msg } from '@lingui/macro' -import { MessageDescriptor } from '@lingui/core/src' +import { msg } from '@lingui/macro' import { useLingui } from '@lingui/react' +import { usePathname, useRouter } from 'next/navigation' type LOCALES = 'en' | 'sr' | 'es' | 'pseudo' -const languages: { [key: string]: MessageDescriptor } = { +const languages = { en: msg`English`, sr: msg`Serbian`, es: msg`Spanish` -} +} as const export function Switcher() { const router = useRouter() const { i18n } = useLingui() + const pathname = usePathname() const [locale, setLocale] = useState( - router.locale!.split('-')[0] as LOCALES + pathname?.split('/')[1] as LOCALES ) // disabled for DEMO - so we can demonstrate the 'pseudo' locale functionality @@ -28,8 +31,11 @@ export function Switcher() { function handleChange(event: React.ChangeEvent) { const locale = event.target.value as LOCALES + const pathNameWithoutLocale = pathname?.split('/')?.slice(2) ?? [] + const newPath = `/${locale}/${pathNameWithoutLocale.join('/')}` + setLocale(locale) - router.push(router.pathname, router.pathname, { locale }) + router.push(newPath) } return ( @@ -37,7 +43,7 @@ export function Switcher() { {Object.keys(languages).map((locale) => { return ( ) })} diff --git a/examples/nextjs-swc/src/locales/en.po b/examples/nextjs-swc/src/locales/en.po index 8cf61f511..0c5f27f36 100644 --- a/examples/nextjs-swc/src/locales/en.po +++ b/examples/nextjs-swc/src/locales/en.po @@ -13,11 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -#: src/components/Developers.tsx:20 +#: src/components/Developers.tsx:23 msgid "{selected, plural, one {Developer} other {Developers}}" msgstr "{selected, plural, one {Developer} other {Developers}}" -#: src/components/Switcher.tsx:10 +#: src/components/Switcher.tsx:12 msgid "English" msgstr "English" @@ -26,22 +26,23 @@ msgstr "English" msgid "next-explanation" msgstr "Next.js is an open-source React front-end development web framework that enables functionality such as server-side rendering and generating static websites for React based web applications. It is a production-ready framework that allows developers to quickly create static and dynamic JAMstack websites and is used widely by many large companies." -#: src/components/Developers.tsx:9 +#: src/components/Developers.tsx:12 msgid "Plural Test: How many developers?" msgstr "Plural Test: How many developers?" -#: src/components/Switcher.tsx:11 +#: src/components/Switcher.tsx:13 msgid "Serbian" msgstr "Serbian" -#: src/components/Switcher.tsx:12 +#: src/components/Switcher.tsx:14 msgid "Spanish" msgstr "Spanish" -#: src/pages/index.tsx:28 +#: src/app/[lang]/layout.tsx:16 +#: src/components/HomePage.tsx:21 msgid "Translation Demo" msgstr "Translation Demo" -#: src/pages/index.tsx:35 +#: src/components/HomePage.tsx:28 msgid "Welcome to <0>Next.js!" msgstr "Welcome to <0>Next.js!" diff --git a/examples/nextjs-swc/src/locales/es.po b/examples/nextjs-swc/src/locales/es.po index 663b3ddd0..7089e0fdd 100644 --- a/examples/nextjs-swc/src/locales/es.po +++ b/examples/nextjs-swc/src/locales/es.po @@ -13,11 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -#: src/components/Developers.tsx:20 +#: src/components/Developers.tsx:23 msgid "{selected, plural, one {Developer} other {Developers}}" msgstr "{selected, plural, one {Programador} other {Programadores}}" -#: src/components/Switcher.tsx:10 +#: src/components/Switcher.tsx:12 msgid "English" msgstr "Inglés" @@ -26,22 +26,23 @@ msgstr "Inglés" msgid "next-explanation" msgstr "Next.js es un marco de trabajo web de desarrollo front-end de React de código abierto que permite funciones como la representación del lado del servidor y la generación de sitios web estáticos para aplicaciones web basadas en React. Es un marco listo para producción que permite a los desarrolladores crear rápidamente sitios web JAMstack estáticos y dinámicos y es ampliamente utilizado por muchas grandes empresas." -#: src/components/Developers.tsx:9 +#: src/components/Developers.tsx:12 msgid "Plural Test: How many developers?" msgstr "Prueba Plural: Cuantos programadores?" -#: src/components/Switcher.tsx:11 +#: src/components/Switcher.tsx:13 msgid "Serbian" msgstr "Serbio" -#: src/components/Switcher.tsx:12 +#: src/components/Switcher.tsx:14 msgid "Spanish" msgstr "Español" -#: src/pages/index.tsx:28 +#: src/app/[lang]/layout.tsx:16 +#: src/components/HomePage.tsx:21 msgid "Translation Demo" msgstr "Demostración de Traducción" -#: src/pages/index.tsx:35 +#: src/components/HomePage.tsx:28 msgid "Welcome to <0>Next.js!" msgstr "Bienvenido a <0>Next.js!" diff --git a/examples/nextjs-swc/src/locales/pseudo.po b/examples/nextjs-swc/src/locales/pseudo.po index c2158ba7a..a907b95ba 100644 --- a/examples/nextjs-swc/src/locales/pseudo.po +++ b/examples/nextjs-swc/src/locales/pseudo.po @@ -13,11 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -#: src/components/Developers.tsx:20 +#: src/components/Developers.tsx:23 msgid "{selected, plural, one {Developer} other {Developers}}" msgstr "" -#: src/components/Switcher.tsx:10 +#: src/components/Switcher.tsx:12 msgid "English" msgstr "" @@ -26,22 +26,23 @@ msgstr "" msgid "next-explanation" msgstr "" -#: src/components/Developers.tsx:9 +#: src/components/Developers.tsx:12 msgid "Plural Test: How many developers?" msgstr "" -#: src/components/Switcher.tsx:11 +#: src/components/Switcher.tsx:13 msgid "Serbian" msgstr "" -#: src/components/Switcher.tsx:12 +#: src/components/Switcher.tsx:14 msgid "Spanish" msgstr "" -#: src/pages/index.tsx:28 +#: src/app/[lang]/layout.tsx:16 +#: src/components/HomePage.tsx:21 msgid "Translation Demo" msgstr "" -#: src/pages/index.tsx:35 +#: src/components/HomePage.tsx:28 msgid "Welcome to <0>Next.js!" msgstr "" diff --git a/examples/nextjs-swc/src/locales/sr.po b/examples/nextjs-swc/src/locales/sr.po index 748509afa..9db9c88fc 100644 --- a/examples/nextjs-swc/src/locales/sr.po +++ b/examples/nextjs-swc/src/locales/sr.po @@ -13,11 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -#: src/components/Developers.tsx:20 +#: src/components/Developers.tsx:23 msgid "{selected, plural, one {Developer} other {Developers}}" msgstr "{selected, plural, one {Програмер} other {Програмера}}" -#: src/components/Switcher.tsx:10 +#: src/components/Switcher.tsx:12 msgid "English" msgstr "Енглески" @@ -26,22 +26,23 @@ msgstr "Енглески" msgid "next-explanation" msgstr "Некст.јс је отворени изворни Реацт-ов развојни вебоквир који омогућава функционалност као што је приказивање на страни сервера и генерисање статичких веблокација за веб апликације засноване на Реацт-у." -#: src/components/Developers.tsx:9 +#: src/components/Developers.tsx:12 msgid "Plural Test: How many developers?" msgstr "Тест за Множину: Колико програмера?" -#: src/components/Switcher.tsx:11 +#: src/components/Switcher.tsx:13 msgid "Serbian" msgstr "Српски" -#: src/components/Switcher.tsx:12 +#: src/components/Switcher.tsx:14 msgid "Spanish" msgstr "Шпански" -#: src/pages/index.tsx:28 +#: src/app/[lang]/layout.tsx:16 +#: src/components/HomePage.tsx:21 msgid "Translation Demo" msgstr "Демо Превод" -#: src/pages/index.tsx:35 +#: src/components/HomePage.tsx:28 msgid "Welcome to <0>Next.js!" msgstr "Добродошли у <0>Нект.јс!" diff --git a/examples/nextjs-swc/src/middleware.ts b/examples/nextjs-swc/src/middleware.ts new file mode 100644 index 000000000..bd051c14a --- /dev/null +++ b/examples/nextjs-swc/src/middleware.ts @@ -0,0 +1,52 @@ +/* + * For more info see + * https://nextjs.org/docs/app/building-your-application/routing/internationalization + * */ +import { type NextRequest, NextResponse } from 'next/server' + +import Negotiator from 'negotiator' +import linguiConfig from '../lingui.config' + +const { locales } = linguiConfig + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + const pathnameHasLocale = locales.some( + (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` + ) + + if (pathnameHasLocale) return + + // Redirect if there is no locale + const locale = getRequestLocale(request.headers) + request.nextUrl.pathname = `/${locale}${pathname}` + // e.g. incoming request is /products + // The new URL is now /en/products + return NextResponse.redirect(request.nextUrl) +} + +function getRequestLocale(requestHeaders: Headers): string { + const langHeader = requestHeaders.get('accept-language') || undefined + const languages = new Negotiator({ + headers: { 'accept-language': langHeader } + }).languages(locales.slice()) + + const activeLocale = languages[0] || locales[0] || 'en' + + return activeLocale +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - images - .svg, .png, .jpg, .jpeg, .gif, .webp + * Feel free to modify this pattern to include more paths. + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)' + ] +} diff --git a/examples/nextjs-swc/src/pages/[lang]/pages-router-demo/index.tsx b/examples/nextjs-swc/src/pages/[lang]/pages-router-demo/index.tsx new file mode 100644 index 000000000..eb8b2a8fe --- /dev/null +++ b/examples/nextjs-swc/src/pages/[lang]/pages-router-demo/index.tsx @@ -0,0 +1,30 @@ +import { GetStaticProps } from 'next' +import { loadCatalog } from '../../../pagesRouterI18n' +import { HomePage } from '../../../components/HomePage' + +import linguiConfig from '../../../../lingui.config' +import type { GetStaticPaths } from 'next' + +export const getStaticPaths = (async () => { + const paths = linguiConfig.locales.map((lang) => ({ params: { lang } })) + + return { + paths, + fallback: false + } +}) satisfies GetStaticPaths + +export const getStaticProps: GetStaticProps = async (ctx) => { + const locale = ctx.params?.lang + const translation = await loadCatalog( + typeof locale === 'string' ? locale : 'en' + ) + + return { + props: { + translation + } + } +} + +export default HomePage diff --git a/examples/nextjs-swc/src/pages/_app.tsx b/examples/nextjs-swc/src/pages/_app.tsx index 0883e2eb7..8bed27c61 100644 --- a/examples/nextjs-swc/src/pages/_app.tsx +++ b/examples/nextjs-swc/src/pages/_app.tsx @@ -2,17 +2,15 @@ import { i18n } from '@lingui/core' import { I18nProvider } from '@lingui/react' import '../styles/globals.css' import type { AppProps } from 'next/app' -import { useLinguiInit } from '../utils' +import { useLinguiInit } from '../pagesRouterI18n' function MyApp({ Component, pageProps }: AppProps) { useLinguiInit(pageProps.translation) return ( - <> - - - - + + + ) } diff --git a/examples/nextjs-swc/src/utils.ts b/examples/nextjs-swc/src/pagesRouterI18n.ts similarity index 88% rename from examples/nextjs-swc/src/utils.ts rename to examples/nextjs-swc/src/pagesRouterI18n.ts index e87030fd6..d51f4ba5e 100644 --- a/examples/nextjs-swc/src/utils.ts +++ b/examples/nextjs-swc/src/pagesRouterI18n.ts @@ -1,6 +1,6 @@ import { i18n, Messages } from '@lingui/core' -import { useRouter } from 'next/router' import { useEffect } from 'react' +import { usePathname } from 'next/navigation' export async function loadCatalog(locale: string) { const catalog = await import(`@lingui/loader!./locales/${locale}.po`) @@ -8,9 +8,9 @@ export async function loadCatalog(locale: string) { } export function useLinguiInit(messages: Messages) { - const router = useRouter() - const locale = router.locale || router.defaultLocale! const isClient = typeof window !== 'undefined' + const pathname = usePathname() + const locale = pathname?.split('/')[1] ?? 'en' if (!isClient && locale !== i18n.locale) { // there is single instance of i18n on the server diff --git a/examples/nextjs-swc/src/withLingui.tsx b/examples/nextjs-swc/src/withLingui.tsx new file mode 100644 index 000000000..a6d7b0df1 --- /dev/null +++ b/examples/nextjs-swc/src/withLingui.tsx @@ -0,0 +1,45 @@ +import React, { ReactNode } from 'react' +import { allI18nInstances } from './appRouterI18n' +import { setI18n } from '@lingui/react/server' + +export type PageLangParam = { + params: { lang: string } +} + +type PageProps = PageLangParam & { + searchParams?: any // in query +} + +type LayoutProps = PageLangParam & { + children: React.ReactNode +} + +type PageExposedToNextJS = (props: Props) => ReactNode + +export const withLinguiPage = ( + AppRouterPage: React.ComponentType +): PageExposedToNextJS => { + return function WithLingui(props) { + const lang = props.params.lang + const i18n = allI18nInstances[lang]! + setI18n(i18n) + + return + } +} + +type LayoutExposedToNextJS = ( + props: Props +) => ReactNode + +export const withLinguiLayout = ( + AppRouterPage: React.ComponentType +): LayoutExposedToNextJS => { + return function WithLingui(props) { + const lang = props.params.lang + const i18n = allI18nInstances[lang]! + setI18n(i18n) + + return + } +} diff --git a/examples/nextjs-swc/tsconfig.json b/examples/nextjs-swc/tsconfig.json index 8671ca618..f325eeae1 100644 --- a/examples/nextjs-swc/tsconfig.json +++ b/examples/nextjs-swc/tsconfig.json @@ -13,8 +13,8 @@ "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "esnext", + "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, // "downlevelIteration": true,