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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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": {
|
"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": {
|
||||||
|
|||||||
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 {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
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 './api-error';
|
||||||
export * from './env';
|
export * from './env';
|
||||||
export * from './health';
|
export * from './health';
|
||||||
|
export * from './i18n-tools';
|
||||||
|
|||||||
86
pnpm-lock.yaml
generated
86
pnpm-lock.yaml
generated
@ -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
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