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:
parent
300a418e85
commit
e855192d23
@ -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"
|
||||
},
|
||||
|
||||
@ -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<Response>();
|
||||
const http = host.switchToHttp();
|
||||
const response = http.getResponse<Response>();
|
||||
const language = negotiateLanguage(http.getRequest<Request>().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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
apps/api/src/i18n/api-i18n.ts
Normal file
39
apps/api/src/i18n/api-i18n.ts
Normal 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;
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
30
apps/web/src/i18n/index.ts
Normal file
30
apps/web/src/i18n/index.ts
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<nav
|
||||
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
||||
aria-hidden={collapsed}
|
||||
aria-label={t('layout.sidebar.label', 'Pages')}
|
||||
aria-label={t('layout.sidebar.label')}
|
||||
>
|
||||
<p className="sidebar__hint">
|
||||
{t('layout.sidebar.placeholder', 'Your ponds and pages will appear here.')}
|
||||
</p>
|
||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<header className="topbar">
|
||||
<button
|
||||
@ -15,11 +15,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
className="icon-button"
|
||||
onClick={onToggleSidebar}
|
||||
aria-expanded={!sidebarCollapsed}
|
||||
aria-label={
|
||||
sidebarCollapsed
|
||||
? t('layout.sidebar.expand', 'Show sidebar')
|
||||
: t('layout.sidebar.collapse', 'Hide sidebar')
|
||||
}
|
||||
aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
|
||||
>
|
||||
{/* Simple hamburger glyph; replaced by an icon set later. */}
|
||||
<span aria-hidden>☰</span>
|
||||
@ -29,7 +25,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
</Link>
|
||||
<span className="topbar__spacer" />
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<h1>{t('home.title', 'Welcome to Dorfteich')}</h1>
|
||||
<p>
|
||||
{t(
|
||||
'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>
|
||||
)}
|
||||
<h1>{t('home.title')}</h1>
|
||||
<p>{t('home.intro')}</p>
|
||||
{health.isPending && <span className="status-pill">{t('home.api.checking')}</span>}
|
||||
{health.isSuccess && (
|
||||
<span className="status-pill status-pill--ok">
|
||||
{t('home.api.ok', 'API reachable')} · {health.data.version}
|
||||
{t('home.api.ok')} · {health.data.version}
|
||||
</span>
|
||||
)}
|
||||
{health.isError && (
|
||||
<span className="status-pill status-pill--error">
|
||||
{t('home.api.error', 'API not reachable')}
|
||||
</span>
|
||||
<span className="status-pill status-pill--error">{t('home.api.error')}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<h1>{t('notFound.title', 'Page not found')}</h1>
|
||||
<p>{t('notFound.body', 'The address you opened does not exist.')}</p>
|
||||
<Link to="/">{t('notFound.home', 'Back to the start page')}</Link>
|
||||
<h1>{t('notFound.title')}</h1>
|
||||
<p>{t('notFound.body')}</p>
|
||||
<Link to="/">{t('notFound.home')}</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
27
packages/shared/i18n/de/common.json
Normal file
27
packages/shared/i18n/de/common.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
packages/shared/i18n/de/errors.json
Normal file
11
packages/shared/i18n/de/errors.json
Normal 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."
|
||||
}
|
||||
27
packages/shared/i18n/en/common.json
Normal file
27
packages/shared/i18n/en/common.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
packages/shared/i18n/en/errors.json
Normal file
11
packages/shared/i18n/en/errors.json
Normal 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."
|
||||
}
|
||||
@ -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",
|
||||
|
||||
20
packages/shared/src/i18n-tools.test.ts
Normal file
20
packages/shared/src/i18n-tools.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
24
packages/shared/src/i18n-tools.ts
Normal file
24
packages/shared/src/i18n-tools.ts
Normal 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));
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './api-error';
|
||||
export * from './env';
|
||||
export * from './health';
|
||||
export * from './i18n-tools';
|
||||
|
||||
86
pnpm-lock.yaml
generated
86
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
52
scripts/i18n-check.mjs
Normal file
52
scripts/i18n-check.mjs
Normal 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.`);
|
||||
Loading…
Reference in New Issue
Block a user