@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,847 +0,0 @@
1
- import { C as discoverLocales, S as writeLocaleFile, T as tryReadFile, _ as runExtractionPipeline, a as detectInstallMethod, b as mergeEntries, d as computeSourceStatus, g as generateTypes, h as toApiPayload, i as compareVersions, l as runChecks, m as planLocalization, o as generateUpdateCommand, p as applyTranslations, s as VERSION, u as computeLocaleStatus, v as toLocaleEntries, w as findSourceFiles } from "./update-C-JdpZwG.js";
2
- import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
4
- import { computeKey, getActiveEntries, readLocaleFile } from "@lingo.dev/spec";
5
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { z } from "zod";
8
- //#region src/mcp/hints.ts
9
- /**
10
- * Contextual agent hints appended to tool responses.
11
- * Fired once per session, never repeated, disabled via LINGO_NO_HINTS env var.
12
- */
13
- const firedHints = /* @__PURE__ */ new Set();
14
- const HINTS = {
15
- engine: {
16
- condition: (ctx) => ctx.trigger === "first_translate",
17
- message: "For glossary + brand voice, connect an engine: `lingo login && lingo link`"
18
- },
19
- manyLocales: {
20
- condition: (ctx) => ctx.trigger === "translate" && (ctx.targetLocaleCount ?? 0) >= 5,
21
- message: "Per-locale brand voice and model selection available with a connected engine."
22
- },
23
- manyStrings: {
24
- condition: (ctx) => ctx.trigger === "extract" && (ctx.stringCount ?? 0) >= 100,
25
- message: "At scale, a connected engine provides caching + glossary consistency."
26
- }
27
- };
28
- function getHint(ctx) {
29
- if (process.env.LINGO_NO_HINTS === "1") return void 0;
30
- for (const [id, hint] of Object.entries(HINTS)) {
31
- if (firedHints.has(id)) continue;
32
- if (hint.condition(ctx)) {
33
- firedHints.add(id);
34
- return hint.message;
35
- }
36
- }
37
- }
38
- //#endregion
39
- //#region src/mcp/prompts.ts
40
- /**
41
- * MCP prompts for guided AI agent workflows.
42
- * These are the single source of truth for how AI agents use @lingo.dev/react.
43
- * Skills reference these prompts - never duplicate the instructions.
44
- */
45
- const PROMPTS = {
46
- lingo_setup: {
47
- name: "lingo_setup",
48
- description: "Comprehensive guide for first-time @lingo.dev/react setup in a project",
49
- arguments: [{
50
- name: "framework",
51
- description: "Detected framework (next-pages, next-app, vite, react-router)",
52
- required: false
53
- }, {
54
- name: "targetLocales",
55
- description: "Target locales to translate to (e.g., 'es fr ja')",
56
- required: false
57
- }],
58
- text: `# Setting up @lingo.dev/react
59
-
60
- You are setting up internationalization in a React project. Follow every step precisely.
61
-
62
- ## Philosophy
63
-
64
- Context is REQUIRED on every translation call. Always. No exceptions.
65
- Route is REQUIRED on every withLingoProps call. Always. No exceptions.
66
- The human doesn't write i18n code - YOU do. Write clean, explicit, reviewable code.
67
-
68
- ## Step 1: Detect Framework
69
-
70
- Read package.json and the project structure. Determine:
71
- - **Next.js Pages Router**: has \`next\` dependency + \`pages/\` or \`src/pages/\` directory
72
- - **Next.js App Router**: has \`next\` dependency + \`app/\` or \`src/app/\` directory
73
- - **Vite + React**: has \`vite\` dependency
74
- - **React Router / Remix**: has \`react-router\` or \`@remix-run\` dependency
75
-
76
- ## Step 2: Install Packages
77
-
78
- Detect package manager from lockfile (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb).
79
-
80
- For Next.js:
81
- \`\`\`bash
82
- pnpm add @lingo.dev/react @lingo.dev/react-next
83
- \`\`\`
84
-
85
- For other frameworks:
86
- \`\`\`bash
87
- pnpm add @lingo.dev/react
88
- \`\`\`
89
-
90
- The CLI should already be installed (\`@lingo.dev/cli\`). If not:
91
- \`\`\`bash
92
- pnpm add -D @lingo.dev/cli
93
- \`\`\`
94
-
95
- ## Step 3: Configure Framework (Next.js Pages Router)
96
-
97
- Edit \`next.config.ts\`:
98
- \`\`\`ts
99
- import type { NextConfig } from "next";
100
-
101
- const nextConfig: NextConfig = {
102
- i18n: {
103
- locales: ["en", "es"], // adjust to requested locales
104
- defaultLocale: "en",
105
- },
106
- };
107
-
108
- export default nextConfig;
109
- \`\`\`
110
-
111
- This gives automatic sub-path routing: \`/\` (English), \`/es/\` (Spanish).
112
-
113
- ## Step 4: Wrap App Root
114
-
115
- ### Next.js Pages Router
116
-
117
- \`\`\`tsx
118
- // pages/_app.tsx
119
- import type { AppProps } from "next/app";
120
- import { withLingoApp, LingoHead } from "@lingo.dev/react-next/pages/client";
121
-
122
- export default withLingoApp(function App({ Component, pageProps }: AppProps) {
123
- return (
124
- <>
125
- <LingoHead />
126
- <Component {...pageProps} />
127
- </>
128
- );
129
- });
130
- \`\`\`
131
-
132
- \`withLingoApp\` does three things:
133
- 1. Wraps with \`LingoProvider\` (locale from router, messages from pageProps)
134
- 2. Sets \`dir="rtl"\` for RTL locales (blocking script for SSR + useEffect for CSR)
135
- 3. Warns in dev mode when a page is missing \`withLingoProps\`
136
-
137
- \`LingoHead\` generates \`<link rel="alternate" hreflang="...">\` tags for SEO.
138
-
139
- ## Step 5: Add withLingoProps to Every Page
140
-
141
- EVERY page that uses translations must have \`withLingoProps\`. Route is required.
142
-
143
- \`\`\`tsx
144
- import { withLingoProps } from "@lingo.dev/react-next/pages/server";
145
-
146
- export const getStaticProps = withLingoProps({ route: "src/pages/index" });
147
- \`\`\`
148
-
149
- The \`route\` value is the file path relative to project root, without extension.
150
- - \`src/pages/index.tsx\` -> route: \`"src/pages/index"\`
151
- - \`src/pages/about.tsx\` -> route: \`"src/pages/about"\`
152
- - \`src/pages/blog/[slug].tsx\` -> route: \`"src/pages/blog/[slug]"\`
153
- - \`pages/index.tsx\` (no src/) -> route: \`"pages/index"\`
154
-
155
- With custom data:
156
- \`\`\`tsx
157
- export const getStaticProps = withLingoProps({
158
- route: "src/pages/blog/[slug]",
159
- handler: async (ctx) => {
160
- const post = await getPost(ctx.params!.slug as string);
161
- if (!post) return { notFound: true };
162
- return { props: { post }, revalidate: 60 };
163
- },
164
- });
165
- \`\`\`
166
-
167
- For dynamic routes, also add \`withLingoPaths\`:
168
- \`\`\`tsx
169
- import { withLingoPaths } from "@lingo.dev/react-next/pages/server";
170
-
171
- export const getStaticPaths = withLingoPaths(async () => {
172
- const posts = await getPosts();
173
- return posts.map((post) => ({ params: { slug: post.slug } }));
174
- });
175
- \`\`\`
176
-
177
- This auto-expands paths across all locales with \`fallback: "blocking"\`.
178
-
179
- ## Step 6: Replace Strings with l.text()
180
-
181
- Import \`useLingo\` and wrap every user-facing string.
182
-
183
- \`\`\`tsx
184
- import { useLingo } from "@lingo.dev/react";
185
-
186
- export default function Page() {
187
- const l = useLingo();
188
- return (
189
- <div>
190
- <h1>{l.text("Welcome to Acme", { context: "Homepage hero heading" })}</h1>
191
- <p>{l.text("The best tool for teams.", { context: "Homepage hero subheading" })}</p>
192
- <button>{l.text("Get started", { context: "Homepage CTA button" })}</button>
193
- </div>
194
- );
195
- }
196
- \`\`\`
197
-
198
- ### API Reference
199
-
200
- **\`l.text(source, { context, values? })\`** - Plain text translation
201
- \`\`\`tsx
202
- l.text("Hello", { context: "Hero greeting" })
203
- l.text("Hello, {name}!", { context: "Dashboard welcome", values: { name: "Max" } })
204
- l.text("Save", { context: "Form submit button" })
205
- l.text("Save", { context: "Toolbar save action" }) // same text, different context = different translation
206
- \`\`\`
207
-
208
- **\`l.jsx(source, { context, tags?, values? })\`** - Rich text with JSX
209
- \`\`\`tsx
210
- l.jsx("Read our <link>terms of service</link>", {
211
- context: "Footer legal link",
212
- tags: { link: (children) => <a href="/terms">{children}</a> },
213
- })
214
- l.jsx("Welcome, <bold>{name}</bold>!", {
215
- context: "Dashboard header",
216
- tags: { bold: (children) => <strong>{children}</strong> },
217
- values: { name: user.name },
218
- })
219
- \`\`\`
220
-
221
- **\`l.plural(count, forms, { context })\`** - Pluralization
222
- \`\`\`tsx
223
- l.plural(itemCount, { one: "# item", other: "# items" }, { context: "Cart item count" })
224
- l.plural(days, { one: "# day remaining", other: "# days remaining" }, { context: "Trial countdown" })
225
- \`\`\`
226
-
227
- **\`l.select(value, forms, { context })\`** - Select by value
228
- \`\`\`tsx
229
- l.select(role, { admin: "Admin Panel", user: "Dashboard", other: "Home" }, { context: "Navigation label" })
230
- \`\`\`
231
-
232
- **Formatting (no context needed - these use Intl APIs):**
233
- \`\`\`tsx
234
- l.num(1234567) // "1,234,567"
235
- l.currency(29.99, "USD") // "$29.99"
236
- l.percent(0.156) // "16%"
237
- l.date(new Date()) // "Mar 17, 2026"
238
- l.time(new Date()) // "10:30 AM"
239
- l.relative(-1, "day") // "yesterday"
240
- l.list(["Alice", "Bob", "Charlie"]) // "Alice, Bob, and Charlie"
241
- \`\`\`
242
-
243
- ### Context Rules
244
-
245
- 1. Context is REQUIRED on every \`l.text()\`, \`l.jsx()\`, \`l.plural()\`, \`l.select()\` call
246
- 2. Context describes WHERE and HOW the text is used (not WHAT it says)
247
- 3. Good context: "Checkout form submit button", "Hero section heading", "Error toast message"
248
- 4. Bad context: "button", "heading", "text" (too generic)
249
- 5. Same source text with different context = different translation (e.g., "Save" in a form vs toolbar)
250
- 6. Context is included in the hash key - changing context changes the key
251
-
252
- ### Locale Switcher
253
-
254
- Add a locale switcher component:
255
- \`\`\`tsx
256
- import { useLocaleSwitch } from "@lingo.dev/react-next/pages/client";
257
- import { useRouter } from "next/router";
258
-
259
- export function LocaleSwitcher() {
260
- const { locale, locales } = useRouter();
261
- const switchLocale = useLocaleSwitch();
262
-
263
- return (
264
- <select value={locale} onChange={(e) => switchLocale(e.target.value)}>
265
- {locales?.map((l) => (
266
- <option key={l} value={l}>{l.toUpperCase()}</option>
267
- ))}
268
- </select>
269
- );
270
- }
271
- \`\`\`
272
-
273
- ## Step 7: Extract
274
-
275
- Run the \`lingo_extract\` tool (or \`pnpm lingo extract\`). This:
276
- - Scans source files for all \`l.text()\`, \`l.jsx()\`, \`l.plural()\`, \`l.select()\` calls
277
- - Generates \`locales/en.jsonc\` with source messages, @context metadata, @src file locations
278
- - Generates \`lingo.d.ts\` with TypeScript types (exact context unions, value types)
279
-
280
- After extraction, TypeScript enforces:
281
- - Only extracted source strings are valid in \`l.text()\`
282
- - Context must match the exact extracted value (union type)
283
- - Required values must be provided for messages with placeholders
284
-
285
- ## Step 8: Translate
286
-
287
- Run the \`lingo_translate\` tool (or \`pnpm lingo localize --target-locale es\`).
288
- This sends source messages with context to the Lingo.dev API and writes translated JSONC files.
289
-
290
- ## Step 9: Verify
291
-
292
- Run the \`lingo_check\` tool (or \`pnpm lingo check\`). This verifies:
293
- - All \`l.text()\` calls have context
294
- - JSONC files are up to date with source code
295
- - \`lingo.d.ts\` is up to date
296
- - All target locales have complete translations
297
-
298
- Only report success when \`lingo_check\` returns 0 issues.`
299
- },
300
- lingo_localize: {
301
- name: "lingo_localize",
302
- description: "Guide for ongoing localization: find untranslated strings, wrap them, extract, translate, verify",
303
- arguments: [{
304
- name: "filePath",
305
- description: "Specific file to localize (optional - checks whole project if omitted)",
306
- required: false
307
- }],
308
- text: `# Localizing strings
309
-
310
- You are helping maintain translations in an existing @lingo.dev/react project.
311
-
312
- ## Step 1: Check Current State
313
-
314
- Run the \`lingo_check\` tool first. It reports:
315
- - Missing context on any \`l.text()\` calls
316
- - Stale JSONC files (source code changed, JSONC not updated)
317
- - Stale TypeScript types
318
- - Missing translations in target locales
319
- - Orphaned entries (removed from source code)
320
-
321
- ## Step 2: Fix Issues
322
-
323
- Based on what \`lingo_check\` reports:
324
-
325
- **Missing context:** Find the calls without context and add it.
326
- \`\`\`tsx
327
- // Before (will fail lingo_check):
328
- l.text("Save")
329
-
330
- // After:
331
- l.text("Save", { context: "Settings form submit button" })
332
- \`\`\`
333
-
334
- **Untranslated strings:** Find hardcoded user-facing strings and wrap them:
335
- \`\`\`tsx
336
- // Before:
337
- <h1>Welcome back</h1>
338
- <p>You have {count} notifications</p>
339
-
340
- // After:
341
- <h1>{l.text("Welcome back", { context: "Dashboard greeting heading" })}</h1>
342
- <p>{l.plural(count, { one: "You have # notification", other: "You have # notifications" }, { context: "Dashboard notification count" })}</p>
343
- \`\`\`
344
-
345
- **Missing withLingoProps:** Every page using \`useLingo()\` needs it:
346
- \`\`\`tsx
347
- export const getStaticProps = withLingoProps({ route: "src/pages/dashboard" });
348
- \`\`\`
349
-
350
- ## Step 3: Extract + Translate + Verify
351
-
352
- 1. Run \`lingo_extract\` to update JSONC + types
353
- 2. Run \`lingo_translate\` to translate new strings
354
- 3. Run \`lingo_check\` to verify 0 issues
355
-
356
- ## What to Wrap
357
-
358
- Wrap ALL user-facing text:
359
- - Headings, paragraphs, labels
360
- - Button text, link text
361
- - Placeholder text, aria-labels
362
- - Error messages, success messages, toast notifications
363
- - Table headers, form labels
364
- - Alt text for images
365
-
366
- Do NOT wrap:
367
- - Code identifiers, CSS class names
368
- - URLs, email addresses
369
- - Numbers that are already formatted via \`l.num()\`
370
- - Technical identifiers (API keys, IDs)`
371
- },
372
- lingo_migrate: {
373
- name: "lingo_migrate",
374
- description: "Guide for migrating from another i18n library (next-intl, i18next, react-intl, lingui) to @lingo.dev/react",
375
- arguments: [{
376
- name: "fromLibrary",
377
- description: "Library to migrate from (next-intl, i18next, react-intl, lingui)",
378
- required: false
379
- }],
380
- text: `# Migrating to @lingo.dev/react
381
-
382
- You are migrating a project from another i18n library to @lingo.dev/react.
383
-
384
- ## Key Difference
385
-
386
- Old libraries use manual key strings: \`t("pages.home.title")\`
387
- @lingo.dev/react uses source text + context: \`l.text("Welcome", { context: "Home page title" })\`
388
-
389
- No key management. No key naming conventions. The key is a deterministic hash computed from source + context.
390
-
391
- ## API Mapping
392
-
393
- | Pattern | next-intl | react-intl | i18next | @lingo.dev/react |
394
- |---------|-----------|------------|---------|------------------|
395
- | Plain text | \`t("key")\` | \`formatMessage({ id: "key" })\` | \`t("key")\` | \`l.text("source", { context: "..." })\` |
396
- | With values | \`t("key", { name })\` | \`formatMessage({ id }, { name })\` | \`t("key", { name })\` | \`l.text("Hello, {name}", { context: "...", values: { name } })\` |
397
- | Rich text | \`t.rich("key", { link })\` | \`<FormattedMessage components />\` | \`<Trans components />\` | \`l.jsx("Click <link>here</link>", { context: "...", tags: { link } })\` |
398
- | Plurals | ICU in JSON | \`<FormattedPlural />\` | ICU in JSON | \`l.plural(count, { one: "...", other: "..." }, { context: "..." })\` |
399
- | Provider | \`NextIntlClientProvider\` | \`IntlProvider\` | \`I18nextProvider\` | \`LingoProvider\` (via \`withLingoApp\`) |
400
- | Hook | \`useTranslations()\` | \`useIntl()\` | \`useTranslation()\` | \`useLingo()\` |
401
-
402
- ## Migration Steps
403
-
404
- 1. **Install @lingo.dev/react** alongside the existing library (both can coexist).
405
-
406
- 2. **Set up @lingo.dev/react** following the \`lingo_setup\` prompt (add withLingoApp wrapping the existing provider, configure next.config.ts if not already done).
407
-
408
- 3. **Migrate one component at a time:**
409
- - Replace the old hook with \`const l = useLingo()\`
410
- - Replace each translation call using the mapping above
411
- - The source text should be the DEFAULT LANGUAGE text (usually English)
412
- - Derive context from the old key name: \`home.hero.title\` -> context: \`"Home page hero title"\`
413
- - Add \`withLingoProps({ route: "..." })\` if the page doesn't have it
414
-
415
- 4. **Run extraction** after each component: \`lingo_extract\`
416
-
417
- 5. **Run translation** to carry over existing translations: \`lingo_translate\`
418
- The API produces new translations from the source text + context.
419
-
420
- 6. **Remove the old library** once all components are migrated.
421
- - Remove old provider wrapping
422
- - Remove old JSON/YAML message files
423
- - Remove old dependencies from package.json
424
-
425
- 7. **Verify** with \`lingo_check\` - 0 issues means migration is complete.
426
-
427
- ## Gotchas
428
-
429
- - Old libraries use nested key namespaces (\`home.hero.title\`). @lingo.dev/react uses flat hash keys. Don't try to replicate the namespace structure - let the hash system handle it.
430
- - If the old library has ICU MessageFormat in JSON files, the same patterns work in @lingo.dev/react since it uses intl-messageformat under the hood.
431
- - Existing translations in old JSON files cannot be directly imported - the keys are different (hash vs manual). Run \`lingo_translate\` to generate fresh translations with context.`
432
- }
433
- };
434
- //#endregion
435
- //#region src/mcp/server.ts
436
- /**
437
- * Local MCP server for AI coding assistants.
438
- * Exposes i18n tools (extract, translate, search, etc.) over stdio transport.
439
- * Started via `lingo mcp`.
440
- */
441
- let translateCallCount = 0;
442
- let updateNudge = null;
443
- async function checkUpdateOnce() {
444
- try {
445
- const controller = new AbortController();
446
- const timeout = setTimeout(() => controller.abort(), 3e3);
447
- const res = await fetch("https://registry.npmjs.org/@lingo.dev/cli/latest", {
448
- headers: { Accept: "application/json" },
449
- signal: controller.signal
450
- });
451
- clearTimeout(timeout);
452
- if (!res.ok) return;
453
- const info = compareVersions(VERSION, (await res.json()).version);
454
- if (!info) return;
455
- const cmd = generateUpdateCommand(detectInstallMethod(), info.latest);
456
- updateNudge = {
457
- current: info.current,
458
- latest: info.latest,
459
- command: cmd ? cmd.join(" ") : `npm install -D @lingo.dev/cli@${info.latest}`
460
- };
461
- } catch {}
462
- }
463
- function withUpdateNudge(response) {
464
- if (!updateNudge) return response;
465
- return {
466
- ...response,
467
- content: [...response.content, {
468
- type: "text",
469
- text: [
470
- "",
471
- `IMPORTANT: You are using @lingo.dev/cli v${updateNudge.current}.`,
472
- `v${updateNudge.latest} contains critical improvements to extraction accuracy, context handling, and translation quality.`,
473
- `Results from the current version may be incomplete or incorrect.`,
474
- `Update before proceeding: ${updateNudge.command}`
475
- ].join("\n")
476
- }]
477
- };
478
- }
479
- function cwd() {
480
- return process.cwd();
481
- }
482
- function localesDir(out) {
483
- return path.resolve(cwd(), out);
484
- }
485
- async function startMcpServer() {
486
- checkUpdateOnce();
487
- const server = new McpServer({
488
- name: "@lingo.dev/react",
489
- version: "0.1.0"
490
- }, { capabilities: {
491
- prompts: {},
492
- tools: {}
493
- } });
494
- server.tool("lingo_search", "Search existing translations by source text, hash key, or context", {
495
- query: z.string().describe("Search term (matches source text, key, or context)"),
496
- out: z.string().default("locales").describe("Locale files directory")
497
- }, async ({ query, out }) => {
498
- const dir = localesDir(out);
499
- const files = await fs.readdir(dir).catch(() => []);
500
- const results = [];
501
- const q = query.toLowerCase();
502
- for (const file of files.filter((f) => f.endsWith(".jsonc"))) {
503
- const locale = file.replace(/\.jsonc$/, "");
504
- const content = await tryReadFile(path.join(dir, file));
505
- if (!content) continue;
506
- const parsed = readLocaleFile(content);
507
- for (const entry of parsed.entries) if (entry.key.includes(query) || entry.value.toLowerCase().includes(q) || entry.metadata.context?.toLowerCase().includes(q)) results.push({
508
- locale,
509
- key: entry.key,
510
- value: entry.value,
511
- context: entry.metadata.context,
512
- src: entry.metadata.src
513
- });
514
- }
515
- return { content: [{
516
- type: "text",
517
- text: JSON.stringify({
518
- matches: results,
519
- total: results.length
520
- }, null, 2)
521
- }] };
522
- });
523
- server.tool("lingo_add", "Add a single translatable string to the source locale JSONC file", {
524
- source: z.string().describe("Source text (e.g., 'Save changes')"),
525
- context: z.string().optional().describe("Disambiguation context"),
526
- filePath: z.string().optional().describe("Source file location (e.g., 'src/form.tsx:42')"),
527
- sourceLocale: z.string().default("en").describe("Source locale code"),
528
- out: z.string().default("locales").describe("Locale files directory")
529
- }, async ({ source, context, filePath, sourceLocale, out }) => {
530
- const key = computeKey(source, context);
531
- const localePath = path.join(localesDir(out), `${sourceLocale}.jsonc`);
532
- const content = await tryReadFile(localePath);
533
- const existing = content ? readLocaleFile(content) : { entries: [] };
534
- if (existing.entries.some((e) => e.key === key)) return { content: [{
535
- type: "text",
536
- text: JSON.stringify({
537
- key,
538
- added: false,
539
- reason: "Key already exists"
540
- })
541
- }] };
542
- const entry = {
543
- key,
544
- value: source,
545
- metadata: {
546
- ...context ? { context } : {},
547
- ...filePath ? { src: filePath } : {}
548
- }
549
- };
550
- const merged = { entries: [...existing.entries, entry] };
551
- await fs.mkdir(path.dirname(localePath), { recursive: true });
552
- await fs.writeFile(localePath, writeLocaleFile(merged));
553
- return { content: [{
554
- type: "text",
555
- text: JSON.stringify({
556
- key,
557
- added: true,
558
- source,
559
- context
560
- })
561
- }] };
562
- });
563
- server.tool("lingo_extract", "Extract translatable strings from source files into JSONC locale file + generate TypeScript declarations", {
564
- src: z.string().default("src").describe("Source directory to scan"),
565
- sourceLocale: z.string().default("en").describe("Source locale code"),
566
- out: z.string().default("locales").describe("Output directory for locale files")
567
- }, async ({ src, sourceLocale, out }) => {
568
- const srcDir = path.resolve(cwd(), src);
569
- const outDir = localesDir(out);
570
- const localePath = path.join(outDir, `${sourceLocale}.jsonc`);
571
- const typesPath = path.resolve(cwd(), "lingo.d.ts");
572
- const filePaths = await findSourceFiles(srcDir);
573
- if (filePaths.length === 0) return { content: [{
574
- type: "text",
575
- text: JSON.stringify({ error: `No .ts/.tsx files found in ${src}/` })
576
- }] };
577
- const { messages, warnings, collisions } = runExtractionPipeline(await Promise.all(filePaths.map(async (f) => ({
578
- code: await fs.readFile(f, "utf-8"),
579
- filePath: path.relative(cwd(), f)
580
- }))));
581
- if (collisions.length > 0) return {
582
- content: [{
583
- type: "text",
584
- text: JSON.stringify({
585
- error: "Hash collisions detected",
586
- collisions
587
- })
588
- }],
589
- isError: true
590
- };
591
- const entries = toLocaleEntries(messages);
592
- const existingContent = await tryReadFile(localePath);
593
- const existing = existingContent ? readLocaleFile(existingContent) : { entries: [] };
594
- const merged = mergeEntries(existing, entries);
595
- await fs.mkdir(outDir, { recursive: true });
596
- await fs.writeFile(localePath, writeLocaleFile(merged));
597
- await fs.writeFile(typesPath, generateTypes(merged));
598
- const newCount = entries.filter((e) => !existing.entries.some((ex) => ex.key === e.key)).length;
599
- const orphanCount = merged.entries.filter((e) => e.metadata.orphan).length;
600
- const hint = getHint({
601
- trigger: "extract",
602
- stringCount: messages.length
603
- });
604
- return withUpdateNudge({ content: [{
605
- type: "text",
606
- text: JSON.stringify({
607
- extracted: messages.length,
608
- new: newCount,
609
- orphaned: orphanCount,
610
- warnings: warnings.map((w) => `${w.src}: ${w.message}`),
611
- ...hint ? { hint } : {},
612
- nextAction: newCount > 0 ? "New strings found. Run lingo_translate to localize them." : void 0
613
- }, null, 2)
614
- }] });
615
- });
616
- server.tool("lingo_translate", "Localize missing strings to target locales via the Lingo.dev API", {
617
- targetLocales: z.array(z.string()).describe("Target locale codes (e.g., ['es', 'fr'])"),
618
- sourceLocale: z.string().default("en").describe("Source locale code"),
619
- out: z.string().default("locales").describe("Locale files directory"),
620
- apiKey: z.string().optional().describe("Lingo.dev API key (defaults to LINGO_API_KEY env var)")
621
- }, async ({ targetLocales, sourceLocale, out, apiKey: apiKeyArg }) => {
622
- const apiKey = apiKeyArg || process.env.LINGO_API_KEY;
623
- if (!apiKey) return {
624
- content: [{
625
- type: "text",
626
- text: JSON.stringify({ error: "No API key. Set LINGO_API_KEY env var or pass apiKey argument." })
627
- }],
628
- isError: true
629
- };
630
- const outDir = localesDir(out);
631
- const sourceContent = await tryReadFile(path.join(outDir, `${sourceLocale}.jsonc`));
632
- if (!sourceContent) return {
633
- content: [{
634
- type: "text",
635
- text: JSON.stringify({ error: `No source file at ${out}/${sourceLocale}.jsonc. Run lingo_extract first.` })
636
- }],
637
- isError: true
638
- };
639
- const sourceFile = readLocaleFile(sourceContent);
640
- const results = {};
641
- for (const locale of targetLocales) {
642
- const targetPath = path.join(outDir, `${locale}.jsonc`);
643
- const targetContent = await tryReadFile(targetPath);
644
- const targetFile = targetContent ? readLocaleFile(targetContent) : { entries: [] };
645
- const plan = planLocalization(sourceFile, targetFile, locale);
646
- if (plan.missing.length === 0) {
647
- results[locale] = 0;
648
- continue;
649
- }
650
- const { data, hints } = toApiPayload(plan.missing);
651
- const apiUrl = process.env.LINGO_API_URL ?? "https://api.lingo.dev";
652
- const res = await fetch(`${apiUrl}/process/localize`, {
653
- method: "POST",
654
- headers: {
655
- "Content-Type": "application/json",
656
- "X-API-Key": apiKey
657
- },
658
- body: JSON.stringify({
659
- sourceLocale,
660
- targetLocale: locale,
661
- data,
662
- hints,
663
- triggerType: "cli"
664
- })
665
- });
666
- if (!res.ok) {
667
- results[locale] = -1;
668
- continue;
669
- }
670
- const response = await res.json();
671
- const translated = applyTranslations(plan.missing, response.data);
672
- const merged = mergeEntries(targetFile, [...getActiveEntries(targetFile.entries), ...translated]);
673
- await fs.mkdir(outDir, { recursive: true });
674
- await fs.writeFile(targetPath, writeLocaleFile(merged));
675
- results[locale] = plan.missing.length;
676
- }
677
- translateCallCount++;
678
- const hint = getHint({
679
- trigger: translateCallCount === 1 ? "first_translate" : "translate",
680
- targetLocaleCount: targetLocales.length
681
- });
682
- return withUpdateNudge({ content: [{
683
- type: "text",
684
- text: JSON.stringify({
685
- localized: results,
686
- ...hint ? { hint } : {},
687
- nextAction: "Run lingo_status to verify completeness."
688
- }, null, 2)
689
- }] });
690
- });
691
- server.tool("lingo_status", "Show per-locale translation completeness", {
692
- sourceLocale: z.string().default("en").describe("Source locale code"),
693
- out: z.string().default("locales").describe("Locale files directory")
694
- }, async ({ sourceLocale, out }) => {
695
- const outDir = localesDir(out);
696
- const sourceContent = await tryReadFile(path.join(outDir, `${sourceLocale}.jsonc`));
697
- if (!sourceContent) return {
698
- content: [{
699
- type: "text",
700
- text: JSON.stringify({ error: `No source file at ${out}/${sourceLocale}.jsonc` })
701
- }],
702
- isError: true
703
- };
704
- const sourceFile = readLocaleFile(sourceContent);
705
- const targets = await discoverLocales(outDir, sourceLocale);
706
- const statuses = [computeSourceStatus(sourceFile, sourceLocale)];
707
- for (const locale of targets) {
708
- const content = await tryReadFile(path.join(outDir, `${locale}.jsonc`));
709
- const targetFile = content ? readLocaleFile(content) : { entries: [] };
710
- statuses.push(computeLocaleStatus(sourceFile, targetFile, locale));
711
- }
712
- const totalMissing = statuses.reduce((sum, s) => sum + s.missing, 0);
713
- return withUpdateNudge({ content: [{
714
- type: "text",
715
- text: JSON.stringify({
716
- statuses,
717
- complete: totalMissing === 0,
718
- nextAction: totalMissing > 0 ? `${totalMissing} strings missing. Run lingo_translate to fill gaps.` : void 0
719
- }, null, 2)
720
- }] });
721
- });
722
- server.tool("lingo_cleanup", "Remove orphaned (deleted from source) entries from locale files. Defaults to dry run for safety.", {
723
- locale: z.string().optional().describe("Specific locale to clean (default: all)"),
724
- out: z.string().default("locales").describe("Locale files directory"),
725
- dryRun: z.boolean().default(true).describe("Preview changes without writing (default: true). Set to false to actually delete.")
726
- }, async ({ locale, out, dryRun }) => {
727
- const outDir = localesDir(out);
728
- const files = locale ? [`${locale}.jsonc`] : await fs.readdir(outDir).catch(() => []);
729
- const results = {};
730
- for (const file of files.filter((f) => f.endsWith(".jsonc"))) {
731
- const filePath = path.join(outDir, file);
732
- const content = await tryReadFile(filePath);
733
- if (!content) continue;
734
- const parsed = readLocaleFile(content);
735
- const active = getActiveEntries(parsed.entries);
736
- const removed = parsed.entries.length - active.length;
737
- if (removed > 0 && !dryRun) await fs.writeFile(filePath, writeLocaleFile({ entries: active }));
738
- results[file.replace(/\.jsonc$/, "")] = removed;
739
- }
740
- return { content: [{
741
- type: "text",
742
- text: JSON.stringify({
743
- ...dryRun ? {
744
- dryRun: true,
745
- message: "Set dryRun: false to actually delete orphans."
746
- } : {},
747
- cleaned: results
748
- }, null, 2)
749
- }] };
750
- });
751
- server.tool("lingo_inspect", "Get full details for a translation key across all locales", {
752
- source: z.string().optional().describe("Source text to look up"),
753
- key: z.string().optional().describe("Hash key to look up"),
754
- context: z.string().optional().describe("Context for hash computation"),
755
- out: z.string().default("locales").describe("Locale files directory")
756
- }, async ({ source, key: keyArg, context, out }) => {
757
- const key = keyArg ?? (source ? computeKey(source, context) : void 0);
758
- if (!key) return {
759
- content: [{
760
- type: "text",
761
- text: JSON.stringify({ error: "Provide either source or key" })
762
- }],
763
- isError: true
764
- };
765
- const outDir = localesDir(out);
766
- const files = await fs.readdir(outDir).catch(() => []);
767
- const translations = {};
768
- let sourceEntry = null;
769
- for (const file of files.filter((f) => f.endsWith(".jsonc"))) {
770
- const locale = file.replace(/\.jsonc$/, "");
771
- const content = await tryReadFile(path.join(outDir, file));
772
- if (!content) continue;
773
- const entry = readLocaleFile(content).entries.find((e) => e.key === key);
774
- translations[locale] = entry?.value ?? null;
775
- if (!sourceEntry && entry) sourceEntry = {
776
- source: entry.value,
777
- context: entry.metadata.context,
778
- src: entry.metadata.src
779
- };
780
- }
781
- return { content: [{
782
- type: "text",
783
- text: JSON.stringify({
784
- key,
785
- ...sourceEntry ?? {},
786
- translations
787
- }, null, 2)
788
- }] };
789
- });
790
- server.tool("lingo_check", "Verify i18n consistency: context on all calls, translations complete, types up to date", {
791
- src: z.string().default("src").describe("Source directory to scan"),
792
- sourceLocale: z.string().default("en").describe("Source locale code"),
793
- out: z.string().default("locales").describe("Locale files directory")
794
- }, async ({ src, sourceLocale, out }) => {
795
- const srcDir = path.resolve(cwd(), src);
796
- const outDir = localesDir(out);
797
- const localePath = path.join(outDir, `${sourceLocale}.jsonc`);
798
- const typesPath = path.resolve(cwd(), "lingo.d.ts");
799
- const filePaths = await findSourceFiles(srcDir);
800
- const { messages, warnings } = runExtractionPipeline(await Promise.all(filePaths.map(async (f) => ({
801
- code: await fs.readFile(f, "utf-8"),
802
- filePath: path.relative(cwd(), f)
803
- }))));
804
- const entries = toLocaleEntries(messages);
805
- const existingJsoncContent = await tryReadFile(localePath) ?? "";
806
- const merged = mergeEntries(existingJsoncContent ? readLocaleFile(existingJsoncContent) : { entries: [] }, entries);
807
- const generatedJsonc = writeLocaleFile(merged);
808
- const generatedTypes = generateTypes(merged);
809
- const existingTypes = await tryReadFile(typesPath) ?? "";
810
- const targetLocales = await discoverLocales(outDir, sourceLocale).catch(() => []);
811
- const targetFiles = await Promise.all(targetLocales.map(async (locale) => {
812
- const content = await tryReadFile(path.join(outDir, `${locale}.jsonc`));
813
- return {
814
- locale,
815
- file: content ? readLocaleFile(content) : { entries: [] }
816
- };
817
- }));
818
- const result = runChecks({
819
- warnings,
820
- messageCount: messages.length,
821
- generatedJsonc,
822
- existingJsonc: existingJsoncContent,
823
- generatedTypes,
824
- existingTypes,
825
- sourceFile: merged,
826
- targetFiles
827
- });
828
- return withUpdateNudge({
829
- content: [{
830
- type: "text",
831
- text: JSON.stringify(result, null, 2)
832
- }],
833
- ...result.issues.length > 0 ? { isError: true } : {}
834
- });
835
- });
836
- for (const prompt of Object.values(PROMPTS)) server.prompt(prompt.name, prompt.description, prompt.arguments, async () => ({ messages: [{
837
- role: "user",
838
- content: {
839
- type: "text",
840
- text: prompt.text
841
- }
842
- }] }));
843
- const transport = new StdioServerTransport();
844
- await server.connect(transport);
845
- }
846
- //#endregion
847
- export { startMcpServer };