From 6337858ff4b004a8b02e8133dc7ed90a3b32f08a Mon Sep 17 00:00:00 2001 From: Nedelcho Nikolov Date: Mon, 17 Jun 2024 20:24:51 +0300 Subject: [PATCH] Multilanguage setup --- dictionaries.ts | 12 ++++++ dictionaries/bg.json | 5 +++ dictionaries/en.json | 5 +++ i18n-config.ts | 6 +++ package-lock.json | 25 ++++++++++++ package.json | 13 ++++--- src/app/{ => [lang]}/favicon.ico | Bin src/app/{ => [lang]}/globals.css | 0 src/app/{ => [lang]}/layout.tsx | 9 ++++- src/app/{ => [lang]}/page.tsx | 8 +++- src/middleware.ts | 65 +++++++++++++++++++++++++++++++ 11 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 dictionaries.ts create mode 100644 dictionaries/bg.json create mode 100644 dictionaries/en.json create mode 100644 i18n-config.ts rename src/app/{ => [lang]}/favicon.ico (100%) rename src/app/{ => [lang]}/globals.css (100%) rename src/app/{ => [lang]}/layout.tsx (65%) rename src/app/{ => [lang]}/page.tsx (95%) create mode 100644 src/middleware.ts diff --git a/dictionaries.ts b/dictionaries.ts new file mode 100644 index 0000000..262c41f --- /dev/null +++ b/dictionaries.ts @@ -0,0 +1,12 @@ +import "server-only"; +import type { Locale } from "./i18n-config"; + +// We enumerate all dictionaries here for better linting and typescript support +// We also get the default import for cleaner types +const dictionaries = { + en: () => import("./dictionaries/en.json").then((module) => module.default), + bg: () => import("./dictionaries/bg.json").then((module) => module.default), +}; + +export const getDictionary = async (locale: Locale) => + dictionaries[locale]?.() ?? dictionaries.en(); \ No newline at end of file diff --git a/dictionaries/bg.json b/dictionaries/bg.json new file mode 100644 index 0000000..401f239 --- /dev/null +++ b/dictionaries/bg.json @@ -0,0 +1,5 @@ +{ + "main": { + "hello": "Добре дошли" + } +} \ No newline at end of file diff --git a/dictionaries/en.json b/dictionaries/en.json new file mode 100644 index 0000000..68bf901 --- /dev/null +++ b/dictionaries/en.json @@ -0,0 +1,5 @@ +{ + "main": { + "hello": "Welcome" + } +} \ No newline at end of file diff --git a/i18n-config.ts b/i18n-config.ts new file mode 100644 index 0000000..59cf9bc --- /dev/null +++ b/i18n-config.ts @@ -0,0 +1,6 @@ +export const i18n = { + defaultLocale: "en", + locales: ["en", "bg"], +} as const; + +export type Locale = (typeof i18n)["locales"][number]; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9b678bd..c624245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "name": "openfest", "version": "0.1.0", "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^0.6.3", "next": "14.2.3", "react": "^18", "react-dom": "^18" }, "devDependencies": { + "@types/negotiator": "^0.6.3", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -103,6 +106,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -448,6 +459,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz", @@ -3112,6 +3129,14 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", diff --git a/package.json b/package.json index ca956ed..5957909 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,21 @@ "lint": "next lint" }, "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^0.6.3", + "next": "14.2.3", "react": "^18", - "react-dom": "^18", - "next": "14.2.3" + "react-dom": "^18" }, "devDependencies": { - "typescript": "^5", + "@types/negotiator": "^0.6.3", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.3", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.3" + "typescript": "^5" } } diff --git a/src/app/favicon.ico b/src/app/[lang]/favicon.ico similarity index 100% rename from src/app/favicon.ico rename to src/app/[lang]/favicon.ico diff --git a/src/app/globals.css b/src/app/[lang]/globals.css similarity index 100% rename from src/app/globals.css rename to src/app/[lang]/globals.css diff --git a/src/app/layout.tsx b/src/app/[lang]/layout.tsx similarity index 65% rename from src/app/layout.tsx rename to src/app/[lang]/layout.tsx index 3314e47..7513bbd 100644 --- a/src/app/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,6 +1,11 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { i18n, type Locale } from "../../../i18n-config"; + +export async function generateStaticParams() { + return i18n.locales.map((locale) => ({ lang: locale })); +} const inter = Inter({ subsets: ["latin"] }); @@ -11,11 +16,13 @@ export const metadata: Metadata = { export default function RootLayout({ children, + params }: Readonly<{ children: React.ReactNode; + params: { lang: Locale }; }>) { return ( - + {children} ); diff --git a/src/app/page.tsx b/src/app/[lang]/page.tsx similarity index 95% rename from src/app/page.tsx rename to src/app/[lang]/page.tsx index 2acfd44..453ed06 100644 --- a/src/app/page.tsx +++ b/src/app/[lang]/page.tsx @@ -1,10 +1,16 @@ import Image from "next/image"; +import { getDictionary } from '../../../dictionaries' +import { Locale } from "../../../i18n-config"; + + +export default async function Home({ params: { lang }, }: { params: { lang: Locale }; }) { + const dictionary = await getDictionary(lang); -export default function Home() { return (

+ {dictionary.main.hello}, Get started by editing  src/app/page.tsx

diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..e0a80a7 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { i18n } from "../i18n-config"; + +import { match as matchLocale } from "@formatjs/intl-localematcher"; +import Negotiator from "negotiator"; + +function getLocale(request: NextRequest): string | undefined { + // Negotiator expects plain object so we need to transform headers + const negotiatorHeaders: Record = {}; + request.headers.forEach((value, key) => (negotiatorHeaders[key] = value)); + + // @ts-ignore locales are readonly + const locales: string[] = i18n.locales; + + // Use negotiator and intl-localematcher to get best locale + let languages = new Negotiator({ headers: negotiatorHeaders }).languages( + locales + ); + + const locale = matchLocale(languages, locales, i18n.defaultLocale); + + return locale; +} + +export function middleware(request: NextRequest) { + const pathname = request.nextUrl.pathname; + // `/_next/` and `/api/` are ignored by the watcher, but we need to ignore files in `public` manually. + // If you have one + if ( + [ + '/manifest.json', + '/favicon.ico', + '/next.svg', + '/vercel.svg', + // Your other files in `public` + ].includes(pathname) + ) + return + + // Check if there is any supported locale in the pathname + const pathnameIsMissingLocale = i18n.locales.every( + (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}` + ); + + // Redirect if there is no locale + if (pathnameIsMissingLocale) { + const locale = getLocale(request); + + // e.g. incoming request is /products + // The new URL is now /en-US/products + return NextResponse.redirect( + new URL( + `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`, + request.url + ) + ); + } +} + +export const config = { + // Matcher ignoring `/_next/` and `/api/` + matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], +};