@lingo.dev/cli 1.0.1 → 1.0.3
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/dist/bin.js +108 -0
- package/dist/flush-telemetry.js +38 -0
- package/dist/index.js +3 -0
- package/dist/renderer-D2iDOMA6.js +1533 -0
- package/dist/server-DGIsMSAq.js +847 -0
- package/dist/update-RHUBOb93.js +816 -0
- package/package.json +2 -2
|
@@ -0,0 +1,847 @@
|
|
|
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-RHUBOb93.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.rich(source, { context, tags?, values? })\`** - Rich text with tags
|
|
209
|
+
\`\`\`tsx
|
|
210
|
+
l.rich("Read our <link>terms of service</link>", {
|
|
211
|
+
context: "Footer legal link",
|
|
212
|
+
tags: { link: (children) => <a href="/terms">{children}</a> },
|
|
213
|
+
})
|
|
214
|
+
l.rich("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.rich()\`, \`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.rich()\`, \`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.rich("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 };
|