@better-translate/cli 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 +5 -0
- package/dist/ai-sdk-generator-WPQCTPGA.js +37 -0
- package/dist/ai-sdk-generator.d.ts +3 -0
- package/dist/ai-sdk-generator.d.ts.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +174 -0
- package/dist/chunk-JFSWNLL6.js +854 -0
- package/dist/chunk-WMIZO3GE.js +19 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/config-loader.d.ts +6 -0
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +8 -0
- package/dist/define-config.d.ts +3 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/env.d.ts +3 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/generate.d.ts +3 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/markdown.d.ts +18 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/messages.d.ts +13 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/module-loader.d.ts +2 -0
- package/dist/module-loader.d.ts.map +1 -0
- package/dist/prompts.d.ts +24 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/provider-models.d.ts +5 -0
- package/dist/provider-models.d.ts.map +1 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/validation.d.ts +12 -0
- package/dist/validation.d.ts.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
// src/config-loader.ts
|
|
2
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3
|
+
import path3 from "path";
|
|
4
|
+
|
|
5
|
+
// src/env.ts
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import dotenv from "dotenv";
|
|
9
|
+
function loadEnvFiles(cwd) {
|
|
10
|
+
const mode = process.env.NODE_ENV || "development";
|
|
11
|
+
const candidates = [
|
|
12
|
+
".env",
|
|
13
|
+
".env.local",
|
|
14
|
+
`.env.${mode}`,
|
|
15
|
+
`.env.${mode}.local`
|
|
16
|
+
];
|
|
17
|
+
const loaded = [];
|
|
18
|
+
for (const name of candidates) {
|
|
19
|
+
const filePath = path.join(cwd, name);
|
|
20
|
+
if (!existsSync(filePath)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
dotenv.config({
|
|
24
|
+
override: true,
|
|
25
|
+
path: filePath,
|
|
26
|
+
quiet: true
|
|
27
|
+
});
|
|
28
|
+
loaded.push(filePath);
|
|
29
|
+
}
|
|
30
|
+
return loaded;
|
|
31
|
+
}
|
|
32
|
+
function loadEnvFilesFromDirectories(directories) {
|
|
33
|
+
const loaded = /* @__PURE__ */ new Set();
|
|
34
|
+
for (const directory of directories) {
|
|
35
|
+
for (const filePath of loadEnvFiles(directory)) {
|
|
36
|
+
loaded.add(filePath);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return [...loaded];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/module-loader.ts
|
|
43
|
+
import { randomUUID } from "crypto";
|
|
44
|
+
import { readFile, rm, writeFile } from "fs/promises";
|
|
45
|
+
import path2 from "path";
|
|
46
|
+
import { pathToFileURL } from "url";
|
|
47
|
+
import ts from "typescript";
|
|
48
|
+
async function importJavaScriptModule(modulePath) {
|
|
49
|
+
return await import(`${pathToFileURL(modulePath).href}?t=${Date.now()}`);
|
|
50
|
+
}
|
|
51
|
+
async function importTypeScriptModule(modulePath) {
|
|
52
|
+
const source = await readFile(modulePath, "utf8");
|
|
53
|
+
const tempPath = path2.join(
|
|
54
|
+
path2.dirname(modulePath),
|
|
55
|
+
`.better-translate-${randomUUID()}.mjs`
|
|
56
|
+
);
|
|
57
|
+
const transpiled = ts.transpileModule(source, {
|
|
58
|
+
compilerOptions: {
|
|
59
|
+
esModuleInterop: true,
|
|
60
|
+
module: ts.ModuleKind.ESNext,
|
|
61
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
62
|
+
resolveJsonModule: true,
|
|
63
|
+
target: ts.ScriptTarget.ES2022
|
|
64
|
+
},
|
|
65
|
+
fileName: modulePath
|
|
66
|
+
});
|
|
67
|
+
await writeFile(tempPath, transpiled.outputText, "utf8");
|
|
68
|
+
try {
|
|
69
|
+
return await importJavaScriptModule(tempPath);
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(tempPath, {
|
|
72
|
+
force: true
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function importModule(modulePath) {
|
|
77
|
+
if (path2.extname(modulePath) === ".ts") {
|
|
78
|
+
return importTypeScriptModule(modulePath);
|
|
79
|
+
}
|
|
80
|
+
return importJavaScriptModule(modulePath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/validation.ts
|
|
84
|
+
function isRecord(value) {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
function assert(condition, message) {
|
|
88
|
+
if (!condition) {
|
|
89
|
+
throw new Error(message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function isTranslationMessages(value) {
|
|
93
|
+
if (!isRecord(value)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return Object.values(value).every((entry) => {
|
|
97
|
+
if (typeof entry === "string") {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return isTranslationMessages(entry);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function assertTranslationMessages(value, message) {
|
|
104
|
+
assert(isTranslationMessages(value), message);
|
|
105
|
+
}
|
|
106
|
+
function flattenTranslationKeys(messages, prefix = "") {
|
|
107
|
+
const keys = [];
|
|
108
|
+
for (const [key, value] of Object.entries(messages)) {
|
|
109
|
+
const nextKey = prefix ? `${prefix}.${key}` : key;
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
keys.push(nextKey);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
keys.push(...flattenTranslationKeys(value, nextKey));
|
|
115
|
+
}
|
|
116
|
+
return keys;
|
|
117
|
+
}
|
|
118
|
+
function assertExactMessageShape(reference, candidate, pathParts = []) {
|
|
119
|
+
assert(
|
|
120
|
+
isRecord(candidate),
|
|
121
|
+
`Generated translations must be an object at "${pathParts.join(".") || "<root>"}".`
|
|
122
|
+
);
|
|
123
|
+
const referenceKeys = Object.keys(reference).sort();
|
|
124
|
+
const candidateKeys = Object.keys(candidate).sort();
|
|
125
|
+
assert(
|
|
126
|
+
referenceKeys.length === candidateKeys.length,
|
|
127
|
+
`Generated translations changed the keys at "${pathParts.join(".") || "<root>"}".`
|
|
128
|
+
);
|
|
129
|
+
for (const key of referenceKeys) {
|
|
130
|
+
assert(
|
|
131
|
+
key in candidate,
|
|
132
|
+
`Generated translations are missing the key "${[...pathParts, key].join(".")}".`
|
|
133
|
+
);
|
|
134
|
+
const referenceValue = reference[key];
|
|
135
|
+
const candidateValue = candidate[key];
|
|
136
|
+
if (typeof referenceValue === "string") {
|
|
137
|
+
assert(
|
|
138
|
+
typeof candidateValue === "string",
|
|
139
|
+
`Generated translations must keep "${[...pathParts, key].join(".")}" as a string.`
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
assertExactMessageShape(referenceValue, candidateValue, [
|
|
144
|
+
...pathParts,
|
|
145
|
+
key
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function normalizeMarkdownExtensions(value) {
|
|
150
|
+
if (value === void 0) {
|
|
151
|
+
return [".md", ".mdx"];
|
|
152
|
+
}
|
|
153
|
+
assert(
|
|
154
|
+
Array.isArray(value),
|
|
155
|
+
"markdown.extensions must be an array when provided."
|
|
156
|
+
);
|
|
157
|
+
assert(
|
|
158
|
+
value.length > 0,
|
|
159
|
+
"markdown.extensions must include at least one extension."
|
|
160
|
+
);
|
|
161
|
+
const normalized = [...new Set(value)];
|
|
162
|
+
for (const extension of normalized) {
|
|
163
|
+
assert(
|
|
164
|
+
extension === ".md" || extension === ".mdx",
|
|
165
|
+
'markdown.extensions only supports ".md" and ".mdx".'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
function toJavaScriptIdentifier(value) {
|
|
171
|
+
const parts = value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.toLowerCase());
|
|
172
|
+
if (parts.length === 0) {
|
|
173
|
+
return "messages";
|
|
174
|
+
}
|
|
175
|
+
const [head, ...tail] = parts;
|
|
176
|
+
const identifier = `${head}${tail.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join("")}`;
|
|
177
|
+
return /^[a-zA-Z_$]/.test(identifier) ? identifier : `_${identifier}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/config-loader.ts
|
|
181
|
+
var DEFAULT_CONFIG_FILE = "better-translate.config.ts";
|
|
182
|
+
function resolveProviderModelSpec(model) {
|
|
183
|
+
assert(
|
|
184
|
+
isRecord(model),
|
|
185
|
+
'Config requires model to be a non-empty string or openai("model-id", { apiKey }).'
|
|
186
|
+
);
|
|
187
|
+
assert(
|
|
188
|
+
model.kind === "provider-model",
|
|
189
|
+
'Config requires model to be a non-empty string or openai("model-id", { apiKey }).'
|
|
190
|
+
);
|
|
191
|
+
assert(
|
|
192
|
+
model.provider === "openai",
|
|
193
|
+
'Only openai("model-id", { apiKey }) is supported for built-in provider mode right now.'
|
|
194
|
+
);
|
|
195
|
+
assert(
|
|
196
|
+
typeof model.modelId === "string" && model.modelId.trim().length > 0,
|
|
197
|
+
'openai("model-id", { apiKey }) requires a non-empty model id.'
|
|
198
|
+
);
|
|
199
|
+
assert(
|
|
200
|
+
typeof model.apiKey === "string" && model.apiKey.trim().length > 0,
|
|
201
|
+
'openai("model-id", { apiKey }) requires a non-empty apiKey string.'
|
|
202
|
+
);
|
|
203
|
+
return {
|
|
204
|
+
apiKey: model.apiKey.trim(),
|
|
205
|
+
kind: "provider-model",
|
|
206
|
+
modelId: model.modelId.trim(),
|
|
207
|
+
provider: "openai"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function resolveConfig(rawConfig, configDirectory) {
|
|
211
|
+
assert(
|
|
212
|
+
isRecord(rawConfig),
|
|
213
|
+
"better-translate.config.ts must export a config object."
|
|
214
|
+
);
|
|
215
|
+
const sourceLocale = rawConfig.sourceLocale;
|
|
216
|
+
assert(
|
|
217
|
+
typeof sourceLocale === "string" && sourceLocale.trim().length > 0,
|
|
218
|
+
"Config requires a non-empty sourceLocale string."
|
|
219
|
+
);
|
|
220
|
+
const locales = rawConfig.locales;
|
|
221
|
+
assert(Array.isArray(locales), "Config requires a locales array.");
|
|
222
|
+
const normalizedLocales = locales.map((locale) => {
|
|
223
|
+
assert(
|
|
224
|
+
typeof locale === "string" && locale.trim().length > 0,
|
|
225
|
+
"Config locales must be non-empty strings."
|
|
226
|
+
);
|
|
227
|
+
return locale.trim();
|
|
228
|
+
});
|
|
229
|
+
assert(
|
|
230
|
+
normalizedLocales.length > 0,
|
|
231
|
+
"Config requires at least one target locale."
|
|
232
|
+
);
|
|
233
|
+
assert(
|
|
234
|
+
new Set(normalizedLocales).size === normalizedLocales.length,
|
|
235
|
+
"Config locales must not contain duplicates."
|
|
236
|
+
);
|
|
237
|
+
assert(
|
|
238
|
+
!normalizedLocales.includes(sourceLocale),
|
|
239
|
+
"Config locales must not include the sourceLocale."
|
|
240
|
+
);
|
|
241
|
+
const messages = rawConfig.messages;
|
|
242
|
+
assert(isRecord(messages), "Config requires a messages object.");
|
|
243
|
+
assert(
|
|
244
|
+
typeof messages.entry === "string" && messages.entry.trim().length > 0,
|
|
245
|
+
"Config requires messages.entry."
|
|
246
|
+
);
|
|
247
|
+
const markdown = rawConfig.markdown;
|
|
248
|
+
const model = rawConfig.model;
|
|
249
|
+
const gateway = rawConfig.gateway;
|
|
250
|
+
const resolvedBase = {
|
|
251
|
+
locales: normalizedLocales,
|
|
252
|
+
markdown: markdown === void 0 ? void 0 : (() => {
|
|
253
|
+
assert(
|
|
254
|
+
isRecord(markdown),
|
|
255
|
+
"markdown must be an object when provided."
|
|
256
|
+
);
|
|
257
|
+
assert(
|
|
258
|
+
typeof markdown.rootDir === "string" && markdown.rootDir.trim().length > 0,
|
|
259
|
+
"markdown.rootDir must be a non-empty string."
|
|
260
|
+
);
|
|
261
|
+
return {
|
|
262
|
+
extensions: normalizeMarkdownExtensions(markdown.extensions),
|
|
263
|
+
rootDir: path3.resolve(configDirectory, markdown.rootDir)
|
|
264
|
+
};
|
|
265
|
+
})(),
|
|
266
|
+
messages: {
|
|
267
|
+
entry: path3.resolve(configDirectory, messages.entry)
|
|
268
|
+
},
|
|
269
|
+
sourceLocale
|
|
270
|
+
};
|
|
271
|
+
if (typeof model === "string") {
|
|
272
|
+
assert(
|
|
273
|
+
model.trim().length > 0,
|
|
274
|
+
'Config requires a non-empty model string, for example "openai/gpt-4.1".'
|
|
275
|
+
);
|
|
276
|
+
assert(
|
|
277
|
+
isRecord(gateway),
|
|
278
|
+
"Config requires a gateway object when model is a string."
|
|
279
|
+
);
|
|
280
|
+
assert(
|
|
281
|
+
typeof gateway.apiKey === "string" && gateway.apiKey.trim().length > 0,
|
|
282
|
+
"Config requires gateway.apiKey with the AI Gateway key string."
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
...resolvedBase,
|
|
286
|
+
gateway: {
|
|
287
|
+
apiKey: gateway.apiKey.trim()
|
|
288
|
+
},
|
|
289
|
+
model: model.trim()
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
assert(
|
|
293
|
+
gateway === void 0,
|
|
294
|
+
"Config must not include gateway when model is created with openai(...)."
|
|
295
|
+
);
|
|
296
|
+
const resolvedModel = resolveProviderModelSpec(model);
|
|
297
|
+
return {
|
|
298
|
+
...resolvedBase,
|
|
299
|
+
model: resolvedModel
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async function loadCliConfig(options = {}) {
|
|
303
|
+
const cwd = options.cwd ?? process.cwd();
|
|
304
|
+
const configPath = path3.resolve(
|
|
305
|
+
cwd,
|
|
306
|
+
options.configPath ?? DEFAULT_CONFIG_FILE
|
|
307
|
+
);
|
|
308
|
+
const configDirectory = path3.dirname(configPath);
|
|
309
|
+
loadEnvFilesFromDirectories(
|
|
310
|
+
configDirectory === cwd ? [cwd] : [cwd, configDirectory]
|
|
311
|
+
);
|
|
312
|
+
assert(
|
|
313
|
+
existsSync2(configPath),
|
|
314
|
+
`Could not find Better Translate config at "${configPath}".`
|
|
315
|
+
);
|
|
316
|
+
const module = await importModule(configPath);
|
|
317
|
+
const rawConfig = module.default ?? module.config ?? module;
|
|
318
|
+
return {
|
|
319
|
+
config: resolveConfig(rawConfig, configDirectory),
|
|
320
|
+
directory: configDirectory,
|
|
321
|
+
path: configPath
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/generate.ts
|
|
326
|
+
import { mkdir, writeFile as writeFile2 } from "fs/promises";
|
|
327
|
+
import path6 from "path";
|
|
328
|
+
|
|
329
|
+
// src/markdown.ts
|
|
330
|
+
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
331
|
+
import path4 from "path";
|
|
332
|
+
import matter from "gray-matter";
|
|
333
|
+
async function walkDirectory(directory) {
|
|
334
|
+
const entries = await readdir(directory, {
|
|
335
|
+
withFileTypes: true
|
|
336
|
+
});
|
|
337
|
+
const files = await Promise.all(
|
|
338
|
+
entries.map(async (entry) => {
|
|
339
|
+
const entryPath = path4.join(directory, entry.name);
|
|
340
|
+
if (entry.isDirectory()) {
|
|
341
|
+
return walkDirectory(entryPath);
|
|
342
|
+
}
|
|
343
|
+
if (entry.isFile()) {
|
|
344
|
+
return [entryPath];
|
|
345
|
+
}
|
|
346
|
+
return [];
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
return files.flat();
|
|
350
|
+
}
|
|
351
|
+
function extractFrontmatterStrings(value) {
|
|
352
|
+
if (!isRecord(value)) {
|
|
353
|
+
return {};
|
|
354
|
+
}
|
|
355
|
+
const result = {};
|
|
356
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
357
|
+
if (typeof entry === "string") {
|
|
358
|
+
result[key] = entry;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (isRecord(entry)) {
|
|
362
|
+
const nested = extractFrontmatterStrings(entry);
|
|
363
|
+
if (Object.keys(nested).length > 0) {
|
|
364
|
+
result[key] = nested;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
function createStringOnlySchema(value) {
|
|
371
|
+
const properties = {};
|
|
372
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
373
|
+
properties[key] = typeof entry === "string" ? { type: "string" } : createStringOnlySchema(entry);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
additionalProperties: false,
|
|
377
|
+
properties,
|
|
378
|
+
required: Object.keys(value),
|
|
379
|
+
type: "object"
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function createMarkdownOutputSchema(frontmatterStrings) {
|
|
383
|
+
return {
|
|
384
|
+
additionalProperties: false,
|
|
385
|
+
properties: {
|
|
386
|
+
body: {
|
|
387
|
+
type: "string"
|
|
388
|
+
},
|
|
389
|
+
frontmatter: createStringOnlySchema(frontmatterStrings)
|
|
390
|
+
},
|
|
391
|
+
required: ["body", "frontmatter"],
|
|
392
|
+
type: "object"
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function mergeFrontmatterStrings(source, translatedStrings) {
|
|
396
|
+
const merged = {
|
|
397
|
+
...source
|
|
398
|
+
};
|
|
399
|
+
for (const [key, entry] of Object.entries(translatedStrings)) {
|
|
400
|
+
if (typeof entry === "string") {
|
|
401
|
+
merged[key] = entry;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const nestedSource = isRecord(source[key]) ? source[key] : {};
|
|
405
|
+
merged[key] = mergeFrontmatterStrings(
|
|
406
|
+
nestedSource,
|
|
407
|
+
entry
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return merged;
|
|
411
|
+
}
|
|
412
|
+
function applyMarkdownTranslation(sourceFrontmatter, translatedStrings, body) {
|
|
413
|
+
return matter.stringify(
|
|
414
|
+
body,
|
|
415
|
+
mergeFrontmatterStrings(sourceFrontmatter, translatedStrings)
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
async function listMarkdownSourceFiles(rootDir, extensions) {
|
|
419
|
+
const files = await walkDirectory(rootDir);
|
|
420
|
+
return files.filter(
|
|
421
|
+
(filePath) => extensions.some((extension) => filePath.endsWith(extension))
|
|
422
|
+
).sort();
|
|
423
|
+
}
|
|
424
|
+
async function loadMarkdownDocument(rootDir, sourcePath) {
|
|
425
|
+
const sourceText = await readFile2(sourcePath, "utf8");
|
|
426
|
+
const parsed = matter(sourceText);
|
|
427
|
+
const frontmatter = isRecord(parsed.data) ? parsed.data : {};
|
|
428
|
+
const frontmatterStrings = extractFrontmatterStrings(frontmatter);
|
|
429
|
+
const relativePath = path4.relative(rootDir, sourcePath).split(path4.sep).join("/");
|
|
430
|
+
return {
|
|
431
|
+
body: parsed.content,
|
|
432
|
+
frontmatter,
|
|
433
|
+
frontmatterStrings,
|
|
434
|
+
relativePath,
|
|
435
|
+
schema: createMarkdownOutputSchema(frontmatterStrings),
|
|
436
|
+
sourceText,
|
|
437
|
+
sourcePath
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function deriveTargetMarkdownRoot(rootDir, sourceLocale, targetLocale) {
|
|
441
|
+
const basename = path4.basename(rootDir);
|
|
442
|
+
assert(
|
|
443
|
+
basename === sourceLocale,
|
|
444
|
+
`markdown.rootDir must end with the source locale "${sourceLocale}" so the CLI can mirror sibling locale folders.`
|
|
445
|
+
);
|
|
446
|
+
return path4.join(path4.dirname(rootDir), targetLocale);
|
|
447
|
+
}
|
|
448
|
+
function deriveTargetMarkdownPath(rootDir, sourceLocale, targetLocale, relativePath) {
|
|
449
|
+
return path4.join(
|
|
450
|
+
deriveTargetMarkdownRoot(rootDir, sourceLocale, targetLocale),
|
|
451
|
+
relativePath
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/messages.ts
|
|
456
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
457
|
+
import path5 from "path";
|
|
458
|
+
import {
|
|
459
|
+
createTranslationJsonSchema
|
|
460
|
+
} from "@better-translate/core";
|
|
461
|
+
function getExportedMessages(module, sourceLocale) {
|
|
462
|
+
const value = module.default ?? module[sourceLocale];
|
|
463
|
+
assert(
|
|
464
|
+
value !== void 0,
|
|
465
|
+
`The source translation module must export a default object or a named "${sourceLocale}" export.`
|
|
466
|
+
);
|
|
467
|
+
assertTranslationMessages(
|
|
468
|
+
value,
|
|
469
|
+
"The source translation file must export nested objects with string leaves only."
|
|
470
|
+
);
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
async function loadSourceMessages(sourcePath, sourceLocale) {
|
|
474
|
+
const extension = path5.extname(sourcePath);
|
|
475
|
+
const sourceText = await readFile3(sourcePath, "utf8");
|
|
476
|
+
if (extension === ".json") {
|
|
477
|
+
const parsed = JSON.parse(sourceText);
|
|
478
|
+
assertTranslationMessages(
|
|
479
|
+
parsed,
|
|
480
|
+
"The source JSON file must contain nested objects with string leaves only."
|
|
481
|
+
);
|
|
482
|
+
return {
|
|
483
|
+
format: "json",
|
|
484
|
+
keyPaths: flattenTranslationKeys(parsed),
|
|
485
|
+
messages: parsed,
|
|
486
|
+
schema: createTranslationJsonSchema(parsed),
|
|
487
|
+
sourceText,
|
|
488
|
+
sourcePath
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
assert(
|
|
492
|
+
extension === ".ts",
|
|
493
|
+
`Unsupported source translation extension "${extension}". Use .json or .ts.`
|
|
494
|
+
);
|
|
495
|
+
const module = await importModule(sourcePath);
|
|
496
|
+
const messages = getExportedMessages(module, sourceLocale);
|
|
497
|
+
return {
|
|
498
|
+
format: "ts",
|
|
499
|
+
keyPaths: flattenTranslationKeys(messages),
|
|
500
|
+
messages,
|
|
501
|
+
schema: createTranslationJsonSchema(messages),
|
|
502
|
+
sourceText,
|
|
503
|
+
sourcePath
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function replaceLocaleSegment(basename, sourceLocale, targetLocale) {
|
|
507
|
+
if (basename === sourceLocale) {
|
|
508
|
+
return targetLocale;
|
|
509
|
+
}
|
|
510
|
+
const escapedSourceLocale = sourceLocale.replace(
|
|
511
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
512
|
+
"\\$&"
|
|
513
|
+
);
|
|
514
|
+
const pattern = new RegExp(`(^|[._-])${escapedSourceLocale}(?=$|[._-])`);
|
|
515
|
+
const match = basename.match(pattern);
|
|
516
|
+
if (!match) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
return basename.replace(pattern, `${match[1]}${targetLocale}`);
|
|
520
|
+
}
|
|
521
|
+
function deriveTargetMessagesPath(sourcePath, sourceLocale, targetLocale) {
|
|
522
|
+
const extension = path5.extname(sourcePath);
|
|
523
|
+
const basename = path5.basename(sourcePath, extension);
|
|
524
|
+
const replaced = replaceLocaleSegment(basename, sourceLocale, targetLocale);
|
|
525
|
+
assert(
|
|
526
|
+
replaced,
|
|
527
|
+
`Could not derive a target messages filename from "${sourcePath}". The basename must contain the source locale "${sourceLocale}".`
|
|
528
|
+
);
|
|
529
|
+
return path5.join(path5.dirname(sourcePath), `${replaced}${extension}`);
|
|
530
|
+
}
|
|
531
|
+
function serializeMessages(messages, format, locale) {
|
|
532
|
+
if (format === "json") {
|
|
533
|
+
return `${JSON.stringify(messages, null, 2)}
|
|
534
|
+
`;
|
|
535
|
+
}
|
|
536
|
+
const identifier = toJavaScriptIdentifier(locale);
|
|
537
|
+
const objectLiteral = JSON.stringify(messages, null, 2);
|
|
538
|
+
return `export const ${identifier} = ${objectLiteral} as const;
|
|
539
|
+
|
|
540
|
+
export default ${identifier};
|
|
541
|
+
`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/prompts.ts
|
|
545
|
+
var MESSAGE_SYSTEM_INSTRUCTIONS = [
|
|
546
|
+
"You are translating application locale files.",
|
|
547
|
+
"Mimic the source file's structure, tone, and formatting conventions.",
|
|
548
|
+
"Do not switch styles, rename concepts, regroup content, or invent missing text.",
|
|
549
|
+
"Return only structured output that preserves the exact object shape and every placeholder token."
|
|
550
|
+
].join(" ");
|
|
551
|
+
var MESSAGE_RULES = [
|
|
552
|
+
"- Keep the exact same keys and nested structure.",
|
|
553
|
+
"- Mimic the source file's wording style, punctuation style, capitalization style, and sentence density.",
|
|
554
|
+
"- Preserve placeholders like {name}, {count}, and similar tokens exactly.",
|
|
555
|
+
"- Preserve URLs, code identifiers, product names, and formatting markers unless they are natural-language text.",
|
|
556
|
+
"- Do not add explanations, comments, metadata, or extra keys.",
|
|
557
|
+
"- Do not simplify, reorganize, or normalize the content. Stay as close as possible to the source file's format."
|
|
558
|
+
].join("\n");
|
|
559
|
+
var MARKDOWN_SYSTEM_INSTRUCTIONS = [
|
|
560
|
+
"You are translating Markdown and MDX documents.",
|
|
561
|
+
"Mimic the source document's structure, formatting, and writing style.",
|
|
562
|
+
"Do not switch layouts, reorder sections, or alter code fences, JSX tags, links, or formatting markers."
|
|
563
|
+
].join(" ");
|
|
564
|
+
var MARKDOWN_RULES = [
|
|
565
|
+
"- Preserve headings, lists, emphasis markers, tables, code fences, inline code, HTML, JSX, and MDX tags.",
|
|
566
|
+
"- Preserve links, image URLs, import paths, filenames, and code snippets.",
|
|
567
|
+
"- Translate prose and string frontmatter values only.",
|
|
568
|
+
"- Keep the same section order, spacing intent, and formatting pattern as the source document.",
|
|
569
|
+
"- Do not add commentary, summaries, or extra sections."
|
|
570
|
+
].join("\n");
|
|
571
|
+
function createMessagesPrompt(options) {
|
|
572
|
+
const prompt = [
|
|
573
|
+
`Translate the source locale file into "${options.targetLocale}".`,
|
|
574
|
+
`Source locale: ${options.sourceLocale}`,
|
|
575
|
+
`Target locale: ${options.targetLocale}`,
|
|
576
|
+
`Source file: ${options.sourcePath}`,
|
|
577
|
+
"",
|
|
578
|
+
"Rules:",
|
|
579
|
+
MESSAGE_RULES,
|
|
580
|
+
"",
|
|
581
|
+
"Key paths:",
|
|
582
|
+
options.keyPaths.map((key) => `- ${key}`).join("\n"),
|
|
583
|
+
"",
|
|
584
|
+
"Source file content to mimic:",
|
|
585
|
+
options.sourceText,
|
|
586
|
+
"",
|
|
587
|
+
"Source messages:",
|
|
588
|
+
JSON.stringify(options.sourceMessages, null, 2)
|
|
589
|
+
].join("\n");
|
|
590
|
+
return {
|
|
591
|
+
prompt,
|
|
592
|
+
system: MESSAGE_SYSTEM_INSTRUCTIONS
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function createMarkdownPrompt(options) {
|
|
596
|
+
const prompt = [
|
|
597
|
+
`Translate this markdown document into "${options.targetLocale}".`,
|
|
598
|
+
`Source locale: ${options.sourceLocale}`,
|
|
599
|
+
`Target locale: ${options.targetLocale}`,
|
|
600
|
+
`Document path: ${options.relativePath}`,
|
|
601
|
+
"",
|
|
602
|
+
"Rules:",
|
|
603
|
+
MARKDOWN_RULES,
|
|
604
|
+
"",
|
|
605
|
+
"Source file content to mimic:",
|
|
606
|
+
options.sourceText,
|
|
607
|
+
"",
|
|
608
|
+
"Frontmatter string values:",
|
|
609
|
+
JSON.stringify(options.frontmatterStrings, null, 2),
|
|
610
|
+
"",
|
|
611
|
+
"Markdown body:",
|
|
612
|
+
options.body
|
|
613
|
+
].join("\n");
|
|
614
|
+
return {
|
|
615
|
+
prompt,
|
|
616
|
+
system: MARKDOWN_SYSTEM_INSTRUCTIONS
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/generate.ts
|
|
621
|
+
function createConsoleLogger() {
|
|
622
|
+
return {
|
|
623
|
+
error(message) {
|
|
624
|
+
console.error(message);
|
|
625
|
+
},
|
|
626
|
+
info(message) {
|
|
627
|
+
console.log(message);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function describeError(error) {
|
|
632
|
+
return error instanceof Error ? error.message : String(error);
|
|
633
|
+
}
|
|
634
|
+
async function persistWrite(write, options) {
|
|
635
|
+
if (options.dryRun) {
|
|
636
|
+
options.logger.info(
|
|
637
|
+
`[dry-run] ${write.kind}:${write.locale} ${write.targetPath}`
|
|
638
|
+
);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await mkdir(path6.dirname(write.targetPath), {
|
|
642
|
+
recursive: true
|
|
643
|
+
});
|
|
644
|
+
await writeFile2(write.targetPath, write.content, "utf8");
|
|
645
|
+
options.logger.info(
|
|
646
|
+
`wrote ${write.kind}:${write.locale} ${write.targetPath}`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
function prepareGatewayEnvironment(apiKey) {
|
|
650
|
+
assert(
|
|
651
|
+
typeof apiKey === "string" && apiKey.length > 0,
|
|
652
|
+
"Missing AI Gateway key. Provide gateway.apiKey in better-translate.config.ts."
|
|
653
|
+
);
|
|
654
|
+
process.env.AI_GATEWAY_API_KEY = apiKey;
|
|
655
|
+
}
|
|
656
|
+
async function createDefaultGenerator(model) {
|
|
657
|
+
const { generateWithAiSdk } = await import("./ai-sdk-generator-WPQCTPGA.js");
|
|
658
|
+
return async (request) => generateWithAiSdk(model, request);
|
|
659
|
+
}
|
|
660
|
+
async function resolveRuntimeModel(config) {
|
|
661
|
+
if ("gateway" in config) {
|
|
662
|
+
prepareGatewayEnvironment(config.gateway.apiKey);
|
|
663
|
+
return {
|
|
664
|
+
description: `Using AI Gateway model: ${config.model}`,
|
|
665
|
+
model: config.model
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
669
|
+
const provider = createOpenAI({
|
|
670
|
+
apiKey: config.model.apiKey
|
|
671
|
+
});
|
|
672
|
+
return {
|
|
673
|
+
description: `Using built-in OpenAI provider model: ${config.model.modelId}`,
|
|
674
|
+
model: provider(config.model.modelId)
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function validateMarkdownTranslation(frontmatterStrings, value) {
|
|
678
|
+
assert(
|
|
679
|
+
isRecord(value),
|
|
680
|
+
"Generated markdown output must be an object with body and frontmatter."
|
|
681
|
+
);
|
|
682
|
+
assert(
|
|
683
|
+
typeof value.body === "string",
|
|
684
|
+
"Generated markdown output must include a string body."
|
|
685
|
+
);
|
|
686
|
+
assert(
|
|
687
|
+
isRecord(value.frontmatter),
|
|
688
|
+
"Generated markdown output must include a frontmatter object."
|
|
689
|
+
);
|
|
690
|
+
assertExactMessageShape(frontmatterStrings, value.frontmatter);
|
|
691
|
+
return {
|
|
692
|
+
body: value.body,
|
|
693
|
+
frontmatter: value.frontmatter
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
async function generateProject(options = {}) {
|
|
697
|
+
const cwd = options.cwd ?? process.cwd();
|
|
698
|
+
const logger = options.logger ?? createConsoleLogger();
|
|
699
|
+
logger.info("Loading Better Translate config...");
|
|
700
|
+
const loadedConfig = await loadCliConfig({
|
|
701
|
+
configPath: options.configPath,
|
|
702
|
+
cwd
|
|
703
|
+
});
|
|
704
|
+
const { config } = loadedConfig;
|
|
705
|
+
logger.info(`Using config: ${loadedConfig.path}`);
|
|
706
|
+
const resolvedModel = await resolveRuntimeModel(config);
|
|
707
|
+
logger.info(resolvedModel.description);
|
|
708
|
+
logger.info(`Source locale: ${config.sourceLocale}`);
|
|
709
|
+
logger.info(`Target locales: ${config.locales.join(", ")}`);
|
|
710
|
+
const generator = options.generator ?? await createDefaultGenerator(resolvedModel.model);
|
|
711
|
+
const writes = [];
|
|
712
|
+
logger.info(`Loading source messages from ${config.messages.entry}...`);
|
|
713
|
+
const sourceMessages = await loadSourceMessages(
|
|
714
|
+
config.messages.entry,
|
|
715
|
+
config.sourceLocale
|
|
716
|
+
);
|
|
717
|
+
logger.info(
|
|
718
|
+
`Loaded ${sourceMessages.keyPaths.length} translation key${sourceMessages.keyPaths.length === 1 ? "" : "s"}.`
|
|
719
|
+
);
|
|
720
|
+
const markdownSources = config.markdown ? await listMarkdownSourceFiles(
|
|
721
|
+
config.markdown.rootDir,
|
|
722
|
+
config.markdown.extensions
|
|
723
|
+
) : [];
|
|
724
|
+
if (config.markdown) {
|
|
725
|
+
logger.info(
|
|
726
|
+
`Found ${markdownSources.length} markdown file${markdownSources.length === 1 ? "" : "s"} in ${config.markdown.rootDir}.`
|
|
727
|
+
);
|
|
728
|
+
} else {
|
|
729
|
+
logger.info("Markdown generation disabled.");
|
|
730
|
+
}
|
|
731
|
+
for (const locale of config.locales) {
|
|
732
|
+
logger.info(`Starting locale "${locale}"...`);
|
|
733
|
+
const messagePrompt = createMessagesPrompt({
|
|
734
|
+
keyPaths: sourceMessages.keyPaths,
|
|
735
|
+
sourceLocale: config.sourceLocale,
|
|
736
|
+
sourceMessages: sourceMessages.messages,
|
|
737
|
+
sourceText: sourceMessages.sourceText,
|
|
738
|
+
sourcePath: sourceMessages.sourcePath,
|
|
739
|
+
targetLocale: locale
|
|
740
|
+
});
|
|
741
|
+
logger.info(`Requesting message translation for "${locale}"...`);
|
|
742
|
+
const translatedMessages = await generator({
|
|
743
|
+
kind: "messages",
|
|
744
|
+
prompt: messagePrompt.prompt,
|
|
745
|
+
schema: sourceMessages.schema,
|
|
746
|
+
sourcePath: sourceMessages.sourcePath,
|
|
747
|
+
system: messagePrompt.system,
|
|
748
|
+
targetLocale: locale,
|
|
749
|
+
validate(value) {
|
|
750
|
+
assertExactMessageShape(sourceMessages.messages, value);
|
|
751
|
+
return value;
|
|
752
|
+
}
|
|
753
|
+
}).catch((error) => {
|
|
754
|
+
throw new Error(
|
|
755
|
+
`Failed translating messages for locale "${locale}" from "${sourceMessages.sourcePath}": ${describeError(error)}`
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
const messageWrite = {
|
|
759
|
+
content: serializeMessages(
|
|
760
|
+
translatedMessages,
|
|
761
|
+
sourceMessages.format,
|
|
762
|
+
locale
|
|
763
|
+
),
|
|
764
|
+
kind: "messages",
|
|
765
|
+
locale,
|
|
766
|
+
sourcePath: sourceMessages.sourcePath,
|
|
767
|
+
targetPath: deriveTargetMessagesPath(
|
|
768
|
+
sourceMessages.sourcePath,
|
|
769
|
+
config.sourceLocale,
|
|
770
|
+
locale
|
|
771
|
+
)
|
|
772
|
+
};
|
|
773
|
+
writes.push(messageWrite);
|
|
774
|
+
logger.info(`Prepared messages:${locale} -> ${messageWrite.targetPath}`);
|
|
775
|
+
await persistWrite(messageWrite, {
|
|
776
|
+
dryRun: options.dryRun,
|
|
777
|
+
logger
|
|
778
|
+
});
|
|
779
|
+
for (const sourcePath of markdownSources) {
|
|
780
|
+
const document = await loadMarkdownDocument(
|
|
781
|
+
config.markdown.rootDir,
|
|
782
|
+
sourcePath
|
|
783
|
+
);
|
|
784
|
+
logger.info(
|
|
785
|
+
`Requesting markdown translation for "${locale}": ${document.relativePath}`
|
|
786
|
+
);
|
|
787
|
+
const markdownPrompt = createMarkdownPrompt({
|
|
788
|
+
body: document.body,
|
|
789
|
+
frontmatterStrings: document.frontmatterStrings,
|
|
790
|
+
relativePath: document.relativePath,
|
|
791
|
+
sourceLocale: config.sourceLocale,
|
|
792
|
+
sourceText: document.sourceText,
|
|
793
|
+
targetLocale: locale
|
|
794
|
+
});
|
|
795
|
+
const translatedDocument = await generator({
|
|
796
|
+
kind: "markdown",
|
|
797
|
+
prompt: markdownPrompt.prompt,
|
|
798
|
+
schema: document.schema,
|
|
799
|
+
sourcePath: document.sourcePath,
|
|
800
|
+
system: markdownPrompt.system,
|
|
801
|
+
targetLocale: locale,
|
|
802
|
+
validate(value) {
|
|
803
|
+
return validateMarkdownTranslation(
|
|
804
|
+
document.frontmatterStrings,
|
|
805
|
+
value
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}).catch((error) => {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`Failed translating markdown for locale "${locale}" from "${document.sourcePath}": ${describeError(error)}`
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
const markdownWrite = {
|
|
814
|
+
content: applyMarkdownTranslation(
|
|
815
|
+
document.frontmatter,
|
|
816
|
+
translatedDocument.frontmatter,
|
|
817
|
+
translatedDocument.body
|
|
818
|
+
),
|
|
819
|
+
kind: "markdown",
|
|
820
|
+
locale,
|
|
821
|
+
sourcePath: document.sourcePath,
|
|
822
|
+
targetPath: deriveTargetMarkdownPath(
|
|
823
|
+
config.markdown.rootDir,
|
|
824
|
+
config.sourceLocale,
|
|
825
|
+
locale,
|
|
826
|
+
document.relativePath
|
|
827
|
+
)
|
|
828
|
+
};
|
|
829
|
+
writes.push(markdownWrite);
|
|
830
|
+
logger.info(`Prepared markdown:${locale} -> ${markdownWrite.targetPath}`);
|
|
831
|
+
await persistWrite(markdownWrite, {
|
|
832
|
+
dryRun: options.dryRun,
|
|
833
|
+
logger
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
logger.info(`Finished locale "${locale}".`);
|
|
837
|
+
}
|
|
838
|
+
if (options.dryRun) {
|
|
839
|
+
logger.info("Dry run enabled. No files were written.");
|
|
840
|
+
}
|
|
841
|
+
logger.info(
|
|
842
|
+
`${options.dryRun ? "planned" : "generated"} ${writes.length} file${writes.length === 1 ? "" : "s"}.`
|
|
843
|
+
);
|
|
844
|
+
return {
|
|
845
|
+
dryRun: Boolean(options.dryRun),
|
|
846
|
+
loadedConfig,
|
|
847
|
+
writes
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export {
|
|
852
|
+
loadCliConfig,
|
|
853
|
+
generateProject
|
|
854
|
+
};
|