@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.
- package/dist/compile.js +142 -0
- package/dist/extract.js +558 -0
- package/dist/strato-i18n/src/formatter.d.ts +10 -0
- package/dist/strato-i18n/src/formatter.js +62 -0
- package/dist/strato-i18n/src/hash.d.ts +10 -0
- package/dist/strato-i18n/src/hash.js +26 -0
- package/dist/strato-i18n/src/icuI18nProvider.d.ts +2 -0
- package/dist/strato-i18n/src/icuI18nProvider.js +92 -0
- package/dist/strato-i18n/src/index.d.ts +3 -0
- package/dist/strato-i18n/src/index.js +19 -0
- package/dist/strato-i18n-cli/src/cli/compile.d.ts +2 -0
- package/dist/strato-i18n-cli/src/cli/compile.js +142 -0
- package/dist/strato-i18n-cli/src/cli/extract.d.ts +2 -0
- package/dist/strato-i18n-cli/src/cli/extract.js +558 -0
- package/package.json +6 -3
- package/src/cli/compile.ts +65 -25
- package/src/cli/extract.ts +298 -62
- package/src/i18n/extracted-messages.json +8 -8
- package/dist/cli/compile.js +0 -73
- package/dist/cli/extract.js +0 -317
- /package/dist/{cli/compile.d.ts → compile.d.ts} +0 -0
- /package/dist/{cli/extract.d.ts → extract.d.ts} +0 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const glob_1 = require("glob");
|
|
43
|
+
const parser_1 = require("@babel/parser");
|
|
44
|
+
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
45
|
+
const t = __importStar(require("@babel/types"));
|
|
46
|
+
const gettextParser = __importStar(require("gettext-parser"));
|
|
47
|
+
const i18n_1 = require("@strato-admin/i18n");
|
|
48
|
+
const minimatch_1 = require("minimatch");
|
|
49
|
+
// Components whose JSX children text is the translatable string (source-as-key)
|
|
50
|
+
const CHILDREN_AS_KEY_COMPONENTS = new Set(['Message', 'RecordMessage']);
|
|
51
|
+
// List of Strato Admin components to extract from
|
|
52
|
+
const DEFAULT_STRATO_COMPONENTS = new Set([
|
|
53
|
+
'ArrayField',
|
|
54
|
+
'AttributeEditor',
|
|
55
|
+
'AutocompleteInput',
|
|
56
|
+
'BooleanField',
|
|
57
|
+
'BulkDeleteButton',
|
|
58
|
+
'Button',
|
|
59
|
+
'Create',
|
|
60
|
+
'CreateButton',
|
|
61
|
+
'DateField',
|
|
62
|
+
'Edit',
|
|
63
|
+
'EditButton',
|
|
64
|
+
'FormField',
|
|
65
|
+
'List',
|
|
66
|
+
'NumberField',
|
|
67
|
+
'NumberInput',
|
|
68
|
+
'ReferenceField',
|
|
69
|
+
'ReferenceInput',
|
|
70
|
+
'ReferenceManyField',
|
|
71
|
+
'Resource',
|
|
72
|
+
'ResourceSchema',
|
|
73
|
+
'SaveButton',
|
|
74
|
+
'SelectInput',
|
|
75
|
+
'Show',
|
|
76
|
+
'StatusIndicatorField.Label',
|
|
77
|
+
'Table',
|
|
78
|
+
'Table.Col',
|
|
79
|
+
'TextAreaInput',
|
|
80
|
+
'TextField',
|
|
81
|
+
'TextInput',
|
|
82
|
+
]);
|
|
83
|
+
// List of translatable props
|
|
84
|
+
const DEFAULT_TRANSLATABLE_PROPS = new Set([
|
|
85
|
+
'label',
|
|
86
|
+
'listLabel',
|
|
87
|
+
'createLabel',
|
|
88
|
+
'editLabel',
|
|
89
|
+
'detailLabel',
|
|
90
|
+
'title',
|
|
91
|
+
'listTitle',
|
|
92
|
+
'createTitle',
|
|
93
|
+
'editTitle',
|
|
94
|
+
'detailTitle',
|
|
95
|
+
'placeholder',
|
|
96
|
+
'emptyText',
|
|
97
|
+
'helperText',
|
|
98
|
+
'description',
|
|
99
|
+
'listDescription',
|
|
100
|
+
'createDescription',
|
|
101
|
+
'editDescription',
|
|
102
|
+
'detailDescription',
|
|
103
|
+
'saveButtonLabel',
|
|
104
|
+
'successMessage',
|
|
105
|
+
'errorMessage',
|
|
106
|
+
]);
|
|
107
|
+
function loadConfig(configPath) {
|
|
108
|
+
const components = new Set(DEFAULT_STRATO_COMPONENTS);
|
|
109
|
+
const translatableProps = new Set(DEFAULT_TRANSLATABLE_PROPS);
|
|
110
|
+
const resolvedConfigPath = configPath || path.join(process.cwd(), 'strato-i18n.config.json');
|
|
111
|
+
if (fs.existsSync(resolvedConfigPath)) {
|
|
112
|
+
try {
|
|
113
|
+
const config = JSON.parse(fs.readFileSync(resolvedConfigPath, 'utf8'));
|
|
114
|
+
if (config.components) {
|
|
115
|
+
config.components.forEach((c) => components.add(c));
|
|
116
|
+
}
|
|
117
|
+
if (config.translatableProps) {
|
|
118
|
+
config.translatableProps.forEach((p) => translatableProps.add(p));
|
|
119
|
+
}
|
|
120
|
+
console.log(`Loaded configuration from ${resolvedConfigPath}`);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
console.error(`Failed to parse configuration file at ${resolvedConfigPath}:`, e.message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { components, translatableProps };
|
|
127
|
+
}
|
|
128
|
+
function getJSXElementName(node) {
|
|
129
|
+
if (t.isJSXIdentifier(node)) {
|
|
130
|
+
return node.name;
|
|
131
|
+
}
|
|
132
|
+
if (t.isJSXMemberExpression(node)) {
|
|
133
|
+
return `${getJSXElementName(node.object)}.${node.property.name}`;
|
|
134
|
+
}
|
|
135
|
+
if (t.isJSXNamespacedName(node)) {
|
|
136
|
+
return `${node.namespace.name}:${node.name.name}`;
|
|
137
|
+
}
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
function parseArgs() {
|
|
141
|
+
const args = process.argv.slice(2);
|
|
142
|
+
let format = undefined;
|
|
143
|
+
let config = undefined;
|
|
144
|
+
let outFile = undefined;
|
|
145
|
+
let locale = undefined;
|
|
146
|
+
const ignorePatterns = [];
|
|
147
|
+
const positionalArgs = [];
|
|
148
|
+
for (let i = 0; i < args.length; i++) {
|
|
149
|
+
if ((args[i] === '--format' || args[i] === '-f') && i + 1 < args.length) {
|
|
150
|
+
format = args[i + 1];
|
|
151
|
+
i++;
|
|
152
|
+
}
|
|
153
|
+
else if (args[i].startsWith('--format=')) {
|
|
154
|
+
format = args[i].split('=')[1];
|
|
155
|
+
}
|
|
156
|
+
else if (args[i] === '--config' && i + 1 < args.length) {
|
|
157
|
+
config = args[i + 1];
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
else if (args[i].startsWith('--config=')) {
|
|
161
|
+
config = args[i].split('=')[1];
|
|
162
|
+
}
|
|
163
|
+
else if ((args[i] === '--out-file' || args[i] === '-o') && i + 1 < args.length) {
|
|
164
|
+
outFile = args[i + 1];
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
else if (args[i].startsWith('--out-file=')) {
|
|
168
|
+
outFile = args[i].split('=')[1];
|
|
169
|
+
}
|
|
170
|
+
else if ((args[i] === '--locale' || args[i] === '-l') && i + 1 < args.length) {
|
|
171
|
+
locale = args[i + 1];
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
else if (args[i].startsWith('--locale=')) {
|
|
175
|
+
locale = args[i].split('=')[1];
|
|
176
|
+
}
|
|
177
|
+
else if ((args[i] === '--ignore' || args[i] === '-i') && i + 1 < args.length) {
|
|
178
|
+
ignorePatterns.push(args[i + 1]);
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
else if (args[i].startsWith('--ignore=')) {
|
|
182
|
+
ignorePatterns.push(args[i].split('=')[1]);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
positionalArgs.push(args[i]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const srcPattern = positionalArgs[0] || 'src/**/*.{ts,tsx}';
|
|
189
|
+
let outDir = 'locales';
|
|
190
|
+
let localeArgs = [];
|
|
191
|
+
if (positionalArgs.length > 1) {
|
|
192
|
+
if (positionalArgs[1].includes('*')) {
|
|
193
|
+
// First target is a glob, so we treat ALL subsequent args as targets
|
|
194
|
+
outDir = '.';
|
|
195
|
+
localeArgs = positionalArgs.slice(1);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
// First target is a directory
|
|
199
|
+
outDir = positionalArgs[1];
|
|
200
|
+
localeArgs = positionalArgs.slice(2);
|
|
201
|
+
if (localeArgs.length === 0) {
|
|
202
|
+
localeArgs = ['en'];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
localeArgs = ['en'];
|
|
208
|
+
}
|
|
209
|
+
return { srcPattern, outDir, localeArgs, format, config, outFile, ignorePatterns, locale };
|
|
210
|
+
}
|
|
211
|
+
function main() {
|
|
212
|
+
const { srcPattern, outDir, localeArgs, format: formatArg, config: configPath, outFile: explicitOutFile, ignorePatterns, locale: explicitLocale, } = parseArgs();
|
|
213
|
+
const { components, translatableProps } = loadConfig(configPath);
|
|
214
|
+
console.log(`Extracting messages from ${srcPattern} (using Babel)...`);
|
|
215
|
+
if (ignorePatterns.length > 0) {
|
|
216
|
+
console.log(`Ignoring patterns: ${ignorePatterns.join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
let files = (0, glob_1.globSync)(srcPattern, { absolute: true });
|
|
219
|
+
if (ignorePatterns.length > 0) {
|
|
220
|
+
files = files.filter((file) => {
|
|
221
|
+
const relativeFile = path.relative(process.cwd(), file);
|
|
222
|
+
const isIgnored = ignorePatterns.some((pattern) => (0, minimatch_1.minimatch)(relativeFile, pattern));
|
|
223
|
+
return !isIgnored;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
console.log(`Processing ${files.length} files...`);
|
|
227
|
+
if (files.length < 10) {
|
|
228
|
+
console.log('Files:', files);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
console.log('Sample files:', files.slice(0, 5));
|
|
232
|
+
}
|
|
233
|
+
const extractedMessages = new Map();
|
|
234
|
+
const addExtractedMessage = (msgid, msgctxt, location, translatorComment, precomputedHash) => {
|
|
235
|
+
const normalizedMsgid = (0, i18n_1.normalizeMessage)(msgid);
|
|
236
|
+
const prettyMsgid = (0, i18n_1.prettyPrintICU)(msgid);
|
|
237
|
+
const key = precomputedHash ? `hash:${precomputedHash}` : msgctxt ? `ctx:${msgctxt}` : `msg:${normalizedMsgid}`;
|
|
238
|
+
if (!extractedMessages.has(key)) {
|
|
239
|
+
extractedMessages.set(key, {
|
|
240
|
+
msgid: prettyMsgid,
|
|
241
|
+
msgctxt,
|
|
242
|
+
precomputedHash,
|
|
243
|
+
locations: new Set(),
|
|
244
|
+
translatorComment,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
extractedMessages.get(key).locations.add(location);
|
|
248
|
+
};
|
|
249
|
+
files.forEach((file) => {
|
|
250
|
+
try {
|
|
251
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
252
|
+
const ast = (0, parser_1.parse)(content, {
|
|
253
|
+
sourceType: 'module',
|
|
254
|
+
plugins: ['typescript', 'jsx', 'decorators-legacy'],
|
|
255
|
+
});
|
|
256
|
+
const relativeFile = path.relative(process.cwd(), file);
|
|
257
|
+
(0, traverse_1.default)(ast, {
|
|
258
|
+
CallExpression(p) {
|
|
259
|
+
const { callee, arguments: args } = p.node;
|
|
260
|
+
if (t.isIdentifier(callee) &&
|
|
261
|
+
(callee.name === 'translate' || callee.name === 'translateLabel') &&
|
|
262
|
+
args.length > 0) {
|
|
263
|
+
const firstArg = args[0];
|
|
264
|
+
let firstArgValue = null;
|
|
265
|
+
if (t.isStringLiteral(firstArg)) {
|
|
266
|
+
firstArgValue = firstArg.value;
|
|
267
|
+
}
|
|
268
|
+
else if (t.isTemplateLiteral(firstArg) && firstArg.quasis.length === 1) {
|
|
269
|
+
firstArgValue = firstArg.quasis[0].value.cooked || firstArg.quasis[0].value.raw;
|
|
270
|
+
}
|
|
271
|
+
if (firstArgValue) {
|
|
272
|
+
let msgid = firstArgValue;
|
|
273
|
+
let msgctxt = undefined;
|
|
274
|
+
// Check for second argument { _: "Default Text" }
|
|
275
|
+
if (args.length > 1) {
|
|
276
|
+
const secondArg = args[1];
|
|
277
|
+
if (t.isObjectExpression(secondArg)) {
|
|
278
|
+
const defaultProp = secondArg.properties.find((prop) => {
|
|
279
|
+
const isMatch = t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === '_';
|
|
280
|
+
return isMatch;
|
|
281
|
+
});
|
|
282
|
+
if (defaultProp && t.isObjectProperty(defaultProp)) {
|
|
283
|
+
if (t.isStringLiteral(defaultProp.value)) {
|
|
284
|
+
msgid = defaultProp.value.value;
|
|
285
|
+
msgctxt = firstArgValue; // The first arg is the explicit ID
|
|
286
|
+
}
|
|
287
|
+
else if (t.isTemplateLiteral(defaultProp.value) && defaultProp.value.quasis.length === 1) {
|
|
288
|
+
msgid = defaultProp.value.quasis[0].value.cooked || defaultProp.value.quasis[0].value.raw;
|
|
289
|
+
msgctxt = firstArgValue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const line = p.node.loc?.start.line || 0;
|
|
295
|
+
const location = `${relativeFile}:${line}`;
|
|
296
|
+
addExtractedMessage(msgid, msgctxt, location);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
JSXOpeningElement(p) {
|
|
301
|
+
const tagName = getJSXElementName(p.node.name);
|
|
302
|
+
const baseNameMatch = components.has(tagName) || Array.from(components).some((c) => tagName.startsWith(c + '.') || tagName === c);
|
|
303
|
+
if (baseNameMatch) {
|
|
304
|
+
p.node.attributes.forEach((attr) => {
|
|
305
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && translatableProps.has(attr.name.name)) {
|
|
306
|
+
let textValue = null;
|
|
307
|
+
if (attr.value) {
|
|
308
|
+
if (t.isStringLiteral(attr.value)) {
|
|
309
|
+
textValue = attr.value.value;
|
|
310
|
+
}
|
|
311
|
+
else if (t.isJSXExpressionContainer(attr.value)) {
|
|
312
|
+
const expr = attr.value.expression;
|
|
313
|
+
if (t.isStringLiteral(expr) || t.isTemplateLiteral(expr)) {
|
|
314
|
+
if (t.isStringLiteral(expr)) {
|
|
315
|
+
textValue = expr.value;
|
|
316
|
+
}
|
|
317
|
+
else if (expr.quasis.length === 1) {
|
|
318
|
+
textValue = expr.quasis[0].value.cooked || expr.quasis[0].value.raw;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (textValue && textValue.trim() !== '') {
|
|
324
|
+
const line = attr.loc?.start.line || 0;
|
|
325
|
+
const location = `${relativeFile}:${line}`;
|
|
326
|
+
addExtractedMessage(textValue, undefined, location);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
JSXElement(p) {
|
|
333
|
+
const tagName = getJSXElementName(p.node.openingElement.name);
|
|
334
|
+
if (!CHILDREN_AS_KEY_COMPONENTS.has(tagName))
|
|
335
|
+
return;
|
|
336
|
+
// Extract text from children: JSXText or StringLiteral in JSXExpressionContainer
|
|
337
|
+
let textValue = null;
|
|
338
|
+
for (const child of p.node.children) {
|
|
339
|
+
if (t.isJSXText(child) && child.value.trim()) {
|
|
340
|
+
textValue = child.value.trim();
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
if (t.isJSXExpressionContainer(child) && t.isStringLiteral(child.expression)) {
|
|
344
|
+
textValue = child.expression.value;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!textValue)
|
|
349
|
+
return;
|
|
350
|
+
// Extract id, context, and comment from opening element attributes
|
|
351
|
+
let explicitId;
|
|
352
|
+
let msgctxt;
|
|
353
|
+
let translatorComment;
|
|
354
|
+
for (const attr of p.node.openingElement.attributes) {
|
|
355
|
+
if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name))
|
|
356
|
+
continue;
|
|
357
|
+
const val = t.isStringLiteral(attr.value)
|
|
358
|
+
? attr.value.value
|
|
359
|
+
: t.isJSXExpressionContainer(attr.value) && t.isStringLiteral(attr.value.expression)
|
|
360
|
+
? attr.value.expression.value
|
|
361
|
+
: undefined;
|
|
362
|
+
if (val === undefined)
|
|
363
|
+
continue;
|
|
364
|
+
if (attr.name.name === 'id')
|
|
365
|
+
explicitId = val;
|
|
366
|
+
if (attr.name.name === 'context')
|
|
367
|
+
msgctxt = val;
|
|
368
|
+
if (attr.name.name === 'comment')
|
|
369
|
+
translatorComment = val;
|
|
370
|
+
}
|
|
371
|
+
// Compute the hash written to `#. id:` and used as the compiled JSON key:
|
|
372
|
+
// id present → literal id (e.g. "action.archive"), no msgctxt
|
|
373
|
+
// context only → hash(context + \x04 + message), msgctxt = context
|
|
374
|
+
// neither → hash(message), no msgctxt
|
|
375
|
+
let precomputedHash;
|
|
376
|
+
if (explicitId) {
|
|
377
|
+
precomputedHash = explicitId;
|
|
378
|
+
msgctxt = undefined; // id supersedes context; no msgctxt in PO
|
|
379
|
+
}
|
|
380
|
+
else if (msgctxt) {
|
|
381
|
+
precomputedHash = (0, i18n_1.generateMessageId)(`${msgctxt}\x04${(0, i18n_1.normalizeMessage)(textValue)}`);
|
|
382
|
+
}
|
|
383
|
+
const line = p.node.loc?.start.line || 0;
|
|
384
|
+
addExtractedMessage(textValue, msgctxt, `${relativeFile}:${line}`, translatorComment, precomputedHash);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
console.error(`Failed to parse ${file}:`, e.message);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
console.log(`Found ${extractedMessages.size} translatable strings.`);
|
|
393
|
+
const targets = [];
|
|
394
|
+
if (explicitOutFile) {
|
|
395
|
+
const ext = path.extname(explicitOutFile).slice(1);
|
|
396
|
+
const format = formatArg || (ext === 'po' ? 'po' : 'json');
|
|
397
|
+
targets.push({ outFile: explicitOutFile, locale: explicitLocale || 'en', format });
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
localeArgs.forEach((arg) => {
|
|
401
|
+
if (arg.includes('*')) {
|
|
402
|
+
const matchedFiles = (0, glob_1.globSync)(arg, { absolute: true });
|
|
403
|
+
matchedFiles.forEach((file) => {
|
|
404
|
+
const ext = path.extname(file).slice(1);
|
|
405
|
+
const format = formatArg || (ext === 'po' ? 'po' : 'json');
|
|
406
|
+
// Guess locale from path: locales/en.po -> en, locales/en/messages.po -> en
|
|
407
|
+
let locale = path.basename(file, '.' + ext);
|
|
408
|
+
if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
|
|
409
|
+
const parts = file.split(path.sep);
|
|
410
|
+
locale = parts[parts.length - 2];
|
|
411
|
+
}
|
|
412
|
+
targets.push({ outFile: file, locale, format });
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
let outFile;
|
|
417
|
+
let locale;
|
|
418
|
+
let format;
|
|
419
|
+
if (arg.includes('.') || arg.includes('/') || arg.includes('\\')) {
|
|
420
|
+
outFile = arg;
|
|
421
|
+
const ext = path.extname(arg).slice(1);
|
|
422
|
+
format = formatArg || (ext === 'po' ? 'po' : 'json');
|
|
423
|
+
locale = path.basename(arg, '.' + ext);
|
|
424
|
+
if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
|
|
425
|
+
const parts = path.resolve(arg).split(path.sep);
|
|
426
|
+
locale = parts[parts.length - 2];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
format = formatArg || 'json';
|
|
431
|
+
const extension = format === 'po' ? 'po' : 'json';
|
|
432
|
+
outFile = path.join(outDir, `${arg}.${extension}`);
|
|
433
|
+
locale = arg;
|
|
434
|
+
}
|
|
435
|
+
targets.push({ outFile, locale, format });
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (targets.length === 0) {
|
|
440
|
+
console.error('No target files found or specified.');
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
targets.forEach(({ outFile, locale, format }) => {
|
|
444
|
+
let existingTranslations = {};
|
|
445
|
+
if (fs.existsSync(outFile)) {
|
|
446
|
+
try {
|
|
447
|
+
if (format === 'po') {
|
|
448
|
+
const fileContent = fs.readFileSync(outFile);
|
|
449
|
+
const parsedPo = gettextParser.po.parse(fileContent);
|
|
450
|
+
Object.entries(parsedPo.translations).forEach(([context, entries]) => {
|
|
451
|
+
Object.entries(entries).forEach(([msgid, data]) => {
|
|
452
|
+
if (msgid === '')
|
|
453
|
+
return;
|
|
454
|
+
// Find the hash: check context (v2), then comment (v3), then msgid (v1)
|
|
455
|
+
const commentHash = data.comments?.extracted?.match(/id: (\w+)/)?.[1];
|
|
456
|
+
const hash = context || commentHash || msgid;
|
|
457
|
+
existingTranslations[hash] = {
|
|
458
|
+
defaultMessage: data.msgid || data.comments?.extracted || '',
|
|
459
|
+
translation: data.msgstr[0] || '',
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
existingTranslations = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
console.error(`Failed to parse existing translation file at ${outFile}:`, e.message);
|
|
470
|
+
return; // Skip this file
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
const parentDir = path.dirname(outFile);
|
|
475
|
+
if (!fs.existsSync(parentDir)) {
|
|
476
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const updatedTranslations = {};
|
|
480
|
+
let addedCount = 0;
|
|
481
|
+
extractedMessages.forEach((data) => {
|
|
482
|
+
const hash = data.precomputedHash ?? (data.msgctxt ? data.msgctxt : (0, i18n_1.generateMessageId)(data.msgid));
|
|
483
|
+
if (existingTranslations[hash]) {
|
|
484
|
+
const existing = existingTranslations[hash];
|
|
485
|
+
updatedTranslations[hash] = {
|
|
486
|
+
defaultMessage: data.msgid,
|
|
487
|
+
translation: existing.translation || (existing.description ? '' : ''),
|
|
488
|
+
locations: Array.from(data.locations),
|
|
489
|
+
msgctxt: data.msgctxt,
|
|
490
|
+
translatorComment: data.translatorComment,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
updatedTranslations[hash] = {
|
|
495
|
+
defaultMessage: data.msgid,
|
|
496
|
+
translation: '',
|
|
497
|
+
locations: Array.from(data.locations),
|
|
498
|
+
msgctxt: data.msgctxt,
|
|
499
|
+
translatorComment: data.translatorComment,
|
|
500
|
+
};
|
|
501
|
+
addedCount++;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
// Keep hardcoded keys (like ra.*)
|
|
505
|
+
Object.keys(existingTranslations).forEach((key) => {
|
|
506
|
+
if (!updatedTranslations[key]) {
|
|
507
|
+
const existing = existingTranslations[key];
|
|
508
|
+
updatedTranslations[key] = {
|
|
509
|
+
defaultMessage: existing.defaultMessage || '',
|
|
510
|
+
translation: existing.translation || '',
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
if (format === 'po') {
|
|
515
|
+
const poData = {
|
|
516
|
+
charset: 'utf-8',
|
|
517
|
+
headers: {
|
|
518
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
519
|
+
language: locale,
|
|
520
|
+
},
|
|
521
|
+
translations: {
|
|
522
|
+
'': {
|
|
523
|
+
'': { msgid: '', msgstr: [''] }, // Header
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
Object.entries(updatedTranslations).forEach(([hash, data]) => {
|
|
528
|
+
const context = data.msgctxt || '';
|
|
529
|
+
if (!poData.translations[context]) {
|
|
530
|
+
poData.translations[context] = {};
|
|
531
|
+
}
|
|
532
|
+
// To achieve multi-line PO visual without \n, we must ensure the strings
|
|
533
|
+
// themselves don't have newlines before gettext-parser sees them.
|
|
534
|
+
// However, we WANT the structured look.
|
|
535
|
+
// If we want gettext-parser to wrap, we usually can't control it.
|
|
536
|
+
// Instead, we will use our previously successful "compiledPo.replace" approach
|
|
537
|
+
// but with a better regex that actually works on the serialized output.
|
|
538
|
+
poData.translations[context][data.defaultMessage] = {
|
|
539
|
+
msgid: data.defaultMessage,
|
|
540
|
+
msgctxt: data.msgctxt ? data.msgctxt : undefined,
|
|
541
|
+
msgstr: [data.translation],
|
|
542
|
+
comments: {
|
|
543
|
+
...(data.translatorComment ? { translator: data.translatorComment } : {}),
|
|
544
|
+
extracted: `id: ${hash}`,
|
|
545
|
+
reference: data.locations?.join('\n'),
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
const compiledPo = gettextParser.po.compile(poData);
|
|
550
|
+
fs.writeFileSync(outFile, compiledPo);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
fs.writeFileSync(outFile, JSON.stringify(updatedTranslations, null, 2));
|
|
554
|
+
}
|
|
555
|
+
console.log(`Updated ${outFile} (${locale}): Added ${addedCount} new messages.`);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strato-admin/i18n-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Strato Admin I18n CLI - Tools for internationalization and extraction",
|
|
5
5
|
"main": "dist/cli/extract.js",
|
|
6
6
|
"types": "dist/cli/extract.d.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"repository": {
|
|
30
30
|
"type": "git",
|
|
31
|
-
"url": "https://github.com/
|
|
31
|
+
"url": "https://github.com/vadimgu/strato-admin.git",
|
|
32
32
|
"directory": "packages/strato-i18n-cli"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
@@ -36,15 +36,18 @@
|
|
|
36
36
|
"@babel/parser": "^7.29.0",
|
|
37
37
|
"@babel/traverse": "^7.29.0",
|
|
38
38
|
"@babel/types": "^7.29.0",
|
|
39
|
+
"@formatjs/icu-messageformat-parser": "^3.5.3",
|
|
39
40
|
"gettext-parser": "^9.0.1",
|
|
40
41
|
"glob": "^13.0.6",
|
|
42
|
+
"minimatch": "^10.2.4",
|
|
41
43
|
"typescript": "^5.9.3",
|
|
42
|
-
"@strato-admin/i18n": "0.
|
|
44
|
+
"@strato-admin/i18n": "0.3.0"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@types/babel__core": "^7.20.5",
|
|
46
48
|
"@types/babel__traverse": "^7.28.0",
|
|
47
49
|
"@types/gettext-parser": "^9.0.0",
|
|
50
|
+
"@types/minimatch": "^6.0.0",
|
|
48
51
|
"@types/node": "^25.5.0"
|
|
49
52
|
},
|
|
50
53
|
"scripts": {
|