skillguard/artifacts/api-server/src/lib/i18n.ts

221 lines
7.2 KiB
TypeScript
Raw Normal View History

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