Add i18n with i18next, German and English, and a key-parity check

Translation resources live in packages/shared/i18n/<lang>/<ns>.json
(common, errors) and ship with de and en. The web app initializes
react-i18next with bundled resources (?lng= wins, then the browser
language); all shell components use useTranslation and the temporary
t() stub is gone. The api localizes its uniform error bodies via a
minimal i18next instance negotiated from Accept-Language. `pnpm
i18n:check` fails CI when any key is missing in any language, backed
by tested helpers in @dorfteich/shared.

Closes #5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-04 19:23:45 +02:00
parent 300a418e85
commit e855192d23
22 changed files with 386 additions and 52 deletions

View File

@ -15,14 +15,15 @@
}, },
"dependencies": { "dependencies": {
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@prisma/client": "^6.3.0",
"prisma": "^6.3.0",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.3.0",
"i18next": "^26.3.4",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-http": "^10.4.0", "pino-http": "^10.4.0",
"prisma": "^6.3.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0" "rxjs": "^7.8.0"
}, },

View File

@ -1,12 +1,14 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { apiError } from '@dorfteich/shared'; import { apiError } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Request, Response } from 'express';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { negotiateLanguage, translateErrorCode } from '../i18n/api-i18n';
/** /**
* Maps every thrown error to the uniform ApiErrorBody shape. HttpExceptions * Maps every thrown error to the uniform ApiErrorBody shape, localized via
* keep their status and get a stable `code`; everything else becomes an * Accept-Language. HttpExceptions keep their status and get a stable
* opaque 500 so internals never leak to clients. * `code`; everything else becomes an opaque 500 so internals never leak.
*/ */
@Catch() @Catch()
export class ApiExceptionFilter implements ExceptionFilter { export class ApiExceptionFilter implements ExceptionFilter {
@ -15,18 +17,29 @@ export class ApiExceptionFilter implements ExceptionFilter {
} }
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<Response>(); const http = host.switchToHttp();
const response = http.getResponse<Response>();
const language = negotiateLanguage(http.getRequest<Request>().headers['accept-language']);
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
const status = exception.getStatus(); 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; return;
} }
this.logger.error({ err: exception }, 'unhandled exception'); this.logger.error({ err: exception }, 'unhandled exception');
response response
.status(HttpStatus.INTERNAL_SERVER_ERROR) .status(HttpStatus.INTERNAL_SERVER_ERROR)
.json(apiError('internal_error', 'Internal server error')); .json(
apiError(
'internal_error',
translateErrorCode('internal_error', language) ?? 'Internal server error',
),
);
} }
} }

View File

@ -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;
}

View File

