@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/dist/cli/index.mjs +61 -63
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.mjs +1 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cli/commands/add.ts +9 -101
- package/src/cli/utils/locales.ts +115 -11
- package/src/context.tsx +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/i18n",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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",
|
package/src/cli/commands/add.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
+
setValueAtPath(json, keyPath, translation);
|
|
98
98
|
writeLocaleFile(config, locale, JSON.stringify(json, null, 2));
|
|
99
99
|
} else {
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
}
|
package/src/cli/utils/locales.ts
CHANGED
|
@@ -123,12 +123,15 @@ export function readLocaleFile(
|
|
|
123
123
|
return { content, exportName: '' };
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
setLocaleState(initialLocale)
|
|
124
|
-
}
|
|
127
|
+
setLocaleState((current) => (current === initialLocale ? current : initialLocale))
|
|
125
128
|
}, [initialLocale])
|
|
126
129
|
|
|
127
130
|
const value = React.useMemo<I18nContextValue>(
|