@mmstack/translate-tools 1.0.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 +146 -0
- package/cli.d.mts +1 -0
- package/cli.mjs +103 -0
- package/cli.mjs.map +1 -0
- package/commands-yoWN-Tne.mjs +722 -0
- package/commands-yoWN-Tne.mjs.map +1 -0
- package/index.d.mts +204 -0
- package/index.d.mts.map +1 -0
- package/index.mjs +2 -0
- package/package.json +38 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
4
|
+
import { TYPE, parse } from "@formatjs/icu-messageformat-parser";
|
|
5
|
+
//#region src/lib/identifier.ts
|
|
6
|
+
/** A bare (unquoted) JavaScript identifier — used by both codegen and registry insertion to decide
|
|
7
|
+
* whether an object key / locale must be string-quoted. Kept in one place so the two emit paths
|
|
8
|
+
* can't disagree on quoting. */
|
|
9
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
10
|
+
function isIdentifier(value) {
|
|
11
|
+
return IDENTIFIER.test(value);
|
|
12
|
+
}
|
|
13
|
+
/** Best-effort valid identifier derived from an arbitrary name (e.g. a namespace). */
|
|
14
|
+
function toIdentifier(name) {
|
|
15
|
+
const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "");
|
|
16
|
+
if (!cleaned) return "source";
|
|
17
|
+
return isIdentifier(cleaned) ? cleaned : `ns${cleaned}`;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/lib/nested.ts
|
|
21
|
+
/** Serialize a nested translation tree to a JSON file body (one namespace, one locale). */
|
|
22
|
+
function toJson(nested) {
|
|
23
|
+
return JSON.stringify(nested, null, 2) + "\n";
|
|
24
|
+
}
|
|
25
|
+
/** Parse + validate a nested translation JSON file back into a {@link NestedTranslation}. */
|
|
26
|
+
function fromJson(json) {
|
|
27
|
+
const parsed = JSON.parse(json);
|
|
28
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Translation JSON must be a plain object.");
|
|
29
|
+
return validate(parsed, "");
|
|
30
|
+
}
|
|
31
|
+
/** Walk a tree's string leaves, yielding `[dottedPath, message]` for each (used for validation). */
|
|
32
|
+
function leafEntries(nested, prefix = "") {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const [key, value] of Object.entries(nested)) {
|
|
35
|
+
const at = prefix ? `${prefix}.${key}` : key;
|
|
36
|
+
if (typeof value === "string") out.push([at, value]);
|
|
37
|
+
else out.push(...leafEntries(value, at));
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** A flat `Map<dottedPath, message>` view of a tree, for key-by-key comparison. */
|
|
42
|
+
function leafMap(nested) {
|
|
43
|
+
return new Map(leafEntries(nested));
|
|
44
|
+
}
|
|
45
|
+
function validate(value, path) {
|
|
46
|
+
for (const [key, child] of Object.entries(value)) {
|
|
47
|
+
const at = path ? `${path}.${key}` : key;
|
|
48
|
+
if (typeof child === "string") continue;
|
|
49
|
+
if (child !== null && typeof child === "object" && !Array.isArray(child)) {
|
|
50
|
+
validate(child, at);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Invalid translation value at "${at}": expected a string or a nested object.`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/lib/codegen.ts
|
|
59
|
+
function key(k) {
|
|
60
|
+
return isIdentifier(k) ? k : JSON.stringify(k);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Serialize a nested translation tree to a TypeScript object-literal string. Double-quoted via
|
|
64
|
+
* `JSON.stringify`: correct escaping for ICU (which uses `'` for its own escaping, so single-quoting
|
|
65
|
+
* would be unsafe). A consumer's formatter normalizes quote style/indentation on save.
|
|
66
|
+
*/
|
|
67
|
+
function objectLiteralText(obj, depth = 1) {
|
|
68
|
+
const entries = Object.entries(obj);
|
|
69
|
+
if (entries.length === 0) return "{}";
|
|
70
|
+
const inner = " ".repeat(depth + 1);
|
|
71
|
+
const pad = " ".repeat(depth);
|
|
72
|
+
return `{\n${entries.map(([k, value]) => {
|
|
73
|
+
const v = typeof value === "string" ? JSON.stringify(value) : objectLiteralText(value, depth + 1);
|
|
74
|
+
return `${inner}${key(k)}: ${v},`;
|
|
75
|
+
}).join("\n")}\n${pad}}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Emit a target-locale TypeScript module that calls `createTranslation` on the source namespace —
|
|
79
|
+
* the TS-first import format. The output is valid TS that round-trips through {@link liftObjectLiteral}.
|
|
80
|
+
*/
|
|
81
|
+
function codegenTranslationFile(input) {
|
|
82
|
+
return `import ${input.defaultImport ? input.namespaceVar : `{ ${input.namespaceVar} }`} from ${JSON.stringify(input.importPath)};\n\nexport const ${input.exportName} = ${input.namespaceVar}.createTranslation(\n ${JSON.stringify(input.locale)},\n ${objectLiteralText(input.translation, 1)},\n);\n`;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/lib/lift.ts
|
|
86
|
+
/**
|
|
87
|
+
* Statically lift a `createNamespace`/`createTranslation` translation object literal into a plain
|
|
88
|
+
* {@link NestedTranslation} — no code execution. String leaves and nested objects pass through;
|
|
89
|
+
* `withParams<…>('msg')` is unwrapped to its message string (the type arg is compile-time only and
|
|
90
|
+
* irrelevant to the round-trip). Anything dynamic (a variable, a concatenation, a spread) throws —
|
|
91
|
+
* the author must inline a literal for the tool to round-trip it.
|
|
92
|
+
*/
|
|
93
|
+
function liftObjectLiteral(obj) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const prop of obj.getProperties()) {
|
|
96
|
+
if (!Node.isPropertyAssignment(prop)) throw new Error(`Unsupported entry in translation object (only "key: value" is supported): ${prop.getText()}`);
|
|
97
|
+
out[propKey(prop.getNameNode())] = liftValue(prop.getInitializerOrThrow());
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function propKey(name) {
|
|
102
|
+
if (Node.isStringLiteral(name) || Node.isNoSubstitutionTemplateLiteral(name)) return name.getLiteralText();
|
|
103
|
+
if (Node.isIdentifier(name)) return name.getText();
|
|
104
|
+
throw new Error(`Unsupported translation key: ${name.getText()}`);
|
|
105
|
+
}
|
|
106
|
+
function liftValue(node) {
|
|
107
|
+
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) return node.getLiteralText();
|
|
108
|
+
if (Node.isObjectLiteralExpression(node)) return liftObjectLiteral(node);
|
|
109
|
+
if (Node.isCallExpression(node)) {
|
|
110
|
+
if (node.getExpression().getText() === "withParams") {
|
|
111
|
+
const arg = node.getArguments()[0];
|
|
112
|
+
if (arg && (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg))) return arg.getLiteralText();
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Unsupported call in a translation value (only withParams('literal') is supported): ${node.getText()}`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Unsupported translation value (expected a string literal, withParams('literal'), or a nested object): ${node.getText()}`);
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/lib/loader.ts
|
|
120
|
+
/**
|
|
121
|
+
* Parse a namespace loader into its import path + accessor chain, matching every shape
|
|
122
|
+
* `@mmstack/translate` accepts at runtime:
|
|
123
|
+
* - `() => import('path').then((m) => m.a.b)` → `['a', 'b']`
|
|
124
|
+
* - `() => import('path').then((m) => m.default)` → `['default']`
|
|
125
|
+
* - `() => import('path')` (shorthand) → `[]` (default / translation auto-resolve)
|
|
126
|
+
* Returns `null` for any other shape (async/await, computed access) — the caller warns and skips.
|
|
127
|
+
*/
|
|
128
|
+
function parseLoader(fn) {
|
|
129
|
+
const body = Node.isArrowFunction(fn) ? fn.getBody() : void 0;
|
|
130
|
+
if (!body || !Node.isCallExpression(body)) return null;
|
|
131
|
+
if (body.getExpression().getKind() === SyntaxKind.ImportKeyword) {
|
|
132
|
+
const pathArg = body.getArguments()[0];
|
|
133
|
+
if (!isPathLiteral(pathArg)) return null;
|
|
134
|
+
return {
|
|
135
|
+
importPath: pathArg.getLiteralText(),
|
|
136
|
+
accessor: []
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const thenAccess = body.getExpression();
|
|
140
|
+
if (!Node.isPropertyAccessExpression(thenAccess) || thenAccess.getName() !== "then") return null;
|
|
141
|
+
const importCall = thenAccess.getExpression();
|
|
142
|
+
if (!Node.isCallExpression(importCall) || importCall.getExpression().getKind() !== SyntaxKind.ImportKeyword) return null;
|
|
143
|
+
const pathArg = importCall.getArguments()[0];
|
|
144
|
+
if (!isPathLiteral(pathArg)) return null;
|
|
145
|
+
const cb = body.getArguments()[0];
|
|
146
|
+
if (!cb || !Node.isArrowFunction(cb)) return null;
|
|
147
|
+
const accessor = accessorChain(cb.getBody());
|
|
148
|
+
if (!accessor) return null;
|
|
149
|
+
return {
|
|
150
|
+
importPath: pathArg.getLiteralText(),
|
|
151
|
+
accessor
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function isPathLiteral(node) {
|
|
155
|
+
return !!node && (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node));
|
|
156
|
+
}
|
|
157
|
+
function accessorChain(node) {
|
|
158
|
+
const parts = [];
|
|
159
|
+
let cur = node;
|
|
160
|
+
while (Node.isPropertyAccessExpression(cur)) {
|
|
161
|
+
parts.unshift(cur.getName());
|
|
162
|
+
cur = cur.getExpression();
|
|
163
|
+
}
|
|
164
|
+
return Node.isIdentifier(cur) && parts.length > 0 ? parts : null;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/lib/registry.ts
|
|
168
|
+
/**
|
|
169
|
+
* Find every `registerNamespace(...)` call in a source file. `registerRemoteNamespace` is
|
|
170
|
+
* deliberately ignored — its translations are served, not authored in files, so there's nothing to
|
|
171
|
+
* export or round-trip.
|
|
172
|
+
*/
|
|
173
|
+
function findRegisterNamespaceCalls(sf) {
|
|
174
|
+
return sf.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => call.getExpression().getText() === "registerNamespace");
|
|
175
|
+
}
|
|
176
|
+
function localeProperty(obj, locale) {
|
|
177
|
+
return obj.getProperties().find((p) => {
|
|
178
|
+
if (!Node.isPropertyAssignment(p)) return false;
|
|
179
|
+
const name = p.getNameNode();
|
|
180
|
+
return (Node.isStringLiteral(name) ? name.getLiteralText() : name.getText()) === locale;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Insert (or replace) a locale's loader in a `registerNamespace(default, other)` call's `other`
|
|
185
|
+
* map, so a freshly-imported locale is actually registered. Mutates the call in place — the caller
|
|
186
|
+
* saves the source file afterwards.
|
|
187
|
+
*/
|
|
188
|
+
function addLocaleLoader(call, locale, importPath, exportName) {
|
|
189
|
+
if (call.getArguments().length < 2) call.addArgument("{}");
|
|
190
|
+
const other = call.getArguments()[1];
|
|
191
|
+
if (!other || !Node.isObjectLiteralExpression(other)) throw new Error("registerNamespace's second argument must be an object literal of locale loaders.");
|
|
192
|
+
localeProperty(other, locale)?.remove();
|
|
193
|
+
other.addPropertyAssignment({
|
|
194
|
+
name: isIdentifier(locale) ? locale : JSON.stringify(locale),
|
|
195
|
+
initializer: `() => import(${JSON.stringify(importPath)}).then((m) => m.${exportName})`
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/lib/discover.ts
|
|
200
|
+
/** Discover every `registerNamespace` registry in a ts-morph project and lift its translations. */
|
|
201
|
+
function discoverFromProject(project, options = {}) {
|
|
202
|
+
const sourceLocale = options.sourceLocale ?? "en";
|
|
203
|
+
const warn = options.onWarn ?? (() => void 0);
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const sf of project.getSourceFiles()) findRegisterNamespaceCalls(sf).forEach((call, index) => {
|
|
206
|
+
const found = discoverCall(project, sf, call, index, sourceLocale, warn);
|
|
207
|
+
if (found) out.push(found);
|
|
208
|
+
});
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
function discoverCall(project, registryFile, call, registryCallIndex, sourceLocale, warn) {
|
|
212
|
+
const defaultArg = call.getArguments()[0];
|
|
213
|
+
if (!defaultArg || !Node.isArrowFunction(defaultArg)) {
|
|
214
|
+
warn(`registerNamespace default loader is not an arrow function in ${registryFile.getFilePath()}`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const parsedDefault = parseLoader(defaultArg);
|
|
218
|
+
if (!parsedDefault) {
|
|
219
|
+
warn(`Unsupported default loader shape in ${registryFile.getFilePath()}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const resolvedDefault = resolveExport(project, registryFile, parsedDefault, warn);
|
|
223
|
+
if (!resolvedDefault || resolvedDefault.kind !== "namespace" || !resolvedDefault.namespace) {
|
|
224
|
+
warn(`Could not resolve namespace from default loader in ${registryFile.getFilePath()}`);
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const locales = [];
|
|
228
|
+
const other = call.getArguments()[1];
|
|
229
|
+
if (other && Node.isObjectLiteralExpression(other)) for (const prop of other.getProperties()) {
|
|
230
|
+
if (!Node.isPropertyAssignment(prop)) continue;
|
|
231
|
+
const locale = nameText(prop.getNameNode());
|
|
232
|
+
const init = prop.getInitializer();
|
|
233
|
+
if (!init || !Node.isArrowFunction(init)) {
|
|
234
|
+
warn(`Locale "${locale}" loader is not an arrow function`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const parsed = parseLoader(init);
|
|
238
|
+
if (!parsed) {
|
|
239
|
+
warn(`Unsupported loader shape for locale "${locale}"`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const resolved = resolveExport(project, registryFile, parsed, warn);
|
|
243
|
+
if (!resolved) continue;
|
|
244
|
+
locales.push({
|
|
245
|
+
locale,
|
|
246
|
+
moduleFilePath: resolved.moduleFilePath,
|
|
247
|
+
exportName: resolved.exportName,
|
|
248
|
+
isDefaultExport: resolved.isDefaultExport,
|
|
249
|
+
translation: resolved.translation
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
namespace: resolvedDefault.namespace,
|
|
254
|
+
registryFilePath: registryFile.getFilePath(),
|
|
255
|
+
registryCallIndex,
|
|
256
|
+
source: {
|
|
257
|
+
locale: sourceLocale,
|
|
258
|
+
moduleFilePath: resolvedDefault.moduleFilePath,
|
|
259
|
+
exportName: resolvedDefault.exportName,
|
|
260
|
+
isDefaultExport: resolvedDefault.isDefaultExport,
|
|
261
|
+
translation: resolvedDefault.translation
|
|
262
|
+
},
|
|
263
|
+
locales
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function resolveExport(project, fromFile, parsed, warn) {
|
|
267
|
+
const moduleFile = resolveModule(project, fromFile.getFilePath(), parsed.importPath);
|
|
268
|
+
if (!moduleFile) {
|
|
269
|
+
warn(`Cannot resolve module "${parsed.importPath}" from ${fromFile.getFilePath()}`);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const target = resolveLoaderTarget(moduleFile, parsed.accessor);
|
|
273
|
+
if (!target) {
|
|
274
|
+
warn(`Could not resolve a ${parsed.accessor[0] ? `export "${parsed.accessor[0]}"` : "default/translation export"} in ${moduleFile.getFilePath()}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const { call: init, isDefaultExport } = target;
|
|
278
|
+
const callee = init.getExpression();
|
|
279
|
+
const base = {
|
|
280
|
+
moduleFilePath: moduleFile.getFilePath(),
|
|
281
|
+
isDefaultExport
|
|
282
|
+
};
|
|
283
|
+
const isCreateNamespace = Node.isIdentifier(callee) && callee.getText() === "createNamespace";
|
|
284
|
+
const isMergedNamespace = Node.isPropertyAccessExpression(callee) && callee.getName() === "createMergedNamespace";
|
|
285
|
+
if (isCreateNamespace || isMergedNamespace) {
|
|
286
|
+
const ns = stringArg(init, 0);
|
|
287
|
+
const lit = objArg(init, 1);
|
|
288
|
+
if (ns === null || !lit) return null;
|
|
289
|
+
return {
|
|
290
|
+
kind: "namespace",
|
|
291
|
+
namespace: ns,
|
|
292
|
+
translation: liftObjectLiteral(lit),
|
|
293
|
+
exportName: target.bindingName || toIdentifier(ns),
|
|
294
|
+
...base
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (Node.isPropertyAccessExpression(callee) && callee.getName() === "createTranslation") {
|
|
298
|
+
const lit = objArg(init, 1);
|
|
299
|
+
if (!lit) return null;
|
|
300
|
+
return {
|
|
301
|
+
kind: "translation",
|
|
302
|
+
translation: liftObjectLiteral(lit),
|
|
303
|
+
exportName: target.bindingName || "translation",
|
|
304
|
+
...base
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
warn(`Export in ${moduleFile.getFilePath()} is not a createNamespace/createMergedNamespace/createTranslation call`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
/** Map a parsed loader accessor to the `create*` call it resolves to, matching the same
|
|
311
|
+
* default / translation auto-resolution `@mmstack/translate` does at runtime. */
|
|
312
|
+
function resolveLoaderTarget(moduleFile, accessor) {
|
|
313
|
+
if (accessor.length > 0 && accessor[0] !== "default") {
|
|
314
|
+
const call = variableCall(moduleFile, accessor[0]);
|
|
315
|
+
return call ? {
|
|
316
|
+
call,
|
|
317
|
+
bindingName: accessor[0],
|
|
318
|
+
isDefaultExport: false
|
|
319
|
+
} : null;
|
|
320
|
+
}
|
|
321
|
+
const fromDefault = defaultExportCall(moduleFile);
|
|
322
|
+
if (fromDefault) return fromDefault;
|
|
323
|
+
if (accessor.length === 0) {
|
|
324
|
+
const call = variableCall(moduleFile, "translation");
|
|
325
|
+
if (call) return {
|
|
326
|
+
call,
|
|
327
|
+
bindingName: "translation",
|
|
328
|
+
isDefaultExport: false
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
function variableCall(moduleFile, name) {
|
|
334
|
+
const init = moduleFile.getVariableDeclaration(name)?.getInitializer();
|
|
335
|
+
return init && Node.isCallExpression(init) ? init : null;
|
|
336
|
+
}
|
|
337
|
+
function defaultExportCall(moduleFile) {
|
|
338
|
+
const assignment = moduleFile.getExportAssignment((a) => !a.isExportEquals());
|
|
339
|
+
if (!assignment) return null;
|
|
340
|
+
const expr = assignment.getExpression();
|
|
341
|
+
if (Node.isIdentifier(expr)) {
|
|
342
|
+
const call = variableCall(moduleFile, expr.getText());
|
|
343
|
+
return call ? {
|
|
344
|
+
call,
|
|
345
|
+
bindingName: expr.getText(),
|
|
346
|
+
isDefaultExport: true
|
|
347
|
+
} : null;
|
|
348
|
+
}
|
|
349
|
+
if (Node.isCallExpression(expr)) return {
|
|
350
|
+
call: expr,
|
|
351
|
+
bindingName: "",
|
|
352
|
+
isDefaultExport: true
|
|
353
|
+
};
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
function resolveModule(project, fromFilePath, importPath) {
|
|
357
|
+
const base = path.resolve(path.dirname(fromFilePath), importPath);
|
|
358
|
+
for (const candidate of [
|
|
359
|
+
base,
|
|
360
|
+
`${base}.ts`,
|
|
361
|
+
path.join(base, "index.ts")
|
|
362
|
+
]) {
|
|
363
|
+
const sf = project.getSourceFile(candidate);
|
|
364
|
+
if (sf) return sf;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function stringArg(call, index) {
|
|
368
|
+
const arg = call.getArguments()[index];
|
|
369
|
+
return arg && (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) ? arg.getLiteralText() : null;
|
|
370
|
+
}
|
|
371
|
+
function objArg(call, index) {
|
|
372
|
+
const arg = call.getArguments()[index];
|
|
373
|
+
return arg && Node.isObjectLiteralExpression(arg) ? arg : void 0;
|
|
374
|
+
}
|
|
375
|
+
function nameText(node) {
|
|
376
|
+
return Node.isStringLiteral(node) ? node.getLiteralText() : node.getText();
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/lib/icu.ts
|
|
380
|
+
/** Validate an ICU message string; returns the parser's error message when invalid. */
|
|
381
|
+
function validateMessage(message) {
|
|
382
|
+
try {
|
|
383
|
+
parse(message);
|
|
384
|
+
return { ok: true };
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
error: e instanceof Error ? e.message : String(e)
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Collect every argument/placeholder name an ICU message references, including names nested inside
|
|
394
|
+
* plural/select options and tag children (e.g. `{count, plural, one {# by {author}} ...}` → count,
|
|
395
|
+
* author). Throws if the message is not valid ICU — call {@link validateMessage} first.
|
|
396
|
+
*/
|
|
397
|
+
function extractPlaceholders(message) {
|
|
398
|
+
const names = /* @__PURE__ */ new Set();
|
|
399
|
+
const walk = (elements) => {
|
|
400
|
+
for (const el of elements) switch (el.type) {
|
|
401
|
+
case TYPE.argument:
|
|
402
|
+
case TYPE.number:
|
|
403
|
+
case TYPE.date:
|
|
404
|
+
case TYPE.time:
|
|
405
|
+
names.add(el.value);
|
|
406
|
+
break;
|
|
407
|
+
case TYPE.select:
|
|
408
|
+
case TYPE.plural:
|
|
409
|
+
names.add(el.value);
|
|
410
|
+
for (const opt of Object.values(el.options)) walk(opt.value);
|
|
411
|
+
break;
|
|
412
|
+
case TYPE.tag:
|
|
413
|
+
names.add(el.value);
|
|
414
|
+
walk(el.children);
|
|
415
|
+
break;
|
|
416
|
+
default: break;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
walk(parse(message));
|
|
420
|
+
return names;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Check a translated message references exactly the same placeholders as its source — the common
|
|
424
|
+
* way a translation silently breaks (a dropped `{name}`, or a renamed one). `missing` are present in
|
|
425
|
+
* the source but not the target; `extra` are in the target but not the source.
|
|
426
|
+
*/
|
|
427
|
+
function placeholderParity(source, target) {
|
|
428
|
+
const s = extractPlaceholders(source);
|
|
429
|
+
const t = extractPlaceholders(target);
|
|
430
|
+
const missing = [...s].filter((name) => !t.has(name));
|
|
431
|
+
const extra = [...t].filter((name) => !s.has(name));
|
|
432
|
+
if (missing.length === 0 && extra.length === 0) return { ok: true };
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
missing,
|
|
436
|
+
extra
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/lib/update.ts
|
|
441
|
+
/**
|
|
442
|
+
* Replace the translation object of an existing `createTranslation(...)` call in place (used when
|
|
443
|
+
* re-importing a locale that already exists), preserving the rest of the file. Returns `false` if
|
|
444
|
+
* the export/call can't be found. The file is re-formatted so the replacement indents cleanly.
|
|
445
|
+
*/
|
|
446
|
+
function replaceTranslationLiteral(project, filePath, exportName, translation) {
|
|
447
|
+
const sf = project.getSourceFile(filePath);
|
|
448
|
+
const init = sf?.getVariableDeclaration(exportName)?.getInitializer();
|
|
449
|
+
if (!init || !Node.isCallExpression(init)) return false;
|
|
450
|
+
const arg = init.getArguments()[1];
|
|
451
|
+
if (!arg) return false;
|
|
452
|
+
arg.replaceWithText(objectLiteralText(translation, 1));
|
|
453
|
+
sf?.formatText();
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/lib/commands.ts
|
|
458
|
+
/** Plan the per-namespace, per-locale JSON files to write (source locale + every target locale). */
|
|
459
|
+
function planExport(namespaces) {
|
|
460
|
+
const files = [];
|
|
461
|
+
for (const ns of namespaces) {
|
|
462
|
+
files.push({
|
|
463
|
+
fileName: `${ns.namespace}.${ns.source.locale}.json`,
|
|
464
|
+
content: toJson(ns.source.translation)
|
|
465
|
+
});
|
|
466
|
+
for (const loc of ns.locales) files.push({
|
|
467
|
+
fileName: `${ns.namespace}.${loc.locale}.json`,
|
|
468
|
+
content: toJson(loc.translation)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return files;
|
|
472
|
+
}
|
|
473
|
+
/** Validate an incoming translation: every leaf is valid ICU, and placeholders match the source. */
|
|
474
|
+
function validateImport(translation, source) {
|
|
475
|
+
const issues = [];
|
|
476
|
+
const src = leafMap(source);
|
|
477
|
+
const present = /* @__PURE__ */ new Set();
|
|
478
|
+
for (const [key, message] of leafEntries(translation)) {
|
|
479
|
+
present.add(key);
|
|
480
|
+
const valid = validateMessage(message);
|
|
481
|
+
if (!valid.ok) {
|
|
482
|
+
issues.push({
|
|
483
|
+
key,
|
|
484
|
+
message: `invalid ICU — ${valid.error}`
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const sourceMessage = src.get(key);
|
|
489
|
+
if (sourceMessage === void 0) {
|
|
490
|
+
issues.push({
|
|
491
|
+
key,
|
|
492
|
+
message: "unknown key (not in the source) — remove it"
|
|
493
|
+
});
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const sourceValid = validateMessage(sourceMessage);
|
|
497
|
+
if (!sourceValid.ok) {
|
|
498
|
+
issues.push({
|
|
499
|
+
key,
|
|
500
|
+
message: `source message is invalid ICU — ${sourceValid.error}`
|
|
501
|
+
});
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const parity = placeholderParity(sourceMessage, message);
|
|
505
|
+
if (!parity.ok) {
|
|
506
|
+
const parts = [];
|
|
507
|
+
if (parity.missing.length) parts.push(`missing ${parity.missing.join(", ")}`);
|
|
508
|
+
if (parity.extra.length) parts.push(`unexpected ${parity.extra.join(", ")}`);
|
|
509
|
+
issues.push({
|
|
510
|
+
key,
|
|
511
|
+
message: `placeholder mismatch (${parts.join("; ")})`
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const key of src.keys()) if (!present.has(key)) issues.push({
|
|
516
|
+
key,
|
|
517
|
+
message: "missing key"
|
|
518
|
+
});
|
|
519
|
+
return issues;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Apply one imported locale to the project: update the existing locale module in place, or — for a
|
|
523
|
+
* new locale — codegen a `createTranslation` module next to the source namespace and register its
|
|
524
|
+
* loader in the `registerNamespace` call. Mutates the project; the caller saves.
|
|
525
|
+
*/
|
|
526
|
+
function applyImport(project, ns, locale, translation, force = false) {
|
|
527
|
+
const existing = ns.locales.find((l) => l.locale === locale);
|
|
528
|
+
if (existing) {
|
|
529
|
+
if (!replaceTranslationLiteral(project, existing.moduleFilePath, existing.exportName, translation)) throw new Error(`Could not update locale "${locale}": expected an export "${existing.exportName}" initialized by a createTranslation(...) call in ${existing.moduleFilePath}.`);
|
|
530
|
+
return {
|
|
531
|
+
created: false,
|
|
532
|
+
filePath: existing.moduleFilePath
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const localeId = localeIdentifier(locale);
|
|
536
|
+
if (!localeId) throw new Error(`Locale "${locale}" has no alphanumeric characters to form a valid identifier.`);
|
|
537
|
+
const exportName = `${toIdentifier(ns.namespace)}${localeId}`;
|
|
538
|
+
const collision = ns.locales.find((l) => l.locale !== locale && localeIdentifier(l.locale) === localeId);
|
|
539
|
+
if (collision) throw new Error(`Locale "${locale}" collides with already-registered locale "${collision.locale}" (both generate identifier "${exportName}").`);
|
|
540
|
+
const sourceBase = path.basename(ns.source.moduleFilePath, ".ts");
|
|
541
|
+
const newFilePath = path.join(path.dirname(ns.source.moduleFilePath), `${ns.namespace}.${locale}.ts`);
|
|
542
|
+
if (!force && fs.existsSync(newFilePath)) throw new Error(`${newFilePath} already exists but locale "${locale}" isn't a registered locale. Remove it or pass --force to overwrite.`);
|
|
543
|
+
const registryCall = registryCallFor(project.getSourceFileOrThrow(ns.registryFilePath), ns);
|
|
544
|
+
const otherArg = registryCall.getArguments()[1];
|
|
545
|
+
if (otherArg && !Node.isObjectLiteralExpression(otherArg)) throw new Error("registerNamespace's second argument must be an object literal of locale loaders.");
|
|
546
|
+
project.createSourceFile(newFilePath, codegenTranslationFile({
|
|
547
|
+
namespaceVar: ns.source.exportName,
|
|
548
|
+
exportName,
|
|
549
|
+
locale,
|
|
550
|
+
importPath: `./${sourceBase}`,
|
|
551
|
+
defaultImport: ns.source.isDefaultExport,
|
|
552
|
+
translation
|
|
553
|
+
}), { overwrite: true });
|
|
554
|
+
addLocaleLoader(registryCall, locale, relativeImport(ns.registryFilePath, newFilePath), exportName);
|
|
555
|
+
return {
|
|
556
|
+
created: true,
|
|
557
|
+
filePath: newFilePath
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** Parse an export file name `namespace.locale.json`. Neither segment may contain a dot, so a
|
|
561
|
+
* stray-dot name (`quote.sl.si.json`) is rejected rather than folded into a bogus locale. */
|
|
562
|
+
function parseImportFileName(fileName) {
|
|
563
|
+
const m = /^([^.]+)\.([^.]+)\.json$/.exec(fileName);
|
|
564
|
+
return m ? {
|
|
565
|
+
namespace: m[1],
|
|
566
|
+
locale: m[2]
|
|
567
|
+
} : null;
|
|
568
|
+
}
|
|
569
|
+
/** Sidecar written into the export dir so a later `import` knows which locale was the source,
|
|
570
|
+
* even if `--source-locale` is omitted (it would otherwise default to `en` and mis-treat the
|
|
571
|
+
* source dump as a target locale). */
|
|
572
|
+
const META_FILE = ".mmtranslate-meta.json";
|
|
573
|
+
const warnToStderr = (message) => console.warn(`⚠ ${message}`);
|
|
574
|
+
/** A namespace name must map to exactly one source; duplicates would clobber files on export and
|
|
575
|
+
* drop all-but-one entry on import, both silently. Fail fast so the user resolves it first. */
|
|
576
|
+
function assertUniqueNamespaces(namespaces) {
|
|
577
|
+
const seen = /* @__PURE__ */ new Set();
|
|
578
|
+
const dupes = /* @__PURE__ */ new Set();
|
|
579
|
+
for (const n of namespaces) {
|
|
580
|
+
if (seen.has(n.namespace)) dupes.add(n.namespace);
|
|
581
|
+
seen.add(n.namespace);
|
|
582
|
+
}
|
|
583
|
+
if (dupes.size) throw new Error(`Duplicate namespace name(s): ${[...dupes].join(", ")}. Each namespace must be registered once — rename or remove the duplicate before export/import.`);
|
|
584
|
+
}
|
|
585
|
+
function readMetaSourceLocale(inDir) {
|
|
586
|
+
const metaPath = path.join(inDir, META_FILE);
|
|
587
|
+
if (!fs.existsSync(metaPath)) return void 0;
|
|
588
|
+
try {
|
|
589
|
+
const value = JSON.parse(fs.readFileSync(metaPath, "utf8"))?.sourceLocale;
|
|
590
|
+
return typeof value === "string" ? value : void 0;
|
|
591
|
+
} catch {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function buildProject(opts) {
|
|
596
|
+
const project = new Project({
|
|
597
|
+
skipAddingFilesFromTsConfig: true,
|
|
598
|
+
compilerOptions: { allowJs: false }
|
|
599
|
+
});
|
|
600
|
+
for (const glob of opts.srcGlobs) project.addSourceFilesAtPaths(path.isAbsolute(glob) ? glob : path.join(opts.cwd, glob));
|
|
601
|
+
return project;
|
|
602
|
+
}
|
|
603
|
+
function runExport(opts) {
|
|
604
|
+
const sourceLocale = opts.sourceLocale ?? "en";
|
|
605
|
+
const namespaces = discoverFromProject(buildProject(opts), {
|
|
606
|
+
sourceLocale,
|
|
607
|
+
onWarn: warnToStderr
|
|
608
|
+
});
|
|
609
|
+
assertUniqueNamespaces(namespaces);
|
|
610
|
+
for (const ns of namespaces) for (const [what, value] of [["Namespace", ns.namespace], ...ns.locales.map((l) => ["Locale", l.locale])]) if (value.includes(".")) warnToStderr(`${what} "${value}" contains a "." — its exported JSON cannot be re-imported (import expects <namespace>.<locale>.json with no dots in either segment).`);
|
|
611
|
+
const files = planExport(namespaces);
|
|
612
|
+
fs.mkdirSync(opts.outDir, { recursive: true });
|
|
613
|
+
for (const file of files) fs.writeFileSync(path.join(opts.outDir, file.fileName), file.content);
|
|
614
|
+
fs.writeFileSync(path.join(opts.outDir, META_FILE), JSON.stringify({ sourceLocale }, null, 2) + "\n");
|
|
615
|
+
return files;
|
|
616
|
+
}
|
|
617
|
+
function runImport(opts) {
|
|
618
|
+
const sourceLocale = opts.sourceLocale ?? readMetaSourceLocale(opts.inDir);
|
|
619
|
+
const project = buildProject(opts);
|
|
620
|
+
const namespaces = discoverFromProject(project, {
|
|
621
|
+
sourceLocale,
|
|
622
|
+
onWarn: warnToStderr
|
|
623
|
+
});
|
|
624
|
+
assertUniqueNamespaces(namespaces);
|
|
625
|
+
const byName = new Map(namespaces.map((n) => [n.namespace, n]));
|
|
626
|
+
const rejected = [];
|
|
627
|
+
const skipped = [];
|
|
628
|
+
let applied = 0;
|
|
629
|
+
for (const fileName of fs.readdirSync(opts.inDir)) {
|
|
630
|
+
if (fileName === META_FILE) continue;
|
|
631
|
+
const parsed = parseImportFileName(fileName);
|
|
632
|
+
if (!parsed) {
|
|
633
|
+
if (fileName.endsWith(".json")) skipped.push({
|
|
634
|
+
file: fileName,
|
|
635
|
+
reason: "not a <namespace>.<locale>.json name (neither segment may contain a dot)"
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const ns = byName.get(parsed.namespace);
|
|
640
|
+
if (!ns) {
|
|
641
|
+
skipped.push({
|
|
642
|
+
file: fileName,
|
|
643
|
+
reason: `no namespace "${parsed.namespace}" found in the scanned sources`
|
|
644
|
+
});
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (parsed.locale === ns.source.locale) continue;
|
|
648
|
+
let translation;
|
|
649
|
+
try {
|
|
650
|
+
translation = fromJson(fs.readFileSync(path.join(opts.inDir, fileName), "utf8"));
|
|
651
|
+
} catch (e) {
|
|
652
|
+
rejected.push({
|
|
653
|
+
file: fileName,
|
|
654
|
+
issues: [{
|
|
655
|
+
key: "(parse)",
|
|
656
|
+
message: e instanceof Error ? e.message : String(e)
|
|
657
|
+
}]
|
|
658
|
+
});
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const issues = validateImport(translation, ns.source.translation);
|
|
662
|
+
if (issues.length) {
|
|
663
|
+
rejected.push({
|
|
664
|
+
file: fileName,
|
|
665
|
+
issues
|
|
666
|
+
});
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
applyImport(project, ns, parsed.locale, translation, opts.force);
|
|
671
|
+
applied++;
|
|
672
|
+
} catch (e) {
|
|
673
|
+
rejected.push({
|
|
674
|
+
file: fileName,
|
|
675
|
+
issues: [{
|
|
676
|
+
key: "(apply)",
|
|
677
|
+
message: e instanceof Error ? e.message : String(e)
|
|
678
|
+
}]
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
project.saveSync();
|
|
683
|
+
return {
|
|
684
|
+
applied,
|
|
685
|
+
rejected,
|
|
686
|
+
skipped
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function runGenerateManifest(opts) {
|
|
690
|
+
const sourceLocale = opts.sourceLocale ?? "en";
|
|
691
|
+
const namespaces = discoverFromProject(buildProject(opts), {
|
|
692
|
+
sourceLocale,
|
|
693
|
+
onWarn: warnToStderr
|
|
694
|
+
});
|
|
695
|
+
assertUniqueNamespaces(namespaces);
|
|
696
|
+
const entries = namespaces.map((n) => ({
|
|
697
|
+
namespace: n.namespace,
|
|
698
|
+
sourceLocale,
|
|
699
|
+
registry: path.relative(opts.cwd, n.registryFilePath),
|
|
700
|
+
source: path.relative(opts.cwd, n.source.moduleFilePath),
|
|
701
|
+
locales: n.locales.map((l) => l.locale)
|
|
702
|
+
}));
|
|
703
|
+
const content = `// Generated by \`mmtranslate generate-manifest\`. Edit the globs / entries as needed.\nexport default ${JSON.stringify(entries, null, 2)};\n`;
|
|
704
|
+
fs.writeFileSync(opts.outFile, content);
|
|
705
|
+
return content;
|
|
706
|
+
}
|
|
707
|
+
function localeIdentifier(locale) {
|
|
708
|
+
return locale.split(/[^A-Za-z0-9]/).filter(Boolean).map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase()).join("");
|
|
709
|
+
}
|
|
710
|
+
function relativeImport(fromFile, toFile) {
|
|
711
|
+
const rel = path.relative(path.dirname(fromFile), toFile).replace(/\.ts$/, "").split(path.sep).join("/");
|
|
712
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
713
|
+
}
|
|
714
|
+
function registryCallFor(sf, ns) {
|
|
715
|
+
const call = findRegisterNamespaceCalls(sf)[ns.registryCallIndex];
|
|
716
|
+
if (!call) throw new Error(`Could not locate the registerNamespace call for "${ns.namespace}" in ${sf.getFilePath()}.`);
|
|
717
|
+
return call;
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
export { toJson as S, codegenTranslationFile as _, runGenerateManifest as a, leafEntries as b, replaceTranslationLiteral as c, validateMessage as d, discoverFromProject as f, liftObjectLiteral as g, parseLoader as h, runExport as i, extractPlaceholders as l, findRegisterNamespaceCalls as m, parseImportFileName as n, runImport as o, addLocaleLoader as p, planExport as r, validateImport as s, applyImport as t, placeholderParity as u, objectLiteralText as v, leafMap as x, fromJson as y };
|
|
721
|
+
|
|
722
|
+
//# sourceMappingURL=commands-yoWN-Tne.mjs.map
|