From 4272c84c83da8fa822c0d5776ebdce5a28e6d032 Mon Sep 17 00:00:00 2001
From: Villagers654 <110007851+Villagers654@users.noreply.github.com>
Date: Thu, 3 Sep 2026 18:11:29 -0400
Subject: [PATCH] Build web localization foundation
---
frontend/package-lock.json | 93 ++++++++++++++++++-
frontend/package.json | 2 +
frontend/src/App.tsx | 50 +++++-----
frontend/src/components/ui/error/NotFound.tsx | 12 ++-
frontend/src/i18n/LanguageSelector.tsx | 27 ++++++
frontend/src/i18n/LocalizationProvider.tsx | 63 +++++++++++++
frontend/src/i18n/README.md | 13 +++
frontend/src/i18n/config.ts | 43 +++++++++
frontend/src/i18n/i18n.ts | 35 +++++++
frontend/src/i18n/i18next.d.ts | 9 ++
frontend/src/i18n/index.ts | 3 +
frontend/src/i18n/locales/en.ts | 82 ++++++++++++++++
frontend/src/i18n/resources.ts | 11 +++
frontend/src/layouts/BaseLayout.astro | 28 +++++-
.../src/modules/core/components/Footer.tsx | 44 +++++----
.../src/modules/core/components/Navbar.tsx | 70 +++++++-------
.../project/components/ProjectLayout.tsx | 22 +++--
frontend/tests/i18n/config.test.ts | 36 +++++++
frontend/tests/setup.ts | 26 ++++++
19 files changed, 574 insertions(+), 95 deletions(-)
create mode 100644 frontend/src/i18n/LanguageSelector.tsx
create mode 100644 frontend/src/i18n/LocalizationProvider.tsx
create mode 100644 frontend/src/i18n/README.md
create mode 100644 frontend/src/i18n/config.ts
create mode 100644 frontend/src/i18n/i18n.ts
create mode 100644 frontend/src/i18n/i18next.d.ts
create mode 100644 frontend/src/i18n/index.ts
create mode 100644 frontend/src/i18n/locales/en.ts
create mode 100644 frontend/src/i18n/resources.ts
create mode 100644 frontend/tests/i18n/config.test.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e167562f8..10e40a365 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,6 +16,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"astro": "^7.2.10",
"axios": "^1.20.0",
+ "i18next": "^25.10.10",
"jszip": "^3.10.1",
"lucide-react": "^1.39.0",
"mermaid": "^11.17.2",
@@ -24,6 +25,7 @@
"react-dropzone": "^20.1.1",
"react-easy-crop": "^6.2.3",
"react-helmet-async": "^3.0.0",
+ "react-i18next": "^16.6.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.3",
"react-syntax-highlighter": "^16.1.1",
@@ -722,9 +724,9 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
- "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -6166,6 +6168,15 @@
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz",
+ "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6225,6 +6236,37 @@
"node": ">= 6"
}
},
+ "node_modules/i18next": {
+ "version": "25.10.10",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",
+ "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2"
+ },
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -8472,6 +8514,33 @@
"react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/react-i18next": {
+ "version": "16.6.6",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz",
+ "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 25.10.9",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -9775,6 +9844,15 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -10022,6 +10100,15 @@
}
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/volar-service-css": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 141914d19..601e5abbd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,6 +24,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"astro": "^7.2.10",
"axios": "^1.20.0",
+ "i18next": "^25.10.10",
"jszip": "^3.10.1",
"lucide-react": "^1.39.0",
"mermaid": "^11.17.2",
@@ -32,6 +33,7 @@
"react-dropzone": "^20.1.1",
"react-easy-crop": "^6.2.3",
"react-helmet-async": "^3.0.0",
+ "react-i18next": "^16.6.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.3",
"react-syntax-highlighter": "^16.1.1",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5213e38ee..690b7378a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -26,6 +26,8 @@ import { STATUS_PAGE_URL } from '@/utils/status';
import type { Classification } from '@/data/categories';
import { normalizeUser } from '@/utils/users';
import { clearPendingSignInMethod, completeSignInMethod } from '@/modules/auth/api/authClient';
+import { LocalizationProvider } from '@/i18n';
+import { useTranslation } from 'react-i18next';
const StatusModal = lazy(() => import('@/components/ui/StatusModal').then((module) => ({ default: module.StatusModal })));
const Onboarding = lazy(() => import('@/modules/user/components/Onboarding').then((module) => ({ default: module.Onboarding })));
@@ -45,6 +47,8 @@ const SwaggerDocs = lazy(() => import('@/modules/core/views/SwaggerDocs').then((
const RouteLoading = () =>
;
const StatusRedirect = () => {
+ const { t } = useTranslation('status');
+
useEffect(() => {
if (typeof window !== 'undefined') {
window.location.replace(STATUS_PAGE_URL);
@@ -54,15 +58,15 @@ const StatusRedirect = () => {
return (
-
Opening Modtale Status
+
{t('title')}
- Redirecting to {STATUS_PAGE_URL}.
+ {t('redirecting', { url: STATUS_PAGE_URL })}
- Open Status
+ {t('open')}
@@ -368,25 +372,27 @@ const AppContent: React.FC = () => {
export const App: React.FC = ({ initialPath, ssrData }) => {
return (
-
-
-
-
-
- {import.meta.env.SSR ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
-
+
+
+
+
+
+
+ {import.meta.env.SSR ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
);
};
export default App;
diff --git a/frontend/src/components/ui/error/NotFound.tsx b/frontend/src/components/ui/error/NotFound.tsx
index 6d673c176..8c5cf8eca 100644
--- a/frontend/src/components/ui/error/NotFound.tsx
+++ b/frontend/src/components/ui/error/NotFound.tsx
@@ -2,12 +2,14 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
import { AlertCircle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
const NotFound: React.FC = () => {
+ const { t } = useTranslation('errors');
return (
- 404 - Page Not Found | Modtale
+ {t('notFound.documentTitle')}
@@ -16,21 +18,21 @@ const NotFound: React.FC = () => {
- Page Not Found
+ {t('notFound.title')}
- The mod, modpack, or page you are looking for doesn't exist or has been removed.
+ {t('notFound.description')}
- Return Home
+ {t('notFound.returnHome')}
);
};
-export default NotFound;
\ No newline at end of file
+export default NotFound;
diff --git a/frontend/src/i18n/LanguageSelector.tsx b/frontend/src/i18n/LanguageSelector.tsx
new file mode 100644
index 000000000..4eeffffb7
--- /dev/null
+++ b/frontend/src/i18n/LanguageSelector.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { localeMetadata, supportedLocales, type SupportedLocale } from './config';
+import { useLocalization } from './LocalizationProvider';
+
+export const LanguageSelector: React.FC<{ className?: string }> = ({ className = '' }) => {
+ const { t } = useTranslation('common');
+ const { locale, setLocale } = useLocalization();
+
+ if (supportedLocales.length < 2) return null;
+
+ return (
+
+ );
+};
diff --git a/frontend/src/i18n/LocalizationProvider.tsx b/frontend/src/i18n/LocalizationProvider.tsx
new file mode 100644
index 000000000..f81026024
--- /dev/null
+++ b/frontend/src/i18n/LocalizationProvider.tsx
@@ -0,0 +1,63 @@
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { I18nextProvider } from 'react-i18next';
+import {
+ DEFAULT_LOCALE,
+ LOCALE_STORAGE_KEY,
+ detectPreferredLocale,
+ localeMetadata,
+ type SupportedLocale
+} from './config';
+import { createAppI18n, ensureLocaleLoaded } from './i18n';
+
+type LocalizationContextValue = {
+ locale: SupportedLocale;
+ setLocale: (locale: SupportedLocale) => Promise;
+ formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
+ formatDate: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => string;
+ formatList: (values: string[], options?: Intl.ListFormatOptions) => string;
+};
+
+const LocalizationContext = createContext(null);
+
+export const LocalizationProvider: React.FC = ({ children }) => {
+ const [instance] = useState(() => createAppI18n(DEFAULT_LOCALE));
+ const [locale, setLocaleState] = useState(DEFAULT_LOCALE);
+
+ const setLocale = useCallback(async (nextLocale: SupportedLocale) => {
+ await ensureLocaleLoaded(instance, nextLocale);
+ setLocaleState(nextLocale);
+ if (typeof document !== 'undefined') {
+ document.documentElement.lang = nextLocale;
+ document.documentElement.dir = localeMetadata[nextLocale].direction;
+ }
+ try {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
+ } catch {
+ // A locale still applies for this session when persistence is unavailable.
+ }
+ }, [instance]);
+
+ useEffect(() => {
+ void setLocale(detectPreferredLocale());
+ }, [setLocale]);
+
+ const value = useMemo(() => ({
+ locale,
+ setLocale,
+ formatNumber: (number, options) => new Intl.NumberFormat(locale, options).format(number),
+ formatDate: (date, options) => new Intl.DateTimeFormat(locale, options).format(new Date(date)),
+ formatList: (values, options) => new Intl.ListFormat(locale, options).format(values)
+ }), [locale, setLocale]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useLocalization = () => {
+ const context = useContext(LocalizationContext);
+ if (!context) throw new Error('useLocalization must be used within LocalizationProvider');
+ return context;
+};
diff --git a/frontend/src/i18n/README.md b/frontend/src/i18n/README.md
new file mode 100644
index 000000000..9ea188cd9
--- /dev/null
+++ b/frontend/src/i18n/README.md
@@ -0,0 +1,13 @@
+# Web localization
+
+User-facing copy lives in `locales/.ts` and is accessed through `react-i18next`. English is the fallback and currently the only shipped locale.
+
+To add a language:
+
+1. Copy `locales/en.ts`, preserving its complete key structure.
+2. Add the locale and its native display name/direction to `localeMetadata`.
+3. Add its lazy loader in `resources.ts`.
+
+The language selector appears automatically once a second locale is registered. Locale selection is persisted under `modtale-locale`; otherwise the browser language is negotiated. The root provider updates both `lang` and `dir`, while `useLocalization` supplies locale-aware number, date, and list formatting.
+
+Keep dynamic values in interpolation variables, use i18next plural suffixes (`_one`, `_other`) for counts, and avoid assembling translated sentences from fragments.
diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts
new file mode 100644
index 000000000..ff63b06e7
--- /dev/null
+++ b/frontend/src/i18n/config.ts
@@ -0,0 +1,43 @@
+export const DEFAULT_LOCALE = 'en' as const;
+export const LOCALE_STORAGE_KEY = 'modtale-locale';
+
+export const localeMetadata = {
+ en: { nativeName: 'English', direction: 'ltr' }
+} as const;
+
+export type SupportedLocale = keyof typeof localeMetadata;
+export type TextDirection = (typeof localeMetadata)[SupportedLocale]['direction'];
+
+export const supportedLocales = Object.keys(localeMetadata) as SupportedLocale[];
+
+export const normalizeLocale = (candidate?: string | null): SupportedLocale | null => {
+ if (!candidate) return null;
+ const normalized = candidate.trim().replace('_', '-').toLowerCase();
+ if (!normalized) return null;
+ const exact = supportedLocales.find(locale => locale.toLowerCase() === normalized);
+ if (exact) return exact;
+ const base = normalized.split('-')[0];
+ return supportedLocales.find(locale => locale.toLowerCase() === base) ?? null;
+};
+
+export const detectPreferredLocale = (): SupportedLocale => {
+ if (typeof window === 'undefined') return DEFAULT_LOCALE;
+
+ try {
+ const stored = normalizeLocale(window.localStorage.getItem(LOCALE_STORAGE_KEY));
+ if (stored) return stored;
+ } catch {
+ // Storage can be unavailable in privacy-restricted browsers.
+ }
+
+ const candidates = [
+ ...(window.navigator.languages ?? []),
+ window.navigator.language,
+ window.document.documentElement.lang
+ ];
+ for (const candidate of candidates) {
+ const locale = normalizeLocale(candidate);
+ if (locale) return locale;
+ }
+ return DEFAULT_LOCALE;
+};
diff --git a/frontend/src/i18n/i18n.ts b/frontend/src/i18n/i18n.ts
new file mode 100644
index 000000000..f242b35fd
--- /dev/null
+++ b/frontend/src/i18n/i18n.ts
@@ -0,0 +1,35 @@
+import { createInstance, type i18n } from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import { DEFAULT_LOCALE, supportedLocales, type SupportedLocale } from './config';
+import { defaultResources, loadLocale } from './resources';
+
+export const createAppI18n = (locale: SupportedLocale = DEFAULT_LOCALE): i18n => {
+ const instance = createInstance();
+ void instance.use(initReactI18next).init({
+ lng: locale,
+ fallbackLng: DEFAULT_LOCALE,
+ supportedLngs: supportedLocales,
+ resources: defaultResources,
+ defaultNS: 'common',
+ fallbackNS: 'common',
+ load: 'languageOnly',
+ cleanCode: true,
+ nonExplicitSupportedLngs: true,
+ returnNull: false,
+ interpolation: { escapeValue: false },
+ react: { useSuspense: false },
+ showSupportNotice: false,
+ initImmediate: false
+ });
+ return instance;
+};
+
+export const ensureLocaleLoaded = async (instance: i18n, locale: SupportedLocale) => {
+ if (!instance.hasResourceBundle(locale, 'common')) {
+ const resources = await loadLocale(locale);
+ Object.entries(resources).forEach(([namespace, messages]) => {
+ instance.addResourceBundle(locale, namespace, messages, true, true);
+ });
+ }
+ await instance.changeLanguage(locale);
+};
diff --git a/frontend/src/i18n/i18next.d.ts b/frontend/src/i18n/i18next.d.ts
new file mode 100644
index 000000000..049b3d2d7
--- /dev/null
+++ b/frontend/src/i18n/i18next.d.ts
@@ -0,0 +1,9 @@
+import 'i18next';
+import en from './locales/en';
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'common';
+ resources: typeof en;
+ }
+}
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts
new file mode 100644
index 000000000..b6b0c238c
--- /dev/null
+++ b/frontend/src/i18n/index.ts
@@ -0,0 +1,3 @@
+export * from './config';
+export * from './LanguageSelector';
+export * from './LocalizationProvider';
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
new file mode 100644
index 000000000..569c3e19e
--- /dev/null
+++ b/frontend/src/i18n/locales/en.ts
@@ -0,0 +1,82 @@
+const en = {
+ common: {
+ actions: {
+ back: 'Back',
+ close: 'Close',
+ create: 'Create',
+ download: 'Download',
+ save: 'Save',
+ search: 'Search',
+ signIn: 'Sign in',
+ signOut: 'Sign out'
+ },
+ language: {
+ label: 'Language'
+ },
+ theme: 'Theme'
+ },
+ navigation: {
+ browse: 'Browse',
+ mods: 'Mods',
+ allProjects: 'All Projects',
+ plugins: 'Plugins',
+ modpacks: 'Modpacks',
+ worlds: 'Worlds',
+ artAssets: 'Art Assets',
+ dataAssets: 'Data Assets',
+ api: 'API',
+ dashboard: 'Dashboard',
+ createProject: 'Create Project',
+ yourProfile: 'Your Profile',
+ profile: 'Profile',
+ following: 'Following',
+ userDashboard: 'User Dashboard',
+ adminPanel: 'Admin Panel',
+ signedInAs: 'Signed in as',
+ openMenu: 'Open navigation menu',
+ closeMenu: 'Close navigation menu'
+ },
+ footer: {
+ discover: 'Discover',
+ resources: 'Resources',
+ community: 'Community',
+ apiDocs: 'API Docs',
+ status: 'Status',
+ terms: 'Terms of Service',
+ privacy: 'Privacy Policy',
+ copyright: '© {{year}} Modtale.',
+ description: {
+ default: 'The premier community repository for Hytale. Discover, download, and share Hytale mods, server plugins, worlds, art assets, data assets, and modpacks.',
+ plugins: 'The premier community repository for Hytale plugins. Discover, download, and share server plugins, admin tools, gameplay extensions, and supporting libraries.',
+ modpacks: 'The premier community repository for Hytale modpacks. Discover, download, and share curated Hytale modpacks, collections, and bundled project setups.',
+ worlds: 'The premier community repository for Hytale worlds. Discover, download, and share Hytale save files, maps, lobbies, schematics, and spawns.',
+ art: 'The premier community repository for Hytale art assets. Discover, download, and share Hytale models, textures, animations, and creator resources.',
+ data: 'The premier community repository for Hytale data assets. Discover, download, and share Hytale configs, loot tables, recipes, and data-driven files.'
+ }
+ },
+ status: {
+ title: 'Opening Modtale Status',
+ redirecting: 'Redirecting to {{url}}.',
+ open: 'Open Status'
+ },
+ project: {
+ bannerAlt: 'Project Banner',
+ iconAlt: 'Icon',
+ changeBanner: 'Change Banner',
+ uploadBanner: 'Upload Banner',
+ changeIcon: 'Change Icon',
+ recommendedBannerSize: 'Recommended: 1920x640',
+ shortRecommendedBannerSize: 'Rec: 1920x640',
+ recommendedIconSize: 'Rec: 512x512'
+ },
+ errors: {
+ notFound: {
+ documentTitle: '404 - Page Not Found | Modtale',
+ title: 'Page Not Found',
+ description: "The mod, modpack, or page you are looking for doesn't exist or has been removed.",
+ returnHome: 'Return Home'
+ }
+ }
+} as const;
+
+export default en;
diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts
new file mode 100644
index 000000000..c7136db3d
--- /dev/null
+++ b/frontend/src/i18n/resources.ts
@@ -0,0 +1,11 @@
+import type { Resource } from 'i18next';
+import type { SupportedLocale } from './config';
+import en from './locales/en';
+
+export const defaultResources: Resource = { en };
+
+const localeLoaders: Record Promise<{ default: typeof en }>> = {
+ en: () => Promise.resolve({ default: en })
+};
+
+export const loadLocale = async (locale: SupportedLocale) => (await localeLoaders[locale]()).default;
diff --git a/frontend/src/layouts/BaseLayout.astro b/frontend/src/layouts/BaseLayout.astro
index eed01d9bd..63d1c4d01 100644
--- a/frontend/src/layouts/BaseLayout.astro
+++ b/frontend/src/layouts/BaseLayout.astro
@@ -2,6 +2,7 @@
import '../index.css';
import { DEFAULT_SEO } from '../data/seo-constants';
import { BACKEND_URL } from '../utils/api';
+import { DEFAULT_LOCALE, localeMetadata, supportedLocales } from '../i18n/config';
interface Props {
title?: string;
@@ -50,10 +51,15 @@ const initialDataString = initialData ? JSON.stringify(initialData).replace(/ [locale, metadata.direction]))
+});
---
-
+
@@ -108,6 +114,26 @@ const wikiBootstrapString = wikiBootstrap ? JSON.stringify(wikiBootstrap).replac
} catch (e) {}
})();
+