skillguard/artifacts/api-server/src/lib/i18n.ts
amertensreplit 2236ad179d Add DE/EN/ES multilingual support to SkillGuard (Task #49)
German is source of truth; EN/ES fully translated with no German residue.
Auto-detects browser language (fallback German), persists choice, language
switcher on all pages, localized formats/Clerk/legal. Scans store their language.

Backend (T001-T003): language column on scans, openapi+codegen, ruleCatalogI18n,
language threaded scans route -> analyzeSkill -> runStaticRule -> AI calls.
Route/AI error messages localized via expanded i18n MESSAGES + reqLang(req)
(?lang query -> Accept-Language header -> "de"). No German left in routes.

Frontend (T004-T005): react-i18next framework, LanguageSwitcher, locale-aware
format.ts, Clerk localizations. All page/component strings externalized to
de/en/es locale area files across catalog, education, scan form/report/compare,
history, dashboard, admin, legal pages.

T006 verification + review-fix follow-up (this session):
- Applied formatNumber to all visible metrics in scan-report (risk score,
  severity counts, security/privacy) and scan-compare (risk score, file count,
  diff counts); PDF/HTML export numbers formatted via Intl.NumberFormat(lng).
- Fixed leftover `@workspace/n` import alias in i18n/index.ts -> real package
  `@workspace/api-client-react` (was failing workspace typecheck).
- Verified: full `pnpm run typecheck` green; api-server tests 72/72 pass;
  curl confirms localized error responses (de/en/es) on scans route.

Deviations: AI connection-test prompts left in German intentionally (sent to
the model, not user-facing). proposeFollowUpTasks already created #52.

Replit-Task-Id: 9f137230-db11-45dc-9276-4e5cbcceff03
2026-06-13 09:05:57 +00:00

220 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Request } from "express";
import type { Lang } from "./ruleCatalogI18n";
import { normalizeLang } from "./ruleCatalogI18n";
export type { Lang } from "./ruleCatalogI18n";
export { normalizeLang, SUPPORTED_LANGS } from "./ruleCatalogI18n";
type MessageKey =
| "aiRulesDisabled"
| "aiNoProvider"
| "aiNoToken"
| "aiUnknownError"
| "invalidId"
| "invalidInput"
| "scanNotFound"
| "ruleNotFound"
| "promptNotFound"
| "providerNotFound"
| "rateLimited"
| "zipMissing"
| "fileMissing"
| "textMissing"
| "noAnalyzableFiles"
| "skillUnreadable"
| "analysisFailed"
| "noDownloadableFiles"
| "onlyPassedDownloadable"
| "descriptionFailed"
| "noApiTokenPlain"
| "noApiTokenProvided"
| "modelsLoadFailed"
| "connSuccessReply"
| "connSuccessModels"
| "connSuccessNoModels"
| "connReplyEmpty"
| "connFailed";
const MESSAGES: Record<MessageKey, Record<Lang, string>> = {
aiRulesDisabled: {
de: "KI-Regeln sind im Regelwerk deaktiviert.",
en: "AI rules are disabled in the rule set.",
es: "Las reglas de IA están desactivadas en el conjunto de reglas.",
},
aiNoProvider: {
de: "Kein aktiver KI-Provider konfiguriert. Bitte im Admin-Bereich einrichten.",
en: "No active AI provider configured. Please set one up in the admin area.",
es: "No hay ningún proveedor de IA activo configurado. Configúrelo en el área de administración.",
},
aiNoToken: {
de: 'Für den Provider "{name}" ist kein API-Token hinterlegt.',
en: 'No API token is stored for the provider "{name}".',
es: 'No hay ningún token de API almacenado para el proveedor "{name}".',
},
aiUnknownError: {
de: "Unbekannter KI-Fehler",
en: "Unknown AI error",
es: "Error de IA desconocido",
},
invalidId: {
de: "Ungültige ID",
en: "Invalid ID",
es: "ID no válido",
},
invalidInput: {
de: "Ungültige Eingabe",
en: "Invalid input",
es: "Entrada no válida",
},
scanNotFound: {
de: "Scan nicht gefunden",
en: "Scan not found",
es: "Análisis no encontrado",
},
ruleNotFound: {
de: "Regel nicht gefunden",
en: "Rule not found",
es: "Regla no encontrada",
},
promptNotFound: {
de: "Prompt nicht gefunden",
en: "Prompt not found",
es: "Prompt no encontrado",
},
providerNotFound: {
de: "Provider nicht gefunden",
en: "Provider not found",
es: "Proveedor no encontrado",
},
rateLimited: {
de: "Zu viele Scans in kurzer Zeit. Bitte später erneut versuchen.",
en: "Too many scans in a short time. Please try again later.",
es: "Demasiados análisis en poco tiempo. Inténtelo de nuevo más tarde.",
},
zipMissing: {
de: "ZIP-Inhalt fehlt.",
en: "ZIP content is missing.",
es: "Falta el contenido del ZIP.",
},
fileMissing: {
de: "Dateiinhalt fehlt.",
en: "File content is missing.",
es: "Falta el contenido del archivo.",
},
textMissing: {
de: "Text fehlt.",
en: "Text is missing.",
es: "Falta el texto.",
},
noAnalyzableFiles: {
de: "Keine analysierbaren Dateien gefunden.",
en: "No analyzable files found.",
es: "No se encontraron archivos analizables.",
},
skillUnreadable: {
de: "Das Skill konnte nicht gelesen werden. Bitte prüfen Sie das Format (gültiges ZIP / Textdatei).",
en: "The skill could not be read. Please check the format (valid ZIP / text file).",
es: "No se pudo leer la skill. Compruebe el formato (ZIP válido / archivo de texto).",
},
analysisFailed: {
de: "Die Analyse ist fehlgeschlagen.",
en: "The analysis failed.",
es: "El análisis falló.",
},
noDownloadableFiles: {
de: "Für dieses Skill sind keine herunterladbaren Dateien gespeichert.",
en: "No downloadable files are stored for this skill.",
es: "No hay archivos descargables almacenados para esta skill.",
},
onlyPassedDownloadable: {
de: "Nur Skills mit dem Ergebnis „Bestanden“ können heruntergeladen werden.",
en: "Only skills with a “Passed” result can be downloaded.",
es: "Solo se pueden descargar las skills con resultado «Aprobado».",
},
descriptionFailed: {
de: "Die Beschreibung konnte nicht erzeugt werden. Bitte Provider-Konfiguration und KI-Prompts prüfen.",
en: "The description could not be generated. Please check the provider configuration and AI prompts.",
es: "No se pudo generar la descripción. Compruebe la configuración del proveedor y los prompts de IA.",
},
noApiTokenPlain: {
de: "Kein API-Token hinterlegt.",
en: "No API token stored.",
es: "No hay ningún token de API almacenado.",
},
noApiTokenProvided: {
de: "Kein API-Token angegeben.",
en: "No API token provided.",
es: "No se proporcionó ningún token de API.",
},
modelsLoadFailed: {
de: "Modelle konnten nicht geladen werden.",
en: "Models could not be loaded.",
es: "No se pudieron cargar los modelos.",
},
connSuccessReply: {
de: "Verbindung erfolgreich. Antwort: {reply}",
en: "Connection successful. Reply: {reply}",
es: "Conexión correcta. Respuesta: {reply}",
},
connSuccessModels: {
de: "Verbindung erfolgreich. {count} Modelle verfügbar.",
en: "Connection successful. {count} models available.",
es: "Conexión correcta. {count} modelos disponibles.",
},
connSuccessNoModels: {
de: "Verbindung erfolgreich. Es wurden keine Modelle gefunden bitte das Modell manuell eingeben.",
en: "Connection successful. No models were found please enter the model manually.",
es: "Conexión correcta. No se encontraron modelos: introduzca el modelo manualmente.",
},
connReplyEmpty: {
de: "(leer)",
en: "(empty)",
es: "(vacío)",
},
connFailed: {
de: "Verbindung fehlgeschlagen.",
en: "Connection failed.",
es: "La conexión falló.",
},
};
export function t(
key: MessageKey,
lang: Lang,
vars?: Record<string, string>,
): string {
let msg = MESSAGES[key][lang];
if (vars) {
for (const [k, v] of Object.entries(vars)) {
msg = msg.replace(`{${k}}`, v);
}
}
return msg;
}
// Phrases injected into AI prompts so the model produces output in the
// requested language. The directive overrides any language line baked into the
// stored prompts (which historically said "Antworte auf Deutsch.").
const LANGUAGE_DIRECTIVE: Record<Lang, string> = {
de: "WICHTIG: Verfasse alle Ausgabetexte (Beschreibung, Findings, Empfehlungen) ausschließlich auf Deutsch.",
en: "IMPORTANT: Write all output text (description, findings, remediation) exclusively in English.",
es: "IMPORTANTE: Redacta todos los textos de salida (descripción, hallazgos, recomendaciones) exclusivamente en español.",
};
export function languageDirective(lang: Lang): string {
return LANGUAGE_DIRECTIVE[lang];
}
// Resolve the language for a request's user-facing error messages. Prefers an
// explicit `?lang=` query param, then the `Accept-Language` header (the web
// client sends the active UI language), defaulting to German.
export function reqLang(req: Request): Lang {
const q = req.query?.lang;
if (typeof q === "string" && q) return normalizeLang(q);
const header = req.headers["accept-language"];
if (typeof header === "string" && header) {
return normalizeLang(header.split(",")[0].trim().slice(0, 2));
}
return "de";
}