@strato-admin/i18n-cli 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/cli/compile.d.ts +2 -0
- package/dist/cli/compile.js +73 -0
- package/dist/cli/extract.d.ts +2 -0
- package/dist/cli/extract.js +317 -0
- package/package.json +53 -0
- package/src/cli/compile.ts +84 -0
- package/src/cli/extract.ts +347 -0
- package/src/i18n/extracted-messages.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vadim Gubergrits
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @strato-admin/i18n-cli
|
|
2
|
+
|
|
3
|
+
CLI tools for Strato Admin internationalization.
|
|
4
|
+
|
|
5
|
+
## Extraction
|
|
6
|
+
|
|
7
|
+
The `strato-extract` command extracts translatable strings from your source code into translation files (JSON or PO).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Basic usage
|
|
11
|
+
strato-extract "src/**/*.{ts,tsx}" locales en fr
|
|
12
|
+
|
|
13
|
+
# Extract to PO files
|
|
14
|
+
strato-extract "src/**/*.{ts,tsx}" "locales/*/messages.po"
|
|
15
|
+
|
|
16
|
+
# Use a custom configuration file
|
|
17
|
+
strato-extract --config=my-config.json "src/**/*.{ts,tsx}" "locales/*/messages.po"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
You can configure the extraction process by creating a `@strato-admin/i18n.config.json` file in your project root, or by specifying a path using the `--config` flag.
|
|
23
|
+
|
|
24
|
+
The configuration allows you to extend the list of components and attributes that the extractor looks for.
|
|
25
|
+
|
|
26
|
+
### Example `@strato-admin/i18n.config.json`
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"components": ["MyCustomButton", "SpecialHeader", "Namespace.Component"],
|
|
31
|
+
"translatableProps": ["customLabel", "headerTitle", "tooltip"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **components**: An array of component names (e.g., `Button`, `Table.Col`). The extractor will search for JSX elements matching these names.
|
|
36
|
+
- **translatableProps**: An array of prop names (e.g., `label`, `title`). The extractor will extract the string values of these props when found on any of the specified components.
|
|
37
|
+
|
|
38
|
+
By default, the extractor includes all standard Strato Admin components and common translatable props like `label`, `title`, `placeholder`, etc. Providing a configuration file adds your custom components and props to these defaults.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { globSync } from 'glob';
|
|
5
|
+
import * as gettextParser from 'gettext-parser';
|
|
6
|
+
function main() {
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const pattern = args[0] || 'locales';
|
|
9
|
+
let files = [];
|
|
10
|
+
if (fs.existsSync(pattern) && fs.statSync(pattern).isDirectory()) {
|
|
11
|
+
// Original behavior: if a directory is passed, find .json and .po files inside it
|
|
12
|
+
files = fs.readdirSync(pattern)
|
|
13
|
+
.filter(file => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'))
|
|
14
|
+
.map(file => path.join(pattern, file));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
// New behavior: support glob patterns
|
|
18
|
+
files = globSync(pattern, { absolute: true })
|
|
19
|
+
.filter(file => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'));
|
|
20
|
+
}
|
|
21
|
+
if (files.length === 0) {
|
|
22
|
+
console.log(`No .json or .po files found matching: ${pattern}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let processedCount = 0;
|
|
26
|
+
files.forEach(filePath => {
|
|
27
|
+
const compiledFilePath = filePath.replace(/\.(json|po)$/, '.compiled.json');
|
|
28
|
+
const fileName = path.basename(filePath);
|
|
29
|
+
let translations = {};
|
|
30
|
+
try {
|
|
31
|
+
if (filePath.endsWith('.po')) {
|
|
32
|
+
const parsed = gettextParser.po.parse(fs.readFileSync(filePath));
|
|
33
|
+
Object.entries(parsed.translations).forEach(([context, entries]) => {
|
|
34
|
+
Object.entries(entries).forEach(([msgid, data]) => {
|
|
35
|
+
if (msgid === '')
|
|
36
|
+
return; // skip header
|
|
37
|
+
// Find the hash: check context (v2), then comment (v3), then msgid (v1)
|
|
38
|
+
const commentHash = data.comments?.extracted?.match(/id: (\w+)/)?.[1];
|
|
39
|
+
const hash = context || commentHash || msgid;
|
|
40
|
+
translations[hash] = {
|
|
41
|
+
defaultMessage: data.msgid || data.comments?.extracted || '',
|
|
42
|
+
translation: data.msgstr[0] || ''
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
translations = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error(`Failed to parse translation file at ${filePath}:`, e.message);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const compiledMapping = {};
|
|
56
|
+
Object.entries(translations).forEach(([msgid, data]) => {
|
|
57
|
+
// If translation is empty or whitespace, fallback to defaultMessage
|
|
58
|
+
if (data.translation && data.translation.trim() !== '') {
|
|
59
|
+
compiledMapping[msgid] = data.translation;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
compiledMapping[msgid] = data.defaultMessage;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
fs.writeFileSync(compiledFilePath, JSON.stringify(compiledMapping, null, 2));
|
|
66
|
+
console.log(`Compiled ${fileName} -> ${path.basename(compiledFilePath)} (${Object.keys(compiledMapping).length} messages)`);
|
|
67
|
+
processedCount++;
|
|
68
|
+
});
|
|
69
|
+
console.log(`Successfully compiled ${processedCount} files.`);
|
|
70
|
+
}
|
|
71
|
+
if (require.main === module) {
|
|
72
|
+
main();
|
|
73
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { globSync } from 'glob';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import traverse from '@babel/traverse';
|
|
7
|
+
import * as t from '@babel/types';
|
|
8
|
+
import * as gettextParser from 'gettext-parser';
|
|
9
|
+
import { generateMessageId } from '@strato-admin/i18n';
|
|
10
|
+
// List of Strato Admin components to extract from
|
|
11
|
+
const DEFAULT_STRATO_COMPONENTS = new Set([
|
|
12
|
+
'ArrayField',
|
|
13
|
+
'AttributeEditor',
|
|
14
|
+
'AutocompleteInput',
|
|
15
|
+
'BooleanField',
|
|
16
|
+
'BulkDeleteButton',
|
|
17
|
+
'Button',
|
|
18
|
+
'Create',
|
|
19
|
+
'CreateButton',
|
|
20
|
+
'DateField',
|
|
21
|
+
'Edit',
|
|
22
|
+
'EditButton',
|
|
23
|
+
'FormField',
|
|
24
|
+
'List',
|
|
25
|
+
'NumberField',
|
|
26
|
+
'NumberInput',
|
|
27
|
+
'ReferenceField',
|
|
28
|
+
'ReferenceInput',
|
|
29
|
+
'ReferenceManyField',
|
|
30
|
+
'Resource',
|
|
31
|
+
'ResourceSchema',
|
|
32
|
+
'SaveButton',
|
|
33
|
+
'SelectInput',
|
|
34
|
+
'Show',
|
|
35
|
+
'StatusIndicatorField.Label',
|
|
36
|
+
'Table',
|
|
37
|
+
'Table.Col',
|
|
38
|
+
'TextAreaInput',
|
|
39
|
+
'TextField',
|
|
40
|
+
'TextInput',
|
|
41
|
+
]);
|
|
42
|
+
// List of translatable props
|
|
43
|
+
const DEFAULT_TRANSLATABLE_PROPS = new Set([
|
|
44
|
+
'label',
|
|
45
|
+
'title',
|
|
46
|
+
'placeholder',
|
|
47
|
+
'emptyText',
|
|
48
|
+
'helperText',
|
|
49
|
+
'description',
|
|
50
|
+
'successMessage',
|
|
51
|
+
'errorMessage',
|
|
52
|
+
]);
|
|
53
|
+
function loadConfig(configPath) {
|
|
54
|
+
const components = new Set(DEFAULT_STRATO_COMPONENTS);
|
|
55
|
+
const translatableProps = new Set(DEFAULT_TRANSLATABLE_PROPS);
|
|
56
|
+
const resolvedConfigPath = configPath || path.join(process.cwd(), 'strato-i18n.config.json');
|
|
57
|
+
if (fs.existsSync(resolvedConfigPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const config = JSON.parse(fs.readFileSync(resolvedConfigPath, 'utf8'));
|
|
60
|
+
if (config.components) {
|
|
61
|
+
config.components.forEach((c) => components.add(c));
|
|
62
|
+
}
|
|
63
|
+
if (config.translatableProps) {
|
|
64
|
+
config.translatableProps.forEach((p) => translatableProps.add(p));
|
|
65
|
+
}
|
|
66
|
+
console.log(`Loaded configuration from ${resolvedConfigPath}`);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.error(`Failed to parse configuration file at ${resolvedConfigPath}:`, e.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { components, translatableProps };
|
|
73
|
+
}
|
|
74
|
+
function getJSXElementName(node) {
|
|
75
|
+
if (t.isJSXIdentifier(node)) {
|
|
76
|
+
return node.name;
|
|
77
|
+
}
|
|
78
|
+
if (t.isJSXMemberExpression(node)) {
|
|
79
|
+
return `${getJSXElementName(node.object)}.${node.property.name}`;
|
|
80
|
+
}
|
|
81
|
+
if (t.isJSXNamespacedName(node)) {
|
|
82
|
+
return `${node.namespace.name}:${node.name.name}`;
|
|
83
|
+
}
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
function parseArgs() {
|
|
87
|
+
const args = process.argv.slice(2);
|
|
88
|
+
let format = undefined;
|
|
89
|
+
let config = undefined;
|
|
90
|
+
const positionalArgs = [];
|
|
91
|
+
for (let i = 0; i < args.length; i++) {
|
|
92
|
+
if (args[i] === '--format' && i + 1 < args.length) {
|
|
93
|
+
format = args[i + 1];
|
|
94
|
+
i++;
|
|
95
|
+
}
|
|
96
|
+
else if (args[i].startsWith('--format=')) {
|
|
97
|
+
format = args[i].split('=')[1];
|
|
98
|
+
}
|
|
99
|
+
else if (args[i] === '--config' && i + 1 < args.length) {
|
|
100
|
+
config = args[i + 1];
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
else if (args[i].startsWith('--config=')) {
|
|
104
|
+
config = args[i].split('=')[1];
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
positionalArgs.push(args[i]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const srcPattern = positionalArgs[0] || 'src/**/*.{ts,tsx}';
|
|
111
|
+
let outDir = 'locales';
|
|
112
|
+
let localeArgs = [];
|
|
113
|
+
if (positionalArgs.length > 1) {
|
|
114
|
+
if (positionalArgs[1].includes('*')) {
|
|
115
|
+
// First target is a glob, so we treat ALL subsequent args as targets
|
|
116
|
+
outDir = '.';
|
|
117
|
+
localeArgs = positionalArgs.slice(1);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// First target is a directory
|
|
121
|
+
outDir = positionalArgs[1];
|
|
122
|
+
localeArgs = positionalArgs.slice(2);
|
|
123
|
+
if (localeArgs.length === 0) {
|
|
124
|
+
localeArgs = ['en'];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
localeArgs = ['en'];
|
|
130
|
+
}
|
|
131
|
+
return { srcPattern, outDir, localeArgs, format, config };
|
|
132
|
+
}
|
|
133
|
+
function main() {
|
|
134
|
+
const { srcPattern, outDir, localeArgs, format: formatArg, config: configPath } = parseArgs();
|
|
135
|
+
const { components, translatableProps } = loadConfig(configPath);
|
|
136
|
+
console.log(`Extracting messages from ${srcPattern} (using Babel)...`);
|
|
137
|
+
const files = globSync(srcPattern, { absolute: true });
|
|
138
|
+
const extractedMessages = new Set();
|
|
139
|
+
files.forEach((file) => {
|
|
140
|
+
try {
|
|
141
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
142
|
+
const ast = parse(content, {
|
|
143
|
+
sourceType: 'module',
|
|
144
|
+
plugins: ['typescript', 'jsx', 'decorators-legacy'],
|
|
145
|
+
});
|
|
146
|
+
traverse(ast, {
|
|
147
|
+
JSXOpeningElement(path) {
|
|
148
|
+
const tagName = getJSXElementName(path.node.name);
|
|
149
|
+
const baseNameMatch = components.has(tagName) || Array.from(components).some((c) => tagName.startsWith(c + '.') || tagName === c);
|
|
150
|
+
if (baseNameMatch) {
|
|
151
|
+
path.node.attributes.forEach((attr) => {
|
|
152
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && translatableProps.has(attr.name.name)) {
|
|
153
|
+
let textValue = null;
|
|
154
|
+
if (attr.value) {
|
|
155
|
+
if (t.isStringLiteral(attr.value)) {
|
|
156
|
+
textValue = attr.value.value;
|
|
157
|
+
}
|
|
158
|
+
else if (t.isJSXExpressionContainer(attr.value)) {
|
|
159
|
+
const expr = attr.value.expression;
|
|
160
|
+
if (t.isStringLiteral(expr) || t.isTemplateLiteral(expr)) {
|
|
161
|
+
if (t.isStringLiteral(expr)) {
|
|
162
|
+
textValue = expr.value;
|
|
163
|
+
}
|
|
164
|
+
else if (expr.quasis.length === 1) {
|
|
165
|
+
textValue = expr.quasis[0].value.raw;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (textValue && textValue.trim() !== '') {
|
|
171
|
+
extractedMessages.add(textValue);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
console.error(`Failed to parse ${file}:`, e.message);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
console.log(`Found ${extractedMessages.size} translatable strings.`);
|
|
184
|
+
const targets = [];
|
|
185
|
+
localeArgs.forEach((arg) => {
|
|
186
|
+
if (arg.includes('*')) {
|
|
187
|
+
const matchedFiles = globSync(arg, { absolute: true });
|
|
188
|
+
matchedFiles.forEach((file) => {
|
|
189
|
+
const ext = path.extname(file).slice(1);
|
|
190
|
+
const format = formatArg || (ext === 'po' ? 'po' : 'json');
|
|
191
|
+
// Guess locale from path: locales/en.po -> en, locales/en/messages.po -> en
|
|
192
|
+
let locale = path.basename(file, '.' + ext);
|
|
193
|
+
if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
|
|
194
|
+
const parts = file.split(path.sep);
|
|
195
|
+
locale = parts[parts.length - 2];
|
|
196
|
+
}
|
|
197
|
+
targets.push({ outFile: file, locale, format });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
let outFile;
|
|
202
|
+
let locale;
|
|
203
|
+
let format;
|
|
204
|
+
if (arg.includes('.') || arg.includes('/') || arg.includes('\\')) {
|
|
205
|
+
outFile = arg;
|
|
206
|
+
const ext = path.extname(arg).slice(1);
|
|
207
|
+
format = formatArg || (ext === 'po' ? 'po' : 'json');
|
|
208
|
+
locale = path.basename(arg, '.' + ext);
|
|
209
|
+
if (locale === 'messages' || locale === 'translations' || locale === 'LC_MESSAGES') {
|
|
210
|
+
const parts = path.resolve(arg).split(path.sep);
|
|
211
|
+
locale = parts[parts.length - 2];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
format = formatArg || 'json';
|
|
216
|
+
const extension = format === 'po' ? 'po' : 'json';
|
|
217
|
+
outFile = path.join(outDir, `${arg}.${extension}`);
|
|
218
|
+
locale = arg;
|
|
219
|
+
}
|
|
220
|
+
targets.push({ outFile, locale, format });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
if (targets.length === 0) {
|
|
224
|
+
console.error('No target files found or specified.');
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
targets.forEach(({ outFile, locale, format }) => {
|
|
228
|
+
let existingTranslations = {};
|
|
229
|
+
if (fs.existsSync(outFile)) {
|
|
230
|
+
try {
|
|
231
|
+
if (format === 'po') {
|
|
232
|
+
const fileContent = fs.readFileSync(outFile);
|
|
233
|
+
const parsedPo = gettextParser.po.parse(fileContent);
|
|
234
|
+
Object.entries(parsedPo.translations).forEach(([context, entries]) => {
|
|
235
|
+
Object.entries(entries).forEach(([msgid, data]) => {
|
|
236
|
+
if (msgid === '')
|
|
237
|
+
return;
|
|
238
|
+
// Find the hash: check context (v2), then comment (v3), then msgid (v1)
|
|
239
|
+
const commentHash = data.comments?.extracted?.match(/id: (\w+)/)?.[1];
|
|
240
|
+
const hash = context || commentHash || msgid;
|
|
241
|
+
existingTranslations[hash] = {
|
|
242
|
+
defaultMessage: data.msgid || data.comments?.extracted || '',
|
|
243
|
+
translation: data.msgstr[0] || '',
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
existingTranslations = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
console.error(`Failed to parse existing translation file at ${outFile}:`, e.message);
|
|
254
|
+
return; // Skip this file
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const parentDir = path.dirname(outFile);
|
|
259
|
+
if (!fs.existsSync(parentDir)) {
|
|
260
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const updatedTranslations = {};
|
|
264
|
+
let addedCount = 0;
|
|
265
|
+
extractedMessages.forEach((msg) => {
|
|
266
|
+
const msgid = generateMessageId(msg);
|
|
267
|
+
if (existingTranslations[msgid]) {
|
|
268
|
+
updatedTranslations[msgid] = { ...existingTranslations[msgid] };
|
|
269
|
+
updatedTranslations[msgid].defaultMessage = msg;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
updatedTranslations[msgid] = {
|
|
273
|
+
defaultMessage: msg,
|
|
274
|
+
translation: '',
|
|
275
|
+
};
|
|
276
|
+
addedCount++;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
Object.keys(existingTranslations).forEach((key) => {
|
|
280
|
+
if (!updatedTranslations[key]) {
|
|
281
|
+
updatedTranslations[key] = existingTranslations[key];
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
if (format === 'po') {
|
|
285
|
+
const poData = {
|
|
286
|
+
charset: 'utf-8',
|
|
287
|
+
headers: {
|
|
288
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
289
|
+
language: locale,
|
|
290
|
+
},
|
|
291
|
+
translations: {
|
|
292
|
+
'': {
|
|
293
|
+
'': { msgid: '', msgstr: [''] }, // Header
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
Object.entries(updatedTranslations).forEach(([hash, data]) => {
|
|
298
|
+
poData.translations[''][data.defaultMessage] = {
|
|
299
|
+
msgid: data.defaultMessage,
|
|
300
|
+
msgstr: [data.translation],
|
|
301
|
+
comments: {
|
|
302
|
+
extracted: `id: ${hash}`,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
const compiledPo = gettextParser.po.compile(poData);
|
|
307
|
+
fs.writeFileSync(outFile, compiledPo);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
fs.writeFileSync(outFile, JSON.stringify(updatedTranslations, null, 2));
|
|
311
|
+
}
|
|
312
|
+
console.log(`Updated ${outFile} (${locale}): Added ${addedCount} new messages.`);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (require.main === module) {
|
|
316
|
+
main();
|
|
317
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strato-admin/i18n-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Strato Admin I18n CLI - Tools for internationalization and extraction",
|
|
5
|
+
"main": "dist/cli/extract.js",
|
|
6
|
+
"types": "dist/cli/extract.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"strato-extract": "./dist/cli/extract.js",
|
|
9
|
+
"strato-compile": "./dist/cli/compile.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"strato",
|
|
22
|
+
"admin",
|
|
23
|
+
"react-admin",
|
|
24
|
+
"i18n",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"author": "Vadim Gubergrits <vadim.gubergrits@gmail.com>",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/vgrits/strato-admin.git",
|
|
32
|
+
"directory": "packages/strato-i18n-cli"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@babel/core": "^7.29.0",
|
|
36
|
+
"@babel/parser": "^7.29.0",
|
|
37
|
+
"@babel/traverse": "^7.29.0",
|
|
38
|
+
"@babel/types": "^7.29.0",
|
|
39
|
+
"gettext-parser": "^9.0.1",
|
|
40
|
+
"glob": "^13.0.6",
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"@strato-admin/i18n": "0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/babel__core": "^7.20.5",
|
|
46
|
+
"@types/babel__traverse": "^7.28.0",
|
|
47
|
+
"@types/gettext-parser": "^9.0.0",
|
|
48
|
+
"@types/node": "^25.5.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc -p tsconfig.build.json"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { globSync } from 'glob';
|
|
5
|
+
import * as gettextParser from 'gettext-parser';
|
|
6
|
+
|
|
7
|
+
function main() {
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const pattern = args[0] || 'locales';
|
|
10
|
+
|
|
11
|
+
let files: string[] = [];
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(pattern) && fs.statSync(pattern).isDirectory()) {
|
|
14
|
+
// 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));
|
|
18
|
+
} else {
|
|
19
|
+
// New behavior: support glob patterns
|
|
20
|
+
files = globSync(pattern, { absolute: true })
|
|
21
|
+
.filter(file => (file.endsWith('.json') || file.endsWith('.po')) && !file.endsWith('.compiled.json'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (files.length === 0) {
|
|
25
|
+
console.log(`No .json or .po files found matching: ${pattern}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let processedCount = 0;
|
|
30
|
+
|
|
31
|
+
files.forEach(filePath => {
|
|
32
|
+
const compiledFilePath = filePath.replace(/\.(json|po)$/, '.compiled.json');
|
|
33
|
+
const fileName = path.basename(filePath);
|
|
34
|
+
|
|
35
|
+
let translations: Record<string, { defaultMessage: string, translation: string }> = {};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (filePath.endsWith('.po')) {
|
|
39
|
+
const parsed = gettextParser.po.parse(fs.readFileSync(filePath));
|
|
40
|
+
|
|
41
|
+
Object.entries(parsed.translations).forEach(([context, entries]) => {
|
|
42
|
+
Object.entries(entries).forEach(([msgid, data]: [string, any]) => {
|
|
43
|
+
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;
|
|
48
|
+
|
|
49
|
+
translations[hash] = {
|
|
50
|
+
defaultMessage: data.msgid || data.comments?.extracted || '',
|
|
51
|
+
translation: data.msgstr[0] || ''
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
} else {
|
|
56
|
+
translations = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
} catch (e: any) {
|
|
59
|
+
console.error(`Failed to parse translation file at ${filePath}:`, e.message);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const compiledMapping: Record<string, string> = {};
|
|
64
|
+
|
|
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;
|
|
69
|
+
} else {
|
|
70
|
+
compiledMapping[msgid] = data.defaultMessage;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
fs.writeFileSync(compiledFilePath, JSON.stringify(compiledMapping, null, 2));
|
|
75
|
+
console.log(`Compiled ${fileName} -> ${path.basename(compiledFilePath)} (${Object.keys(compiledMapping).length} messages)`);
|
|
76
|
+
processedCount++;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.log(`Successfully compiled ${processedCount} files.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (require.main === module) {
|
|
83
|
+
main();
|
|
84
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { globSync } from 'glob';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import traverse from '@babel/traverse';
|
|
7
|
+
import * as t from '@babel/types';
|
|
8
|
+
import * as gettextParser from 'gettext-parser';
|
|
9
|
+
import { generateMessageId } from '@strato-admin/i18n';
|
|
10
|
+
|
|
11
|
+
// List of Strato Admin components to extract from
|
|
12
|
+
const DEFAULT_STRATO_COMPONENTS = new Set([
|
|
13
|
+
'ArrayField',
|
|
14
|
+
'AttributeEditor',
|
|
15
|
+
'AutocompleteInput',
|
|
16
|
+
'BooleanField',
|
|
17
|
+
'BulkDeleteButton',
|
|
18
|
+
'Button',
|
|
19
|
+
'Create',
|
|
20
|
+
'CreateButton',
|
|
21
|
+
'DateField',
|
|
22
|
+
'Edit',
|
|
23
|
+
'EditButton',
|
|
24
|
+
'FormField',
|
|
25
|
+
'List',
|
|
26
|
+
'NumberField',
|
|
27
|
+
'NumberInput',
|
|
28
|
+
'ReferenceField',
|
|
29
|
+
'ReferenceInput',
|
|
30
|
+
'ReferenceManyField',
|
|
31
|
+
'Resource',
|
|
32
|
+
'ResourceSchema',
|
|
33
|
+
'SaveButton',
|
|
34
|
+
'SelectInput',
|
|
35
|
+
'Show',
|
|
36
|
+
'StatusIndicatorField.Label',
|
|
37
|
+
'Table',
|
|
38
|
+
'Table.Col',
|
|
39
|
+
'TextAreaInput',
|
|
40
|
+
'TextField',
|
|
41
|
+
'TextInput',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// List of translatable props
|
|
45
|
+
const DEFAULT_TRANSLATABLE_PROPS = new Set([
|
|
46
|
+
'label',
|
|
47
|
+
'title',
|
|
48
|
+
'placeholder',
|
|
49
|
+
'emptyText',
|
|
50
|
+
'helperText',
|
|
51
|
+
'description',
|
|
52
|
+
'successMessage',
|
|
53
|
+
'errorMessage',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
interface Config {
|
|
57
|
+
components?: string[];
|
|
58
|
+
translatableProps?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadConfig(configPath?: string): { components: Set<string>; translatableProps: Set<string> } {
|
|
62
|
+
const components = new Set(DEFAULT_STRATO_COMPONENTS);
|
|
63
|
+
const translatableProps = new Set(DEFAULT_TRANSLATABLE_PROPS);
|
|
64
|
+
|
|
65
|
+
const resolvedConfigPath = configPath || path.join(process.cwd(), 'strato-i18n.config.json');
|
|
66
|
+
|
|
67
|
+
if (fs.existsSync(resolvedConfigPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const config: Config = JSON.parse(fs.readFileSync(resolvedConfigPath, 'utf8'));
|
|
70
|
+
|
|
71
|
+
if (config.components) {
|
|
72
|
+
config.components.forEach((c) => components.add(c));
|
|
73
|
+
}
|
|
74
|
+
if (config.translatableProps) {
|
|
75
|
+
config.translatableProps.forEach((p) => translatableProps.add(p));
|
|
76
|
+
}
|
|
77
|
+
console.log(`Loaded configuration from ${resolvedConfigPath}`);
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
console.error(`Failed to parse configuration file at ${resolvedConfigPath}:`, e.message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { components, translatableProps };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getJSXElementName(node: t.JSXOpeningElement['name']): string {
|
|
87
|
+
if (t.isJSXIdentifier(node)) {
|
|
88
|
+
return node.name;
|
|
89
|
+
}
|
|
90
|
+
if (t.isJSXMemberExpression(node)) {
|
|
91
|
+
return `${getJSXElementName(node.object)}.${node.property.name}`;
|
|
92
|
+
}
|
|
93
|
+
if (t.isJSXNamespacedName(node)) {
|
|
94
|
+
return `${node.namespace.name}:${node.name.name}`;
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseArgs() {
|
|
100
|
+
const args = process.argv.slice(2);
|
|
101
|
+
let format: string | undefined = undefined;
|
|
102
|
+
let config: string | undefined = undefined;
|
|
103
|
+
const positionalArgs: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < args.length; i++) {
|
|
106
|
+
if (args[i] === '--format' && i + 1 < args.length) {
|
|
107
|
+
format = args[i + 1];
|
|
108
|
+
i++;
|
|
109
|
+
} else if (args[i].startsWith('--format=')) {
|
|
110
|
+
format = args[i].split('=')[1];
|
|
111
|
+
} else if (args[i] === '--config' && i + 1 < args.length) {
|
|
112
|
+
config = args[i + 1];
|
|
113
|
+
i++;
|
|
114
|
+
} else if (args[i].startsWith('--config=')) {
|
|
115
|
+
config = args[i].split('=')[1];
|
|
116
|
+
} else {
|
|
117
|
+
positionalArgs.push(args[i]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const srcPattern = positionalArgs[0] || 'src/**/*.{ts,tsx}';
|
|
122
|
+
let outDir = 'locales';
|
|
123
|
+
let localeArgs: string[] = [];
|
|
124
|
+
|
|
125
|
+
if (positionalArgs.length > 1) {
|
|
126
|
+
if (positionalArgs[1].includes('*')) {
|
|
127
|
+
// First target is a glob, so we treat ALL subsequent args as targets
|
|
128
|
+
outDir = '.';
|
|
129
|
+
localeArgs = positionalArgs.slice(1);
|
|
130
|
+
} else {
|
|
131
|
+
// First target is a directory
|
|
132
|
+
outDir = positionalArgs[1];
|
|
133
|
+
localeArgs = positionalArgs.slice(2);
|
|
134
|
+
if (localeArgs.length === 0) {
|
|
135
|
+
localeArgs = ['en'];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
localeArgs = ['en'];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { srcPattern, outDir, localeArgs, format, config };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function main() {
|
|
146
|
+
const { srcPattern, outDir, localeArgs, format: formatArg, config: configPath } = parseArgs();
|
|
147
|
+
const { components, translatableProps } = loadConfig(configPath);
|
|
148
|
+
|
|
149
|
+
console.log(`Extracting messages from ${srcPattern} (using Babel)...`);
|
|
150
|
+
|
|
151
|
+
const files = globSync(srcPattern, { absolute: true });
|
|
152
|
+
const extractedMessages = new Set<string>();
|
|
153
|
+
|
|
154
|
+
files.forEach((file) => {
|
|
155
|
+
try {
|
|
156
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
157
|
+
const ast = parse(content, {
|
|
158
|
+
sourceType: 'module',
|
|
159
|
+
plugins: ['typescript', 'jsx', 'decorators-legacy'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
traverse(ast, {
|
|
163
|
+
JSXOpeningElement(path) {
|
|
164
|
+
const tagName = getJSXElementName(path.node.name);
|
|
165
|
+
|
|
166
|
+
const baseNameMatch =
|
|
167
|
+
components.has(tagName) || Array.from(components).some((c) => tagName.startsWith(c + '.') || tagName === c);
|
|
168
|
+
|
|
169
|
+
if (baseNameMatch) {
|
|
170
|
+
path.node.attributes.forEach((attr) => {
|
|
171
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && translatableProps.has(attr.name.name)) {
|
|
172
|
+
let textValue: string | null = null;
|
|
173
|
+
|
|
174
|
+
if (attr.value) {
|
|
175
|
+
if (t.isStringLiteral(attr.value)) {
|
|
176
|
+
textValue = attr.value.value;
|
|
177
|
+
} else if (t.isJSXExpressionContainer(attr.value)) {
|
|
178
|
+
const expr = attr.value.expression;
|
|
179
|
+
if (t.isStringLiteral(expr) || t.isTemplateLiteral(expr)) {
|
|
180
|
+
if (t.isStringLiteral(expr)) {
|
|
181
|
+
textValue = expr.value;
|
|
182
|
+
} else if (expr.quasis.length === 1) {
|
|
183
|
+
textValue = expr.quasis[0].value.raw;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (textValue && textValue.trim() !== '') {
|
|
190
|
+
extractedMessages.add(textValue);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
} catch (e: any) {
|
|
198
|
+
console.error(`Failed to parse ${file}:`, e.message);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
console.log(`Found ${extractedMessages.size} translatable strings.`);
|
|
203
|
+
|
|
204
|
+
const targets: { outFile: string; locale: string; format: string }[] = [];
|
|
205
|
+
|
|
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
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
format = formatArg || 'json';
|
|
238
|
+
const extension = format === 'po' ? 'po' : 'json';
|
|
239
|
+
outFile = path.join(outDir, `${arg}.${extension}`);
|
|
240
|
+
locale = arg;
|
|
241
|
+
}
|
|
242
|
+
targets.push({ outFile, locale, format });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (targets.length === 0) {
|
|
247
|
+
console.error('No target files found or specified.');
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
targets.forEach(({ outFile, locale, format }) => {
|
|
252
|
+
let existingTranslations: Record<string, { defaultMessage: string; translation: string }> = {};
|
|
253
|
+
|
|
254
|
+
if (fs.existsSync(outFile)) {
|
|
255
|
+
try {
|
|
256
|
+
if (format === 'po') {
|
|
257
|
+
const fileContent = fs.readFileSync(outFile);
|
|
258
|
+
const parsedPo = gettextParser.po.parse(fileContent);
|
|
259
|
+
|
|
260
|
+
Object.entries(parsedPo.translations).forEach(([context, entries]) => {
|
|
261
|
+
Object.entries(entries).forEach(([msgid, data]: [string, any]) => {
|
|
262
|
+
if (msgid === '') return;
|
|
263
|
+
|
|
264
|
+
// Find the hash: check context (v2), then comment (v3), then msgid (v1)
|
|
265
|
+
const commentHash = data.comments?.extracted?.match(/id: (\w+)/)?.[1];
|
|
266
|
+
const hash = context || commentHash || msgid;
|
|
267
|
+
|
|
268
|
+
existingTranslations[hash] = {
|
|
269
|
+
defaultMessage: data.msgid || data.comments?.extracted || '',
|
|
270
|
+
translation: data.msgstr[0] || '',
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
existingTranslations = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
|
276
|
+
}
|
|
277
|
+
} catch (e: any) {
|
|
278
|
+
console.error(`Failed to parse existing translation file at ${outFile}:`, e.message);
|
|
279
|
+
return; // Skip this file
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
const parentDir = path.dirname(outFile);
|
|
283
|
+
if (!fs.existsSync(parentDir)) {
|
|
284
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const updatedTranslations: Record<string, { defaultMessage: string; translation: string }> = {};
|
|
289
|
+
let addedCount = 0;
|
|
290
|
+
|
|
291
|
+
extractedMessages.forEach((msg) => {
|
|
292
|
+
const msgid = generateMessageId(msg);
|
|
293
|
+
if (existingTranslations[msgid]) {
|
|
294
|
+
updatedTranslations[msgid] = { ...existingTranslations[msgid] };
|
|
295
|
+
updatedTranslations[msgid].defaultMessage = msg;
|
|
296
|
+
} else {
|
|
297
|
+
updatedTranslations[msgid] = {
|
|
298
|
+
defaultMessage: msg,
|
|
299
|
+
translation: '',
|
|
300
|
+
};
|
|
301
|
+
addedCount++;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
Object.keys(existingTranslations).forEach((key) => {
|
|
306
|
+
if (!updatedTranslations[key]) {
|
|
307
|
+
updatedTranslations[key] = existingTranslations[key];
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (format === 'po') {
|
|
312
|
+
const poData: any = {
|
|
313
|
+
charset: 'utf-8',
|
|
314
|
+
headers: {
|
|
315
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
316
|
+
language: locale,
|
|
317
|
+
},
|
|
318
|
+
translations: {
|
|
319
|
+
'': {
|
|
320
|
+
'': { msgid: '', msgstr: [''] }, // Header
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
Object.entries(updatedTranslations).forEach(([hash, data]) => {
|
|
326
|
+
poData.translations[''][data.defaultMessage] = {
|
|
327
|
+
msgid: data.defaultMessage,
|
|
328
|
+
msgstr: [data.translation],
|
|
329
|
+
comments: {
|
|
330
|
+
extracted: `id: ${hash}`,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const compiledPo = gettextParser.po.compile(poData);
|
|
336
|
+
fs.writeFileSync(outFile, compiledPo);
|
|
337
|
+
} else {
|
|
338
|
+
fs.writeFileSync(outFile, JSON.stringify(updatedTranslations, null, 2));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log(`Updated ${outFile} (${locale}): Added ${addedCount} new messages.`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (require.main === module) {
|
|
346
|
+
main();
|
|
347
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ra.action.add": {
|
|
3
|
+
"defaultMessage": "Add",
|
|
4
|
+
"description": "Label for the button to add a new record"
|
|
5
|
+
},
|
|
6
|
+
"ra.action.add_filter": {
|
|
7
|
+
"defaultMessage": "Add filter"
|
|
8
|
+
},
|
|
9
|
+
"ra.action.cancel": {
|
|
10
|
+
"defaultMessage": "Cancel"
|
|
11
|
+
},
|
|
12
|
+
"ra.action.delete": {
|
|
13
|
+
"defaultMessage": "Delete"
|
|
14
|
+
},
|
|
15
|
+
"ra.action.edit": {
|
|
16
|
+
"defaultMessage": "Edit"
|
|
17
|
+
},
|
|
18
|
+
"ra.action.remove_filter": {
|
|
19
|
+
"defaultMessage": "Remove filter"
|
|
20
|
+
},
|
|
21
|
+
"ra.action.save": {
|
|
22
|
+
"defaultMessage": "Save",
|
|
23
|
+
"description": "Label for the button to save a record"
|
|
24
|
+
},
|
|
25
|
+
"ra.action.search": {
|
|
26
|
+
"defaultMessage": "Search"
|
|
27
|
+
}
|
|
28
|
+
}
|