From 848deab444de3bc0e46bdbae83bb22194e23be91 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 31 Jan 2026 14:01:50 +0100 Subject: [PATCH 1/3] feat(i18n): improve update process --- CONTRIBUTING.md | 8 +++ package.json | 1 + scripts/compare-translations.ts | 110 ++++++++++++++++++++++++++------ 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2de616db29..c875e9bccf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -301,6 +301,14 @@ For example to check if all Japanese translation keys are up-to-date, run: pnpm i18n:check ja-JP ``` +To automatically add missing keys with English placeholders, use `--fix`: + +```bash +pnpm i18n:check:fix fr-FR +``` + +This will add missing keys with `"EN TEXT TO REPLACE: {english text}"` as placeholder values, making it easier to see what needs translation. + #### Country variants (advanced) Most languages only need a single locale file. Country variants are only needed when you want to support regional differences (e.g., `es-ES` for Spain vs `es-419` for Latin America). diff --git a/package.json b/package.json index 97a3ea02e2..f91ce39946 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dev": "nuxt dev", "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", "i18n:check": "node --experimental-transform-types scripts/compare-translations.ts", + "i18n:check:fix": "node --experimental-transform-types scripts/compare-translations.ts --fix", "knip": "knip", "knip:fix": "knip --fix", "knip:production": "knip --production", diff --git a/scripts/compare-translations.ts b/scripts/compare-translations.ts index c51dae1a60..58443d0db6 100644 --- a/scripts/compare-translations.ts +++ b/scripts/compare-translations.ts @@ -38,6 +38,35 @@ const loadJson = (filePath: string): NestedObject => { return JSON.parse(readFileSync(filePath, 'utf-8')) as NestedObject } +const addMissingKeys = ( + obj: NestedObject, + keysToAdd: string[], + referenceFlat: Record, +): NestedObject => { + const result: NestedObject = { ...obj } + + for (const keyPath of keysToAdd) { + const parts = keyPath.split('.') + let current = result + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]! + if (!(part in current) || typeof current[part] !== 'object') { + current[part] = {} + } + current = current[part] as NestedObject + } + + const lastPart = parts[parts.length - 1]! + if (!(lastPart in current)) { + const enValue = referenceFlat[keyPath] + current[lastPart] = `EN TEXT TO REPLACE: ${enValue}` + } + } + + return result +} + const removeKeysFromObject = (obj: NestedObject, keysToRemove: string[]): NestedObject => { const result: NestedObject = {} @@ -89,24 +118,41 @@ const logSection = ( const processLocale = ( localeFile: string, referenceKeys: string[], -): { missing: string[]; removed: string[] } => { + referenceFlat: Record, + fix = false, +): { missing: string[]; removed: string[]; added: string[] } => { const filePath = join(LOCALES_DIRECTORY, localeFile) - const content = loadJson(filePath) + let content = loadJson(filePath) const flattenedKeys = Object.keys(flattenObject(content)) const missingKeys = referenceKeys.filter(key => !flattenedKeys.includes(key)) const extraneousKeys = flattenedKeys.filter(key => !referenceKeys.includes(key)) + let modified = false + if (extraneousKeys.length > 0) { - // Remove extraneous keys and write back - const cleaned = removeKeysFromObject(content, extraneousKeys) - writeFileSync(filePath, JSON.stringify(cleaned, null, 2) + '\n', 'utf-8') + content = removeKeysFromObject(content, extraneousKeys) + modified = true + } + + if (fix && missingKeys.length > 0) { + content = addMissingKeys(content, missingKeys, referenceFlat) + modified = true + } + + if (modified) { + writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8') } - return { missing: missingKeys, removed: extraneousKeys } + return { missing: missingKeys, removed: extraneousKeys, added: fix ? missingKeys : [] } } -const runSingleLocale = (locale: string, referenceKeys: string[]): void => { +const runSingleLocale = ( + locale: string, + referenceKeys: string[], + referenceFlat: Record, + fix = false, +): void => { const localeFile = locale.endsWith('.json') ? locale : `${locale}.json` const filePath = join(LOCALES_DIRECTORY, localeFile) @@ -115,16 +161,26 @@ const runSingleLocale = (locale: string, referenceKeys: string[]): void => { process.exit(1) } - const content = loadJson(filePath) + let content = loadJson(filePath) const flattenedKeys = Object.keys(flattenObject(content)) const missingKeys = referenceKeys.filter(key => !flattenedKeys.includes(key)) - console.log(`${COLORS.cyan}=== Missing keys for ${localeFile} ===${COLORS.reset}`) + console.log( + `${COLORS.cyan}=== Missing keys for ${localeFile}${fix ? ' (with --fix)' : ''} ===${COLORS.reset}`, + ) console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeys.length} keys)`) console.log(`Target: ${localeFile} (${flattenedKeys.length} keys)`) if (missingKeys.length === 0) { console.log(`\n${COLORS.green}No missing keys!${COLORS.reset}\n`) + } else if (fix) { + content = addMissingKeys(content, missingKeys, referenceFlat) + writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8') + console.log( + `\n${COLORS.green}Added ${missingKeys.length} missing key(s) with EN placeholder:${COLORS.reset}`, + ) + missingKeys.forEach(key => console.log(` - ${key}`)) + console.log('') } else { console.log(`\n${COLORS.yellow}Missing ${missingKeys.length} key(s):${COLORS.reset}`) missingKeys.forEach(key => console.log(` - ${key}`)) @@ -132,25 +188,33 @@ const runSingleLocale = (locale: string, referenceKeys: string[]): void => { } } -const runAllLocales = (referenceKeys: string[]): void => { +const runAllLocales = ( + referenceKeys: string[], + referenceFlat: Record, + fix = false, +): void => { const localeFiles = readdirSync(LOCALES_DIRECTORY).filter( file => file.endsWith('.json') && file !== REFERENCE_FILE_NAME, ) - console.log(`${COLORS.cyan}=== Translation Audit ===${COLORS.reset}`) + console.log(`${COLORS.cyan}=== Translation Audit${fix ? ' (with --fix)' : ''} ===${COLORS.reset}`) console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeys.length} keys)`) console.log(`Checking ${localeFiles.length} locale(s)...`) let totalMissing = 0 let totalRemoved = 0 + let totalAdded = 0 for (const localeFile of localeFiles) { - const { missing, removed } = processLocale(localeFile, referenceKeys) + const { missing, removed, added } = processLocale(localeFile, referenceKeys, referenceFlat, fix) if (missing.length > 0 || removed.length > 0) { console.log(`\n${COLORS.cyan}--- ${localeFile} ---${COLORS.reset}`) - if (missing.length > 0) { + if (added.length > 0) { + logSection('ADDED MISSING KEYS (with EN placeholder)', added, COLORS.green, '', '') + totalAdded += added.length + } else if (missing.length > 0) { logSection( 'MISSING KEYS (in en.json but not in this locale)', missing, @@ -175,13 +239,18 @@ const runAllLocales = (referenceKeys: string[]): void => { } console.log(`\n${COLORS.cyan}=== Summary ===${COLORS.reset}`) + if (totalAdded > 0) { + console.log( + `${COLORS.green} Added missing keys (EN placeholder): ${totalAdded}${COLORS.reset}`, + ) + } if (totalMissing > 0) { console.log(`${COLORS.yellow} Missing keys across all locales: ${totalMissing}${COLORS.reset}`) } if (totalRemoved > 0) { console.log(`${COLORS.magenta} Removed extraneous keys: ${totalRemoved}${COLORS.reset}`) } - if (totalMissing === 0 && totalRemoved === 0) { + if (totalMissing === 0 && totalRemoved === 0 && totalAdded === 0) { console.log(`${COLORS.green} All locales are in sync!${COLORS.reset}`) } console.log('') @@ -190,16 +259,19 @@ const runAllLocales = (referenceKeys: string[]): void => { const run = (): void => { const referenceFilePath = join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME) const referenceContent = loadJson(referenceFilePath) - const referenceKeys = Object.keys(flattenObject(referenceContent)) + const referenceFlat = flattenObject(referenceContent) + const referenceKeys = Object.keys(referenceFlat) - const targetLocale = process.argv[2] + const args = process.argv.slice(2) + const fix = args.includes('--fix') + const targetLocale = args.find(arg => !arg.startsWith('--')) if (targetLocale) { - // Single locale mode: just show missing keys (no modifications) - runSingleLocale(targetLocale, referenceKeys) + // Single locale mode + runSingleLocale(targetLocale, referenceKeys, referenceFlat, fix) } else { // All locales mode: check all and remove extraneous keys - runAllLocales(referenceKeys) + runAllLocales(referenceKeys, referenceFlat, fix) } } From 100f01447b5b5a58de5b424ee685dfa10900ee5c Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 31 Jan 2026 14:02:09 +0100 Subject: [PATCH 2/3] update the fr part at the same time :) --- i18n/locales/fr-FR.json | 65 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 82c4a3ac06..18929bd5d8 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -123,7 +123,11 @@ "vulns": "Vulnérabilités", "updated": "Mis à jour", "view_dependency_graph": "Voir le graphe de dépendances", - "inspect_dependency_tree": "Inspecter l'arbre de dépendances" + "inspect_dependency_tree": "Inspecter l'arbre de dépendances", + "size_tooltip": { + "unpacked": "{size} taille décompressée (ce paquet)", + "total": "{size} taille totale décompressée (incluant les {count} dépendances pour linux-x64)" + } }, "links": { "repo": "dépôt", @@ -177,14 +181,18 @@ "other_versions": "Autres versions", "more_tagged": "{count} de plus avec tag", "all_covered": "Toutes les versions sont couvertes par les tags ci-dessus", - "deprecated_title": "{version} (dépréciée)" + "deprecated_title": "{version} (dépréciée)", + "view_all": "Voir la version | Voir les {count} versions" }, "dependencies": { "title": "Dépendances ({count})", "list_label": "Dépendances du paquet", "show_all": "afficher les {count} dépendances", "optional": "optionnelle", - "view_vulnerabilities": "Voir les vulnérabilités" + "view_vulnerabilities": "Voir les vulnérabilités", + "outdated_major": "{count} version majeure en retard (dernière : {latest}) | {count} versions majeures en retard (dernière : {latest})", + "outdated_minor": "{count} version mineure en retard (dernière : {latest}) | {count} versions mineures en retard (dernière : {latest})", + "outdated_patch": "Mise à jour patch disponible (dernière : {latest})" }, "peer_dependencies": { "title": "Dépendances peer ({count})", @@ -245,7 +253,8 @@ "no_esm": "Pas de support des ES Modules", "types_included": "Types inclus", "types_available": "Types disponibles via {package}", - "no_types": "Pas de types TypeScript" + "no_types": "Pas de types TypeScript", + "types_label": "Types" }, "license": { "view_spdx": "Voir le texte de la licence sur SPDX" @@ -320,13 +329,25 @@ "maintainers": "Mainteneurs", "keywords": "Mots-clés", "versions": "Versions", - "dependencies": "Dépendances" + "dependencies": "Dépendances", + "get_started": "Commencer" }, "sort": { "downloads": "Plus téléchargés", "updated": "Récemment mis à jour", "name_asc": "Nom (A-Z)", "name_desc": "Nom (Z-A)" + }, + "copy_name": "Copier le nom du paquet", + "replacement": { + "title": "Vous n'avez peut-être pas besoin de cette dépendance.", + "native": "Ceci peut être remplacé par {replacement}, disponible depuis Node {nodeVersion}.", + "simple": "La {community} a signalé ce paquet comme redondant, avec ce conseil : {replacement}.", + "documented": "La {community} a signalé que ce paquet a des alternatives plus performantes.", + "none": "Ce paquet a été signalé comme n'étant plus nécessaire, et sa fonctionnalité est probablement disponible nativement dans tous les moteurs.", + "learn_more": "En savoir plus", + "mdn": "MDN", + "community": "communauté" } }, "connector": { @@ -356,7 +377,8 @@ "warning": "ATTENTION", "warning_text": "Cela permet à npmx d'accéder à votre CLI npm. Ne vous connectez qu'aux sites de confiance.", "connect": "Connecter", - "connecting": "Connexion..." + "connecting": "Connexion...", + "connected_as_user": "Connecté·e en tant que ~{user}" } }, "operations": { @@ -730,5 +752,36 @@ "empty": "Aucune organisation trouvée", "view_all": "Tout voir" } + }, + "version": "Version", + "built_at": "compilé {0}", + "alt_logo": "Logo npmx", + "account_menu": { + "connect": "connexion", + "account": "Compte", + "npm_cli": "npm CLI", + "atmosphere": "Atmosphère", + "npm_cli_desc": "Gérer les paquets et orgs", + "atmosphere_desc": "Fonctionnalités sociales et identité", + "connect_npm_cli": "Connexion à npm CLI", + "connect_atmosphere": "Connexion à Atmosphère", + "connecting": "Connexion en cours...", + "ops": "{count} op | {count} ops", + "disconnect": "Déconnexion" + }, + "auth": { + "modal": { + "title": "Atmosphère", + "connected_as": "Connecté·e en tant que {'@'}{handle}", + "disconnect": "Déconnexion", + "connect_prompt": "Connectez-vous avec votre compte Atmosphère", + "handle_label": "Identifiant", + "handle_placeholder": "alice.npmx.social", + "connect": "Connexion", + "create_account": "Créer un nouveau compte", + "connect_bluesky": "Connexion avec Bluesky", + "what_is_atmosphere": "Qu'est-ce qu'un compte Atmosphère ?", + "atmosphere_explanation": "{npmx} utilise {atproto} pour alimenter plusieurs de ses fonctionnalités sociales, permettant aux utilisateurs de posséder leurs données et d'utiliser un seul compte pour toutes les applications compatibles. Une fois votre compte créé, vous pouvez utiliser d'autres applications comme {bluesky} ou {tangled} avec le même compte." + } } } From 5aba16e8e30cb736d735ecef8622cc2106636031 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 13:05:14 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- lunaria/files/fr-FR.json | 65 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json index 82c4a3ac06..18929bd5d8 100644 --- a/lunaria/files/fr-FR.json +++ b/lunaria/files/fr-FR.json @@ -123,7 +123,11 @@ "vulns": "Vulnérabilités", "updated": "Mis à jour", "view_dependency_graph": "Voir le graphe de dépendances", - "inspect_dependency_tree": "Inspecter l'arbre de dépendances" + "inspect_dependency_tree": "Inspecter l'arbre de dépendances", + "size_tooltip": { + "unpacked": "{size} taille décompressée (ce paquet)", + "total": "{size} taille totale décompressée (incluant les {count} dépendances pour linux-x64)" + } }, "links": { "repo": "dépôt", @@ -177,14 +181,18 @@ "other_versions": "Autres versions", "more_tagged": "{count} de plus avec tag", "all_covered": "Toutes les versions sont couvertes par les tags ci-dessus", - "deprecated_title": "{version} (dépréciée)" + "deprecated_title": "{version} (dépréciée)", + "view_all": "Voir la version | Voir les {count} versions" }, "dependencies": { "title": "Dépendances ({count})", "list_label": "Dépendances du paquet", "show_all": "afficher les {count} dépendances", "optional": "optionnelle", - "view_vulnerabilities": "Voir les vulnérabilités" + "view_vulnerabilities": "Voir les vulnérabilités", + "outdated_major": "{count} version majeure en retard (dernière : {latest}) | {count} versions majeures en retard (dernière : {latest})", + "outdated_minor": "{count} version mineure en retard (dernière : {latest}) | {count} versions mineures en retard (dernière : {latest})", + "outdated_patch": "Mise à jour patch disponible (dernière : {latest})" }, "peer_dependencies": { "title": "Dépendances peer ({count})", @@ -245,7 +253,8 @@ "no_esm": "Pas de support des ES Modules", "types_included": "Types inclus", "types_available": "Types disponibles via {package}", - "no_types": "Pas de types TypeScript" + "no_types": "Pas de types TypeScript", + "types_label": "Types" }, "license": { "view_spdx": "Voir le texte de la licence sur SPDX" @@ -320,13 +329,25 @@ "maintainers": "Mainteneurs", "keywords": "Mots-clés", "versions": "Versions", - "dependencies": "Dépendances" + "dependencies": "Dépendances", + "get_started": "Commencer" }, "sort": { "downloads": "Plus téléchargés", "updated": "Récemment mis à jour", "name_asc": "Nom (A-Z)", "name_desc": "Nom (Z-A)" + }, + "copy_name": "Copier le nom du paquet", + "replacement": { + "title": "Vous n'avez peut-être pas besoin de cette dépendance.", + "native": "Ceci peut être remplacé par {replacement}, disponible depuis Node {nodeVersion}.", + "simple": "La {community} a signalé ce paquet comme redondant, avec ce conseil : {replacement}.", + "documented": "La {community} a signalé que ce paquet a des alternatives plus performantes.", + "none": "Ce paquet a été signalé comme n'étant plus nécessaire, et sa fonctionnalité est probablement disponible nativement dans tous les moteurs.", + "learn_more": "En savoir plus", + "mdn": "MDN", + "community": "communauté" } }, "connector": { @@ -356,7 +377,8 @@ "warning": "ATTENTION", "warning_text": "Cela permet à npmx d'accéder à votre CLI npm. Ne vous connectez qu'aux sites de confiance.", "connect": "Connecter", - "connecting": "Connexion..." + "connecting": "Connexion...", + "connected_as_user": "Connecté·e en tant que ~{user}" } }, "operations": { @@ -730,5 +752,36 @@ "empty": "Aucune organisation trouvée", "view_all": "Tout voir" } + }, + "version": "Version", + "built_at": "compilé {0}", + "alt_logo": "Logo npmx", + "account_menu": { + "connect": "connexion", + "account": "Compte", + "npm_cli": "npm CLI", + "atmosphere": "Atmosphère", + "npm_cli_desc": "Gérer les paquets et orgs", + "atmosphere_desc": "Fonctionnalités sociales et identité", + "connect_npm_cli": "Connexion à npm CLI", + "connect_atmosphere": "Connexion à Atmosphère", + "connecting": "Connexion en cours...", + "ops": "{count} op | {count} ops", + "disconnect": "Déconnexion" + }, + "auth": { + "modal": { + "title": "Atmosphère", + "connected_as": "Connecté·e en tant que {'@'}{handle}", + "disconnect": "Déconnexion", + "connect_prompt": "Connectez-vous avec votre compte Atmosphère", + "handle_label": "Identifiant", + "handle_placeholder": "alice.npmx.social", + "connect": "Connexion", + "create_account": "Créer un nouveau compte", + "connect_bluesky": "Connexion avec Bluesky", + "what_is_atmosphere": "Qu'est-ce qu'un compte Atmosphère ?", + "atmosphere_explanation": "{npmx} utilise {atproto} pour alimenter plusieurs de ses fonctionnalités sociales, permettant aux utilisateurs de posséder leurs données et d'utiliser un seul compte pour toutes les applications compatibles. Une fois votre compte créé, vous pouvez utiliser d'autres applications comme {bluesky} ou {tangled} avec le même compte." + } } }