@lingo.dev/cli 1.0.0 → 1.0.1
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/guides/api.md +3 -3
- package/guides/index.md +1 -1
- package/guides/migrate.md +1 -1
- package/guides/setup.md +2 -2
- package/package.json +3 -4
- package/dist/bin.js +0 -108
- package/dist/flush-telemetry.js +0 -38
- package/dist/index.js +0 -3
- package/dist/renderer-DvB06WWF.js +0 -1533
- package/dist/server-BxEBM9NU.js +0 -847
- package/dist/update-C-JdpZwG.js +0 -816
package/dist/update-C-JdpZwG.js
DELETED
|
@@ -1,816 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { Context, Data, Effect, Layer } from "effect";
|
|
3
|
-
import * as fs$1 from "node:fs/promises";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import { buildIcuPlural, buildIcuSelect, computeKey, getActiveEntries, getActiveEntries as getActiveEntries$1, readLocaleFile as readLocaleFile$1 } from "@lingo.dev/spec";
|
|
6
|
-
import { parse } from "@babel/parser";
|
|
7
|
-
import _traverse from "@babel/traverse";
|
|
8
|
-
import * as fs from "node:fs";
|
|
9
|
-
import * as os from "node:os";
|
|
10
|
-
import * as cp from "node:child_process";
|
|
11
|
-
import * as ci from "ci-info";
|
|
12
|
-
import semver from "semver";
|
|
13
|
-
//#region src/services/fs.ts
|
|
14
|
-
/**
|
|
15
|
-
* Shared filesystem utilities for CLI commands.
|
|
16
|
-
*/
|
|
17
|
-
/**
|
|
18
|
-
* Reads a file, returning null if it doesn't exist.
|
|
19
|
-
* Rethrows non-ENOENT errors (permission denied, I/O errors, etc.)
|
|
20
|
-
*/
|
|
21
|
-
async function tryReadFile(filePath) {
|
|
22
|
-
try {
|
|
23
|
-
return await fs$1.readFile(filePath, "utf-8");
|
|
24
|
-
} catch (e) {
|
|
25
|
-
if (e?.code === "ENOENT") return null;
|
|
26
|
-
throw e;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Recursively finds .ts/.tsx source files, skipping node_modules, dotfiles, and .d.ts.
|
|
31
|
-
*/
|
|
32
|
-
async function findSourceFiles(dir) {
|
|
33
|
-
const files = [];
|
|
34
|
-
async function walk(d) {
|
|
35
|
-
let entries;
|
|
36
|
-
try {
|
|
37
|
-
entries = await fs$1.readdir(d, { withFileTypes: true });
|
|
38
|
-
} catch {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
for (const entry of entries) {
|
|
42
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
43
|
-
const fullPath = path.join(d, entry.name);
|
|
44
|
-
if (entry.isDirectory()) await walk(fullPath);
|
|
45
|
-
else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith(".d.ts")) files.push(fullPath);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
await walk(dir);
|
|
49
|
-
return files.sort();
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Lists locale codes from .jsonc files in a directory, excluding the source locale.
|
|
53
|
-
*/
|
|
54
|
-
async function discoverLocales(outDir, sourceLocale) {
|
|
55
|
-
let files;
|
|
56
|
-
try {
|
|
57
|
-
files = await fs$1.readdir(outDir);
|
|
58
|
-
} catch {
|
|
59
|
-
return [];
|
|
60
|
-
}
|
|
61
|
-
return files.filter((f) => f.endsWith(".jsonc") && f !== `${sourceLocale}.jsonc`).map((f) => f.replace(/\.jsonc$/, "")).sort();
|
|
62
|
-
}
|
|
63
|
-
//#endregion
|
|
64
|
-
//#region src/services/jsonc.ts
|
|
65
|
-
function formatComment(metadata) {
|
|
66
|
-
const lines = [];
|
|
67
|
-
if (metadata.context) lines.push(` * @context ${metadata.context}`);
|
|
68
|
-
if (metadata.src) lines.push(` * @src ${metadata.src}`);
|
|
69
|
-
if (metadata.orphan) lines.push(` * @orphan`);
|
|
70
|
-
if (lines.length === 0) return "";
|
|
71
|
-
return ` /*\n ${lines.join("\n ")}\n */`;
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Generates a JSONC string from locale entries with metadata comments.
|
|
75
|
-
* Entries are sorted by key for deterministic output.
|
|
76
|
-
*/
|
|
77
|
-
function writeLocaleFile(file) {
|
|
78
|
-
const sorted = [...file.entries].sort((a, b) => a.key.localeCompare(b.key));
|
|
79
|
-
if (sorted.length === 0) return "{}\n";
|
|
80
|
-
const parts = ["{"];
|
|
81
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
82
|
-
const entry = sorted[i];
|
|
83
|
-
const comment = formatComment(entry.metadata);
|
|
84
|
-
if (comment) parts.push(comment);
|
|
85
|
-
const escapedValue = JSON.stringify(entry.value);
|
|
86
|
-
const comma = i < sorted.length - 1 ? "," : "";
|
|
87
|
-
parts.push(` "${entry.key}": ${escapedValue}${comma}`);
|
|
88
|
-
if (i < sorted.length - 1) parts.push("");
|
|
89
|
-
}
|
|
90
|
-
parts.push("}\n");
|
|
91
|
-
return parts.join("\n");
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Merges newly extracted entries into an existing locale file.
|
|
95
|
-
* - Preserves existing translations (values not overwritten)
|
|
96
|
-
* - Updates metadata (context, src) from fresh extraction
|
|
97
|
-
* - Marks keys present in existing but absent from extracted as @orphan
|
|
98
|
-
* - Adds new keys from extraction
|
|
99
|
-
*/
|
|
100
|
-
function mergeEntries(existing, extracted) {
|
|
101
|
-
const extractedByKey = new Map(extracted.map((e) => [e.key, e]));
|
|
102
|
-
const existingByKey = new Map(existing.entries.map((e) => [e.key, e]));
|
|
103
|
-
const merged = [];
|
|
104
|
-
for (const entry of existing.entries) {
|
|
105
|
-
const fresh = extractedByKey.get(entry.key);
|
|
106
|
-
if (fresh) {
|
|
107
|
-
const metadata = { ...fresh.metadata };
|
|
108
|
-
delete metadata.orphan;
|
|
109
|
-
merged.push({
|
|
110
|
-
key: entry.key,
|
|
111
|
-
value: entry.value,
|
|
112
|
-
metadata
|
|
113
|
-
});
|
|
114
|
-
} else merged.push({
|
|
115
|
-
key: entry.key,
|
|
116
|
-
value: entry.value,
|
|
117
|
-
metadata: {
|
|
118
|
-
...entry.metadata,
|
|
119
|
-
orphan: true
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
for (const entry of extracted) if (!existingByKey.has(entry.key)) merged.push(entry);
|
|
124
|
-
return { entries: merged };
|
|
125
|
-
}
|
|
126
|
-
//#endregion
|
|
127
|
-
//#region src/services/extractor.ts
|
|
128
|
-
/**
|
|
129
|
-
* AST-based extraction of translatable strings from TypeScript/TSX source files.
|
|
130
|
-
* Uses Babel to parse and traverse, extracting l.text(), l.jsx(), l.plural(),
|
|
131
|
-
* and l.select() calls with source text, context, and source location.
|
|
132
|
-
*
|
|
133
|
-
* Scope rules:
|
|
134
|
-
* - Tracks variables assigned from useLingo() or createLingo()
|
|
135
|
-
* - Always matches `l` as a heuristic fallback (convention: `const l = useLingo()`)
|
|
136
|
-
* - Ignores calls on any other objects (no false positives from document.text(), etc.)
|
|
137
|
-
* - Warns on destructured usage: `const { text } = useLingo()`
|
|
138
|
-
*/
|
|
139
|
-
const traverse = typeof _traverse === "function" ? _traverse : _traverse.default;
|
|
140
|
-
/** Methods where the first arg is the source string */
|
|
141
|
-
const TEXT_METHODS = new Set(["text", "jsx"]);
|
|
142
|
-
/** Methods where the second arg is a forms object that becomes the ICU source */
|
|
143
|
-
const FORMS_METHODS = new Set(["plural", "select"]);
|
|
144
|
-
const ALL_METHODS = new Set([...TEXT_METHODS, ...FORMS_METHODS]);
|
|
145
|
-
/** Functions that return a Lingo object */
|
|
146
|
-
const LINGO_FACTORIES = new Set(["useLingo", "createLingo"]);
|
|
147
|
-
function getLingoMethodName(node, lingoVars) {
|
|
148
|
-
const callee = node.callee;
|
|
149
|
-
if (callee.type !== "MemberExpression") return null;
|
|
150
|
-
if (callee.object.type !== "Identifier" || !lingoVars.has(callee.object.name)) return null;
|
|
151
|
-
if (callee.property.type !== "Identifier") return null;
|
|
152
|
-
const name = callee.property.name;
|
|
153
|
-
return ALL_METHODS.has(name) ? name : null;
|
|
154
|
-
}
|
|
155
|
-
function extractStaticString(node) {
|
|
156
|
-
if (node.type === "StringLiteral") return node.value;
|
|
157
|
-
if (node.type === "TemplateLiteral" && node.expressions.length === 0 && node.quasis.length === 1) return node.quasis[0].value.cooked ?? node.quasis[0].value.raw;
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
function extractContextFromOptions(node) {
|
|
161
|
-
if (!node || node.type !== "ObjectExpression") return void 0;
|
|
162
|
-
for (const prop of node.properties) if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "context" && (prop.value.type === "StringLiteral" || prop.value.type === "TemplateLiteral")) return extractStaticString(prop.value) ?? void 0;
|
|
163
|
-
}
|
|
164
|
-
function extractFormsObject(node) {
|
|
165
|
-
if (node.type !== "ObjectExpression") return null;
|
|
166
|
-
const forms = {};
|
|
167
|
-
for (const prop of node.properties) {
|
|
168
|
-
if (prop.type !== "ObjectProperty") return null;
|
|
169
|
-
const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.type === "StringLiteral" ? prop.key.value : null;
|
|
170
|
-
if (key === null) return null;
|
|
171
|
-
const value = extractStaticString(prop.value);
|
|
172
|
-
if (value === null) return null;
|
|
173
|
-
forms[key] = value;
|
|
174
|
-
}
|
|
175
|
-
return forms;
|
|
176
|
-
}
|
|
177
|
-
function formatSrc(filePath, line) {
|
|
178
|
-
return line ? `${filePath}:${line}` : filePath;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Extracts translatable messages from a single source file's code string.
|
|
182
|
-
* Pure function - no filesystem access.
|
|
183
|
-
*/
|
|
184
|
-
function extractMessages(code, filePath) {
|
|
185
|
-
const ast = parse(code, {
|
|
186
|
-
sourceType: "module",
|
|
187
|
-
plugins: ["typescript", "jsx"],
|
|
188
|
-
errorRecovery: true
|
|
189
|
-
});
|
|
190
|
-
const messages = [];
|
|
191
|
-
const warnings = [];
|
|
192
|
-
const lingoVars = new Set(["l"]);
|
|
193
|
-
traverse(ast, {
|
|
194
|
-
VariableDeclarator(path) {
|
|
195
|
-
const init = path.node.init;
|
|
196
|
-
if (!init || init.type !== "CallExpression") return;
|
|
197
|
-
const callee = init.callee;
|
|
198
|
-
if (callee.type !== "Identifier" || !LINGO_FACTORIES.has(callee.name)) return;
|
|
199
|
-
if (path.node.id.type === "Identifier") lingoVars.add(path.node.id.name);
|
|
200
|
-
else if (path.node.id.type === "ObjectPattern") {
|
|
201
|
-
const src = formatSrc(filePath, path.node.loc?.start.line);
|
|
202
|
-
warnings.push({
|
|
203
|
-
src,
|
|
204
|
-
message: "Destructured usage of useLingo() cannot be statically extracted. Use `const l = useLingo()` instead."
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
},
|
|
208
|
-
CallExpression(path) {
|
|
209
|
-
const methodName = getLingoMethodName(path.node, lingoVars);
|
|
210
|
-
if (!methodName) return;
|
|
211
|
-
const src = formatSrc(filePath, path.node.loc?.start.line);
|
|
212
|
-
if (TEXT_METHODS.has(methodName)) {
|
|
213
|
-
const firstArg = path.node.arguments[0];
|
|
214
|
-
if (!firstArg) return;
|
|
215
|
-
const source = extractStaticString(firstArg);
|
|
216
|
-
if (source === null) {
|
|
217
|
-
warnings.push({
|
|
218
|
-
src,
|
|
219
|
-
message: `Dynamic expression in l.${methodName}() cannot be statically extracted`
|
|
220
|
-
});
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const context = extractContextFromOptions(path.node.arguments[1]);
|
|
224
|
-
if (!context) warnings.push({
|
|
225
|
-
src,
|
|
226
|
-
message: `Missing context in l.${methodName}(). Provide { context: "..." } for type safety and better translations.`
|
|
227
|
-
});
|
|
228
|
-
messages.push({
|
|
229
|
-
key: computeKey(source, context),
|
|
230
|
-
source,
|
|
231
|
-
context,
|
|
232
|
-
src
|
|
233
|
-
});
|
|
234
|
-
} else {
|
|
235
|
-
const formsArg = path.node.arguments[1];
|
|
236
|
-
if (!formsArg) return;
|
|
237
|
-
const forms = extractFormsObject(formsArg);
|
|
238
|
-
if (forms === null) {
|
|
239
|
-
warnings.push({
|
|
240
|
-
src,
|
|
241
|
-
message: `Dynamic forms in l.${methodName}() cannot be statically extracted`
|
|
242
|
-
});
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const source = methodName === "plural" ? buildIcuPlural(forms) : buildIcuSelect(forms);
|
|
246
|
-
const context = extractContextFromOptions(path.node.arguments[2]);
|
|
247
|
-
if (!context) warnings.push({
|
|
248
|
-
src,
|
|
249
|
-
message: `Missing context in l.${methodName}(). Provide { context: "..." } as third argument.`
|
|
250
|
-
});
|
|
251
|
-
messages.push({
|
|
252
|
-
key: computeKey(source, context),
|
|
253
|
-
source,
|
|
254
|
-
context,
|
|
255
|
-
src
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
return {
|
|
261
|
-
messages,
|
|
262
|
-
warnings
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Detects hash collisions: different (source, context) pairs that produce the same key.
|
|
267
|
-
* Same source + same context appearing in multiple files is NOT a collision.
|
|
268
|
-
* Call this after extracting all messages from a project, before writing locale files.
|
|
269
|
-
*/
|
|
270
|
-
function detectCollisions(messages) {
|
|
271
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
272
|
-
for (const msg of messages) {
|
|
273
|
-
const group = byKey.get(msg.key);
|
|
274
|
-
if (group) group.push(msg);
|
|
275
|
-
else byKey.set(msg.key, [msg]);
|
|
276
|
-
}
|
|
277
|
-
const collisions = [];
|
|
278
|
-
for (const [key, entries] of byKey) {
|
|
279
|
-
const unique = /* @__PURE__ */ new Map();
|
|
280
|
-
for (const e of entries) {
|
|
281
|
-
const identity = `${e.source}\0${e.context ?? ""}`;
|
|
282
|
-
if (!unique.has(identity)) unique.set(identity, e);
|
|
283
|
-
}
|
|
284
|
-
if (unique.size <= 1) continue;
|
|
285
|
-
collisions.push({
|
|
286
|
-
key,
|
|
287
|
-
entries: [...unique.values()].map((e) => ({
|
|
288
|
-
source: e.source,
|
|
289
|
-
context: e.context,
|
|
290
|
-
src: e.src
|
|
291
|
-
}))
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
return collisions;
|
|
295
|
-
}
|
|
296
|
-
//#endregion
|
|
297
|
-
//#region src/services/pipeline.ts
|
|
298
|
-
/**
|
|
299
|
-
* Extraction pipeline orchestration.
|
|
300
|
-
* Runs AST extraction across source files, detects collisions,
|
|
301
|
-
* and produces structured results for JSONC writing and type generation.
|
|
302
|
-
*/
|
|
303
|
-
/**
|
|
304
|
-
* Runs the extraction pipeline across multiple source files.
|
|
305
|
-
* Pure function - takes source code strings, returns structured results.
|
|
306
|
-
*
|
|
307
|
-
* The caller is responsible for:
|
|
308
|
-
* - Reading source files from disk
|
|
309
|
-
* - Writing JSONC/JSON output
|
|
310
|
-
* - Displaying warnings and errors
|
|
311
|
-
*/
|
|
312
|
-
function runExtractionPipeline(sourceFiles) {
|
|
313
|
-
const messages = [];
|
|
314
|
-
const warnings = [];
|
|
315
|
-
for (const { code, filePath } of sourceFiles) {
|
|
316
|
-
const result = extractMessages(code, filePath);
|
|
317
|
-
messages.push(...result.messages);
|
|
318
|
-
warnings.push(...result.warnings);
|
|
319
|
-
}
|
|
320
|
-
return {
|
|
321
|
-
messages,
|
|
322
|
-
warnings,
|
|
323
|
-
collisions: detectCollisions(messages)
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Converts extracted messages to locale entries for JSONC writing.
|
|
328
|
-
* Deduplicates by key (same message in multiple files produces one entry).
|
|
329
|
-
*/
|
|
330
|
-
function toLocaleEntries(messages) {
|
|
331
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
332
|
-
for (const msg of messages) {
|
|
333
|
-
if (byKey.has(msg.key)) continue;
|
|
334
|
-
byKey.set(msg.key, {
|
|
335
|
-
key: msg.key,
|
|
336
|
-
value: msg.source,
|
|
337
|
-
metadata: {
|
|
338
|
-
...msg.context ? { context: msg.context } : {},
|
|
339
|
-
src: msg.src
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
return [...byKey.values()];
|
|
344
|
-
}
|
|
345
|
-
//#endregion
|
|
346
|
-
//#region src/services/typegen.ts
|
|
347
|
-
function icuTypeToTsType(icuType) {
|
|
348
|
-
switch (icuType) {
|
|
349
|
-
case "plural":
|
|
350
|
-
case "selectordinal":
|
|
351
|
-
case "number": return "number";
|
|
352
|
-
case "date":
|
|
353
|
-
case "time": return "Date | number";
|
|
354
|
-
case "select": return "string";
|
|
355
|
-
default: return "string | number";
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Extracts top-level ICU placeholders from a template string.
|
|
360
|
-
* Handles nested braces (plural/select alternatives) and ICU quote escaping.
|
|
361
|
-
*
|
|
362
|
-
* Examples:
|
|
363
|
-
* - "{name}" → [{ name: "name", tsType: "string | number" }]
|
|
364
|
-
* - "{count, plural, one {# item} other {# items}}" → [{ name: "count", tsType: "number" }]
|
|
365
|
-
*/
|
|
366
|
-
function extractPlaceholders(template) {
|
|
367
|
-
const placeholders = /* @__PURE__ */ new Map();
|
|
368
|
-
let depth = 0;
|
|
369
|
-
let start = -1;
|
|
370
|
-
for (let i = 0; i < template.length; i++) {
|
|
371
|
-
const ch = template[i];
|
|
372
|
-
if (ch === "'") {
|
|
373
|
-
const end = template.indexOf("'", i + 1);
|
|
374
|
-
if (end > i) i = end;
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
if (ch === "{") {
|
|
378
|
-
if (depth === 0) start = i;
|
|
379
|
-
depth++;
|
|
380
|
-
} else if (ch === "}") {
|
|
381
|
-
depth--;
|
|
382
|
-
if (depth === 0 && start >= 0) {
|
|
383
|
-
const content = template.slice(start + 1, i);
|
|
384
|
-
const commaIdx = content.indexOf(",");
|
|
385
|
-
const name = (commaIdx >= 0 ? content.slice(0, commaIdx) : content).trim();
|
|
386
|
-
const icuType = commaIdx >= 0 ? content.slice(commaIdx + 1).trim().split(/[\s,]/)[0] : void 0;
|
|
387
|
-
if (name && /^\w+$/.test(name) && !placeholders.has(name)) placeholders.set(name, {
|
|
388
|
-
name,
|
|
389
|
-
tsType: icuTypeToTsType(icuType)
|
|
390
|
-
});
|
|
391
|
-
start = -1;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return [...placeholders.values()];
|
|
396
|
-
}
|
|
397
|
-
function escapeForTs(s) {
|
|
398
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
|
|
399
|
-
}
|
|
400
|
-
/**
|
|
401
|
-
* Generates a TypeScript declaration file from a source locale file.
|
|
402
|
-
* The output augments @lingo.dev/react's LingoMessages interface via
|
|
403
|
-
* `declare module` so that l.text() and l.jsx() get:
|
|
404
|
-
*
|
|
405
|
-
* 1. Autocomplete on source strings
|
|
406
|
-
* 2. Compile errors for typos
|
|
407
|
-
* 3. Required values validation per message
|
|
408
|
-
* 4. Required context enforcement with exact union narrowing
|
|
409
|
-
*
|
|
410
|
-
* Entries without context are skipped - they fail `lingo check` instead.
|
|
411
|
-
* Output is deterministic (same input = same output, safe to commit).
|
|
412
|
-
*/
|
|
413
|
-
function generateTypes(file) {
|
|
414
|
-
const sources = /* @__PURE__ */ new Map();
|
|
415
|
-
for (const entry of file.entries) {
|
|
416
|
-
if (entry.metadata.orphan) continue;
|
|
417
|
-
const existing = sources.get(entry.value);
|
|
418
|
-
if (!existing) sources.set(entry.value, {
|
|
419
|
-
placeholders: extractPlaceholders(entry.value),
|
|
420
|
-
contexts: new Set(entry.metadata.context ? [entry.metadata.context] : [])
|
|
421
|
-
});
|
|
422
|
-
else if (entry.metadata.context) existing.contexts.add(entry.metadata.context);
|
|
423
|
-
}
|
|
424
|
-
const sorted = [...sources.entries()].filter(([, data]) => data.contexts.size > 0).sort(([a], [b]) => a.localeCompare(b));
|
|
425
|
-
const lines = [
|
|
426
|
-
"// Generated by lingo - do not edit",
|
|
427
|
-
"export {};",
|
|
428
|
-
"",
|
|
429
|
-
"declare module \"@lingo.dev/react\" {",
|
|
430
|
-
" interface LingoMessages {"
|
|
431
|
-
];
|
|
432
|
-
for (const [source, { placeholders, contexts }] of sorted) {
|
|
433
|
-
const escaped = escapeForTs(source);
|
|
434
|
-
const contextUnion = [...contexts].sort().map((c) => `"${escapeForTs(c)}"`).join(" | ");
|
|
435
|
-
const fields = [];
|
|
436
|
-
if (placeholders.length > 0) {
|
|
437
|
-
const valueFields = placeholders.map((p) => `${p.name}: ${p.tsType}`).join("; ");
|
|
438
|
-
fields.push(`values: { ${valueFields} }`);
|
|
439
|
-
}
|
|
440
|
-
fields.push(`context: ${contextUnion}`);
|
|
441
|
-
lines.push(` "${escaped}": { ${fields.join("; ")} };`);
|
|
442
|
-
}
|
|
443
|
-
lines.push(" }");
|
|
444
|
-
lines.push("}");
|
|
445
|
-
lines.push("");
|
|
446
|
-
return lines.join("\n");
|
|
447
|
-
}
|
|
448
|
-
//#endregion
|
|
449
|
-
//#region src/services/localize.ts
|
|
450
|
-
/**
|
|
451
|
-
* Localization planning: diffs source locale against target locales
|
|
452
|
-
* to find missing translations, and converts between JSONC entries
|
|
453
|
-
* and the processing API's data format.
|
|
454
|
-
*/
|
|
455
|
-
/**
|
|
456
|
-
* Computes what needs to be localized: source entries that don't exist
|
|
457
|
-
* in the target file. Orphaned entries are excluded from both sides.
|
|
458
|
-
*/
|
|
459
|
-
function planLocalization(sourceFile, targetFile, targetLocale) {
|
|
460
|
-
const targetKeys = new Set(getActiveEntries(targetFile.entries).map((e) => e.key));
|
|
461
|
-
return {
|
|
462
|
-
targetLocale,
|
|
463
|
-
missing: getActiveEntries(sourceFile.entries).filter((e) => !targetKeys.has(e.key)),
|
|
464
|
-
existing: getActiveEntries(targetFile.entries).length
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Converts locale entries to the processing API's data + hints format.
|
|
469
|
-
* Hints include @context and @src metadata for translation quality.
|
|
470
|
-
*/
|
|
471
|
-
function toApiPayload(entries) {
|
|
472
|
-
const data = {};
|
|
473
|
-
const hints = {};
|
|
474
|
-
for (const entry of entries) {
|
|
475
|
-
data[entry.key] = entry.value;
|
|
476
|
-
const h = [];
|
|
477
|
-
if (entry.metadata.context) h.push(`context: ${entry.metadata.context}`);
|
|
478
|
-
if (entry.metadata.src) h.push(`src: ${entry.metadata.src}`);
|
|
479
|
-
if (h.length > 0) hints[entry.key] = h;
|
|
480
|
-
}
|
|
481
|
-
return {
|
|
482
|
-
data,
|
|
483
|
-
hints
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* Merges API response translations into locale entries,
|
|
488
|
-
* preserving source metadata (context, src) on the new entries.
|
|
489
|
-
*/
|
|
490
|
-
function applyTranslations(missing, translated) {
|
|
491
|
-
return missing.map((entry) => ({
|
|
492
|
-
key: entry.key,
|
|
493
|
-
value: translated[entry.key] ?? entry.value,
|
|
494
|
-
metadata: { ...entry.metadata }
|
|
495
|
-
}));
|
|
496
|
-
}
|
|
497
|
-
//#endregion
|
|
498
|
-
//#region src/services/status.ts
|
|
499
|
-
/**
|
|
500
|
-
* Translation completeness reporting.
|
|
501
|
-
* Compares target locale files against the source locale to compute
|
|
502
|
-
* per-locale stats: total, translated, missing, orphaned.
|
|
503
|
-
*/
|
|
504
|
-
/**
|
|
505
|
-
* Computes translation completeness for a single target locale
|
|
506
|
-
* by comparing its keys against the source locale.
|
|
507
|
-
*/
|
|
508
|
-
function computeLocaleStatus(sourceFile, targetFile, locale) {
|
|
509
|
-
const sourceKeys = new Set(getActiveEntries(sourceFile.entries).map((e) => e.key));
|
|
510
|
-
const targetKeys = new Set(getActiveEntries(targetFile.entries).map((e) => e.key));
|
|
511
|
-
const total = sourceKeys.size;
|
|
512
|
-
const translated = [...sourceKeys].filter((k) => targetKeys.has(k)).length;
|
|
513
|
-
return {
|
|
514
|
-
locale,
|
|
515
|
-
total,
|
|
516
|
-
translated,
|
|
517
|
-
missing: total - translated,
|
|
518
|
-
orphaned: targetFile.entries.filter((e) => e.metadata.orphan).length
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
/**
|
|
522
|
-
* Computes completeness for the source locale itself
|
|
523
|
-
* (always 100% translated, but may have orphans).
|
|
524
|
-
*/
|
|
525
|
-
function computeSourceStatus(sourceFile, locale) {
|
|
526
|
-
const active = getActiveEntries(sourceFile.entries);
|
|
527
|
-
const orphaned = sourceFile.entries.length - active.length;
|
|
528
|
-
return {
|
|
529
|
-
locale,
|
|
530
|
-
total: active.length,
|
|
531
|
-
translated: active.length,
|
|
532
|
-
missing: 0,
|
|
533
|
-
orphaned
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Formats a status report as a human-readable table.
|
|
538
|
-
*/
|
|
539
|
-
function formatStatusTable(statuses) {
|
|
540
|
-
return [
|
|
541
|
-
"Locale Total Translated Missing Orphaned",
|
|
542
|
-
"-".repeat(48),
|
|
543
|
-
...statuses.map((s) => `${s.locale.padEnd(9)}${String(s.total).padEnd(8)}${String(s.translated).padEnd(13)}${String(s.missing).padEnd(10)}${s.orphaned}`)
|
|
544
|
-
].join("\n");
|
|
545
|
-
}
|
|
546
|
-
//#endregion
|
|
547
|
-
//#region src/services/check.ts
|
|
548
|
-
/**
|
|
549
|
-
* Extracts missing-context issues from extraction warnings.
|
|
550
|
-
*/
|
|
551
|
-
function checkMissingContext(warnings) {
|
|
552
|
-
return warnings.filter((w) => w.message.includes("Missing context")).map((w) => ({
|
|
553
|
-
type: "missing-context",
|
|
554
|
-
src: w.src,
|
|
555
|
-
message: w.message
|
|
556
|
-
}));
|
|
557
|
-
}
|
|
558
|
-
/**
|
|
559
|
-
* Compares generated JSONC/types with existing files on disk.
|
|
560
|
-
*/
|
|
561
|
-
function checkStaleness(generatedJsonc, existingJsonc, generatedTypes, existingTypes) {
|
|
562
|
-
const issues = [];
|
|
563
|
-
if (generatedJsonc !== existingJsonc) issues.push({
|
|
564
|
-
type: "stale-jsonc",
|
|
565
|
-
message: "Source locale file is stale. Run `lingo extract` to update."
|
|
566
|
-
});
|
|
567
|
-
if (generatedTypes !== existingTypes) issues.push({
|
|
568
|
-
type: "stale-types",
|
|
569
|
-
message: "lingo.d.ts is stale. Run `lingo extract` to update."
|
|
570
|
-
});
|
|
571
|
-
return issues;
|
|
572
|
-
}
|
|
573
|
-
/**
|
|
574
|
-
* Checks target locale files for missing translations.
|
|
575
|
-
*/
|
|
576
|
-
function checkTranslations(sourceFile, targetFiles) {
|
|
577
|
-
const issues = [];
|
|
578
|
-
const activeSourceKeys = new Set(sourceFile.entries.filter((e) => !e.metadata.orphan).map((e) => e.key));
|
|
579
|
-
for (const { locale, file } of targetFiles) {
|
|
580
|
-
const translatedKeys = new Set(file.entries.filter((e) => !e.metadata.orphan && e.value).map((e) => e.key));
|
|
581
|
-
const missing = [...activeSourceKeys].filter((k) => !translatedKeys.has(k));
|
|
582
|
-
if (missing.length > 0) issues.push({
|
|
583
|
-
type: "missing-translation",
|
|
584
|
-
message: `${locale}: ${missing.length}/${activeSourceKeys.size} messages missing`
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
return issues;
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Counts orphaned entries in the source locale file.
|
|
591
|
-
*/
|
|
592
|
-
function checkOrphaned(sourceFile) {
|
|
593
|
-
const orphaned = sourceFile.entries.filter((e) => e.metadata.orphan);
|
|
594
|
-
if (orphaned.length === 0) return [];
|
|
595
|
-
return [{
|
|
596
|
-
type: "orphaned",
|
|
597
|
-
message: `${orphaned.length} orphaned entries in source locale. Run \`lingo extract\` to mark, \`lingo cleanup\` to remove.`
|
|
598
|
-
}];
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Runs all checks and returns a unified result.
|
|
602
|
-
*/
|
|
603
|
-
function runChecks(params) {
|
|
604
|
-
const contextIssues = checkMissingContext(params.warnings);
|
|
605
|
-
const stalenessIssues = checkStaleness(params.generatedJsonc, params.existingJsonc, params.generatedTypes, params.existingTypes);
|
|
606
|
-
const translationIssues = checkTranslations(params.sourceFile, params.targetFiles);
|
|
607
|
-
const orphanIssues = checkOrphaned(params.sourceFile);
|
|
608
|
-
return {
|
|
609
|
-
issues: [
|
|
610
|
-
...contextIssues,
|
|
611
|
-
...stalenessIssues,
|
|
612
|
-
...translationIssues,
|
|
613
|
-
...orphanIssues
|
|
614
|
-
],
|
|
615
|
-
stats: {
|
|
616
|
-
messages: params.messageCount,
|
|
617
|
-
locales: params.targetFiles.length + 1,
|
|
618
|
-
missingContext: contextIssues.length,
|
|
619
|
-
missingTranslations: translationIssues.reduce((sum, i) => {
|
|
620
|
-
const match = i.message.match(/(\d+)\//);
|
|
621
|
-
return sum + (match ? parseInt(match[1]) : 0);
|
|
622
|
-
}, 0),
|
|
623
|
-
orphaned: params.sourceFile.entries.filter((e) => e.metadata.orphan).length,
|
|
624
|
-
staleJsonc: stalenessIssues.some((i) => i.type === "stale-jsonc"),
|
|
625
|
-
staleTypes: stalenessIssues.some((i) => i.type === "stale-types")
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Formats check results for human-readable CLI output.
|
|
631
|
-
*/
|
|
632
|
-
function formatCheckResult(result) {
|
|
633
|
-
const lines = [];
|
|
634
|
-
if (result.issues.length === 0) {
|
|
635
|
-
lines.push(` All checks passed. ${result.stats.messages} messages, ${result.stats.locales} locale(s).`);
|
|
636
|
-
return lines.join("\n");
|
|
637
|
-
}
|
|
638
|
-
for (const issue of result.issues) {
|
|
639
|
-
const prefix = issue.src ? `${issue.src} - ` : "";
|
|
640
|
-
lines.push(` ${prefix}${issue.message}`);
|
|
641
|
-
}
|
|
642
|
-
lines.push("");
|
|
643
|
-
lines.push(` ${result.issues.length} issue(s) found.`);
|
|
644
|
-
return lines.join("\n");
|
|
645
|
-
}
|
|
646
|
-
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
647
|
-
//#endregion
|
|
648
|
-
//#region src/services/update.ts
|
|
649
|
-
var UpdateError = class extends Data.TaggedError("UpdateError") {};
|
|
650
|
-
var UpdateService = class extends Context.Tag("UpdateService")() {};
|
|
651
|
-
function detectInstallMethod() {
|
|
652
|
-
if (process.env.npm_lifecycle_event === "npx" || process.env.npm_execpath?.includes("npx") || process.env.npm_execpath?.includes("dlx")) return { type: "npx" };
|
|
653
|
-
const dir = typeof __dirname !== "undefined" ? __dirname : path.dirname(new URL(import.meta.url).pathname);
|
|
654
|
-
if (dir.includes(".bun/install/global")) return { type: "bun-global" };
|
|
655
|
-
if (dir.includes("pnpm/global") || dir.includes(".pnpm-global")) return { type: "pnpm-global" };
|
|
656
|
-
if (dir.includes(".config/yarn/global") || dir.includes("/yarn/global/")) return { type: "yarn-global" };
|
|
657
|
-
if (dir.includes("/lib/node_modules/")) return { type: "npm-global" };
|
|
658
|
-
const pm = detectLocalPm();
|
|
659
|
-
if (pm) return {
|
|
660
|
-
type: "local",
|
|
661
|
-
pm
|
|
662
|
-
};
|
|
663
|
-
return { type: "unknown" };
|
|
664
|
-
}
|
|
665
|
-
function detectLocalPm() {
|
|
666
|
-
const lockfiles = [
|
|
667
|
-
["pnpm-lock.yaml", "pnpm"],
|
|
668
|
-
["bun.lockb", "bun"],
|
|
669
|
-
["bun.lock", "bun"],
|
|
670
|
-
["yarn.lock", "yarn"],
|
|
671
|
-
["package-lock.json", "npm"]
|
|
672
|
-
];
|
|
673
|
-
let dir = process.cwd();
|
|
674
|
-
while (true) {
|
|
675
|
-
for (const [file, pm] of lockfiles) try {
|
|
676
|
-
fs.accessSync(path.join(dir, file));
|
|
677
|
-
return pm;
|
|
678
|
-
} catch {}
|
|
679
|
-
const parent = path.dirname(dir);
|
|
680
|
-
if (parent === dir) return null;
|
|
681
|
-
dir = parent;
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
function compareVersions(current, latest) {
|
|
685
|
-
if (!semver.gt(latest, current)) return null;
|
|
686
|
-
return {
|
|
687
|
-
current,
|
|
688
|
-
latest,
|
|
689
|
-
isMajor: semver.major(latest) > semver.major(current)
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
const PACKAGE_NAME = "@lingo.dev/cli";
|
|
693
|
-
const CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
|
|
694
|
-
const CHECK_TIMEOUT_MS = 3e3;
|
|
695
|
-
function cacheFile() {
|
|
696
|
-
return path.join(os.homedir(), ".lingo", "update-check.json");
|
|
697
|
-
}
|
|
698
|
-
function readCache() {
|
|
699
|
-
try {
|
|
700
|
-
return JSON.parse(fs.readFileSync(cacheFile(), "utf-8"));
|
|
701
|
-
} catch {
|
|
702
|
-
return null;
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
function writeCache(cache) {
|
|
706
|
-
try {
|
|
707
|
-
const dir = path.join(os.homedir(), ".lingo");
|
|
708
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
709
|
-
fs.writeFileSync(cacheFile(), JSON.stringify(cache));
|
|
710
|
-
} catch {}
|
|
711
|
-
}
|
|
712
|
-
function shouldSkipCheck() {
|
|
713
|
-
if (ci.isCI) return true;
|
|
714
|
-
if (process.env.NO_UPDATE_NOTIFIER === "1") return true;
|
|
715
|
-
if (process.env.LINGO_DISABLE_UPDATE_CHECK === "1") return true;
|
|
716
|
-
return false;
|
|
717
|
-
}
|
|
718
|
-
function fetchLatestVersion() {
|
|
719
|
-
return Effect.catchAll(Effect.tryPromise({
|
|
720
|
-
try: async () => {
|
|
721
|
-
const controller = new AbortController();
|
|
722
|
-
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
|
723
|
-
try {
|
|
724
|
-
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
725
|
-
headers: { Accept: "application/json" },
|
|
726
|
-
signal: controller.signal
|
|
727
|
-
});
|
|
728
|
-
if (!res.ok) return null;
|
|
729
|
-
return (await res.json()).version ?? null;
|
|
730
|
-
} finally {
|
|
731
|
-
clearTimeout(timeout);
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
catch: (e) => e
|
|
735
|
-
}), () => Effect.succeed(null));
|
|
736
|
-
}
|
|
737
|
-
function checkForUpdate() {
|
|
738
|
-
return Effect.gen(function* () {
|
|
739
|
-
if (shouldSkipCheck()) return null;
|
|
740
|
-
const cached = readCache();
|
|
741
|
-
if (cached && Date.now() - cached.lastCheck < CHECK_INTERVAL_MS) return compareVersions(VERSION, cached.latest);
|
|
742
|
-
const latest = yield* fetchLatestVersion();
|
|
743
|
-
if (!latest) return cached ? compareVersions(VERSION, cached.latest) : null;
|
|
744
|
-
writeCache({
|
|
745
|
-
lastCheck: Date.now(),
|
|
746
|
-
latest
|
|
747
|
-
});
|
|
748
|
-
return compareVersions(VERSION, latest);
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
function generateUpdateCommand(method, version) {
|
|
752
|
-
const pkg = version ? `${PACKAGE_NAME}@${version}` : `${PACKAGE_NAME}@latest`;
|
|
753
|
-
switch (method.type) {
|
|
754
|
-
case "npm-global": return [
|
|
755
|
-
"npm",
|
|
756
|
-
"install",
|
|
757
|
-
"-g",
|
|
758
|
-
pkg
|
|
759
|
-
];
|
|
760
|
-
case "pnpm-global": return [
|
|
761
|
-
"pnpm",
|
|
762
|
-
"add",
|
|
763
|
-
"-g",
|
|
764
|
-
pkg
|
|
765
|
-
];
|
|
766
|
-
case "bun-global": return [
|
|
767
|
-
"bun",
|
|
768
|
-
"add",
|
|
769
|
-
"-g",
|
|
770
|
-
pkg
|
|
771
|
-
];
|
|
772
|
-
case "yarn-global": return [
|
|
773
|
-
"yarn",
|
|
774
|
-
"global",
|
|
775
|
-
"add",
|
|
776
|
-
pkg
|
|
777
|
-
];
|
|
778
|
-
case "local": {
|
|
779
|
-
const addCmd = method.pm === "yarn" ? "add" : method.pm === "npm" ? "install" : "add";
|
|
780
|
-
const devFlag = method.pm === "npm" ? "--save-dev" : "-D";
|
|
781
|
-
return [
|
|
782
|
-
method.pm,
|
|
783
|
-
addCmd,
|
|
784
|
-
devFlag,
|
|
785
|
-
pkg
|
|
786
|
-
];
|
|
787
|
-
}
|
|
788
|
-
case "npx":
|
|
789
|
-
case "unknown": return null;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
function executeUpdate(method, version) {
|
|
793
|
-
const command = generateUpdateCommand(method, version);
|
|
794
|
-
if (!command) return Effect.fail(new UpdateError({ message: method.type === "npx" ? `Running via npx. Use: npx ${PACKAGE_NAME}@latest` : `Could not detect package manager. Update manually:\n npm i -g ${PACKAGE_NAME}@latest\n pnpm add -g ${PACKAGE_NAME}@latest\n bun add -g ${PACKAGE_NAME}@latest` }));
|
|
795
|
-
return Effect.tryPromise({
|
|
796
|
-
try: () => new Promise((resolve, reject) => {
|
|
797
|
-
const child = cp.spawn(command[0], command.slice(1), {
|
|
798
|
-
stdio: "inherit",
|
|
799
|
-
shell: process.platform === "win32"
|
|
800
|
-
});
|
|
801
|
-
child.on("close", (code) => {
|
|
802
|
-
if (code === 0) resolve();
|
|
803
|
-
else reject(/* @__PURE__ */ new Error(`Process exited with code ${code}`));
|
|
804
|
-
});
|
|
805
|
-
child.on("error", reject);
|
|
806
|
-
}),
|
|
807
|
-
catch: (e) => new UpdateError({ message: `Update failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
808
|
-
});
|
|
809
|
-
}
|
|
810
|
-
const UpdateServiceLive = Layer.succeed(UpdateService, {
|
|
811
|
-
check: checkForUpdate(),
|
|
812
|
-
execute: (version) => executeUpdate(detectInstallMethod(), version),
|
|
813
|
-
installMethod: detectInstallMethod()
|
|
814
|
-
});
|
|
815
|
-
//#endregion
|
|
816
|
-
export { discoverLocales as C, writeLocaleFile as S, tryReadFile as T, runExtractionPipeline as _, detectInstallMethod as a, mergeEntries as b, formatCheckResult as c, computeSourceStatus as d, formatStatusTable as f, generateTypes as g, toApiPayload as h, compareVersions as i, runChecks as l, planLocalization as m, UpdateService as n, generateUpdateCommand as o, applyTranslations as p, UpdateServiceLive as r, VERSION as s, UpdateError as t, computeLocaleStatus as u, toLocaleEntries as v, findSourceFiles as w, readLocaleFile$1 as x, getActiveEntries$1 as y };
|