diff --git a/apps/api/package.json b/apps/api/package.json index 3561c09..5f8e0cc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,14 +15,15 @@ }, "dependencies": { "@dorfteich/shared": "workspace:*", - "@prisma/client": "^6.3.0", - "prisma": "^6.3.0", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@prisma/client": "^6.3.0", + "i18next": "^26.3.4", "nestjs-pino": "^4.3.0", "pino": "^9.6.0", "pino-http": "^10.4.0", + "prisma": "^6.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0" }, diff --git a/apps/api/src/common/api-exception.filter.ts b/apps/api/src/common/api-exception.filter.ts index 8d3146a..53bc742 100644 --- a/apps/api/src/common/api-exception.filter.ts +++ b/apps/api/src/common/api-exception.filter.ts @@ -1,12 +1,14 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; import { apiError } from '@dorfteich/shared'; -import type { Response } from 'express'; +import type { Request, Response } from 'express'; import { PinoLogger } from 'nestjs-pino'; +import { negotiateLanguage, translateErrorCode } from '../i18n/api-i18n'; + /** - * Maps every thrown error to the uniform ApiErrorBody shape. HttpExceptions - * keep their status and get a stable `code`; everything else becomes an - * opaque 500 so internals never leak to clients. + * Maps every thrown error to the uniform ApiErrorBody shape, localized via + * Accept-Language. HttpExceptions keep their status and get a stable + * `code`; everything else becomes an opaque 500 so internals never leak. */ @Catch() export class ApiExceptionFilter implements ExceptionFilter { @@ -15,18 +17,29 @@ export class ApiExceptionFilter implements ExceptionFilter { } catch(exception: unknown, host: ArgumentsHost): void { - const response = host.switchToHttp().getResponse(); + const http = host.switchToHttp(); + const response = http.getResponse(); + const language = negotiateLanguage(http.getRequest().headers['accept-language']); if (exception instanceof HttpException) { const status = exception.getStatus(); - response.status(status).json(apiError(codeForStatus(status), exception.message)); + const code = codeForStatus(status); + // Catalogued codes get the localized text; uncatalogued ones keep + // the (developer-provided, English) exception message as fallback. + const message = translateErrorCode(code, language) ?? exception.message; + response.status(status).json(apiError(code, message)); return; } this.logger.error({ err: exception }, 'unhandled exception'); response .status(HttpStatus.INTERNAL_SERVER_ERROR) - .json(apiError('internal_error', 'Internal server error')); + .json( + apiError( + 'internal_error', + translateErrorCode('internal_error', language) ?? 'Internal server error', + ), + ); } } diff --git a/apps/api/src/i18n/api-i18n.ts b/apps/api/src/i18n/api-i18n.ts new file mode 100644 index 0000000..5065d3a --- /dev/null +++ b/apps/api/src/i18n/api-i18n.ts @@ -0,0 +1,39 @@ +import deErrors from '@dorfteich/shared/i18n/de/errors.json'; +import enErrors from '@dorfteich/shared/i18n/en/errors.json'; +import { createInstance, type i18n as I18n } from 'i18next'; + +/** + * Minimal i18next instance for user-facing api texts (error messages, + * later e-mails). Kept separate from any request context — callers pass + * the language explicitly. + */ +export const apiI18n: I18n = createInstance(); + +void apiI18n.init({ + resources: { + en: { errors: enErrors }, + de: { errors: deErrors }, + }, + fallbackLng: 'en', + supportedLngs: ['de', 'en'], + interpolation: { escapeValue: false }, +}); + +/** Best supported language for an Accept-Language header value. */ +export function negotiateLanguage(acceptLanguage: string | undefined): 'de' | 'en' { + if (!acceptLanguage) return 'en'; + // First matching language tag wins; quality factors are ignored on + // purpose — with two languages the added complexity buys nothing. + for (const part of acceptLanguage.split(',')) { + const tag = part.trim().toLowerCase(); + if (tag.startsWith('de')) return 'de'; + if (tag.startsWith('en')) return 'en'; + } + return 'en'; +} + +/** Localized message for an error code, or undefined when uncatalogued. */ +export function translateErrorCode(code: string, language: 'de' | 'en'): string | undefined { + const key = `errors:${code}`; + return apiI18n.exists(key) ? apiI18n.t(key, { lng: language }) : undefined; +} diff --git a/apps/web/package.json b/apps/web/package.json index 1ab431b..e84d7aa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,8 +15,11 @@ "dependencies": { "@dorfteich/shared": "workspace:*", "@tanstack/react-query": "^5.66.0", + "i18next": "^26.3.4", + "i18next-browser-languagedetector": "^8.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^17.0.8", "react-router-dom": "^7.1.0" }, "devDependencies": { diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 0000000..31473f3 --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,30 @@ +import deCommon from '@dorfteich/shared/i18n/de/common.json'; +import deErrors from '@dorfteich/shared/i18n/de/errors.json'; +import enCommon from '@dorfteich/shared/i18n/en/common.json'; +import enErrors from '@dorfteich/shared/i18n/en/errors.json'; +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; + +/** + * Translation resources are bundled (no async loading): the whole catalog + * is small, and the offline-capable editor (ADR 0003) must not depend on + * fetching language files. Detection: ?lng=… wins, then the browser + * language; the user-profile setting (issue #17) will be layered on top. + */ +void i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { common: enCommon, errors: enErrors }, + de: { common: deCommon, errors: deErrors }, + }, + defaultNS: 'common', + fallbackLng: 'en', + supportedLngs: ['de', 'en'], + interpolation: { escapeValue: false }, // React already escapes. + detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lng', caches: [] }, + }); + +export default i18n; diff --git a/apps/web/src/i18n/t.ts b/apps/web/src/i18n/t.ts deleted file mode 100644 index 69ed874..0000000 --- a/apps/web/src/i18n/t.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Temporary translation stub so no component hard-codes strings directly. - * Issue #5 replaces this module with the real i18next setup; call sites - * (`t('key', 'English fallback')`) already match the final signature. - */ -export function t(_key: string, fallback: string): string { - return fallback; -} diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 4d60552..82ae310 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { t } from '../i18n/t'; +import { useTranslation } from 'react-i18next'; interface SidebarProps { collapsed: boolean; @@ -10,15 +10,14 @@ interface SidebarProps { * must not reflow the main content beyond reclaiming the width. */ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element { + const { t } = useTranslation(); return ( ); } diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index c0bdbb0..ff34d8d 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -1,13 +1,13 @@ +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { t } from '../i18n/t'; - interface TopBarProps { sidebarCollapsed: boolean; onToggleSidebar: () => void; } export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element { + const { t } = useTranslation(); return (
); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d643df4..2d9a5c8 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; +import './i18n'; import './styles/tokens.css'; import './styles/base.css'; diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 9aeaef0..36320a7 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,32 +1,24 @@ import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; -import { t } from '../i18n/t'; import { fetchHealth } from '../lib/api'; export function HomePage(): React.JSX.Element { + const { t } = useTranslation(); const health = useQuery({ queryKey: ['healthz'], queryFn: fetchHealth, retry: 1 }); return ( <> -

{t('home.title', 'Welcome to Dorfteich')}

-

- {t( - 'home.intro', - 'Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.', - )} -

- {health.isPending && ( - {t('home.api.checking', 'Checking API …')} - )} +

{t('home.title')}

+

{t('home.intro')}

+ {health.isPending && {t('home.api.checking')}} {health.isSuccess && ( - {t('home.api.ok', 'API reachable')} · {health.data.version} + {t('home.api.ok')} · {health.data.version} )} {health.isError && ( - - {t('home.api.error', 'API not reachable')} - + {t('home.api.error')} )} ); diff --git a/apps/web/src/pages/NotFoundPage.tsx b/apps/web/src/pages/NotFoundPage.tsx index a6cd106..fde3428 100644 --- a/apps/web/src/pages/NotFoundPage.tsx +++ b/apps/web/src/pages/NotFoundPage.tsx @@ -1,13 +1,13 @@ +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { t } from '../i18n/t'; - export function NotFoundPage(): React.JSX.Element { + const { t } = useTranslation(); return ( <> -

{t('notFound.title', 'Page not found')}

-

{t('notFound.body', 'The address you opened does not exist.')}

- {t('notFound.home', 'Back to the start page')} +

{t('notFound.title')}

+

{t('notFound.body')}

+ {t('notFound.home')} ); } diff --git a/eslint.config.mjs b/eslint.config.mjs index 0ba6724..7f3e7b1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,13 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, prettier, + { + // Plain-Node maintenance scripts (no TypeScript, no bundler). + files: ['scripts/**/*.mjs'], + languageOptions: { + globals: { console: 'readonly', process: 'readonly' }, + }, + }, { rules: { // Unused values are usually bugs; underscore-prefix marks intentional ones. diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json new file mode 100644 index 0000000..57841ce --- /dev/null +++ b/packages/shared/i18n/de/common.json @@ -0,0 +1,27 @@ +{ + "layout": { + "sidebar": { + "expand": "Seitenleiste einblenden", + "collapse": "Seitenleiste ausblenden", + "label": "Seiten", + "placeholder": "Deine Teiche und Seiten erscheinen hier." + }, + "user": { + "anonymous": "Nicht angemeldet" + } + }, + "home": { + "title": "Willkommen im Dorfteich", + "intro": "Dorfteich ist ein Open-Source-Wiki mit Echtzeit-Zusammenarbeit. Diese Instanz wird gerade eingerichtet.", + "api": { + "checking": "Prüfe API …", + "ok": "API erreichbar", + "error": "API nicht erreichbar" + } + }, + "notFound": { + "title": "Seite nicht gefunden", + "body": "Die aufgerufene Adresse existiert nicht.", + "home": "Zurück zur Startseite" + } +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json new file mode 100644 index 0000000..c8114ec --- /dev/null +++ b/packages/shared/i18n/de/errors.json @@ -0,0 +1,11 @@ +{ + "bad_request": "Die Anfrage ist ungültig.", + "unauthorized": "Bitte melde dich an, um fortzufahren.", + "forbidden": "Dir fehlt die Berechtigung für diese Aktion.", + "not_found": "Die angeforderte Ressource existiert nicht.", + "conflict": "Die Anfrage steht im Widerspruch zum aktuellen Zustand.", + "gone": "Diese Ressource ist nicht mehr verfügbar.", + "payload_too_large": "Die übermittelten Daten sind zu groß.", + "rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.", + "internal_error": "Interner Serverfehler." +} diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json new file mode 100644 index 0000000..c475142 --- /dev/null +++ b/packages/shared/i18n/en/common.json @@ -0,0 +1,27 @@ +{ + "layout": { + "sidebar": { + "expand": "Show sidebar", + "collapse": "Hide sidebar", + "label": "Pages", + "placeholder": "Your ponds and pages will appear here." + }, + "user": { + "anonymous": "Not signed in" + } + }, + "home": { + "title": "Welcome to Dorfteich", + "intro": "Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.", + "api": { + "checking": "Checking API …", + "ok": "API reachable", + "error": "API not reachable" + } + }, + "notFound": { + "title": "Page not found", + "body": "The address you opened does not exist.", + "home": "Back to the start page" + } +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json new file mode 100644 index 0000000..df2c348 --- /dev/null +++ b/packages/shared/i18n/en/errors.json @@ -0,0 +1,11 @@ +{ + "bad_request": "The request is invalid.", + "unauthorized": "Please sign in to continue.", + "forbidden": "You do not have permission for this action.", + "not_found": "The requested resource does not exist.", + "conflict": "The request conflicts with the current state.", + "gone": "This resource is no longer available.", + "payload_too_large": "The submitted data is too large.", + "rate_limited": "Too many requests — please try again later.", + "internal_error": "Internal server error." +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 70c48e8..60e7141 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,10 +12,12 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" - } + }, + "./i18n/*": "./i18n/*" }, "files": [ - "dist" + "dist", + "i18n" ], "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts --clean", diff --git a/packages/shared/src/i18n-tools.test.ts b/packages/shared/src/i18n-tools.test.ts new file mode 100644 index 0000000..a3ce505 --- /dev/null +++ b/packages/shared/src/i18n-tools.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { missingTranslationKeys, translationKeys } from './i18n-tools'; + +describe('i18n tooling', () => { + it('flattens nested keys', () => { + expect(translationKeys({ a: { b: 'x', c: { d: 'y' } }, e: 'z' })).toEqual([ + 'a.b', + 'a.c.d', + 'e', + ]); + }); + + it('finds keys missing in one language', () => { + const en = { home: { title: 'Hi', body: 'Text' } }; + const de = { home: { title: 'Hallo' } }; + expect(missingTranslationKeys(en, de)).toEqual(['home.body']); + expect(missingTranslationKeys(de, en)).toEqual([]); + }); +}); diff --git a/packages/shared/src/i18n-tools.ts b/packages/shared/src/i18n-tools.ts new file mode 100644 index 0000000..7d163bc --- /dev/null +++ b/packages/shared/src/i18n-tools.ts @@ -0,0 +1,24 @@ +/** + * Tooling helpers for translation resources. Used by the repo-level + * `pnpm i18n:check` script and its tests: every key must exist in every + * language, in both directions. + */ + +type TranslationTree = { [key: string]: string | TranslationTree }; + +/** Flattens {a:{b:"x"}} to ["a.b"]. */ +export function translationKeys(tree: TranslationTree, prefix = ''): string[] { + return Object.entries(tree).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === 'string' ? [path] : translationKeys(value, path); + }); +} + +/** Keys present in `reference` but missing in `candidate`. */ +export function missingTranslationKeys( + reference: TranslationTree, + candidate: TranslationTree, +): string[] { + const have = new Set(translationKeys(candidate)); + return translationKeys(reference).filter((key) => !have.has(key)); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 58f1cde..ae0698b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './api-error'; export * from './env'; export * from './health'; +export * from './i18n-tools'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a165f9b..ba30b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@prisma/client': specifier: ^6.3.0 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + i18next: + specifier: ^26.3.4 + version: 26.3.4(typescript@5.9.3) nestjs-pino: specifier: ^4.3.0 version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2) @@ -108,12 +111,21 @@ importers: '@tanstack/react-query': specifier: ^5.66.0 version: 5.101.2(react@19.2.7) + i18next: + specifier: ^26.3.4 + version: 26.3.4(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 react: specifier: ^19.0.0 version: 19.2.7 react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.4(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) react-router-dom: specifier: ^7.1.0 version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -254,6 +266,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -2264,10 +2280,24 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@26.3.4: + resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2774,6 +2804,22 @@ packages: peerDependencies: react: ^19.2.7 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -3232,6 +3278,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3352,6 +3403,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -3564,6 +3619,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -5464,6 +5521,10 @@ snapshots: help-me@5.0.0: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5472,6 +5533,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + i18next@26.3.4(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5919,6 +5988,17 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-i18next@17.0.8(i18next@26.3.4(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.4(typescript@5.9.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 5.9.3 + react-refresh@0.17.0: {} react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -6386,6 +6466,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + util-deprecate@1.0.2: {} vary@1.1.2: {} @@ -6482,6 +6566,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + watchpack@2.5.2: dependencies: graceful-fs: 4.2.11 diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs new file mode 100644 index 0000000..3403f85 --- /dev/null +++ b/scripts/i18n-check.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Verifies that every translation key exists in every language, in both + * directions. Run via `pnpm i18n:check`; requires @dorfteich/shared to be + * built (`pnpm build`) because it imports the shared tooling helpers. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { missingTranslationKeys } from '../packages/shared/dist/index.mjs'; + +const i18nDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../packages/shared/i18n'); +const languages = readdirSync(i18nDir).sort(); + +if (languages.length < 2) { + console.error(`i18n:check: expected at least two languages in ${i18nDir}`); + process.exit(1); +} + +const namespaces = new Set(languages.flatMap((lang) => readdirSync(path.join(i18nDir, lang)))); + +let problems = 0; +for (const namespaceFile of [...namespaces].sort()) { + const trees = {}; + for (const lang of languages) { + try { + trees[lang] = JSON.parse(readFileSync(path.join(i18nDir, lang, namespaceFile), 'utf8')); + } catch { + console.error(`✗ ${lang}/${namespaceFile}: missing or invalid JSON`); + problems += 1; + trees[lang] = {}; + } + } + for (const reference of languages) { + for (const candidate of languages) { + if (reference === candidate) continue; + for (const key of missingTranslationKeys(trees[reference], trees[candidate])) { + console.error( + `✗ ${candidate}/${namespaceFile}: missing key "${key}" (present in ${reference})`, + ); + problems += 1; + } + } + } +} + +if (problems > 0) { + console.error(`i18n:check: ${problems} problem(s) found.`); + process.exit(1); +} +console.log(`i18n:check: ${languages.join(', ')} — all keys present in all languages.`);