@ -15,8 +15,11 @@
"dependencies": { "dependencies": {
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.1.0" "react-router-dom": "^7.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

@ -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;
}

View File

@ -1,4 +1,4 @@
import { t } from '../i18n/t'; import { useTranslation } from 'react-i18next';
interface SidebarProps { interface SidebarProps {
collapsed: boolean; collapsed: boolean;
@ -10,15 +10,14 @@ interface SidebarProps {
* must not reflow the main content beyond reclaiming the width. * must not reflow the main content beyond reclaiming the width.
*/ */
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element { export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
const { t } = useTranslation();
return ( return (
<nav <nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'} className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed} aria-hidden={collapsed}
aria-label={t('layout.sidebar.label', 'Pages')} aria-label={t('layout.sidebar.label')}
> >
<p className="sidebar__hint"> <p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
{t('layout.sidebar.placeholder', 'Your ponds and pages will appear here.')}
</p>
</nav> </nav>
); );
} }

View File

@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { t } from '../i18n/t';
interface TopBarProps { interface TopBarProps {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
onToggleSidebar: () => void; onToggleSidebar: () => void;
} }
export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element { export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element {
const { t } = useTranslation();
return ( return (
<header className="topbar"> <header className="topbar">
<button <button
@ -15,11 +15,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
className="icon-button" className="icon-button"
onClick={onToggleSidebar} onClick={onToggleSidebar}
aria-expanded={!sidebarCollapsed} aria-expanded={!sidebarCollapsed}
aria-label={ aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
sidebarCollapsed
? t('layout.sidebar.expand', 'Show sidebar')
: t('layout.sidebar.collapse', 'Hide sidebar')
}
> >
{/* Simple hamburger glyph; replaced by an icon set later. */} {/* Simple hamburger glyph; replaced by an icon set later. */}
<span aria-hidden></span> <span aria-hidden></span>
@ -29,7 +25,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
</Link> </Link>
<span className="topbar__spacer" /> <span className="topbar__spacer" />
{/* User menu arrives with authentication (issue #16). */} {/* User menu arrives with authentication (issue #16). */}
<span className="sidebar__hint">{t('layout.user.anonymous', 'Not signed in')}</span> <span className="sidebar__hint">{t('layout.user.anonymous')}</span>
</header> </header>
); );
} }

View File

@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { App } from './App'; import { App } from './App';
import './i18n';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/base.css'; import './styles/base.css';

View File

@ -1,32 +1,24 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { t } from '../i18n/t';
import { fetchHealth } from '../lib/api'; import { fetchHealth } from '../lib/api';
export function HomePage(): React.JSX.Element { export function HomePage(): React.JSX.Element {
const { t } = useTranslation();
const health = useQuery({ queryKey: ['healthz'], queryFn: fetchHealth, retry: 1 }); const health = useQuery({ queryKey: ['healthz'], queryFn: fetchHealth, retry: 1 });
return ( return (
<> <>
<h1>{t('home.title', 'Welcome to Dorfteich')}</h1> <h1>{t('home.title')}</h1>
<p> <p>{t('home.intro')}</p>
{t( {health.isPending && <span className="status-pill">{t('home.api.checking')}</span>}
'home.intro',
'Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.',
)}
</p>
{health.isPending && (
<span className="status-pill">{t('home.api.checking', 'Checking API …')}</span>
)}
{health.isSuccess && ( {health.isSuccess && (
<span className="status-pill status-pill--ok"> <span className="status-pill status-pill--ok">
{t('home.api.ok', 'API reachable')} · {health.data.version} {t('home.api.ok')} · {health.data.version}
</span> </span>
)} )}
{health.isError && ( {health.isError && (
<span className="status-pill status-pill--error"> <span className="status-pill status-pill--error">{t('home.api.error')}</span>
{t('home.api.error', 'API not reachable')}
</span>
)} )}
</> </>
); );

View File

@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { t } from '../i18n/t';
export function NotFoundPage(): React.JSX.Element { export function NotFoundPage(): React.JSX.Element {
const { t } = useTranslation();
return ( return (
<> <>
<h1>{t('notFound.title', 'Page not found')}</h1> <h1>{t('notFound.title')}</h1>
<p>{t('notFound.body', 'The address you opened does not exist.')}</p> <p>{t('notFound.body')}</p>
<Link to="/">{t('notFound.home', 'Back to the start page')}</Link> <Link to="/">{t('notFound.home')}</Link>
</> </>
); );
} }

View File

@ -12,6 +12,13 @@ export default tseslint.config(
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
prettier, prettier,
{
// Plain-Node maintenance scripts (no TypeScript, no bundler).
files: ['scripts/**/*.mjs'],
languageOptions: {
globals: { console: 'readonly', process: 'readonly' },
},
},
{ {
rules: { rules: {
// Unused values are usually bugs; underscore-prefix marks intentional ones. // Unused values are usually bugs; underscore-prefix marks intentional ones.

View File

@ -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"
}
}

View File

@ -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."
}

View File

@ -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"
}
}

View File

@ -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."
}

View File

@ -12,10 +12,12 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.mjs",
"require": "./dist/index.js" "require": "./dist/index.js"
} },
"./i18n/*": "./i18n/*"
}, },
"files": [ "files": [
"dist" "dist",
"i18n"
], ],
"scripts": { "scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean", "build": "tsup src/index.ts --format esm,cjs --dts --clean",

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export * from './api-error'; export * from './api-error';
export * from './env'; export * from './env';
export * from './health'; export * from './health';
export * from './i18n-tools';

86
pnpm-lock.yaml generated
View File

@ -44,6 +44,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^6.3.0 specifier: ^6.3.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) 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: nestjs-pino:
specifier: ^4.3.0 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) 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': '@tanstack/react-query':
specifier: ^5.66.0 specifier: ^5.66.0
version: 5.101.2(react@19.2.7) 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: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.7 version: 19.2.7
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.7(react@19.2.7) 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: react-router-dom:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@ -254,6 +266,10 @@ packages:
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0-0 '@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': '@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -2264,10 +2280,24 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 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: http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} 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: iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2774,6 +2804,22 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.7 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: react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3232,6 +3278,11 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 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: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -3352,6 +3403,10 @@ packages:
jsdom: jsdom:
optional: true 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: watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -3564,6 +3619,8 @@ snapshots:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7 '@babel/helper-plugin-utils': 7.29.7
'@babel/runtime@7.29.7': {}
'@babel/template@7.29.7': '@babel/template@7.29.7':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@ -5464,6 +5521,10 @@ snapshots:
help-me@5.0.0: {} help-me@5.0.0: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
http-errors@2.0.1: http-errors@2.0.1:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@ -5472,6 +5533,14 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 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: iconv-lite@0.7.2:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -5919,6 +5988,17 @@ snapshots:
react: 19.2.7 react: 19.2.7
scheduler: 0.27.0 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-refresh@0.17.0: {}
react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): 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: dependencies:
punycode: 2.3.1 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: {} util-deprecate@1.0.2: {}
vary@1.1.2: {} vary@1.1.2: {}
@ -6482,6 +6566,8 @@ snapshots:
- tsx - tsx
- yaml - yaml
void-elements@3.1.0: {}
watchpack@2.5.2: watchpack@2.5.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11

52
scripts/i18n-check.mjs Normal file
View File

@ -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.`);