@djangocfg/i18n 2.1.542 → 2.1.543

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/i18n",
3
- "version": "2.1.542",
3
+ "version": "2.1.543",
4
4
  "description": "Lightweight i18n library for @djangocfg packages with built-in translations for English, Russian, and Korean",
5
5
  "keywords": [
6
6
  "i18n",
@@ -75,7 +75,7 @@
75
75
  "jiti": "^2.7.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@djangocfg/typescript-config": "^2.1.542",
78
+ "@djangocfg/typescript-config": "^2.1.543",
79
79
  "@types/node": "^25.9.5",
80
80
  "@types/react": "19.2.15",
81
81
  "eslint": "^9.39.5",
@@ -11,6 +11,8 @@ import {
11
11
  readLocaleFile,
12
12
  writeLocaleFile,
13
13
  getValueAtPath,
14
+ setValueAtPath,
15
+ renderTsLocale,
14
16
  loadLocale,
15
17
  } from '../utils';
16
18
 
@@ -65,7 +67,6 @@ export const addCommand = defineCommand({
65
67
  consola.info(`Directory: ${localesDir}`);
66
68
  consola.log('');
67
69
 
68
- const keyParts = keyPath.split('.');
69
70
  let updated = 0;
70
71
  let skipped = 0;
71
72
 
@@ -92,14 +93,16 @@ export const addCommand = defineCommand({
92
93
  const { content, exportName } = readLocaleFile(config, locale);
93
94
 
94
95
  if (config.fileExtension === '.json') {
95
- // JSON file
96
96
  const json = JSON.parse(content);
97
- setNestedValue(json, keyParts, translation);
97
+ setValueAtPath(json, keyPath, translation);
98
98
  writeLocaleFile(config, locale, JSON.stringify(json, null, 2));
99
99
  } else {
100
- // TypeScript file - use regex to insert
101
- const newContent = insertKeyIntoTS(content, exportName, keyParts, translation);
102
- writeLocaleFile(config, locale, newContent);
100
+ // `existing` is the same file, already parsed by `loadLocale` above
101
+ // for the key-exists check. Editing that object and re-serialising is
102
+ // what makes nested keys work: the old text-splicing path inserted a
103
+ // duplicate parent block and lost its siblings.
104
+ setValueAtPath(existing, keyPath, translation);
105
+ writeLocaleFile(config, locale, renderTsLocale(content, exportName, existing));
103
106
  }
104
107
 
105
108
  consola.success(` ${locale}: Added "${translation}"`);
@@ -116,98 +119,3 @@ export const addCommand = defineCommand({
116
119
  }
117
120
  },
118
121
  });
119
-
120
- /**
121
- * Set nested value in object
122
- */
123
- function setNestedValue(obj: Record<string, unknown>, keys: string[], value: string): void {
124
- let current = obj;
125
- for (let i = 0; i < keys.length - 1; i++) {
126
- const key = keys[i];
127
- if (!(key in current) || typeof current[key] !== 'object') {
128
- current[key] = {};
129
- }
130
- current = current[key] as Record<string, unknown>;
131
- }
132
- current[keys[keys.length - 1]] = value;
133
- }
134
-
135
- /**
136
- * Insert key into TypeScript locale file
137
- */
138
- function insertKeyIntoTS(
139
- content: string,
140
- exportName: string,
141
- keyParts: string[],
142
- value: string
143
- ): string {
144
- // Find the object for the first key part
145
- const firstKey = keyParts[0];
146
- const restKeys = keyParts.slice(1);
147
-
148
- // Check if top-level key exists
149
- const topLevelRegex = new RegExp(`(${firstKey}:\\s*\\{)`, 'g');
150
- const hasTopLevel = topLevelRegex.test(content);
151
-
152
- if (!hasTopLevel) {
153
- // Add new top-level key before closing brace
154
- const closingBrace = content.lastIndexOf('}');
155
- const indent = ' ';
156
- const newEntry = buildNestedEntry(keyParts, value, indent);
157
- return (
158
- content.slice(0, closingBrace) + '\n' + newEntry + '\n' + content.slice(closingBrace)
159
- );
160
- }
161
-
162
- // Navigate to the right nesting level and insert
163
- // This is a simplified approach - for complex cases, consider AST parsing
164
- let insertPoint = content.indexOf(`${firstKey}:`);
165
- if (insertPoint === -1) return content;
166
-
167
- // Find the opening brace for this section
168
- let braceCount = 0;
169
- let foundStart = false;
170
- let insertIndex = insertPoint;
171
-
172
- for (let i = insertPoint; i < content.length; i++) {
173
- if (content[i] === '{') {
174
- braceCount++;
175
- foundStart = true;
176
- } else if (content[i] === '}') {
177
- braceCount--;
178
- if (foundStart && braceCount === 0) {
179
- // Insert before this closing brace
180
- insertIndex = i;
181
- break;
182
- }
183
- }
184
- }
185
-
186
- // Build the entry
187
- const indent = ' '; // 4 spaces for nested
188
- const entry = restKeys.length === 0
189
- ? `${indent}${keyParts[keyParts.length - 1]}: '${escapeQuotes(value)}',`
190
- : buildNestedEntry(restKeys, value, indent);
191
-
192
- return content.slice(0, insertIndex) + '\n' + entry + '\n ' + content.slice(insertIndex);
193
- }
194
-
195
- /**
196
- * Build nested entry string
197
- */
198
- function buildNestedEntry(keys: string[], value: string, baseIndent: string): string {
199
- if (keys.length === 1) {
200
- return `${baseIndent}${keys[0]}: '${escapeQuotes(value)}',`;
201
- }
202
-
203
- const [first, ...rest] = keys;
204
- const nestedContent = buildNestedEntry(rest, value, baseIndent + ' ');
205
- return `${baseIndent}${first}: {\n${nestedContent}\n${baseIndent}},`;
206
- }
207
-
208
- /**
209
- * Escape single quotes in value
210
- */
211
- function escapeQuotes(value: string): string {
212
- return value.replace(/'/g, "\\'");
213
- }
@@ -123,12 +123,15 @@ export function readLocaleFile(
123
123
  return { content, exportName: '' };
124
124
  }
125
125
 
126
- const exportMatch = content.match(/export const (\w+)/);
127
- if (!exportMatch) {
126
+ // Destructured rather than indexed: `\w+` cannot match empty, so a match
127
+ // always carries group 1 — but only the binding proves that to the compiler,
128
+ // and the same `throw` covers both halves instead of asserting one of them.
129
+ const [, exportName] = content.match(/export const (\w+)/) ?? [];
130
+ if (!exportName) {
128
131
  throw new Error(`Could not find export in ${locale}${config.fileExtension}`);
129
132
  }
130
133
 
131
- return { content, exportName: exportMatch[1] };
134
+ return { content, exportName };
132
135
  }
133
136
 
134
137
  /**
@@ -183,23 +186,124 @@ export function getValueAtPath(obj: Record<string, unknown>, keyPath: string): u
183
186
  }
184
187
 
185
188
  /**
186
- * Set value at nested path
189
+ * Set value at nested path.
190
+ *
191
+ * `isPlainRecord` rather than `typeof x === 'object'`: that test is true for
192
+ * `null` and for arrays. A `null` on the path crashed with "Cannot set
193
+ * properties of null", and an array swallowed the write silently — the value
194
+ * went nowhere and the CLI reported success.
195
+ *
196
+ * An empty `keyPath` writes nothing. `''.split('.')` yields `['']`, and the
197
+ * previous code indexed `keys[-1]`, producing a literal `"undefined"` key in
198
+ * the locale file.
187
199
  */
188
200
  export function setValueAtPath(
189
201
  obj: Record<string, unknown>,
190
202
  keyPath: string,
191
203
  value: unknown
192
204
  ): void {
193
- const keys = keyPath.split('.');
205
+ const keys = keyPath.split('.').filter(Boolean);
206
+ const leaf = keys.pop();
207
+ if (leaf === undefined) return;
208
+
194
209
  let current = obj;
210
+ for (const key of keys) {
211
+ const next = current[key];
212
+ current = isPlainRecord(next) ? next : (current[key] = {});
213
+ }
214
+
215
+ current[leaf] = value;
216
+ }
217
+
218
+ /** A value that can carry nested keys: an object, but not `null` and not an array. */
219
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
220
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
221
+ }
222
+
223
+ /** Object keys that need no quoting in a TS/JS object literal. */
224
+ const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
225
+
226
+ /**
227
+ * Serialise a translations object back to a TypeScript object literal.
228
+ *
229
+ * Locale leaves are always strings and branches always plain objects, so this
230
+ * needs no general-purpose value support — anything else is a bug upstream and
231
+ * `JSON.stringify` is the honest fallback rather than a silent `[object]`.
232
+ */
233
+ function toObjectLiteral(value: unknown, indent: string): string {
234
+ if (!isPlainRecord(value)) return JSON.stringify(value ?? null);
235
+
236
+ const inner = indent + ' ';
237
+ const body = Object.entries(value)
238
+ .map(([key, child]) => {
239
+ const name = BARE_KEY.test(key) ? key : JSON.stringify(key);
240
+ return `${inner}${name}: ${toObjectLiteral(child, inner)},`;
241
+ })
242
+ .join('\n');
243
+
244
+ return body ? `{\n${body}\n${indent}}` : '{}';
245
+ }
195
246
 
196
- for (let i = 0; i < keys.length - 1; i++) {
197
- const key = keys[i];
198
- if (!(key in current) || typeof current[key] !== 'object') {
199
- current[key] = {};
247
+ /**
248
+ * Replace the exported object literal in a TS locale file, keeping everything
249
+ * around it — the type import, the doc comment, and the `: I18nTranslations`
250
+ * annotation — untouched.
251
+ *
252
+ * The previous approach edited the text with `indexOf` and brace counting, and
253
+ * inserted a whole nested chain at the outermost level: adding `ui.form.cancel`
254
+ * to a file that already had `ui.form.save` produced a SECOND `form:` block
255
+ * inside `ui`. In a TS object literal the later key wins, so the existing
256
+ * translation was silently dropped — a data-loss bug, not a formatting one.
257
+ * The file's own comment said "for complex cases, consider AST parsing".
258
+ * Serialising the object `loadLocale` already parsed removes the need for both.
259
+ */
260
+ export function renderTsLocale(
261
+ originalContent: string,
262
+ exportName: string,
263
+ translations: Record<string, unknown>
264
+ ): string {
265
+ const declaration = new RegExp(`(export\\s+const\\s+${exportName}\\b[^=]*=\\s*)`);
266
+ const match = originalContent.match(declaration);
267
+ if (!match || match.index === undefined) {
268
+ throw new Error(`Could not find "export const ${exportName}" to update`);
269
+ }
270
+
271
+ const bodyStart = match.index + match[0].length;
272
+ if (originalContent[bodyStart] !== '{') {
273
+ throw new Error(`Expected an object literal after "export const ${exportName}"`);
274
+ }
275
+
276
+ const bodyEnd = matchBrace(originalContent, bodyStart);
277
+ return (
278
+ originalContent.slice(0, bodyStart) +
279
+ toObjectLiteral(translations, '') +
280
+ originalContent.slice(bodyEnd + 1)
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Index of the `}` closing the `{` at `start`.
286
+ *
287
+ * String-aware: a brace inside a translation (`"{count} items"`) would
288
+ * otherwise unbalance the count and truncate the file.
289
+ */
290
+ function matchBrace(source: string, start: number): number {
291
+ let depth = 0;
292
+ let quote: string | null = null;
293
+
294
+ for (let i = start; i < source.length; i++) {
295
+ const char = source[i];
296
+
297
+ if (quote) {
298
+ if (char === '\\') i++;
299
+ else if (char === quote) quote = null;
300
+ continue;
200
301
  }
201
- current = current[key] as Record<string, unknown>;
302
+
303
+ if (char === '"' || char === "'" || char === '`') quote = char;
304
+ else if (char === '{') depth++;
305
+ else if (char === '}' && --depth === 0) return i;
202
306
  }
203
307
 
204
- current[keys[keys.length - 1]] = value;
308
+ throw new Error('Unbalanced braces in locale file');
205
309
  }
package/src/context.tsx CHANGED
@@ -117,11 +117,14 @@ export function I18nProvider({
117
117
  [onLocaleChange]
118
118
  )
119
119
 
120
- // Sync with external locale changes
120
+ // Sync with external locale changes.
121
+ //
122
+ // The comparison moved inside the updater rather than `locale` joining the
123
+ // deps: this effect must run when the PROP changes, not when local state
124
+ // does. Depending on `locale` would re-run it after every `setLocale` call
125
+ // and reset the user's choice back to `initialLocale`.
121
126
  React.useEffect(() => {
122
- if (initialLocale !== locale) {
123
- setLocaleState(initialLocale)
124
- }
127
+ setLocaleState((current) => (current === initialLocale ? current : initialLocale))
125
128
  }, [initialLocale])
126
129
 
127
130
  const value = React.useMemo<I18nContextValue>(