diff --git a/apps/app/src/i18n/index.ts b/apps/app/src/i18n/index.ts index 1a3a72cc8..c4dcd374e 100644 --- a/apps/app/src/i18n/index.ts +++ b/apps/app/src/i18n/index.ts @@ -94,37 +94,66 @@ export const setLocale = (newLocale: Language) => { }; /** - * Translation function with fallback behavior - * Fallback chain: target language → English → key itself - * - * @param key - Translation key - * @param localeOverride - Optional locale override (defaults to current locale) - * @returns Translated string or fallback + * Resolve a translation entry with the locale → English → null fallback chain. */ -export const t = (key: string, params?: Record & { lng?: Language }): string => { - const loc = params?.lng ?? locale(); +const lookupEntry = (loc: Language, candidateKey: string): string | null => { + if (TRANSLATIONS[loc]?.[candidateKey]) return TRANSLATIONS[loc][candidateKey]; + if (loc !== "en" && TRANSLATIONS.en?.[candidateKey]) return TRANSLATIONS.en[candidateKey]; + return null; +}; - // Try target language first - let result: string; - if (TRANSLATIONS[loc]?.[key]) { - result = TRANSLATIONS[loc][key]; - } else if (loc !== "en" && TRANSLATIONS.en?.[key]) { - // Fallback to English - result = TRANSLATIONS.en[key]; - } else { - // Final fallback to key itself (prevents raw keys from showing in UI) - return key; +const pluralRulesCache = new Map(); +const pluralRule = (loc: Language, count: number): Intl.LDMLPluralRule => { + let rules = pluralRulesCache.get(loc); + if (!rules) { + rules = new Intl.PluralRules(loc); + pluralRulesCache.set(loc, rules); } + return rules.select(count); +}; - // Replace params if provided (skip the lng meta-key) - if (params) { - for (const [k, v] of Object.entries(params)) { - if (k === "lng") continue; - result = result.replace(`{${k}}`, String(v)); - } +/** + * Pick the right key variant for a count. Tries `${key}_zero` (only when count === 0), + * then `${key}_${rule}` (e.g. `_one` / `_other`), then `${key}_other`, then the bare + * key. Asian locales (no grammatical plural) define only the bare key and hit the + * final step. Each candidate runs through the locale → English fallback so an + * untranslated key still resolves to the English `_one` / `_other` variant. + */ +const resolvePluralKey = (loc: Language, key: string, count: number): string => { + const candidates: string[] = []; + if (count === 0) candidates.push(`${key}_zero`); + candidates.push(`${key}_${pluralRule(loc, count)}`, `${key}_other`, key); + + for (const candidate of candidates) { + if (lookupEntry(loc, candidate) !== null) return candidate; } + return key; +}; - return result; +/** + * Translation function with fallback behavior. + * - Locale fallback: target language → English → key itself. + * - Plural fallback: when params include a numeric `count`, the lookup picks + * `${key}_one` / `${key}_other` (or `${key}_zero` when count === 0) per + * `Intl.PluralRules`, and falls back to the bare key when no variants exist. + */ +export const t = (key: string, params?: Record & { lng?: Language }): string => { + const loc = params?.lng ?? locale(); + + const lookupKey = + typeof params?.count === "number" ? resolvePluralKey(loc, key, params.count) : key; + + const result = lookupEntry(loc, lookupKey); + if (result === null) return key; + + if (!params) return result; + + let out = result; + for (const [k, v] of Object.entries(params)) { + if (k === "lng") continue; + out = out.replace(`{${k}}`, String(v)); + } + return out; }; /** diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index e53fcaa3d..94ed718e6 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -457,9 +457,12 @@ export default { "den.status_browser_signup": "Acabeu la creació del compte al vostre navegador per connectar OpenWork.", "den.status_cloud_signed_in_as": "OpenWork Cloud connectat com a {email}.", "den.status_cloud_signin_done": "OpenWork Cloud connectat.", - "den.status_loaded_orgs": "S'ha carregat {count} org{plural}.", - "den.status_loaded_skills": "S'han carregat {count} Skill{plural} del núvol per a {name}.", - "den.status_loaded_workers": "S'ha carregat {count} worker{plural} per a {name}.", + "den.status_loaded_orgs_one": "S'ha carregat {count} org.", + "den.status_loaded_orgs_other": "S'ha carregat {count} orgs.", + "den.status_loaded_skills_one": "S'han carregat {count} Skill del núvol per a {name}.", + "den.status_loaded_skills_other": "S'han carregat {count} Skills del núvol per a {name}.", + "den.status_loaded_workers_one": "S'ha carregat {count} worker per a {name}.", + "den.status_loaded_workers_other": "S'ha carregat {count} workers per a {name}.", "den.status_no_skills": "No s'han trobat Skills al núvol per a {name}.", "den.status_no_workers": "No s'ha trobat cap worker per a {name}.", "den.status_opened_worker": "S'ha obert {name} a OpenWork.", @@ -477,13 +480,13 @@ export default { "den.worker_provider_label": "{provider} worker", "den.worker_secondary_cloud": "Worker Cloud", "extensions.app_count_one": "Aplicació {count} connectada", - "extensions.app_count_many": "Aplicacions {count} connectades", + "extensions.app_count_other": "Aplicacions {count} connectades", "extensions.apps_mcp_header": "Aplicacions (MCP)", "extensions.filter_all": "Tots", "extensions.filter_apps": "Aplicacions", "extensions.filter_plugins": "Plugins", "extensions.plugin_count_one": "{count} plugin", - "extensions.plugin_count_many": "{count} plugins", + "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "Les apps (MCP) i els Plugins d'OpenCode són al mateix lloc.", "extensions.title": "Extensions", @@ -828,7 +831,6 @@ export default { "message_list.open_session": "Sessió oberta", "message_list.step_updates_progress": "Progrés de les actualitzacions", "message_list.subagent_loading_transcript": "S'està carregant la transcripció", - "message_list.subagent_message_count": "missatge {count}{plural}", "message_list.subagent_running": "Córrer", "message_list.subagent_session_fallback": "Sessió de subagent", "message_list.subagent_type_task": "tasca {agentType}", @@ -884,8 +886,8 @@ export default { "model_picker.connect_provider_hint": "Connecta aquest proveïdor per veure i desar models", "model_picker.default_model_desc": "Tria el model predeterminat per a xats nous i, a continuació, ajusteu els perfils de raonament a la seva targeta abans de prémer Fet.", "model_picker.default_model_title": "Model per defecte", - "model_picker.model_count": "Models {count}", - "model_picker.model_count_one": "1 model", + "model_picker.model_count_one": "{count} model", + "model_picker.model_count_other": "{count} models", "model_picker.more_providers": "Més proveïdors", "model_picker.no_results": "No hi ha cap model que coincideixi amb la teva cerca.", "model_picker.other_connected_models": "Altres models connectats", @@ -913,7 +915,6 @@ export default { "onboarding.create_first_workspace": "Crea el teu primer workspace", "onboarding.create_workspace": "Crea un workspace", "onboarding.engine_running": "El motor ja funciona", - "onboarding.folders_allowed": "Carpeta {count} {plural} permesa", "onboarding.getting_ready": "Preparant-ho tot", "onboarding.install": "Instal·la OpenCode", "onboarding.install_instruction": "Instal·la OpenCode per habilitar el servidor local (no cal cap terminal).", @@ -1215,7 +1216,6 @@ export default { "session.share_worker_url_phones_hint": "Fes servir-lo en telèfons o ordinadors portàtils connectats a aquest worker.", "session.share_worker_url_resolving_hint": "S'està resolent l'URL del worker; mentrestant es mostra l'URL del host com a alternativa.", "session.shared_folder_upload_failed": "La càrrega de la carpeta compartida ha fallat", - "session.show_earlier": "Mostra {count} missatge{plural} anteriors", "session.status_active": "Sessió activa", "session.status_compacting": "Context compactant", "session.status_delegating": "Delegant", @@ -1932,7 +1932,8 @@ export default { "status.mcp_connected": "{count} MCP connectat", "status.open_docs": "Obre la documentació", "status.openwork_ready": "OpenWork llest", - "status.providers_connected": "Proveïdor {count}{plural} connectat", + "status.providers_connected_one": "{count} proveïdor connectat", + "status.providers_connected_other": "{count} proveïdors connectat", "status.ready_for_tasks": "Preparat per a noves tasques", "status.reloading_engine": "Recàrrega del motor", "status.restarting_engine": "Reiniciant el motor", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 536b42176..ab317750d 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -460,9 +460,12 @@ export default { "den.status_browser_signup": "Finish account creation in your browser to connect OpenWork.", "den.status_cloud_signed_in_as": "Connected OpenWork Cloud as {email}.", "den.status_cloud_signin_done": "Connected OpenWork Cloud.", - "den.status_loaded_orgs": "Connected {count} organization{plural}.", - "den.status_loaded_skills": "Loaded {count} cloud skill{plural} for {name}.", - "den.status_loaded_workers": "Loaded {count} worker{plural} for {name}.", + "den.status_loaded_orgs_one": "Connected {count} organization.", + "den.status_loaded_orgs_other": "Connected {count} organizations.", + "den.status_loaded_skills_one": "Loaded {count} cloud skill for {name}.", + "den.status_loaded_skills_other": "Loaded {count} cloud skills for {name}.", + "den.status_loaded_workers_one": "Loaded {count} worker for {name}.", + "den.status_loaded_workers_other": "Loaded {count} workers for {name}.", "den.status_no_skills": "No cloud skills found for {name}.", "den.status_no_workers": "No workers found for {name}.", "den.status_opened_worker": "Opened {name} in OpenWork.", @@ -480,13 +483,13 @@ export default { "den.worker_provider_label": "{provider} worker", "den.worker_secondary_cloud": "Cloud worker", "extensions.app_count_one": "{count} app connected", - "extensions.app_count_many": "{count} apps connected", + "extensions.app_count_other": "{count} apps connected", "extensions.apps_mcp_header": "Apps (MCP)", "extensions.filter_all": "All", "extensions.filter_apps": "Apps", "extensions.filter_plugins": "Plugins", "extensions.plugin_count_one": "{count} plugin", - "extensions.plugin_count_many": "{count} plugins", + "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "Apps (MCP) and OpenCode plugins live in one place.", "extensions.title": "Extensions", @@ -836,7 +839,6 @@ export default { "message_list.open_session": "Open session", "message_list.step_updates_progress": "Updates progress", "message_list.subagent_loading_transcript": "Loading transcript", - "message_list.subagent_message_count": "{count} message{plural}", "message_list.subagent_running": "Running", "message_list.subagent_session_fallback": "Subagent session", "message_list.subagent_type_task": "{agentType} task", @@ -892,8 +894,8 @@ export default { "model_picker.connect_provider_hint": "Connect this provider to browse and save models", "model_picker.default_model_desc": "Choose the default model for new chats, then fine-tune reasoning profiles on its card before pressing Done.", "model_picker.default_model_title": "Default model", - "model_picker.model_count": "{count} models", - "model_picker.model_count_one": "1 model", + "model_picker.model_count_one": "{count} model", + "model_picker.model_count_other": "{count} models", "model_picker.more_providers": "More providers", "model_picker.no_results": "No models match your search.", "model_picker.other_connected_models": "Other connected models", @@ -921,7 +923,6 @@ export default { "onboarding.create_first_workspace": "Create your first workspace", "onboarding.create_workspace": "Create a workspace", "onboarding.engine_running": "Engine already running", - "onboarding.folders_allowed": "{count} folder{plural} allowed", "onboarding.getting_ready": "Getting everything ready", "onboarding.install": "Install OpenCode", "onboarding.install_instruction": "Install OpenCode to enable the local server (no terminal required).", @@ -1248,7 +1249,6 @@ export default { "session.share_worker_url_phones_hint": "Use on phones or laptops connecting to this worker.", "session.share_worker_url_resolving_hint": "Worker URL is resolving; host URL shown as fallback.", "session.shared_folder_upload_failed": "Shared folder upload failed", - "session.show_earlier": "Show {count} earlier message{plural}", "session.status_active": "Session Active", "session.status_compacting": "Compacting Context", "session.status_delegating": "Delegating", @@ -2016,7 +2016,8 @@ export default { "status.mcp_connected": "{count} MCP connected", "status.open_docs": "Open documentation", "status.openwork_ready": "OpenWork Ready", - "status.providers_connected": "{count} provider{plural} connected", + "status.providers_connected_one": "{count} provider connected", + "status.providers_connected_other": "{count} providers connected", "status.ready_for_tasks": "Ready for new tasks", "status.reloading_engine": "Reloading engine", "status.restarting_engine": "Restarting engine", diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index 68c6f9b5d..622df963c 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -457,9 +457,12 @@ export default { "den.status_browser_signup": "Termina de crear tu cuenta en el navegador para conectar OpenWork.", "den.status_cloud_signed_in_as": "Conectado a OpenWork Cloud como {email}.", "den.status_cloud_signin_done": "OpenWork Cloud conectado.", - "den.status_loaded_orgs": "Se cargaron {count} organización{plural}.", - "den.status_loaded_skills": "Se cargaron {count} Skill{plural} de Cloud para {name}.", - "den.status_loaded_workers": "Se cargaron {count} worker{plural} para {name}.", + "den.status_loaded_orgs_one": "Se cargaron {count} organización.", + "den.status_loaded_orgs_other": "Se cargaron {count} organizacións.", + "den.status_loaded_skills_one": "Se cargaron {count} Skill de Cloud para {name}.", + "den.status_loaded_skills_other": "Se cargaron {count} Skills de Cloud para {name}.", + "den.status_loaded_workers_one": "Se cargaron {count} worker para {name}.", + "den.status_loaded_workers_other": "Se cargaron {count} workers para {name}.", "den.status_no_skills": "No se encontraron Skills en Cloud para {name}.", "den.status_no_workers": "No se encontraron workers para {name}.", "den.status_opened_worker": "Se abrió {name} en OpenWork.", @@ -477,13 +480,13 @@ export default { "den.worker_provider_label": "Worker de {provider}", "den.worker_secondary_cloud": "Worker de Cloud", "extensions.app_count_one": "Aplicación {count} conectada", - "extensions.app_count_many": "Aplicaciones {count} conectadas", + "extensions.app_count_other": "Aplicaciones {count} conectadas", "extensions.apps_mcp_header": "Aplicaciones (MCP)", "extensions.filter_all": "Todo", "extensions.filter_apps": "Aplicaciones", "extensions.filter_plugins": "Plugins", "extensions.plugin_count_one": "Plugin {count}", - "extensions.plugin_count_many": "Plugins {count}", + "extensions.plugin_count_other": "Plugins {count}", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "Las aplicaciones (MCP) y los Plugins OpenCode se encuentran en un solo lugar.", "extensions.title": "Extensiones", @@ -828,7 +831,6 @@ export default { "message_list.open_session": "sesión abierta", "message_list.step_updates_progress": "Progreso de las actualizaciones", "message_list.subagent_loading_transcript": "Cargando transcripción", - "message_list.subagent_message_count": "Mensaje {count}{plural}", "message_list.subagent_running": "En ejecución", "message_list.subagent_session_fallback": "sesión de subagente", "message_list.subagent_type_task": "tarea {agentType}", @@ -884,8 +886,8 @@ export default { "model_picker.connect_provider_hint": "Conecta este proveedor para buscar y guardar modelos", "model_picker.default_model_desc": "Elige el modelo predeterminado para nuevos chats, luego ajuste los perfiles de razonamiento en su tarjeta antes de presionar Listo.", "model_picker.default_model_title": "Modelo predeterminado", - "model_picker.model_count": "Modelos {count}", - "model_picker.model_count_one": "1 modelo", + "model_picker.model_count_one": "{count} modelo", + "model_picker.model_count_other": "{count} modelos", "model_picker.more_providers": "Más proveedores", "model_picker.no_results": "Ningún modelo coincide con tu búsqueda.", "model_picker.other_connected_models": "Otros modelos conectados", @@ -913,7 +915,6 @@ export default { "onboarding.create_first_workspace": "Crea tu primer espacio de trabajo", "onboarding.create_workspace": "Crear un espacio de trabajo", "onboarding.engine_running": "Motor ya en marcha", - "onboarding.folders_allowed": "{count} carpeta{plural} permitida{plural}", "onboarding.getting_ready": "Preparando todo", "onboarding.install": "Instalar OpenCode", "onboarding.install_instruction": "Instala OpenCode para habilitar el servidor local (no se necesita terminal).", @@ -1215,7 +1216,6 @@ export default { "session.share_worker_url_phones_hint": "Úsalo en móviles o portátiles que se conecten a este worker.", "session.share_worker_url_resolving_hint": "La URL del worker se está resolviendo; mientras tanto se muestra la URL del host como alternativa.", "session.shared_folder_upload_failed": "Falló la subida a la carpeta compartida", - "session.show_earlier": "Mostrar {count} mensaje{plural} anterior", "session.status_active": "Sesión activa", "session.status_compacting": "Compactando contexto", "session.status_delegating": "Delegar", @@ -1932,7 +1932,8 @@ export default { "status.mcp_connected": "{count} MCP conectado", "status.open_docs": "Abrir documentación", "status.openwork_ready": "OpenWork listo", - "status.providers_connected": "Proveedor {count}{plural} conectado", + "status.providers_connected_one": "{count} proveedor conectado", + "status.providers_connected_other": "{count} proveedors conectado", "status.ready_for_tasks": "Listo para nuevas tareas", "status.reloading_engine": "Recarga del motor", "status.restarting_engine": "Reiniciando el motor", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index d8a9f1792..865c65973 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -457,9 +457,12 @@ export default { "den.status_browser_signup": "Terminez la création du compte dans votre navigateur pour connecter OpenWork.", "den.status_cloud_signed_in_as": "OpenWork Cloud connecté en tant que {email}.", "den.status_cloud_signin_done": "OpenWork Cloud connecté.", - "den.status_loaded_orgs": "{count} organisation{plural} chargée.", - "den.status_loaded_skills": "{count} Skill{plural} cloud chargé pour {name}.", - "den.status_loaded_workers": "{count} worker{plural} chargé pour {name}.", + "den.status_loaded_orgs_one": "{count} organisation chargée.", + "den.status_loaded_orgs_other": "{count} organisations chargée.", + "den.status_loaded_skills_one": "{count} Skill cloud chargé pour {name}.", + "den.status_loaded_skills_other": "{count} Skills cloud chargé pour {name}.", + "den.status_loaded_workers_one": "{count} worker chargé pour {name}.", + "den.status_loaded_workers_other": "{count} workers chargé pour {name}.", "den.status_no_skills": "Aucun Skill cloud trouvé pour {name}.", "den.status_no_workers": "Aucun worker trouvé pour {name}.", "den.status_opened_worker": "{name} ouvert dans OpenWork.", @@ -477,13 +480,13 @@ export default { "den.worker_provider_label": "worker {provider}", "den.worker_secondary_cloud": "Worker cloud", "extensions.app_count_one": "{count} application connectée", - "extensions.app_count_many": "{count} applications connectées", + "extensions.app_count_other": "{count} applications connectées", "extensions.apps_mcp_header": "Applications (MCP)", "extensions.filter_all": "Tout", "extensions.filter_apps": "Applications", "extensions.filter_plugins": "Plugins", "extensions.plugin_count_one": "{count} plugin", - "extensions.plugin_count_many": "{count} plugins", + "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "Les applications (MCP) et les Plugins OpenCode se trouvent au même endroit.", "extensions.title": "Extensions", @@ -828,7 +831,6 @@ export default { "message_list.open_session": "Ouvrir la session", "message_list.step_updates_progress": "Met à jour la progression", "message_list.subagent_loading_transcript": "Chargement de la transcription", - "message_list.subagent_message_count": "{count} message{plural}", "message_list.subagent_running": "En cours d'exécution", "message_list.subagent_session_fallback": "Session du sous-agent", "message_list.subagent_type_task": "tâche {agentType}", @@ -884,8 +886,8 @@ export default { "model_picker.connect_provider_hint": "Connectez ce fournisseur pour parcourir et enregistrer des modèles", "model_picker.default_model_desc": "Choisissez le modèle par défaut pour les nouveaux chats, puis ajustez les profils de raisonnement sur sa carte avant d'appuyer sur Terminé.", "model_picker.default_model_title": "Modèle par défaut", - "model_picker.model_count": "{count} modèles", - "model_picker.model_count_one": "1 modèle", + "model_picker.model_count_one": "{count} modèle", + "model_picker.model_count_other": "{count} modèles", "model_picker.more_providers": "Plus de fournisseurs", "model_picker.no_results": "Aucun modèle ne correspond à votre recherche.", "model_picker.other_connected_models": "Autres modèles connectés", @@ -913,7 +915,6 @@ export default { "onboarding.create_first_workspace": "Créer votre premier espace de travail", "onboarding.create_workspace": "Créer un espace de travail", "onboarding.engine_running": "Moteur déjà en cours d'exécution", - "onboarding.folders_allowed": "{count} dossier{plural} autorisé", "onboarding.getting_ready": "Préparation en cours", "onboarding.install": "Installer OpenCode", "onboarding.install_instruction": "Installez OpenCode pour activer le serveur local (aucun terminal requis).", @@ -1215,7 +1216,6 @@ export default { "session.share_worker_url_phones_hint": "À utiliser sur les téléphones ou ordinateurs portables se connectant à ce worker.", "session.share_worker_url_resolving_hint": "L'URL du worker est en cours de résolution ; l'URL de l'hôte est affichée en secours.", "session.shared_folder_upload_failed": "Échec du téléversement dans le dossier partagé", - "session.show_earlier": "Afficher {count} message{plural} précédent", "session.status_active": "Session active", "session.status_compacting": "Compaction du contexte", "session.status_delegating": "Délégation", @@ -1932,7 +1932,8 @@ export default { "status.mcp_connected": "{count} MCP connecté", "status.open_docs": "Ouvrir la documentation", "status.openwork_ready": "OpenWork prêt", - "status.providers_connected": "{count} fournisseur{plural} connecté", + "status.providers_connected_one": "{count} fournisseur connecté", + "status.providers_connected_other": "{count} fournisseurs connecté", "status.ready_for_tasks": "Prêt pour de nouvelles tâches", "status.reloading_engine": "Rechargement du moteur", "status.restarting_engine": "Redémarrage du moteur", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index 52de08146..63d12228c 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -458,14 +458,12 @@ export default { "den.worker_not_ready_title": "このワーカーはまだ開ける状態ではありません。", "den.worker_provider_label": "{provider}ワーカー", "den.worker_secondary_cloud": "クラウドワーカー", - "extensions.app_count_one": "{count}件のアプリ接続済み", - "extensions.app_count_many": "{count}件のアプリ接続済み", + "extensions.app_count": "{count}件のアプリ接続済み", "extensions.apps_mcp_header": "アプリ(MCP)", "extensions.filter_all": "すべて", "extensions.filter_apps": "アプリ", "extensions.filter_plugins": "プラグイン", - "extensions.plugin_count_one": "{count}件のプラグイン", - "extensions.plugin_count_many": "{count}件のプラグイン", + "extensions.plugin_count": "{count}件のプラグイン", "extensions.plugins_opencode_header": "プラグイン(OpenCode)", "extensions.subtitle": "アプリ(MCP)とOpenCodeプラグインをまとめて管理できます。", "extensions.title": "拡張機能", @@ -810,7 +808,6 @@ export default { "message_list.open_session": "セッションを開く", "message_list.step_updates_progress": "進捗を更新", "message_list.subagent_loading_transcript": "トランスクリプトを読み込み中", - "message_list.subagent_message_count": "{count}件のメッセージ", "message_list.subagent_running": "実行中", "message_list.subagent_session_fallback": "サブエージェントセッション", "message_list.subagent_type_task": "{agentType}タスク", @@ -867,7 +864,6 @@ export default { "model_picker.default_model_desc": "新しいチャットのデフォルトモデルを選択し、その後、カードで推論プロファイルを微調整してから「完了」を押します。", "model_picker.default_model_title": "デフォルトモデル", "model_picker.model_count": "{count}件のモデル", - "model_picker.model_count_one": "1件のモデル", "model_picker.more_providers": "さらにプロバイダー", "model_picker.no_results": "検索に一致するモデルがありません。", "model_picker.other_connected_models": "その他の接続済みモデル", @@ -895,7 +891,6 @@ export default { "onboarding.create_first_workspace": "最初のワークスペースを作成", "onboarding.create_workspace": "ワークスペースを作成", "onboarding.engine_running": "エンジンは既に実行中です", - "onboarding.folders_allowed": "{count}個のフォルダが許可済み", "onboarding.getting_ready": "準備しています", "onboarding.install": "OpenCodeをインストール", "onboarding.install_instruction": "ローカルサーバーを有効にするにはOpenCodeをインストールしてください(ターミナル不要)。", @@ -1197,7 +1192,6 @@ export default { "session.share_worker_url_phones_hint": "このワーカーに接続するスマートフォンやノートPCで使用します。", "session.share_worker_url_resolving_hint": "ワーカーURLを解決中。フォールバックとしてホストURLを表示しています。", "session.shared_folder_upload_failed": "共有フォルダへのアップロードに失敗しました", - "session.show_earlier": "以前の{count}件のメッセージを表示", "session.status_active": "セッション稼働中", "session.status_compacting": "コンテキストを圧縮中", "session.status_delegating": "委任中", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index 806e5f9c4..8b94e86b9 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -444,8 +444,10 @@ export default { "den.status_browser_signup": "Conclua a criação da conta no navegador para conectar o OpenWork.", "den.status_cloud_signed_in_as": "OpenWork Cloud conectado como {email}.", "den.status_cloud_signin_done": "OpenWork Cloud conectado.", - "den.status_loaded_orgs": "{count} org{plural} carregada{plural}.", - "den.status_loaded_workers": "{count} worker{plural} carregado{plural} para {name}.", + "den.status_loaded_orgs_one": "{count} org carregada.", + "den.status_loaded_orgs_other": "{count} orgs carregadas.", + "den.status_loaded_workers_one": "{count} worker carregado para {name}.", + "den.status_loaded_workers_other": "{count} workers carregados para {name}.", "den.status_no_workers": "Nenhum worker encontrado para {name}.", "den.status_opened_worker": "{name} aberto no OpenWork.", "den.status_signed_in_as": "Conectado como {email}.", @@ -460,13 +462,13 @@ export default { "den.worker_provider_label": "Worker {provider}", "den.worker_secondary_cloud": "Worker na nuvem", "extensions.app_count_one": "{count} app conectado", - "extensions.app_count_many": "{count} apps conectados", + "extensions.app_count_other": "{count} apps conectados", "extensions.apps_mcp_header": "Apps (MCP)", "extensions.filter_all": "Todos", "extensions.filter_apps": "Apps", "extensions.filter_plugins": "Plugins", "extensions.plugin_count_one": "{count} plugin", - "extensions.plugin_count_many": "{count} plugins", + "extensions.plugin_count_other": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "Apps (MCP) e plugins OpenCode ficam em um só lugar.", "extensions.title": "Extensões", @@ -811,7 +813,6 @@ export default { "message_list.open_session": "Abrir sessão", "message_list.step_updates_progress": "Atualiza o progresso", "message_list.subagent_loading_transcript": "Carregando transcrição", - "message_list.subagent_message_count": "{count} mensagem{plural}", "message_list.subagent_running": "Em execução", "message_list.subagent_session_fallback": "Sessão do subagente", "message_list.subagent_type_task": "Tarefa de {agentType}", @@ -867,8 +868,8 @@ export default { "model_picker.connect_provider_hint": "Conecte este provedor para explorar e salvar modelos", "model_picker.default_model_desc": "Escolha o modelo padrão para novos chats e ajuste os perfis de raciocínio no cartão antes de clicar em Concluído.", "model_picker.default_model_title": "Modelo padrão", - "model_picker.model_count": "{count} modelos", - "model_picker.model_count_one": "1 modelo", + "model_picker.model_count_one": "{count} modelo", + "model_picker.model_count_other": "{count} modelos", "model_picker.more_providers": "Mais provedores", "model_picker.no_results": "Nenhum modelo corresponde à sua busca.", "model_picker.other_connected_models": "Outros modelos conectados", @@ -896,7 +897,6 @@ export default { "onboarding.create_first_workspace": "Crie seu primeiro workspace", "onboarding.create_workspace": "Criar um workspace", "onboarding.engine_running": "Engine já em execução", - "onboarding.folders_allowed": "{count} pasta{plural} permitida{plural}", "onboarding.getting_ready": "Preparando tudo", "onboarding.install": "Instalar OpenCode", "onboarding.install_instruction": "Instale o OpenCode para ativar o servidor local (sem terminal necessário).", @@ -1198,7 +1198,6 @@ export default { "session.share_worker_url_phones_hint": "Use em celulares ou laptops conectando a este worker.", "session.share_worker_url_resolving_hint": "URL do worker está sendo resolvida; URL do host exibida como alternativa.", "session.shared_folder_upload_failed": "Falha no envio para a pasta compartilhada", - "session.show_earlier": "Mostrar {count} mensagem{plural} mais antiga{plural}", "session.status_active": "Sessão Ativa", "session.status_compacting": "Compactando Contexto", "session.status_delegating": "Delegando", @@ -1900,7 +1899,8 @@ export default { "status.mcp_connected": "{count} MCP conectados", "status.open_docs": "Abrir documentação", "status.openwork_ready": "OpenWork Pronto", - "status.providers_connected": "{count} provider{plural} conectado{plural}", + "status.providers_connected_one": "{count} provider conectado", + "status.providers_connected_other": "{count} providers conectados", "status.ready_for_tasks": "Pronto para novas tarefas", "status.reloading_engine": "Recarregando engine", "status.restarting_engine": "Reiniciando engine", diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index c0989a5d8..7c5d0929c 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -459,14 +459,12 @@ export default { "den.worker_not_ready_title": "Worker นี้ยังไม่พร้อมเปิด", "den.worker_provider_label": "{provider} worker", "den.worker_secondary_cloud": "Cloud worker", - "extensions.app_count_one": "{count} แอปเชื่อมต่อแล้ว", - "extensions.app_count_many": "{count} แอปเชื่อมต่อแล้ว", + "extensions.app_count": "{count} แอปเชื่อมต่อแล้ว", "extensions.apps_mcp_header": "แอป (MCP)", "extensions.filter_all": "ทั้งหมด", "extensions.filter_apps": "แอป", "extensions.filter_plugins": "Plugins", - "extensions.plugin_count_one": "{count} ปลั๊กอิน", - "extensions.plugin_count_many": "{count} ปลั๊กอิน", + "extensions.plugin_count": "{count} ปลั๊กอิน", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "แอป (MCP) และ OpenCode plugins อยู่ในที่เดียว", "extensions.title": "ส่วนขยาย", @@ -811,7 +809,6 @@ export default { "message_list.open_session": "เปิดเซสชัน", "message_list.step_updates_progress": "อัปเดตความคืบหน้า", "message_list.subagent_loading_transcript": "กำลังโหลด transcript", - "message_list.subagent_message_count": "{count} ข้อความ", "message_list.subagent_running": "กำลังทำงาน", "message_list.subagent_session_fallback": "เซสชัน Subagent", "message_list.subagent_type_task": "งาน {agentType}", @@ -868,7 +865,6 @@ export default { "model_picker.default_model_desc": "เลือกโมเดลเริ่มต้นสำหรับแชทใหม่ แล้วปรับ reasoning profiles บนการ์ดก่อนกด เสร็จสิ้น", "model_picker.default_model_title": "โมเดลเริ่มต้น", "model_picker.model_count": "{count} โมเดล", - "model_picker.model_count_one": "1 โมเดล", "model_picker.more_providers": "ผู้ให้บริการเพิ่มเติม", "model_picker.no_results": "ไม่พบโมเดลที่ตรงกับการค้นหา", "model_picker.other_connected_models": "โมเดลที่เชื่อมต่ออื่นๆ", @@ -896,7 +892,6 @@ export default { "onboarding.create_first_workspace": "สร้างพื้นที่ทำงานแรกของคุณ", "onboarding.create_workspace": "สร้างพื้นที่ทำงาน", "onboarding.engine_running": "Engine กำลังทำงานอยู่แล้ว", - "onboarding.folders_allowed": "อนุญาต {count} โฟลเดอร์", "onboarding.getting_ready": "กำลังเตรียมทุกอย่าง", "onboarding.install": "ติดตั้ง OpenCode", "onboarding.install_instruction": "ติดตั้ง OpenCode เพื่อเปิดใช้งาน local server (ไม่ต้องใช้ terminal)", @@ -1198,7 +1193,6 @@ export default { "session.share_worker_url_phones_hint": "ใช้บนโทรศัพท์หรือแล็ปท็อปที่เชื่อมต่อกับ worker นี้", "session.share_worker_url_resolving_hint": "URL ของ Worker กำลัง resolve; แสดง host URL เป็นตัวสำรอง", "session.shared_folder_upload_failed": "อัปโหลดไปยังโฟลเดอร์ที่แชร์ไม่สำเร็จ", - "session.show_earlier": "แสดง {count} ข้อความก่อนหน้า", "session.status_active": "เซสชันกำลังทำงาน", "session.status_compacting": "กำลังบีบอัดบริบท", "session.status_delegating": "กำลังมอบหมาย", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 9a45c8bd8..5e4e53cbe 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -444,8 +444,8 @@ export default { "den.status_browser_signup": "Hoàn tất tạo tài khoản trong trình duyệt để kết nối OpenWork.", "den.status_cloud_signed_in_as": "Đã kết nối OpenWork Cloud với {email}.", "den.status_cloud_signin_done": "Đã kết nối OpenWork Cloud.", - "den.status_loaded_orgs": "Đã tải {count} tổ chức{plural}.", - "den.status_loaded_workers": "Đã tải {count} worker{plural} cho {name}.", + "den.status_loaded_orgs": "Đã tải {count} tổ chức.", + "den.status_loaded_workers": "Đã tải {count} worker cho {name}.", "den.status_no_workers": "Không tìm thấy worker cho {name}.", "den.status_opened_worker": "Đã mở {name} trong OpenWork.", "den.status_signed_in_as": "Đã đăng nhập với {email}.", @@ -459,14 +459,12 @@ export default { "den.worker_not_ready_title": "Worker này chưa sẵn sàng.", "den.worker_provider_label": "Worker {provider}", "den.worker_secondary_cloud": "Worker Cloud", - "extensions.app_count_one": "{count} ứng dụng đã kết nối", - "extensions.app_count_many": "{count} ứng dụng đã kết nối", + "extensions.app_count": "{count} ứng dụng đã kết nối", "extensions.apps_mcp_header": "Ứng dụng (MCP)", "extensions.filter_all": "Tất cả", "extensions.filter_apps": "Ứng dụng", "extensions.filter_plugins": "Plugins", - "extensions.plugin_count_one": "{count} plugin", - "extensions.plugin_count_many": "{count} plugins", + "extensions.plugin_count": "{count} plugins", "extensions.plugins_opencode_header": "Plugins (OpenCode)", "extensions.subtitle": "Ứng dụng (MCP) và OpenCode plugins gộp chung một nơi.", "extensions.title": "Tiện ích mở rộng", @@ -811,7 +809,6 @@ export default { "message_list.open_session": "Mở phiên", "message_list.step_updates_progress": "Cập nhật tiến trình", "message_list.subagent_loading_transcript": "Đang tải bản ghi", - "message_list.subagent_message_count": "{count} tin nhắn{plural}", "message_list.subagent_running": "Đang chạy", "message_list.subagent_session_fallback": "Phiên subagent", "message_list.subagent_type_task": "Task {agentType}", @@ -868,7 +865,6 @@ export default { "model_picker.default_model_desc": "Chọn model mặc định cho cuộc trò chuyện mới, rồi tinh chỉnh hồ sơ suy luận trên thẻ trước khi nhấn Xong.", "model_picker.default_model_title": "Model mặc định", "model_picker.model_count": "{count} model", - "model_picker.model_count_one": "1 model", "model_picker.more_providers": "Thêm provider", "model_picker.no_results": "Không có model phù hợp với tìm kiếm.", "model_picker.other_connected_models": "Model đã kết nối khác", @@ -896,7 +892,6 @@ export default { "onboarding.create_first_workspace": "Tạo workspace đầu tiên", "onboarding.create_workspace": "Tạo workspace", "onboarding.engine_running": "Engine đang chạy", - "onboarding.folders_allowed": "{count} thư mục{plural} được phép", "onboarding.getting_ready": "Đang chuẩn bị mọi thứ", "onboarding.install": "Cài đặt OpenCode", "onboarding.install_instruction": "Cài đặt OpenCode để bật máy chủ nội bộ (không cần terminal).", @@ -1198,7 +1193,6 @@ export default { "session.share_worker_url_phones_hint": "Dùng trên điện thoại hoặc laptop kết nối worker này.", "session.share_worker_url_resolving_hint": "URL Worker đang xử lý; URL máy chủ hiển thị tạm.", "session.shared_folder_upload_failed": "Tải lên thư mục chia sẻ thất bại", - "session.show_earlier": "Hiện {count} tin nhắn trước đó{plural}", "session.status_active": "Phiên đang hoạt động", "session.status_compacting": "Đang thu gọn ngữ cảnh", "session.status_delegating": "Đang ủy quyền", @@ -1900,7 +1894,7 @@ export default { "status.mcp_connected": "{count} MCP đã kết nối", "status.open_docs": "Mở tài liệu", "status.openwork_ready": "OpenWork sẵn sàng", - "status.providers_connected": "{count} provider{plural} đã kết nối", + "status.providers_connected": "{count} provider đã kết nối", "status.ready_for_tasks": "Sẵn sàng cho task mới", "status.reloading_engine": "Đang tải lại engine", "status.restarting_engine": "Đang khởi động lại engine", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 18b3077af..8e26fe1a0 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -462,14 +462,12 @@ export default { "den.worker_not_ready_title": "此工作区尚未准备就绪。", "den.worker_provider_label": "{provider}工作区", "den.worker_secondary_cloud": "云端工作区", - "extensions.app_count_one": "{count}个应用已连接", - "extensions.app_count_many": "{count}个应用已连接", + "extensions.app_count": "{count}个应用已连接", "extensions.apps_mcp_header": "应用(MCP)", "extensions.filter_all": "全部", "extensions.filter_apps": "应用", "extensions.filter_plugins": "插件", - "extensions.plugin_count_one": "{count}个插件", - "extensions.plugin_count_many": "{count}个插件", + "extensions.plugin_count": "{count}个插件", "extensions.plugins_opencode_header": "插件(OpenCode)", "extensions.subtitle": "应用(MCP)和OpenCode插件集中管理。", "extensions.title": "扩展", @@ -814,7 +812,6 @@ export default { "message_list.open_session": "打开会话", "message_list.step_updates_progress": "更新进度", "message_list.subagent_loading_transcript": "正在加载转录", - "message_list.subagent_message_count": "{count}条消息", "message_list.subagent_running": "运行中", "message_list.subagent_session_fallback": "子智能体会话", "message_list.subagent_type_task": "{agentType}任务", @@ -871,7 +868,6 @@ export default { "model_picker.default_model_desc": "选择新对话的默认模型,然后在其卡片上微调推理配置后点击完成。", "model_picker.default_model_title": "默认模型", "model_picker.model_count": "{count}个模型", - "model_picker.model_count_one": "1个模型", "model_picker.more_providers": "更多提供商", "model_picker.no_results": "没有匹配的模型。", "model_picker.other_connected_models": "其他已连接的模型", @@ -899,7 +895,6 @@ export default { "onboarding.create_first_workspace": "创建你的第一个工作区", "onboarding.create_workspace": "创建工作区", "onboarding.engine_running": "引擎已在运行", - "onboarding.folders_allowed": "已授权{count}个文件夹", "onboarding.getting_ready": "正在准备中", "onboarding.install": "安装OpenCode", "onboarding.install_instruction": "安装OpenCode以启用本地服务器(无需终端)。", @@ -1201,7 +1196,6 @@ export default { "session.share_worker_url_phones_hint": "在手机或笔记本上连接此工作区时使用。", "session.share_worker_url_resolving_hint": "工作区URL正在解析,当前显示主机URL。", "session.shared_folder_upload_failed": "共享文件夹上传失败", - "session.show_earlier": "显示前{count}条消息", "session.status_active": "会话进行中", "session.status_compacting": "正在压缩上下文", "session.status_delegating": "委托中", diff --git a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx index 6f2867ca2..9ca112326 100644 --- a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx +++ b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx @@ -454,12 +454,7 @@ export function ModelPickerModal(props: ModelPickerModalProps) { {t("model_picker.connect_provider_hint")} - {t( - provider.matchCount === 1 - ? "model_picker.model_count_one" - : "model_picker.model_count", - { count: provider.matchCount }, - )} + {t("model_picker.model_count", { count: provider.matchCount })} diff --git a/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx b/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx index 51bc3d54c..9106becb8 100644 --- a/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx @@ -99,13 +99,7 @@ export function ExtensionsView(props: ExtensionsViewProps) {
- {t( - props.mcpConnectedAppsCount === 1 - ? "extensions.app_count_one" - : "extensions.app_count_many", - undefined, - { count: props.mcpConnectedAppsCount }, - )} + {t("extensions.app_count", { count: props.mcpConnectedAppsCount })}
) : null} @@ -113,13 +107,7 @@ export function ExtensionsView(props: ExtensionsViewProps) {
- {t( - pluginCount === 1 - ? "extensions.plugin_count_one" - : "extensions.plugin_count_many", - undefined, - { count: pluginCount }, - )} + {t("extensions.plugin_count", { count: pluginCount })}
) : null} diff --git a/apps/app/src/react-app/domains/settings/panels/den-settings-panel.tsx b/apps/app/src/react-app/domains/settings/panels/den-settings-panel.tsx index 9fe881303..2b0ab3001 100644 --- a/apps/app/src/react-app/domains/settings/panels/den-settings-panel.tsx +++ b/apps/app/src/react-app/domains/settings/panels/den-settings-panel.tsx @@ -686,7 +686,6 @@ export function DenSettingsPanel(props: DenSettingsPanelProps) { showToast({ title: tx("den.status_loaded_orgs", { count: response.orgs.length, - plural: response.orgs.length === 1 ? "" : "s", }), tone: "info", }); @@ -725,7 +724,6 @@ export function DenSettingsPanel(props: DenSettingsPanelProps) { title: nextWorkers.length > 0 ? tx("den.status_loaded_workers", { count: nextWorkers.length, - plural: nextWorkers.length === 1 ? "" : "s", name: activeOrg?.name ?? tr("den.active_org_title"), }) : tx("den.status_no_workers", { @@ -805,7 +803,6 @@ export function DenSettingsPanel(props: DenSettingsPanelProps) { title: count > 0 ? tx("den.status_loaded_skills", { count, - plural: count === 1 ? "" : "s", name: activeOrg?.name ?? tr("den.active_org_title"), }) : tx("den.status_no_skills", { diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 418a2683a..590192010 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -916,10 +916,7 @@ export function SettingsRoute() { ? "bg-green-7/10 text-green-11 border-green-7/20" : "bg-gray-4/60 text-gray-11 border-gray-7/50"; const providerSummary = providerConnectedIds.length > 0 - ? t("status.providers_connected", undefined, { - count: providerConnectedIds.length, - plural: providerConnectedIds.length === 1 ? "" : "s", - }) + ? t("status.providers_connected", { count: providerConnectedIds.length }) : t("settings.no_providers_connected"); const connectedProviders = providers .filter((provider) => providerConnectedIds.includes(provider.id)) diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs index 5d3c8c7be..3ff64faf8 100644 --- a/scripts/i18n-audit.mjs +++ b/scripts/i18n-audit.mjs @@ -12,6 +12,7 @@ * node scripts/i18n-audit.mjs --dangling # t() calls referencing keys not in en.ts * node scripts/i18n-audit.mjs --aliases # aliased t() calls (translate/tr instead of t) * node scripts/i18n-audit.mjs --placeholders # placeholder integrity check + * node scripts/i18n-audit.mjs --plurals # each locale has bare key OR all CLDR plural forms it needs * node scripts/i18n-audit.mjs --hardcoded # hardcoded English strings in source files * node scripts/i18n-audit.mjs --prune # (destructive) remove unused keys from all locales * node scripts/i18n-audit.mjs --sort # (destructive) alphabetically sort keys in all locales @@ -29,6 +30,10 @@ const APP_SRC = join(REPO_ROOT, "apps/app/src"); const LOCALES = ["ja", "zh", "vi", "pt-BR", "th", "fr", "ca", "es"]; const EN_FILE = join(LOCALES_DIR, "en.ts"); +const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"]; +const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/; +const stripPluralSuffix = (key) => key.replace(PLURAL_SUFFIX_RE, ""); + const mode = process.argv[2] ?? "--all"; const isCi = mode === "--ci"; const isAll = mode === "--all" || isCi; @@ -157,11 +162,24 @@ if (shouldRun("--missing")) { // --- 3. Orphan keys --- if (shouldRun("--orphan")) { console.log("=== Orphan keys (in locale but not in en.ts) ==="); + // Locales without plurals (e.g. Chinese, Japanese) use the bare key while en defines + // suffixed variants. Treat the bare key as valid if en has any plural + // variant of it. The reverse — locale has a suffix that en doesn't — is + // also fine since the runtime falls back to en's bare or other-suffix key. + const enHasAnyPluralVariant = (key) => + PLURAL_SUFFIXES.some((suffix) => enKeys.has(`${key}_${suffix}`)); + const isOrphan = (key) => { + if (enKeys.has(key)) return false; + if (enHasAnyPluralVariant(key)) return false; + const base = stripPluralSuffix(key); + if (base !== key && enKeys.has(base)) return false; + return true; + }; for (const locale of LOCALES) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) continue; const localeKeys = extractKeys(file); - const orphans = [...localeKeys].filter((k) => !enKeys.has(k)); + const orphans = [...localeKeys].filter(isOrphan); if (orphans.length === 0) { console.log(` ${locale}: ✓ no orphans`); @@ -206,7 +224,15 @@ if (shouldRun("--unused", "--prune")) { ); const allSource = repoSourceFiles.map((f) => readFileSync(f, "utf-8")).join("\n"); - const unused = [...enKeys].filter((key) => !allSource.includes(key)); + // A plural-suffixed key (foo_one / foo_other) counts as "used" when the + // base key (foo) is referenced — `t(key, { count })` resolves the suffix at + // runtime so the source never names the suffixed variant directly. + const unused = [...enKeys].filter((key) => { + if (allSource.includes(key)) return false; + const base = stripPluralSuffix(key); + if (base !== key && allSource.includes(base)) return false; + return true; + }); if (unused.length === 0) { console.log(" ✓ all keys referenced in source"); @@ -272,6 +298,14 @@ if (shouldRun("--dangling")) { // Match t("key.name"), t("key.name", ...), translate("key.name"), tr("key.name") const keyRefPattern = /\b(?:t|translate|tr)\(\s*"([a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*?)"/g; + // A `t("foo")` call resolves if `foo` exists OR any plural variant + // (`foo_one`, `foo_other`, etc.) exists — the runtime picks a variant + // based on params.count. + const keyResolves = (key) => { + if (enKeys.has(key)) return true; + return PLURAL_SUFFIXES.some((suffix) => enKeys.has(`${key}_${suffix}`)); + }; + const dangling = []; for (const file of sourceFiles) { const content = readFileSync(file, "utf-8"); @@ -279,7 +313,7 @@ if (shouldRun("--dangling")) { for (let i = 0; i < lines.length; i++) { for (const match of lines[i].matchAll(keyRefPattern)) { const key = match[1]; - if (!enKeys.has(key)) { + if (!keyResolves(key)) { dangling.push({ key, file: file.replace(REPO_ROOT + "/", ""), line: i + 1 }); } } @@ -383,7 +417,7 @@ if (shouldRun("--placeholders")) { if (!localePh.includes(ph)) { console.log(` ✗ ${locale}/${key}: missing placeholder ${ph}`); problems++; - if (!(isCi && ph === "{plural}")) exitCode = 1; + exitCode = 1; } } } @@ -394,7 +428,81 @@ if (shouldRun("--placeholders")) { console.log(); } -// --- 10. Hardcoded English scan --- +// --- 10. Plural completeness --- +// For every key whose en value contains `{count}`, each locale must define +// either the bare key (catch-all) or every plural form its language needs. +// Most languages use `_one`+`_other`; `PLURAL_FORMS` lists the languages +// that need more, verified against `Intl.PluralRules` (what `t()` uses at +// runtime based on CLDR). +const DEFAULT_PLURAL_FORM = ["one", "other"]; +const PLURAL_FORMS = { + ar: ["zero", "one", "two", "few", "many", "other"], // Arabic + be: ["one", "few", "many", "other"], // Belarusian + bs: ["one", "few", "other"], // Bosnian + cs: ["one", "few", "many", "other"], // Czech + cy: ["zero", "one", "two", "few", "many", "other"], // Welsh + ga: ["one", "two", "few", "many", "other"], // Irish + gd: ["one", "two", "few", "other"], // Scottish Gaelic + gv: ["one", "two", "few", "many", "other"], // Manx + he: ["one", "two", "other"], // Hebrew + hr: ["one", "few", "other"], // Croatian + iu: ["one", "two", "other"], // Inuktitut + kw: ["zero", "one", "two", "few", "many", "other"], // Cornish + lt: ["one", "few", "many", "other"], // Lithuanian + lv: ["zero", "one", "other"], // Latvian + mt: ["one", "two", "few", "many", "other"], // Maltese + pl: ["one", "few", "many", "other"], // Polish + ro: ["one", "few", "other"], // Romanian + ru: ["one", "few", "many", "other"], // Russian + sk: ["one", "few", "many", "other"], // Slovak + sl: ["one", "two", "few", "other"], // Slovenian + sr: ["one", "few", "other"], // Serbian + uk: ["one", "few", "many", "other"], // Ukrainian +}; + +if (shouldRun("--plurals")) { + console.log("=== Plural completeness ==="); + + const pluralBases = new Set(); + for (const [key, value] of enKeyValues) { + if (typeof value === "string" && value.includes("{count}")) { + pluralBases.add(stripPluralSuffix(key)); + } + } + + for (const locale of ["en", ...LOCALES]) { + const file = join(LOCALES_DIR, `${locale}.ts`); + if (!existsSync(file)) continue; + const required = PLURAL_FORMS[locale] ?? DEFAULT_PLURAL_FORM; + const localeKeys = extractKeys(file); + const incomplete = []; + + for (const base of pluralBases) { + if (localeKeys.has(base)) continue; + const missing = required.filter((cat) => !localeKeys.has(`${base}_${cat}`)); + if (missing.length === required.length) continue; // locale has none of these — handled by --missing + if (missing.length > 0) incomplete.push({ base, missing }); + } + + if (incomplete.length === 0) { + console.log(` ${locale}: ✓ all plural keys complete`); + } else { + console.log(` ${locale}: ✗ ${incomplete.length} incomplete plural keys`); + exitCode = 1; + if (mode !== "--summary") { + for (const { base, missing } of incomplete) { + console.log(` ${base}: missing ${missing.map((m) => `_${m}`).join(", ")}`); + } + } + } + } + if (pluralBases.size === 0) { + console.log(" (no plural-base keys found in en.ts)"); + } + console.log(); +} + +// --- 11. Hardcoded English scan --- if (shouldRun("--hardcoded")) { console.log("=== Hardcoded English scan ==="); @@ -493,5 +601,4 @@ if (mode === "--sort") { // --- Done --- console.log("=== Done ==="); -console.log("Run with --missing, --orphan, --duplicates, --unused, --dangling, --placeholders, --hardcoded, --prune, or --sort for a single check."); process.exit(exitCode);