@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.
@@ -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 };