@strato-admin/i18n-cli 0.1.0 → 0.3.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.
@@ -3,22 +3,44 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { globSync } from 'glob';
5
5
  import * as gettextParser from 'gettext-parser';
6
+ import { normalizeMessage } from '@strato-admin/i18n';
6
7
 
7
- function main() {
8
+ function parseArgs() {
8
9
  const args = process.argv.slice(2);
9
- const pattern = args[0] || 'locales';
10
+ let outFile: string | undefined = undefined;
11
+ const positionalArgs: string[] = [];
12
+
13
+ for (let i = 0; i < args.length; i++) {
14
+ if ((args[i] === '--out-file' || args[i] === '-o') && i + 1 < args.length) {
15
+ outFile = args[i + 1];
16
+ i++;
17
+ } else if (args[i].startsWith('--out-file=')) {
18
+ outFile = args[i].split('=')[1];
19
+ } else {
20
+ positionalArgs.push(args[i]);
21
+ }
22
+ }
23
+
24
+ const pattern = positionalArgs[0] || 'locales';
25
+ return { pattern, outFile };
26
+ }
27
+
28
+ function main() {
29
+ const { pattern, outFile: explicitOutFile } = parseArgs();
10
30
 
11
31
  let files: string[] = [];
12
32
 
13
33
  if (fs.existsSync(pattern) && fs.statSync(pattern).isDirectory()) {
14
34
  // Original behavior: if a directory is passed, find .json and .po files inside it
15
- files = fs.readdirSync(pattern)
16
- .filter(file => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'))
17
- .map(file => path.join(pattern, file));
35
+ files = fs
36
+ .readdirSync(pattern)
37
+ .filter((file) => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'))
38
+ .map((file) => path.join(pattern, file));
18
39
  } else {
19
40
  // New behavior: support glob patterns
20
- files = globSync(pattern, { absolute: true })
21
- .filter(file => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'));
41
+ files = globSync(pattern, { absolute: true }).filter(
42
+ (file) => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'),
43
+ );
22
44
  }
23
45
 
24
46
  if (files.length === 0) {
@@ -26,29 +48,37 @@ function main() {
26
48
  return;
27
49
  }
28
50
 
51
+ // If explicitOutFile is provided, we only expect ONE input file or we combine them?
52
+ // FormatJS compile behavior: formatjs compile <file> --out-file <outFile>
53
+ if (explicitOutFile && files.length > 1) {
54
+ console.warn(`Warning: Multiple input files found but only one --out-file specified. Using the first one.`);
55
+ files = [files[0]];
56
+ }
57
+
29
58
  let processedCount = 0;
30
59
 
31
- files.forEach(filePath => {
32
- const compiledFilePath = filePath.replace(/\.(json|po)$/, '.compiled.json');
60
+ files.forEach((filePath) => {
61
+ const compiledFilePath = explicitOutFile || filePath.replace(/\.(json|po)$/, '.compiled.json');
33
62
  const fileName = path.basename(filePath);
34
63
 
35
- let translations: Record<string, { defaultMessage: string, translation: string }> = {};
64
+ let translations: Record<string, any> = {};
36
65
 
37
66
  try {
38
67
  if (filePath.endsWith('.po')) {
39
68
  const parsed = gettextParser.po.parse(fs.readFileSync(filePath));
40
-
69
+
41
70
  Object.entries(parsed.translations).forEach(([context, entries]) => {
42
71
  Object.entries(entries).forEach(([msgid, data]: [string, any]) => {
43
72
  if (msgid === '') return; // skip header
44
-
45
- // Find the hash: check context (v2), then comment (v3), then msgid (v1)
46
- const commentHash = data.comments?.extracted?.match(/id: (\w+)/)?.[1];
47
- const hash = context || commentHash || msgid;
73
+
74
+ // Find the hash: #. id: comment is authoritative (covers explicit ids and
75
+ // context-mangled hashes); fall back to msgctxt, then raw msgid.
76
+ const commentHash = data.comments?.extracted?.match(/id: (\S+)/)?.[1];
77
+ const hash = commentHash || context || msgid;
48
78
 
49
79
  translations[hash] = {
50
80
  defaultMessage: data.msgid || data.comments?.extracted || '',
51
- translation: data.msgstr[0] || ''
81
+ translation: data.msgstr[0] || '',
52
82
  };
53
83
  });
54
84
  });
@@ -61,24 +91,34 @@ function main() {
61
91
  }
62
92
 
63
93
  const compiledMapping: Record<string, string> = {};
94
+ const sortedKeys = Object.keys(translations).sort();
64
95
 
65
- Object.entries(translations).forEach(([msgid, data]) => {
66
- // If translation is empty or whitespace, fallback to defaultMessage
67
- if (data.translation && data.translation.trim() !== '') {
68
- compiledMapping[msgid] = data.translation;
96
+ sortedKeys.forEach((hash) => {
97
+ const data = translations[hash];
98
+ // Support both strato-i18n-cli format and formatjs format (with translation or defaultMessage as translation)
99
+ if (typeof data === 'string') {
100
+ compiledMapping[hash] = normalizeMessage(data);
101
+ } else if (data.translation && data.translation.trim() !== '') {
102
+ compiledMapping[hash] = normalizeMessage(data.translation);
69
103
  } else {
70
- compiledMapping[msgid] = data.defaultMessage;
104
+ // Fallback to defaultMessage
105
+ compiledMapping[hash] = normalizeMessage(data.defaultMessage || '');
71
106
  }
72
107
  });
73
108
 
109
+ const parentDir = path.dirname(compiledFilePath);
110
+ if (!fs.existsSync(parentDir)) {
111
+ fs.mkdirSync(parentDir, { recursive: true });
112
+ }
113
+
74
114
  fs.writeFileSync(compiledFilePath, JSON.stringify(compiledMapping, null, 2));
75
- console.log(`Compiled ${fileName} -> ${path.basename(compiledFilePath)} (${Object.keys(compiledMapping).length} messages)`);
115
+ console.log(
116
+ `Compiled ${fileName} -> ${path.basename(compiledFilePath)} (${Object.keys(compiledMapping).length} messages)`,
117
+ );
76
118
  processedCount++;
77
119
  });
78
120
 
79
121
  console.log(`Successfully compiled ${processedCount} files.`);
80
122
  }
81
123
 
82
- if (require.main === module) {
83
- main();
84
- }
124
+ main();
@@ -6,7 +6,11 @@ import { parse } from '@babel/parser';
6
6
  import traverse from '@babel/traverse';
7
7
  import * as t from '@babel/types';
8
8
  import * as gettextParser from 'gettext-parser';
9
- import { generateMessageId } from '@strato-admin/i18n';
9
+ import { generateMessageId, normalizeMessage, prettyPrintICU } from '@strato-admin/i18n';
10
+ import { minimatch } from 'minimatch';
11
+
12
+ // Components whose JSX children text is the translatable string (source-as-key)
13
+ const CHILDREN_AS_KEY_COMPONENTS = new Set(['Message', 'RecordMessage']);
10
14
 
11
15
  // List of Strato Admin components to extract from
12
16
  const DEFAULT_STRATO_COMPONENTS = new Set([
@@ -44,11 +48,24 @@ const DEFAULT_STRATO_COMPONENTS = new Set([
44
48
  // List of translatable props
45
49
  const DEFAULT_TRANSLATABLE_PROPS = new Set([
46
50
  'label',
51
+ 'listLabel',
52
+ 'createLabel',
53
+ 'editLabel',
54
+ 'detailLabel',
47
55
  'title',
56
+ 'listTitle',
57
+ 'createTitle',
58
+ 'editTitle',
59
+ 'detailTitle',
48
60
  'placeholder',
49
61
  'emptyText',
50
62
  'helperText',
51
63
  'description',
64
+ 'listDescription',
65
+ 'createDescription',
66
+ 'editDescription',
67
+ 'detailDescription',
68
+ 'saveButtonLabel',
52
69
  'successMessage',
53
70
  'errorMessage',
54
71
  ]);
@@ -100,10 +117,13 @@ function parseArgs() {
100
117
  const args = process.argv.slice(2);
101
118
  let format: string | undefined = undefined;
102
119
  let config: string | undefined = undefined;
120
+ let outFile: string | undefined = undefined;
121
+ let locale: string | undefined = undefined;
122
+ const ignorePatterns: string[] = [];
103
123
  const positionalArgs: string[] = [];
104
124
 
105
125
  for (let i = 0; i < args.length; i++) {
106
- if (args[i] === '--format' && i + 1 < args.length) {
126
+ if ((args[i] === '--format' || args[i] === '-f') && i + 1 < args.length) {
107
127
  format = args[i + 1];
108
128
  i++;
109
129
  } else if (args[i].startsWith('--format=')) {
@@ -113,6 +133,21 @@ function parseArgs() {
113
133
  i++;
114
134
  } else if (args[i].startsWith('--config=')) {
115
135
  config = args[i].split('=')[1];
136
+ } else if ((args[i] === '--out-file' || args[i] === '-o') && i + 1 < args.length) {
137
+ outFile = args[i + 1];
138
+ i++;
139
+ } else if (args[i].startsWith('--out-file=')) {
140
+ outFile = args[i].split('=')[1];
141
+ } else if ((args[i] === '--locale' || args[i] === '-l') && i + 1 < args.length) {
142
+ locale = args[i + 1];
143
+ i++;
144
+ } else if (args[i].startsWith('--locale=')) {
145
+ locale = args[i].split('=')[1];
146
+ } else if ((args[i] === '--ignore' || args[i] === '-i') && i + 1 < args.length) {
147
+ ignorePatterns.push(args[i + 1]);
148
+ i++;
149
+ } else if (args[i].startsWith('--ignore=')) {
150
+ ignorePatterns.push(args[i].split('=')[1]);
116
151
  } else {
117
152
  positionalArgs.push(args[i]);
118
153
  }
@@ -139,17 +174,77 @@ function parseArgs() {
139
174
  localeArgs = ['en'];
140
175
  }
141
176
 
142
- return { srcPattern, outDir, localeArgs, format, config };
177
+ return { srcPattern, outDir, localeArgs, format, config, outFile, ignorePatterns, locale };
178
+ }
179
+
180
+ interface ExtractedMessage {
181
+ msgid: string;
182
+ msgctxt?: string;
183
+ /** Pre-computed hash or explicit id, written to `#. id:` and used as the compiled JSON key. */
184
+ precomputedHash?: string;
185
+ locations: Set<string>;
186
+ translatorComment?: string;
143
187
  }
144
188
 
145
189
  function main() {
146
- const { srcPattern, outDir, localeArgs, format: formatArg, config: configPath } = parseArgs();
190
+ const {
191
+ srcPattern,
192
+ outDir,
193
+ localeArgs,
194
+ format: formatArg,
195
+ config: configPath,
196
+ outFile: explicitOutFile,
197
+ ignorePatterns,
198
+ locale: explicitLocale,
199
+ } = parseArgs();
147
200
  const { components, translatableProps } = loadConfig(configPath);
148
201
 
149
202
  console.log(`Extracting messages from ${srcPattern} (using Babel)...`);
203
+ if (ignorePatterns.length > 0) {
204
+ console.log(`Ignoring patterns: ${ignorePatterns.join(', ')}`);
205
+ }
150
206
 
151
- const files = globSync(srcPattern, { absolute: true });
152
- const extractedMessages = new Set<string>();
207
+ let files = globSync(srcPattern, { absolute: true });
208
+
209
+ if (ignorePatterns.length > 0) {
210
+ files = files.filter((file) => {
211
+ const relativeFile = path.relative(process.cwd(), file);
212
+ const isIgnored = ignorePatterns.some((pattern) => minimatch(relativeFile, pattern));
213
+ return !isIgnored;
214
+ });
215
+ }
216
+
217
+ console.log(`Processing ${files.length} files...`);
218
+ if (files.length < 10) {
219
+ console.log('Files:', files);
220
+ } else {
221
+ console.log('Sample files:', files.slice(0, 5));
222
+ }
223
+
224
+ const extractedMessages = new Map<string, ExtractedMessage>();
225
+
226
+ const addExtractedMessage = (
227
+ msgid: string,
228
+ msgctxt: string | undefined,
229
+ location: string,
230
+ translatorComment?: string,
231
+ precomputedHash?: string,
232
+ ) => {
233
+ const normalizedMsgid = normalizeMessage(msgid);
234
+ const prettyMsgid = prettyPrintICU(msgid);
235
+ const key = precomputedHash ? `hash:${precomputedHash}` : msgctxt ? `ctx:${msgctxt}` : `msg:${normalizedMsgid}`;
236
+
237
+ if (!extractedMessages.has(key)) {
238
+ extractedMessages.set(key, {
239
+ msgid: prettyMsgid,
240
+ msgctxt,
241
+ precomputedHash,
242
+ locations: new Set(),
243
+ translatorComment,
244
+ });
245
+ }
246
+ extractedMessages.get(key)!.locations.add(location);
247
+ };
153
248
 
154
249
  files.forEach((file) => {
155
250
  try {
@@ -159,15 +254,62 @@ function main() {
159
254
  plugins: ['typescript', 'jsx', 'decorators-legacy'],
160
255
  });
161
256
 
257
+ const relativeFile = path.relative(process.cwd(), file);
258
+
162
259
  traverse(ast, {
163
- JSXOpeningElement(path) {
164
- const tagName = getJSXElementName(path.node.name);
260
+ CallExpression(p) {
261
+ const { callee, arguments: args } = p.node;
262
+ if (
263
+ t.isIdentifier(callee) &&
264
+ (callee.name === 'translate' || callee.name === 'translateLabel') &&
265
+ args.length > 0
266
+ ) {
267
+ const firstArg = args[0];
268
+ let firstArgValue: string | null = null;
269
+ if (t.isStringLiteral(firstArg)) {
270
+ firstArgValue = firstArg.value;
271
+ } else if (t.isTemplateLiteral(firstArg) && firstArg.quasis.length === 1) {
272
+ firstArgValue = firstArg.quasis[0].value.cooked || firstArg.quasis[0].value.raw;
273
+ }
274
+
275
+ if (firstArgValue) {
276
+ let msgid = firstArgValue;
277
+ let msgctxt: string | undefined = undefined;
278
+
279
+ // Check for second argument { _: "Default Text" }
280
+ if (args.length > 1) {
281
+ const secondArg = args[1];
282
+ if (t.isObjectExpression(secondArg)) {
283
+ const defaultProp = secondArg.properties.find((prop) => {
284
+ const isMatch = t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === '_';
285
+ return isMatch;
286
+ });
287
+ if (defaultProp && t.isObjectProperty(defaultProp)) {
288
+ if (t.isStringLiteral(defaultProp.value)) {
289
+ msgid = defaultProp.value.value;
290
+ msgctxt = firstArgValue; // The first arg is the explicit ID
291
+ } else if (t.isTemplateLiteral(defaultProp.value) && defaultProp.value.quasis.length === 1) {
292
+ msgid = defaultProp.value.quasis[0].value.cooked || defaultProp.value.quasis[0].value.raw;
293
+ msgctxt = firstArgValue;
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ const line = p.node.loc?.start.line || 0;
300
+ const location = `${relativeFile}:${line}`;
301
+ addExtractedMessage(msgid, msgctxt, location);
302
+ }
303
+ }
304
+ },
305
+ JSXOpeningElement(p) {
306
+ const tagName = getJSXElementName(p.node.name);
165
307
 
166
308
  const baseNameMatch =
167
309
  components.has(tagName) || Array.from(components).some((c) => tagName.startsWith(c + '.') || tagName === c);
168
310
 
169
311
  if (baseNameMatch) {
170
- path.node.attributes.forEach((attr) => {
312
+ p.node.attributes.forEach((attr) => {
171
313
  if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && translatableProps.has(attr.name.name)) {
172
314
  let textValue: string | null = null;
173
315
 
@@ -180,19 +322,71 @@ function main() {
180
322
  if (t.isStringLiteral(expr)) {
181
323
  textValue = expr.value;
182
324
  } else if (expr.quasis.length === 1) {
183
- textValue = expr.quasis[0].value.raw;
325
+ textValue = expr.quasis[0].value.cooked || expr.quasis[0].value.raw;
184
326
  }
185
327
  }
186
328
  }
187
329
  }
188
330
 
189
331
  if (textValue && textValue.trim() !== '') {
190
- extractedMessages.add(textValue);
332
+ const line = attr.loc?.start.line || 0;
333
+ const location = `${relativeFile}:${line}`;
334
+ addExtractedMessage(textValue, undefined, location);
191
335
  }
192
336
  }
193
337
  });
194
338
  }
195
339
  },
340
+ JSXElement(p) {
341
+ const tagName = getJSXElementName(p.node.openingElement.name);
342
+ if (!CHILDREN_AS_KEY_COMPONENTS.has(tagName)) return;
343
+
344
+ // Extract text from children: JSXText or StringLiteral in JSXExpressionContainer
345
+ let textValue: string | null = null;
346
+ for (const child of p.node.children) {
347
+ if (t.isJSXText(child) && child.value.trim()) {
348
+ textValue = child.value.trim();
349
+ break;
350
+ }
351
+ if (t.isJSXExpressionContainer(child) && t.isStringLiteral(child.expression)) {
352
+ textValue = child.expression.value;
353
+ break;
354
+ }
355
+ }
356
+ if (!textValue) return;
357
+
358
+ // Extract id, context, and comment from opening element attributes
359
+ let explicitId: string | undefined;
360
+ let msgctxt: string | undefined;
361
+ let translatorComment: string | undefined;
362
+ for (const attr of p.node.openingElement.attributes) {
363
+ if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name)) continue;
364
+ const val = t.isStringLiteral(attr.value)
365
+ ? attr.value.value
366
+ : t.isJSXExpressionContainer(attr.value) && t.isStringLiteral(attr.value.expression)
367
+ ? attr.value.expression.value
368
+ : undefined;
369
+ if (val === undefined) continue;
370
+ if (attr.name.name === 'id') explicitId = val;
371
+ if (attr.name.name === 'context') msgctxt = val;
372
+ if (attr.name.name === 'comment') translatorComment = val;
373
+ }
374
+
375
+ // Compute the hash written to `#. id:` and used as the compiled JSON key:
376
+ // id present → literal id (e.g. "action.archive"), no msgctxt
377
+ // context only → hash(context + \x04 + message), msgctxt = context
378
+ // neither → hash(message), no msgctxt
379
+ let precomputedHash: string | undefined;
380
+ if (explicitId) {
381
+ precomputedHash = explicitId;
382
+ msgctxt = undefined; // id supersedes context; no msgctxt in PO
383
+ } else if (msgctxt) {
384
+ precomputedHash = generateMessageId(`${msgctxt}\x04${normalizeMessage(textValue)}`);
385
+ }
386
+
387
+ const line = p.node.loc?.start.line || 0;
388
+ addExtractedMessage(textValue, msgctxt, `${relativeFile}:${line}`, translatorComment, precomputedHash);
389
+ },
196
390
  });
197
391
  } catch (e: any) {
198
392
  console.error(`Failed to parse ${file}:`, e.message);
@@ -203,45 +397,51 @@ function main() {
203
397
 
204
398
  const targets: { outFile: string; locale: string; format: string }[] = [];
205
399
 
206
- localeArgs.forEach((arg) => {
207
- if (arg.includes('*')) {
208
- const matchedFiles = globSync(arg, { absolute: true });
209
- matchedFiles.forEach((file) => {
210
- const ext = path.extname(file).slice(1);
211
- const format = formatArg || (ext === 'po' ? 'po' : 'json');
212
-
213
- // Guess locale from path: locales/en.po -> en, locales/en/messages.po -> en
214
- let locale = path.basename(file, '.' + ext);
215
- if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
216
- const parts = file.split(path.sep);
217
- locale = parts[parts.length - 2];
218
- }
219
- targets.push({ outFile: file, locale, format });
220
- });
221
- } else {
222
- let outFile: string;
223
- let locale: string;
224
- let format: string;
225
-
226
- if (arg.includes('.') || arg.includes('/') || arg.includes('\\')) {
227
- outFile = arg;
228
- const ext = path.extname(arg).slice(1);
229
- format = formatArg || (ext === 'po' ? 'po' : 'json');
230
-
231
- locale = path.basename(arg, '.' + ext);
232
- if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
233
- const parts = path.resolve(arg).split(path.sep);
234
- locale = parts[parts.length - 2];
235
- }
400
+ if (explicitOutFile) {
401
+ const ext = path.extname(explicitOutFile).slice(1);
402
+ const format = formatArg || (ext === 'po' ? 'po' : 'json');
403
+ targets.push({ outFile: explicitOutFile, locale: explicitLocale || 'en', format });
404
+ } else {
405
+ localeArgs.forEach((arg) => {
406
+ if (arg.includes('*')) {
407
+ const matchedFiles = globSync(arg, { absolute: true });
408
+ matchedFiles.forEach((file) => {
409
+ const ext = path.extname(file).slice(1);
410
+ const format = formatArg || (ext === 'po' ? 'po' : 'json');
411
+
412
+ // Guess locale from path: locales/en.po -> en, locales/en/messages.po -> en
413
+ let locale = path.basename(file, '.' + ext);
414
+ if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
415
+ const parts = file.split(path.sep);
416
+ locale = parts[parts.length - 2];
417
+ }
418
+ targets.push({ outFile: file, locale, format });
419
+ });
236
420
  } else {
237
- format = formatArg || 'json';
238
- const extension = format === 'po' ? 'po' : 'json';
239
- outFile = path.join(outDir, `${arg}.${extension}`);
240
- locale = arg;
421
+ let outFile: string;
422
+ let locale: string;
423
+ let format: string;
424
+
425
+ if (arg.includes('.') || arg.includes('/') || arg.includes('\\')) {
426
+ outFile = arg;
427
+ const ext = path.extname(arg).slice(1);
428
+ format = formatArg || (ext === 'po' ? 'po' : 'json');
429
+
430
+ locale = path.basename(arg, '.' + ext);
431
+ if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
432
+ const parts = path.resolve(arg).split(path.sep);
433
+ locale = parts[parts.length - 2];
434
+ }
435
+ } else {
436
+ format = formatArg || 'json';
437
+ const extension = format === 'po' ? 'po' : 'json';
438
+ outFile = path.join(outDir, `${arg}.${extension}`);
439
+ locale = arg;
440
+ }
441
+ targets.push({ outFile, locale, format });
241
442
  }
242
- targets.push({ outFile, locale, format });
243
- }
244
- });
443
+ });
444
+ }
245
445
 
246
446
  if (targets.length === 0) {
247
447
  console.error('No target files found or specified.');
@@ -249,7 +449,7 @@ function main() {
249
449
  }
250
450
 
251
451
  targets.forEach(({ outFile, locale, format }) => {
252
- let existingTranslations: Record<string, { defaultMessage: string; translation: string }> = {};
452
+ let existingTranslations: Record<string, any> = {};
253
453
 
254
454
  if (fs.existsSync(outFile)) {
255
455
  try {
@@ -285,26 +485,49 @@ function main() {
285
485
  }
286
486
  }
287
487
 
288
- const updatedTranslations: Record<string, { defaultMessage: string; translation: string }> = {};
488
+ const updatedTranslations: Record<
489
+ string,
490
+ {
491
+ defaultMessage: string;
492
+ translation: string;
493
+ locations?: string[];
494
+ msgctxt?: string;
495
+ translatorComment?: string;
496
+ }
497
+ > = {};
289
498
  let addedCount = 0;
290
499
 
291
- extractedMessages.forEach((msg) => {
292
- const msgid = generateMessageId(msg);
293
- if (existingTranslations[msgid]) {
294
- updatedTranslations[msgid] = { ...existingTranslations[msgid] };
295
- updatedTranslations[msgid].defaultMessage = msg;
500
+ extractedMessages.forEach((data) => {
501
+ const hash = data.precomputedHash ?? (data.msgctxt ? data.msgctxt : generateMessageId(data.msgid));
502
+ if (existingTranslations[hash]) {
503
+ const existing = existingTranslations[hash];
504
+ updatedTranslations[hash] = {
505
+ defaultMessage: data.msgid,
506
+ translation: existing.translation || (existing.description ? '' : ''),
507
+ locations: Array.from(data.locations),
508
+ msgctxt: data.msgctxt,
509
+ translatorComment: data.translatorComment,
510
+ };
296
511
  } else {
297
- updatedTranslations[msgid] = {
298
- defaultMessage: msg,
512
+ updatedTranslations[hash] = {
513
+ defaultMessage: data.msgid,
299
514
  translation: '',
515
+ locations: Array.from(data.locations),
516
+ msgctxt: data.msgctxt,
517
+ translatorComment: data.translatorComment,
300
518
  };
301
519
  addedCount++;
302
520
  }
303
521
  });
304
522
 
523
+ // Keep hardcoded keys (like ra.*)
305
524
  Object.keys(existingTranslations).forEach((key) => {
306
525
  if (!updatedTranslations[key]) {
307
- updatedTranslations[key] = existingTranslations[key];
526
+ const existing = existingTranslations[key];
527
+ updatedTranslations[key] = {
528
+ defaultMessage: existing.defaultMessage || '',
529
+ translation: existing.translation || '',
530
+ };
308
531
  }
309
532
  });
310
533
 
@@ -323,11 +546,26 @@ function main() {
323
546
  };
324
547
 
325
548
  Object.entries(updatedTranslations).forEach(([hash, data]) => {
326
- poData.translations[''][data.defaultMessage] = {
549
+ const context = data.msgctxt || '';
550
+ if (!poData.translations[context]) {
551
+ poData.translations[context] = {};
552
+ }
553
+
554
+ // To achieve multi-line PO visual without \n, we must ensure the strings
555
+ // themselves don't have newlines before gettext-parser sees them.
556
+ // However, we WANT the structured look.
557
+ // If we want gettext-parser to wrap, we usually can't control it.
558
+ // Instead, we will use our previously successful "compiledPo.replace" approach
559
+ // but with a better regex that actually works on the serialized output.
560
+
561
+ poData.translations[context][data.defaultMessage] = {
327
562
  msgid: data.defaultMessage,
563
+ msgctxt: data.msgctxt ? data.msgctxt : undefined,
328
564
  msgstr: [data.translation],
329
565
  comments: {
566
+ ...(data.translatorComment ? { translator: data.translatorComment } : {}),
330
567
  extracted: `id: ${hash}`,
568
+ reference: data.locations?.join('\n'),
331
569
  },
332
570
  };
333
571
  });
@@ -342,6 +580,4 @@ function main() {
342
580
  });
343
581
  }
344
582
 
345
- if (require.main === module) {
346
- main();
347
- }
583
+ main();
@@ -1,28 +1,28 @@
1
1
  {
2
- "ra.action.add": {
2
+ "strato.action.add": {
3
3
  "defaultMessage": "Add",
4
4
  "description": "Label for the button to add a new record"
5
5
  },
6
- "ra.action.add_filter": {
6
+ "strato.action.add_filter": {
7
7
  "defaultMessage": "Add filter"
8
8
  },
9
- "ra.action.cancel": {
9
+ "strato.action.cancel": {
10
10
  "defaultMessage": "Cancel"
11
11
  },
12
- "ra.action.delete": {
12
+ "strato.action.delete": {
13
13
  "defaultMessage": "Delete"
14
14
  },
15
- "ra.action.edit": {
15
+ "strato.action.edit": {
16
16
  "defaultMessage": "Edit"
17
17
  },
18
- "ra.action.remove_filter": {
18
+ "strato.action.remove_filter": {
19
19
  "defaultMessage": "Remove filter"
20
20
  },
21
- "ra.action.save": {
21
+ "strato.action.save": {
22
22
  "defaultMessage": "Save",
23
23
  "description": "Label for the button to save a record"
24
24
  },
25
- "ra.action.search": {
25
+ "strato.action.search": {
26
26
  "defaultMessage": "Search"
27
27
  }
28
28
  }