Multilanguage setup

This commit is contained in:
Nedelcho Nikolov 2024-06-17 20:24:51 +03:00
parent aa8d674064
commit 6337858ff4
11 changed files with 141 additions and 7 deletions

12
dictionaries.ts Normal file
View File

@ -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();

5
dictionaries/bg.json Normal file
View File

@ -0,0 +1,5 @@
{
"main": {
"hello": "Добре дошли"
}
}

5
dictionaries/en.json Normal file
View File

@ -0,0 +1,5 @@
{
"main": {
"hello": "Welcome"
}
}

6
i18n-config.ts Normal file
View File

@ -0,0 +1,6 @@
export const i18n = {
defaultLocale: "en",
locales: ["en", "bg"],
} as const;
export type Locale = (typeof i18n)["locales"][number];

25
package-lock.json generated
View File

@ -8,11 +8,14 @@
"name": "openfest", "name": "openfest",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"negotiator": "^0.6.3",
"next": "14.2.3", "next": "14.2.3",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18"
}, },
"devDependencies": { "devDependencies": {
"@types/negotiator": "^0.6.3",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
@ -103,6 +106,14 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.14", "version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@ -448,6 +459,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "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": { "node_modules/@types/node": {
"version": "20.14.1", "version": "20.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz",
@ -3112,6 +3129,14 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "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": { "node_modules/next": {
"version": "14.2.3", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",

View File

@ -9,18 +9,21 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"negotiator": "^0.6.3",
"next": "14.2.3",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18"
"next": "14.2.3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@types/negotiator": "^0.6.3",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"eslint": "^8", "typescript": "^5"
"eslint-config-next": "14.2.3"
} }
} }

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,6 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; 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"] }); const inter = Inter({ subsets: ["latin"] });
@ -11,11 +16,13 @@ export const metadata: Metadata = {
export default function RootLayout({ export default function RootLayout({
children, children,
params
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
params: { lang: Locale };
}>) { }>) {
return ( return (
<html lang="en"> <html lang={params.lang}>
<body className={inter.className}>{children}</body> <body className={inter.className}>{children}</body>
</html> </html>
); );

View File

@ -1,10 +1,16 @@
import Image from "next/image"; 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 ( return (
<main className="flex min-h-screen flex-col items-center justify-between p-24"> <main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex"> <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30"> <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
{dictionary.main.hello},
Get started by editing&nbsp; Get started by editing&nbsp;
<code className="font-mono font-bold">src/app/page.tsx</code> <code className="font-mono font-bold">src/app/page.tsx</code>
</p> </p>

65
src/middleware.ts Normal file
View File

@ -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<string, string> = {};
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).*)"],
};