@scoutello/i18n-magic 0.59.1 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@ import dotenv from "dotenv"
3
3
  import OpenAI from "openai"
4
4
  import { checkMissing } from "./commands/check-missing.js"
5
5
  import { removeUnusedKeys } from "./commands/clean.js"
6
-
6
+ import { removeKey } from "./commands/remove-key.js"
7
7
  import { replaceTranslation } from "./commands/replace.js"
8
8
  import { restoreFromNamespaces } from "./commands/restore-from-namespaces.js"
9
9
  import { translateMissing } from "./commands/scan.js"
@@ -59,12 +59,18 @@ const commands: CommandType[] = [
59
59
  "Restore missing keys by searching for them in other namespace files across all locales.",
60
60
  action: restoreFromNamespaces,
61
61
  },
62
+ {
63
+ name: "remove-key",
64
+ description:
65
+ "Remove a specific translation key from all namespaces and locales.",
66
+ action: removeKey,
67
+ },
62
68
  ]
63
69
 
64
70
  for (const command of commands) {
65
71
  const cmd = program.command(command.name).description(command.description)
66
72
 
67
- // Add key option to replace command
73
+ // Add key option to replace and remove-key commands
68
74
  if (command.name === "replace") {
69
75
  cmd
70
76
  .option("-k, --key <key>", "translation key to replace")
@@ -72,8 +78,15 @@ for (const command of commands) {
72
78
  .argument("[key]", "translation key to replace")
73
79
  }
74
80
 
81
+ if (command.name === "remove-key") {
82
+ cmd
83
+ .option("-k, --key <key>", "translation key to remove")
84
+ .allowExcessArguments(true)
85
+ .argument("[key]", "translation key to remove")
86
+ }
87
+
75
88
  cmd.action(async (arg, options) => {
76
- const res = dotenv.config({
89
+ dotenv.config({
77
90
  path: program.opts().env || ".env",
78
91
  })
79
92
 
@@ -105,8 +118,8 @@ for (const command of commands) {
105
118
  }),
106
119
  })
107
120
 
108
- // For replace command, check for key in argument or option
109
- if (command.name === "replace") {
121
+ // For replace and remove-key commands, check for key in argument or option
122
+ if (command.name === "replace" || command.name === "remove-key") {
110
123
  // If key is provided as positional argument, use that first
111
124
  const keyToUse = typeof arg === "string" ? arg : options.key
112
125
  command.action({ ...config, openai }, keyToUse)
@@ -0,0 +1,123 @@
1
+ import prompts from "prompts"
2
+ import type { Configuration } from "../lib/types.js"
3
+ import { loadLocalesFile, writeLocalesFile } from "../lib/utils.js"
4
+
5
+ export const removeKey = async (config: Configuration, key?: string) => {
6
+ const { namespaces, locales, loadPath, savePath } = config
7
+
8
+ if (!key) {
9
+ const response = await prompts({
10
+ type: "text",
11
+ name: "key",
12
+ message: "Enter the translation key to remove:",
13
+ validate: (value) => (value ? true : "Key cannot be empty"),
14
+ onState: (state) => {
15
+ if (state.aborted) {
16
+ process.nextTick(() => {
17
+ process.exit(0)
18
+ })
19
+ }
20
+ },
21
+ })
22
+
23
+ key = response.key
24
+ }
25
+
26
+ if (!key) {
27
+ console.log("\nāŒ Operation cancelled. No key was provided.")
28
+ return
29
+ }
30
+
31
+ console.log(
32
+ `\nšŸ” Searching for key "${key}" across all namespaces and locales...`,
33
+ )
34
+
35
+ const foundIn: Array<{ namespace: string; locale: string }> = []
36
+
37
+ for (const namespace of namespaces) {
38
+ for (const locale of locales) {
39
+ try {
40
+ const existingKeys = await loadLocalesFile(
41
+ loadPath,
42
+ locale,
43
+ namespace,
44
+ {
45
+ silent: true,
46
+ },
47
+ )
48
+
49
+ if (Object.hasOwn(existingKeys, key)) {
50
+ foundIn.push({ namespace, locale })
51
+ }
52
+ } catch {
53
+ // Skip if file doesn't exist or can't be read
54
+ }
55
+ }
56
+ }
57
+
58
+ if (foundIn.length === 0) {
59
+ console.log(`\nāŒ Key "${key}" not found in any namespace or locale.`)
60
+ return
61
+ }
62
+
63
+ console.log(`\nāš ļø Key "${key}" found in ${foundIn.length} location(s):\n`)
64
+
65
+ const maxToShow = 20
66
+ const itemsToShow = foundIn.slice(0, maxToShow)
67
+
68
+ for (const { namespace, locale } of itemsToShow) {
69
+ console.log(` • ${locale}:${namespace}`)
70
+ }
71
+
72
+ if (foundIn.length > maxToShow) {
73
+ console.log(` ... and ${foundIn.length - maxToShow} more`)
74
+ }
75
+
76
+ console.log("")
77
+
78
+ const { confirmed } = await prompts({
79
+ type: "confirm",
80
+ name: "confirmed",
81
+ message: `Do you want to remove "${key}" from ${foundIn.length} location(s)?`,
82
+ initial: false,
83
+ onState: (state) => {
84
+ if (state.aborted) {
85
+ process.nextTick(() => {
86
+ process.exit(0)
87
+ })
88
+ }
89
+ },
90
+ })
91
+
92
+ if (!confirmed) {
93
+ console.log("\nāŒ Operation cancelled. No keys were removed.")
94
+ return
95
+ }
96
+
97
+ console.log(`\nšŸ—‘ļø Removing key "${key}"...`)
98
+
99
+ let removedCount = 0
100
+
101
+ for (const { namespace, locale } of foundIn) {
102
+ try {
103
+ const existingKeys = await loadLocalesFile(loadPath, locale, namespace, {
104
+ silent: true,
105
+ })
106
+
107
+ if (Object.hasOwn(existingKeys, key)) {
108
+ delete existingKeys[key]
109
+ await writeLocalesFile(savePath, locale, namespace, existingKeys)
110
+ removedCount++
111
+ console.log(` āœ“ Removed from ${locale}:${namespace}`)
112
+ }
113
+ } catch (error) {
114
+ console.error(
115
+ ` āœ— Failed to remove from ${locale}:${namespace}: ${error instanceof Error ? error.message : String(error)}`,
116
+ )
117
+ }
118
+ }
119
+
120
+ console.log(
121
+ `\nāœ… Successfully removed key "${key}" from ${removedCount} location(s)`,
122
+ )
123
+ }
package/src/lib/utils.ts CHANGED
@@ -872,34 +872,78 @@ export const addTranslationKeys = async ({
872
872
  log(`ā±ļø Codebase scan completed in ${scanTime.toFixed(2)}ms`)
873
873
 
874
874
  // Step 2: Determine namespaces for each key
875
- const keyToNamespaces = new Map<string, Set<string>>()
875
+ // Resolution order:
876
+ // 1) Explicit namespace prefix (e.g. "dashboard:welcome")
877
+ // 2) Code usage scan matches
878
+ // 3) Existing key in default locale namespace files
879
+ // 4) Key prefix matching a namespace (e.g. "dashboard.title")
880
+ // 5) Default namespace fallback
881
+ const defaultLocaleKeysByNamespace = new Map<string, Record<string, string>>()
882
+ await Promise.all(
883
+ namespaces.map(async (namespace) => {
884
+ try {
885
+ const nsKeys = await loadLocalesFile(loadPath, defaultLocale, namespace, {
886
+ silent: true,
887
+ })
888
+ defaultLocaleKeysByNamespace.set(namespace, nsKeys)
889
+ } catch {
890
+ defaultLocaleKeysByNamespace.set(namespace, {})
891
+ }
892
+ }),
893
+ )
876
894
 
877
- for (const { key, language = "en" } of keys) {
878
- const affectedNamespaces: string[] = []
895
+ const preparedKeys = keys.map(({ key, value, language = "en" }) => {
896
+ const splitKey = key.split(":")
897
+ const hasExplicitNamespace =
898
+ splitKey.length > 1 && namespaces.includes(splitKey[0])
899
+ const normalizedKey = hasExplicitNamespace ? splitKey.slice(1).join(":") : key
900
+ const explicitNamespace = hasExplicitNamespace ? splitKey[0] : null
901
+ const foundNamespaces = new Set<string>()
879
902
 
880
- // Find entries for this specific key
881
- const keyEntries = keysWithNamespaces.filter(
882
- (entry) =>
883
- entry.key === key || entry.key === `${defaultNamespace}:${key}`,
884
- )
903
+ if (explicitNamespace) {
904
+ foundNamespaces.add(explicitNamespace)
905
+ } else {
906
+ for (const entry of keysWithNamespaces) {
907
+ for (const namespace of entry.namespaces) {
908
+ const pureKey = getPureKey(
909
+ entry.key,
910
+ namespace,
911
+ namespace === defaultNamespace,
912
+ )
913
+ if (entry.key === normalizedKey || pureKey === normalizedKey) {
914
+ foundNamespaces.add(namespace)
915
+ }
916
+ }
917
+ }
885
918
 
886
- // Collect unique namespaces where this key is used
887
- const foundNamespaces = new Set<string>()
888
- for (const entry of keyEntries) {
889
- for (const ns of entry.namespaces) {
890
- foundNamespaces.add(ns)
919
+ if (foundNamespaces.size === 0) {
920
+ for (const namespace of namespaces) {
921
+ const namespaceKeys = defaultLocaleKeysByNamespace.get(namespace) || {}
922
+ if (Object.hasOwn(namespaceKeys, normalizedKey)) {
923
+ foundNamespaces.add(namespace)
924
+ }
925
+ }
926
+ }
927
+
928
+ if (foundNamespaces.size === 0) {
929
+ const keyPrefix = normalizedKey.split(".")[0]
930
+ if (namespaces.includes(keyPrefix)) {
931
+ foundNamespaces.add(keyPrefix)
932
+ }
891
933
  }
892
934
  }
893
935
 
894
- if (foundNamespaces.size > 0) {
895
- affectedNamespaces.push(...Array.from(foundNamespaces))
896
- } else {
897
- // If the key is not found in the codebase, use the default namespace
898
- affectedNamespaces.push(defaultNamespace)
936
+ if (foundNamespaces.size === 0) {
937
+ foundNamespaces.add(defaultNamespace)
899
938
  }
900
939
 
901
- keyToNamespaces.set(key, new Set(affectedNamespaces))
902
- }
940
+ return {
941
+ key: normalizedKey,
942
+ value,
943
+ language,
944
+ namespaces: foundNamespaces,
945
+ }
946
+ })
903
947
 
904
948
  // Step 3: Group keys by namespace and locale
905
949
  const namespaceLocaleToKeys = new Map<
@@ -907,21 +951,24 @@ export const addTranslationKeys = async ({
907
951
  Array<{ key: string; value: string; language: string }>
908
952
  >()
909
953
 
910
- for (const { key, value, language = "en" } of keys) {
911
- const namespaces = keyToNamespaces.get(key) || new Set([defaultNamespace])
912
- for (const namespace of namespaces) {
913
- const mapKey = `${namespace}:${language}`
954
+ for (const keyEntry of preparedKeys) {
955
+ for (const namespace of keyEntry.namespaces) {
956
+ const mapKey = `${namespace}:${keyEntry.language}`
914
957
  if (!namespaceLocaleToKeys.has(mapKey)) {
915
958
  namespaceLocaleToKeys.set(mapKey, [])
916
959
  }
917
- namespaceLocaleToKeys.get(mapKey)!.push({ key, value, language })
960
+ namespaceLocaleToKeys.get(mapKey)!.push({
961
+ key: keyEntry.key,
962
+ value: keyEntry.value,
963
+ language: keyEntry.language,
964
+ })
918
965
  }
919
966
  }
920
967
 
921
968
  // Step 4: Collect all unique namespaces that will be affected
922
969
  const affectedNamespaces = new Set<string>()
923
- for (const namespaces of keyToNamespaces.values()) {
924
- for (const ns of namespaces) {
970
+ for (const keyEntry of preparedKeys) {
971
+ for (const ns of keyEntry.namespaces) {
925
972
  affectedNamespaces.add(ns)
926
973
  }
927
974
  }
@@ -930,32 +977,31 @@ export const addTranslationKeys = async ({
930
977
  // This ensures we preserve existing keys in all locales
931
978
  const fileIOStartTime = performance.now()
932
979
  const localeFiles = new Map<string, Record<string, string>>()
980
+ const originalKeyCounts = new Map<string, number>()
933
981
  const loadErrors: Array<{ fileKey: string; error: string }> = []
934
982
 
935
- // Load files for all locales Ɨ all affected namespaces - SEQUENTIALLY to avoid race conditions
983
+ // Load files for all locales Ɨ all affected namespaces in parallel (read-only)
984
+ const localeLoadPromises: Promise<void>[] = []
936
985
  for (const namespace of affectedNamespaces) {
937
986
  for (const locale of config.locales) {
938
987
  const fileKey = `${locale}:${namespace}`
939
-
940
- if (!localeFiles.has(fileKey)) {
941
- try {
942
- const existingKeys = await loadLocalesFile(
943
- loadPath,
944
- locale,
945
- namespace,
946
- { silent: true },
947
- )
948
- localeFiles.set(fileKey, existingKeys)
949
- } catch (error) {
950
- // Don't silently ignore - track the error and DO NOT set empty object
951
- const errorMsg =
952
- error instanceof Error ? error.message : String(error)
953
- loadErrors.push({ fileKey, error: errorMsg })
954
- log(`āš ļø Failed to load ${fileKey}: ${errorMsg}`)
955
- }
956
- }
988
+ localeLoadPromises.push(
989
+ loadLocalesFile(loadPath, locale, namespace, { silent: true })
990
+ .then((existingKeys) => {
991
+ localeFiles.set(fileKey, existingKeys)
992
+ originalKeyCounts.set(fileKey, Object.keys(existingKeys).length)
993
+ })
994
+ .catch((error) => {
995
+ // Don't silently ignore - track the error and DO NOT set empty object
996
+ const errorMsg =
997
+ error instanceof Error ? error.message : String(error)
998
+ loadErrors.push({ fileKey, error: errorMsg })
999
+ log(`āš ļø Failed to load ${fileKey}: ${errorMsg}`)
1000
+ }),
1001
+ )
957
1002
  }
958
1003
  }
1004
+ await Promise.all(localeLoadPromises)
959
1005
 
960
1006
  // If any files failed to load, abort to prevent data loss
961
1007
  if (loadErrors.length > 0) {
@@ -992,14 +1038,14 @@ export const addTranslationKeys = async ({
992
1038
  Array<{ key: string; value: string; namespaces: Set<string> }>
993
1039
  >()
994
1040
 
995
- for (const { key, value, language = "en" } of keys) {
996
- if (!keysByLanguage.has(language)) {
997
- keysByLanguage.set(language, [])
1041
+ for (const keyEntry of preparedKeys) {
1042
+ if (!keysByLanguage.has(keyEntry.language)) {
1043
+ keysByLanguage.set(keyEntry.language, [])
998
1044
  }
999
- keysByLanguage.get(language)!.push({
1000
- key,
1001
- value,
1002
- namespaces: keyToNamespaces.get(key) || new Set([defaultNamespace]),
1045
+ keysByLanguage.get(keyEntry.language)!.push({
1046
+ key: keyEntry.key,
1047
+ value: keyEntry.value,
1048
+ namespaces: keyEntry.namespaces,
1003
1049
  })
1004
1050
  }
1005
1051
 
@@ -1078,22 +1124,6 @@ export const addTranslationKeys = async ({
1078
1124
  // Step 8: Validate and write all files
1079
1125
  // Safety check: ensure we're not accidentally removing keys (only adding)
1080
1126
  const writeStartTime = performance.now()
1081
- const originalKeyCounts = new Map<string, number>()
1082
-
1083
- // Store original key counts for validation
1084
- for (const namespace of affectedNamespaces) {
1085
- for (const locale of config.locales) {
1086
- const fileKey = `${locale}:${namespace}`
1087
- try {
1088
- const original = await loadLocalesFile(loadPath, locale, namespace, {
1089
- silent: true,
1090
- })
1091
- originalKeyCounts.set(fileKey, Object.keys(original).length)
1092
- } catch {
1093
- originalKeyCounts.set(fileKey, 0)
1094
- }
1095
- }
1096
- }
1097
1127
 
1098
1128
  // Validate: new file should have at least as many keys as original
1099
1129
  for (const [fileKey, newKeys] of localeFiles) {
@@ -1123,10 +1153,8 @@ export const addTranslationKeys = async ({
1123
1153
  )
1124
1154
 
1125
1155
  // Build results
1126
- const results = keys.map(({ key, value, language = "en" }) => {
1127
- const namespaces = Array.from(
1128
- keyToNamespaces.get(key) || new Set([defaultNamespace]),
1129
- )
1156
+ const results = preparedKeys.map(({ key, value, language, namespaces }) => {
1157
+ const resolvedNamespaces = Array.from(namespaces)
1130
1158
  const savedLocales = new Set<string>([language])
1131
1159
 
1132
1160
  if (openai) {
@@ -1143,7 +1171,7 @@ export const addTranslationKeys = async ({
1143
1171
  return {
1144
1172
  key,
1145
1173
  value,
1146
- namespace: namespaces.join(", "),
1174
+ namespace: resolvedNamespaces.join(", "),
1147
1175
  locale: Array.from(savedLocales).sort().join(", "),
1148
1176
  }
1149
1177
  })