@katerynakhar/i18n-keeper 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +666 -0
- package/dist/apply.d.ts +66 -0
- package/dist/apply.js +254 -0
- package/dist/apply.js.map +1 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +326 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +525 -0
- package/dist/cli.js.map +1 -0
- package/dist/formats/error.d.ts +12 -0
- package/dist/formats/error.js +19 -0
- package/dist/formats/error.js.map +1 -0
- package/dist/formats/flatten.d.ts +6 -0
- package/dist/formats/flatten.js +26 -0
- package/dist/formats/flatten.js.map +1 -0
- package/dist/formats/json-write.d.ts +7 -0
- package/dist/formats/json-write.js +55 -0
- package/dist/formats/json-write.js.map +1 -0
- package/dist/formats/json.d.ts +6 -0
- package/dist/formats/json.js +21 -0
- package/dist/formats/json.js.map +1 -0
- package/dist/formats/php-write.d.ts +16 -0
- package/dist/formats/php-write.js +130 -0
- package/dist/formats/php-write.js.map +1 -0
- package/dist/formats/php.d.ts +44 -0
- package/dist/formats/php.js +356 -0
- package/dist/formats/php.js.map +1 -0
- package/dist/formats/po-write.d.ts +3 -0
- package/dist/formats/po-write.js +200 -0
- package/dist/formats/po-write.js.map +1 -0
- package/dist/formats/po.d.ts +30 -0
- package/dist/formats/po.js +202 -0
- package/dist/formats/po.js.map +1 -0
- package/dist/formats/write.d.ts +29 -0
- package/dist/formats/write.js +19 -0
- package/dist/formats/write.js.map +1 -0
- package/dist/formats/yaml-write.d.ts +12 -0
- package/dist/formats/yaml-write.js +42 -0
- package/dist/formats/yaml-write.js.map +1 -0
- package/dist/formats/yaml.d.ts +18 -0
- package/dist/formats/yaml.js +51 -0
- package/dist/formats/yaml.js.map +1 -0
- package/dist/glossary.d.ts +38 -0
- package/dist/glossary.js +140 -0
- package/dist/glossary.js.map +1 -0
- package/dist/lengths.d.ts +37 -0
- package/dist/lengths.js +195 -0
- package/dist/lengths.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +374 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +162 -0
- package/dist/memory.js.map +1 -0
- package/dist/placeholders.d.ts +15 -0
- package/dist/placeholders.js +103 -0
- package/dist/placeholders.js.map +1 -0
- package/dist/plurals.d.ts +59 -0
- package/dist/plurals.js +280 -0
- package/dist/plurals.js.map +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +126 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +23 -0
- package/dist/scan.js +250 -0
- package/dist/scan.js.map +1 -0
- package/dist/translate.d.ts +93 -0
- package/dist/translate.js +369 -0
- package/dist/translate.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/apply.ts +334 -0
- package/src/check.ts +468 -0
- package/src/cli.ts +637 -0
- package/src/formats/error.ts +19 -0
- package/src/formats/flatten.ts +38 -0
- package/src/formats/json-write.ts +66 -0
- package/src/formats/json.ts +27 -0
- package/src/formats/php-write.ts +146 -0
- package/src/formats/php.ts +414 -0
- package/src/formats/po-write.ts +230 -0
- package/src/formats/po.ts +233 -0
- package/src/formats/write.ts +41 -0
- package/src/formats/yaml-write.ts +53 -0
- package/src/formats/yaml.ts +62 -0
- package/src/glossary.ts +191 -0
- package/src/lengths.ts +214 -0
- package/src/mcp.ts +451 -0
- package/src/memory.ts +227 -0
- package/src/placeholders.ts +123 -0
- package/src/plurals.ts +320 -0
- package/src/report.ts +162 -0
- package/src/scan.ts +280 -0
- package/src/translate.ts +486 -0
- package/src/types.ts +136 -0
- package/src/version.ts +27 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { check } from './check.js';
|
|
7
|
+
import { FormatError, describeFormatError } from './formats/error.js';
|
|
8
|
+
import {
|
|
9
|
+
GlossaryError,
|
|
10
|
+
glossaryPath,
|
|
11
|
+
loadGlossary,
|
|
12
|
+
type Glossary,
|
|
13
|
+
} from './glossary.js';
|
|
14
|
+
import { LimitsError, limitsPath, loadLimits, type Limits } from './lengths.js';
|
|
15
|
+
import {
|
|
16
|
+
MemoryError,
|
|
17
|
+
emptyMemory,
|
|
18
|
+
loadMemory,
|
|
19
|
+
memoryPath,
|
|
20
|
+
saveMemory,
|
|
21
|
+
syncMemory,
|
|
22
|
+
type Memory,
|
|
23
|
+
} from './memory.js';
|
|
24
|
+
import { ALL_SYNTAXES } from './placeholders.js';
|
|
25
|
+
import { ScanError, detectProject, listLocales } from './scan.js';
|
|
26
|
+
import { RULE_IDS, type Config, type Finding, type Report, type RuleId } from './types.js';
|
|
27
|
+
import { VERSION } from './version.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Everything a tool returns lands in the agent's context, so the text stays
|
|
31
|
+
* compact and findings are paged rather than dumped.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_LIMIT = 25;
|
|
34
|
+
|
|
35
|
+
const commonInput = {
|
|
36
|
+
path: z.string().optional().describe('Project root. Defaults to the working directory.'),
|
|
37
|
+
localesDir: z.string().optional().describe('Locales directory. Auto-detected when omitted.'),
|
|
38
|
+
source: z.string().optional().describe('Source locale. Defaults to en, else the first found.'),
|
|
39
|
+
memory: z.string().optional().describe('Translation memory file. Defaults to .i18n/memory.json.'),
|
|
40
|
+
glossary: z.string().optional().describe('Glossary file. Defaults to .i18n/glossary.json.'),
|
|
41
|
+
limits: z.string().optional().describe('Width limits file. Defaults to .i18n/limits.json.'),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const statShape = {
|
|
45
|
+
locale: z.string(),
|
|
46
|
+
coverage: z.number(),
|
|
47
|
+
missing: z.number(),
|
|
48
|
+
orphan: z.number(),
|
|
49
|
+
stale: z.number(),
|
|
50
|
+
errors: z.number(),
|
|
51
|
+
warnings: z.number(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
interface CommonArgs {
|
|
55
|
+
path?: string;
|
|
56
|
+
localesDir?: string;
|
|
57
|
+
source?: string;
|
|
58
|
+
memory?: string;
|
|
59
|
+
glossary?: string;
|
|
60
|
+
limits?: string;
|
|
61
|
+
ignoreIdentical?: string[];
|
|
62
|
+
syntax?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildConfig(args: CommonArgs): Config {
|
|
66
|
+
const config = detectProject(args.path ?? process.cwd(), {
|
|
67
|
+
localesDir: args.localesDir,
|
|
68
|
+
sourceLocale: args.source,
|
|
69
|
+
});
|
|
70
|
+
if (args.syntax && args.syntax.length > 0) config.placeholderSyntaxes = args.syntax;
|
|
71
|
+
if (args.ignoreIdentical) config.ignoreIdentical = args.ignoreIdentical;
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** An explicitly named memory must exist; the default path is optional. */
|
|
76
|
+
function openMemory(config: Config, explicit?: string): { memory: Memory | null; file: string } {
|
|
77
|
+
const file = memoryPath(config.root, explicit);
|
|
78
|
+
if (explicit && !existsSync(file)) throw new MemoryError(`Memory file not found: ${file}`);
|
|
79
|
+
return { memory: loadMemory(file), file };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An explicitly named limits file must exist; the default path is optional. */
|
|
83
|
+
function openLimits(config: Config, explicit?: string): { limits: Limits | null; file: string } {
|
|
84
|
+
const file = limitsPath(config.root, explicit);
|
|
85
|
+
if (explicit && !existsSync(file)) throw new LimitsError(`Limits file not found: ${file}`);
|
|
86
|
+
return { limits: loadLimits(file), file };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An explicitly named glossary must exist; the default path is optional. */
|
|
90
|
+
function openGlossary(config: Config, explicit?: string): { glossary: Glossary | null; file: string } {
|
|
91
|
+
const file = glossaryPath(config.root, explicit);
|
|
92
|
+
if (explicit && !existsSync(file)) throw new GlossaryError(`Glossary file not found: ${file}`);
|
|
93
|
+
return { glossary: loadGlossary(file), file };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pad(s: string, width: number, right = false): string {
|
|
97
|
+
const gap = ' '.repeat(Math.max(0, width - s.length));
|
|
98
|
+
return right ? gap + s : s + gap;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderTable(report: Report): string {
|
|
102
|
+
const head = ['locale', 'cov', 'miss', 'orph'];
|
|
103
|
+
if (report.memoryLoaded) head.push('stale');
|
|
104
|
+
head.push('err', 'warn');
|
|
105
|
+
|
|
106
|
+
const rows = report.stats.map((s) => {
|
|
107
|
+
const row = [
|
|
108
|
+
s.locale,
|
|
109
|
+
`${(s.coverage * 100).toFixed(1)}%`,
|
|
110
|
+
String(s.missing),
|
|
111
|
+
String(s.orphan),
|
|
112
|
+
];
|
|
113
|
+
if (report.memoryLoaded) row.push(String(s.stale));
|
|
114
|
+
row.push(String(s.errors), String(s.warnings));
|
|
115
|
+
return row;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
119
|
+
const line = (cells: string[]) =>
|
|
120
|
+
cells.map((c, i) => pad(c, widths[i]!, i > 0)).join(' ').trimEnd();
|
|
121
|
+
return [line(head), ...rows.map(line)].join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderFindings(findings: Finding[], offset: number, total: number): string {
|
|
125
|
+
if (total === 0) return 'no findings';
|
|
126
|
+
if (findings.length === 0) return `no findings at offset ${offset} (${total} total)`;
|
|
127
|
+
|
|
128
|
+
const localeWidth = Math.max(...findings.map((f) => f.locale.length));
|
|
129
|
+
const keyWidth = Math.max(...findings.map((f) => f.key.length));
|
|
130
|
+
const ruleWidth = Math.max(...findings.map((f) => f.rule.length));
|
|
131
|
+
|
|
132
|
+
const lines = findings.map(
|
|
133
|
+
(f) =>
|
|
134
|
+
`${f.severity === 'error' ? 'E' : 'W'} ${pad(f.locale, localeWidth)} ` +
|
|
135
|
+
`${pad(f.key, keyWidth)} ${pad(f.rule, ruleWidth)} ${f.detail}`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const end = offset + findings.length;
|
|
139
|
+
const header =
|
|
140
|
+
end < total || offset > 0
|
|
141
|
+
? `findings ${offset + 1}-${end} of ${total} (raise offset for more)`
|
|
142
|
+
: `findings (${total})`;
|
|
143
|
+
|
|
144
|
+
return [header, ...lines].join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function describeError(err: unknown): string {
|
|
148
|
+
if (
|
|
149
|
+
err instanceof ScanError ||
|
|
150
|
+
err instanceof MemoryError ||
|
|
151
|
+
err instanceof GlossaryError ||
|
|
152
|
+
err instanceof LimitsError
|
|
153
|
+
) {
|
|
154
|
+
return err.message;
|
|
155
|
+
}
|
|
156
|
+
if (err instanceof FormatError) return describeFormatError(err);
|
|
157
|
+
return err instanceof Error ? err.message : String(err);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function toolError(err: unknown) {
|
|
161
|
+
return { isError: true, content: [{ type: 'text' as const, text: describeError(err) }] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const server = new McpServer(
|
|
165
|
+
{ name: 'i18n-keeper', version: VERSION },
|
|
166
|
+
{ capabilities: { tools: {} } },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
server.registerTool(
|
|
170
|
+
'i18n_scan',
|
|
171
|
+
{
|
|
172
|
+
title: 'Scan i18n project',
|
|
173
|
+
description:
|
|
174
|
+
'Report which locale directory, layout, locales and translation memory would be used. Run this first when the project layout is unknown.',
|
|
175
|
+
inputSchema: commonInput,
|
|
176
|
+
annotations: { readOnlyHint: true },
|
|
177
|
+
},
|
|
178
|
+
async (args) => {
|
|
179
|
+
try {
|
|
180
|
+
const config = buildConfig(args);
|
|
181
|
+
const { layout, locales } = listLocales(config.localesDir);
|
|
182
|
+
const { memory, file } = openMemory(config, args.memory);
|
|
183
|
+
const glossaryInfo = openGlossary(config, args.glossary);
|
|
184
|
+
const limitsInfo = openLimits(config, args.limits);
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: 'text' as const,
|
|
189
|
+
text: [
|
|
190
|
+
`localesDir: ${config.localesDir}`,
|
|
191
|
+
`layout: ${layout}`,
|
|
192
|
+
`source: ${config.sourceLocale}`,
|
|
193
|
+
`locales: ${locales.join(', ')}`,
|
|
194
|
+
`placeholders: ${config.placeholderSyntaxes.join(', ')}`,
|
|
195
|
+
`memory: ${memory ? file : 'none (run i18n_sync to start tracking)'}`,
|
|
196
|
+
`glossary: ${
|
|
197
|
+
glossaryInfo.glossary
|
|
198
|
+
? `${glossaryInfo.file} (${glossaryInfo.glossary.terms.length} terms, ${glossaryInfo.glossary.doNotTranslate.length} verbatim)`
|
|
199
|
+
: 'none'
|
|
200
|
+
}`,
|
|
201
|
+
`limits: ${
|
|
202
|
+
limitsInfo.limits
|
|
203
|
+
? `${limitsInfo.file} (${Object.keys(limitsInfo.limits.keys).length} keys, ${limitsInfo.limits.patterns.length} patterns)`
|
|
204
|
+
: 'none'
|
|
205
|
+
}`,
|
|
206
|
+
].join('\n'),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return toolError(err);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
server.registerTool(
|
|
217
|
+
'i18n_status',
|
|
218
|
+
{
|
|
219
|
+
title: 'i18n coverage summary',
|
|
220
|
+
description:
|
|
221
|
+
'Per-locale coverage and issue counts, without listing individual findings. Cheap overview; use i18n_check for detail.',
|
|
222
|
+
inputSchema: commonInput,
|
|
223
|
+
outputSchema: {
|
|
224
|
+
sourceLocale: z.string(),
|
|
225
|
+
sourceKeys: z.number(),
|
|
226
|
+
memoryLoaded: z.boolean(),
|
|
227
|
+
locales: z.array(z.object(statShape)),
|
|
228
|
+
},
|
|
229
|
+
annotations: { readOnlyHint: true },
|
|
230
|
+
},
|
|
231
|
+
async (args) => {
|
|
232
|
+
try {
|
|
233
|
+
const config = buildConfig(args);
|
|
234
|
+
const { memory } = openMemory(config, args.memory);
|
|
235
|
+
const { glossary } = openGlossary(config, args.glossary);
|
|
236
|
+
const { limits } = openLimits(config, args.limits);
|
|
237
|
+
const report = check(config, memory, glossary, limits);
|
|
238
|
+
return {
|
|
239
|
+
content: [
|
|
240
|
+
{
|
|
241
|
+
type: 'text' as const,
|
|
242
|
+
text:
|
|
243
|
+
`${report.sourceLocale} · ${report.sourceKeys} keys` +
|
|
244
|
+
`${report.memoryLoaded ? '' : ' · no memory'}\n\n${renderTable(report)}`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
structuredContent: {
|
|
248
|
+
sourceLocale: report.sourceLocale,
|
|
249
|
+
sourceKeys: report.sourceKeys,
|
|
250
|
+
memoryLoaded: report.memoryLoaded,
|
|
251
|
+
locales: report.stats.map((s) => ({
|
|
252
|
+
locale: s.locale,
|
|
253
|
+
coverage: Number(s.coverage.toFixed(4)),
|
|
254
|
+
missing: s.missing,
|
|
255
|
+
orphan: s.orphan,
|
|
256
|
+
stale: s.stale,
|
|
257
|
+
errors: s.errors,
|
|
258
|
+
warnings: s.warnings,
|
|
259
|
+
})),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return toolError(err);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
server.registerTool(
|
|
269
|
+
'i18n_check',
|
|
270
|
+
{
|
|
271
|
+
title: 'Lint locale files',
|
|
272
|
+
description:
|
|
273
|
+
'Find missing keys, orphans, empty values, structure mismatches, broken placeholders and — when a translation memory exists — translations whose source has changed since (stale). Deterministic: nothing is translated and no network is used. Filter by locale, rule or severity, and page with offset.',
|
|
274
|
+
inputSchema: {
|
|
275
|
+
...commonInput,
|
|
276
|
+
locale: z.array(z.string()).optional().describe('Only these target locales.'),
|
|
277
|
+
rule: z
|
|
278
|
+
.array(z.enum(RULE_IDS))
|
|
279
|
+
.optional()
|
|
280
|
+
.describe('Only these rules. Naming a rule also enables it if it is off by default.'),
|
|
281
|
+
severity: z.enum(['error', 'warning']).optional().describe('Only this severity.'),
|
|
282
|
+
limit: z
|
|
283
|
+
.number()
|
|
284
|
+
.int()
|
|
285
|
+
.min(1)
|
|
286
|
+
.max(200)
|
|
287
|
+
.optional()
|
|
288
|
+
.describe(`Findings per call (default ${DEFAULT_LIMIT}).`),
|
|
289
|
+
offset: z.number().int().min(0).optional().describe('Skip this many findings.'),
|
|
290
|
+
ignoreIdentical: z
|
|
291
|
+
.array(z.string())
|
|
292
|
+
.optional()
|
|
293
|
+
.describe('Values allowed to equal the source, e.g. ["OK", "Email"].'),
|
|
294
|
+
syntax: z
|
|
295
|
+
.array(z.enum(ALL_SYNTAXES as [string, ...string[]]))
|
|
296
|
+
.optional()
|
|
297
|
+
.describe('Override placeholder syntaxes. Add "laravel" for :name projects.'),
|
|
298
|
+
noMemory: z.boolean().optional().describe('Ignore the memory; disables stale detection.'),
|
|
299
|
+
noGlossary: z.boolean().optional().describe('Ignore the glossary.'),
|
|
300
|
+
noLimits: z.boolean().optional().describe('Ignore the width limits.'),
|
|
301
|
+
},
|
|
302
|
+
outputSchema: {
|
|
303
|
+
sourceLocale: z.string(),
|
|
304
|
+
sourceKeys: z.number(),
|
|
305
|
+
memoryLoaded: z.boolean(),
|
|
306
|
+
errors: z.number(),
|
|
307
|
+
warnings: z.number(),
|
|
308
|
+
total: z.number(),
|
|
309
|
+
offset: z.number(),
|
|
310
|
+
findings: z.array(
|
|
311
|
+
z.object({
|
|
312
|
+
rule: z.string(),
|
|
313
|
+
severity: z.string(),
|
|
314
|
+
locale: z.string(),
|
|
315
|
+
key: z.string(),
|
|
316
|
+
detail: z.string(),
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
},
|
|
320
|
+
annotations: { readOnlyHint: true },
|
|
321
|
+
},
|
|
322
|
+
async (args) => {
|
|
323
|
+
try {
|
|
324
|
+
const config = buildConfig(args);
|
|
325
|
+
const rules = new Set((args.rule ?? []) as RuleId[]);
|
|
326
|
+
// Naming a rule turns it on, so asking for an off-by-default rule works.
|
|
327
|
+
for (const rule of rules) {
|
|
328
|
+
if (config.rules[rule] === 'off') config.rules[rule] = 'warning';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const memory = args.noMemory ? null : openMemory(config, args.memory).memory;
|
|
332
|
+
const glossary = args.noGlossary ? null : openGlossary(config, args.glossary).glossary;
|
|
333
|
+
const limits = args.noLimits ? null : openLimits(config, args.limits).limits;
|
|
334
|
+
const report = check(config, memory, glossary, limits);
|
|
335
|
+
|
|
336
|
+
const locales = new Set(args.locale ?? []);
|
|
337
|
+
let findings = report.findings;
|
|
338
|
+
if (locales.size > 0) findings = findings.filter((f) => locales.has(f.locale));
|
|
339
|
+
if (rules.size > 0) findings = findings.filter((f) => rules.has(f.rule));
|
|
340
|
+
if (args.severity) findings = findings.filter((f) => f.severity === args.severity);
|
|
341
|
+
|
|
342
|
+
const total = findings.length;
|
|
343
|
+
const offset = args.offset ?? 0;
|
|
344
|
+
const limit = args.limit ?? DEFAULT_LIMIT;
|
|
345
|
+
const page = findings.slice(offset, offset + limit);
|
|
346
|
+
|
|
347
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
348
|
+
const stats =
|
|
349
|
+
locales.size > 0 ? report.stats.filter((s) => locales.has(s.locale)) : report.stats;
|
|
350
|
+
|
|
351
|
+
const text = [
|
|
352
|
+
`${report.sourceLocale} · ${report.sourceKeys} keys · ${errors} errors · ${total - errors} warnings` +
|
|
353
|
+
`${report.memoryLoaded ? '' : ' · no memory'}`,
|
|
354
|
+
'',
|
|
355
|
+
renderTable({ ...report, stats }),
|
|
356
|
+
'',
|
|
357
|
+
renderFindings(page, offset, total),
|
|
358
|
+
].join('\n');
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
content: [{ type: 'text' as const, text }],
|
|
362
|
+
structuredContent: {
|
|
363
|
+
sourceLocale: report.sourceLocale,
|
|
364
|
+
sourceKeys: report.sourceKeys,
|
|
365
|
+
memoryLoaded: report.memoryLoaded,
|
|
366
|
+
errors,
|
|
367
|
+
warnings: total - errors,
|
|
368
|
+
total,
|
|
369
|
+
offset,
|
|
370
|
+
findings: page.map((f) => ({
|
|
371
|
+
rule: f.rule,
|
|
372
|
+
severity: f.severity,
|
|
373
|
+
locale: f.locale,
|
|
374
|
+
key: f.key,
|
|
375
|
+
detail: f.detail,
|
|
376
|
+
})),
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return toolError(err);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
server.registerTool(
|
|
386
|
+
'i18n_sync',
|
|
387
|
+
{
|
|
388
|
+
title: 'Record translations in the memory',
|
|
389
|
+
description:
|
|
390
|
+
'Write the current translations into .i18n/memory.json so later source edits can be detected as stale. Without force this never clears a stale flag: entries whose translation is unchanged keep their old source hash. Run it after translations are updated, and once when adopting the tool.',
|
|
391
|
+
inputSchema: {
|
|
392
|
+
...commonInput,
|
|
393
|
+
locale: z.array(z.string()).optional().describe('Only record these locales.'),
|
|
394
|
+
origin: z
|
|
395
|
+
.enum(['human', 'machine'])
|
|
396
|
+
.optional()
|
|
397
|
+
.describe('Who produced these translations. Default human, which also marks them reviewed.'),
|
|
398
|
+
force: z
|
|
399
|
+
.boolean()
|
|
400
|
+
.optional()
|
|
401
|
+
.describe('Re-record unchanged translations too, accepting them as current and clearing stale.'),
|
|
402
|
+
},
|
|
403
|
+
outputSchema: {
|
|
404
|
+
memory: z.string(),
|
|
405
|
+
created: z.number(),
|
|
406
|
+
updated: z.number(),
|
|
407
|
+
keptStale: z.number(),
|
|
408
|
+
unchanged: z.number(),
|
|
409
|
+
removed: z.number(),
|
|
410
|
+
},
|
|
411
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
412
|
+
},
|
|
413
|
+
async (args) => {
|
|
414
|
+
try {
|
|
415
|
+
const config = buildConfig(args);
|
|
416
|
+
const file = memoryPath(config.root, args.memory);
|
|
417
|
+
const memory = loadMemory(file) ?? emptyMemory(config.sourceLocale);
|
|
418
|
+
const origin = args.origin ?? 'human';
|
|
419
|
+
const result = syncMemory(config, memory, {
|
|
420
|
+
origin,
|
|
421
|
+
reviewed: origin === 'human',
|
|
422
|
+
locales: args.locale,
|
|
423
|
+
force: args.force === true,
|
|
424
|
+
});
|
|
425
|
+
saveMemory(file, memory);
|
|
426
|
+
|
|
427
|
+
const note =
|
|
428
|
+
result.keptStale > 0 && !args.force
|
|
429
|
+
? `\n${result.keptStale} translation(s) left stale on purpose — retranslate them, or sync with force to accept as-is.`
|
|
430
|
+
: '';
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: 'text' as const,
|
|
436
|
+
text:
|
|
437
|
+
`memory ${file}\n` +
|
|
438
|
+
`created ${result.created} updated ${result.updated} ` +
|
|
439
|
+
`kept-stale ${result.keptStale} unchanged ${result.unchanged} removed ${result.removed}` +
|
|
440
|
+
note,
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
structuredContent: { memory: file, ...result },
|
|
444
|
+
};
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return toolError(err);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await server.connect(new StdioServerTransport());
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { loadBundle } from './scan.js';
|
|
5
|
+
import type { Config } from './types.js';
|
|
6
|
+
|
|
7
|
+
export const MEMORY_FILE = '.i18n/memory.json';
|
|
8
|
+
|
|
9
|
+
export interface MemoryEntry {
|
|
10
|
+
/** Hash of the source string at the moment this translation was recorded. */
|
|
11
|
+
sourceHash: string;
|
|
12
|
+
value: string;
|
|
13
|
+
origin: 'human' | 'machine';
|
|
14
|
+
reviewed: boolean;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
/** When a person signed off on it, for entries that have been reviewed. */
|
|
17
|
+
reviewedAt?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Unreviewed {
|
|
21
|
+
locale: string;
|
|
22
|
+
key: string;
|
|
23
|
+
entry: MemoryEntry;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Everything still waiting for a person to look at it, in a stable order. */
|
|
27
|
+
export function unreviewed(memory: Memory, locales?: string[], keys?: string[]): Unreviewed[] {
|
|
28
|
+
const wantedLocale = locales && locales.length > 0 ? new Set(locales) : null;
|
|
29
|
+
const wantedKey = keys && keys.length > 0 ? new Set(keys) : null;
|
|
30
|
+
const out: Unreviewed[] = [];
|
|
31
|
+
|
|
32
|
+
for (const locale of Object.keys(memory.entries).sort()) {
|
|
33
|
+
if (wantedLocale && !wantedLocale.has(locale)) continue;
|
|
34
|
+
const byKey = memory.entries[locale] ?? {};
|
|
35
|
+
for (const key of Object.keys(byKey).sort()) {
|
|
36
|
+
if (wantedKey && !wantedKey.has(key)) continue;
|
|
37
|
+
const entry = byKey[key]!;
|
|
38
|
+
if (!entry.reviewed) out.push({ locale, key, entry });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Signs off on translations a person has actually looked at.
|
|
47
|
+
*
|
|
48
|
+
* Nothing else in the tool can set this: a machine translation is recorded
|
|
49
|
+
* unreviewed and stays that way until someone says otherwise, which is the
|
|
50
|
+
* whole point of recording it.
|
|
51
|
+
*/
|
|
52
|
+
export function markReviewed(entries: Unreviewed[], now: string): number {
|
|
53
|
+
for (const { entry } of entries) {
|
|
54
|
+
entry.reviewed = true;
|
|
55
|
+
entry.reviewedAt = now;
|
|
56
|
+
}
|
|
57
|
+
return entries.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Memory {
|
|
61
|
+
version: 1;
|
|
62
|
+
sourceLocale: string;
|
|
63
|
+
/** locale -> key -> entry. Nested rather than a flat list, for readable diffs. */
|
|
64
|
+
entries: Record<string, Record<string, MemoryEntry>>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class MemoryError extends Error {}
|
|
68
|
+
|
|
69
|
+
/** 48 bits is plenty to notice an edited string, and keeps the file readable. */
|
|
70
|
+
export function hashValue(value: string): string {
|
|
71
|
+
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 12);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function memoryPath(root: string, explicit?: string): string {
|
|
75
|
+
return resolve(root, explicit ?? MEMORY_FILE);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function emptyMemory(sourceLocale: string): Memory {
|
|
79
|
+
return { version: 1, sourceLocale, entries: {} };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadMemory(file: string): Memory | null {
|
|
83
|
+
if (!existsSync(file)) return null;
|
|
84
|
+
let parsed: unknown;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new MemoryError(
|
|
89
|
+
`Invalid memory file ${file}\n ${err instanceof Error ? err.message : String(err)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const memory = parsed as Partial<Memory>;
|
|
93
|
+
if (memory?.version !== 1 || typeof memory.entries !== 'object' || memory.entries === null) {
|
|
94
|
+
throw new MemoryError(`Unrecognised memory file format in ${file} (expected version 1)`);
|
|
95
|
+
}
|
|
96
|
+
return { version: 1, sourceLocale: memory.sourceLocale ?? '', entries: memory.entries };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Writes with sorted keys so the file diffs cleanly in review. */
|
|
100
|
+
export function saveMemory(file: string, memory: Memory): void {
|
|
101
|
+
const entries: Record<string, Record<string, MemoryEntry>> = {};
|
|
102
|
+
for (const locale of Object.keys(memory.entries).sort()) {
|
|
103
|
+
const byKey = memory.entries[locale] ?? {};
|
|
104
|
+
const sorted: Record<string, MemoryEntry> = {};
|
|
105
|
+
for (const key of Object.keys(byKey).sort()) sorted[key] = byKey[key]!;
|
|
106
|
+
entries[locale] = sorted;
|
|
107
|
+
}
|
|
108
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
109
|
+
writeFileSync(
|
|
110
|
+
file,
|
|
111
|
+
`${JSON.stringify({ version: 1, sourceLocale: memory.sourceLocale, entries }, null, 2)}\n`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface StaleVerdict {
|
|
116
|
+
stale: boolean;
|
|
117
|
+
tracked: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A translation is stale when the source moved and the translation demonstrably
|
|
122
|
+
* did not. If the target no longer matches what was recorded, someone edited it
|
|
123
|
+
* by hand — we cannot claim it is outdated, so we stay quiet.
|
|
124
|
+
*/
|
|
125
|
+
export function judge(
|
|
126
|
+
entry: MemoryEntry | undefined,
|
|
127
|
+
sourceValue: string,
|
|
128
|
+
targetValue: string,
|
|
129
|
+
): StaleVerdict {
|
|
130
|
+
if (!entry) return { stale: false, tracked: false };
|
|
131
|
+
const moved = entry.sourceHash !== hashValue(sourceValue);
|
|
132
|
+
return { stale: moved && entry.value === targetValue, tracked: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface SyncOptions {
|
|
136
|
+
origin: 'human' | 'machine';
|
|
137
|
+
reviewed: boolean;
|
|
138
|
+
locales?: string[];
|
|
139
|
+
/** Accept the current state wholesale, clearing stale flags. */
|
|
140
|
+
force: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SyncResult {
|
|
144
|
+
created: number;
|
|
145
|
+
updated: number;
|
|
146
|
+
keptStale: number;
|
|
147
|
+
unchanged: number;
|
|
148
|
+
removed: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Records the current translations. Without --force this never clears a stale
|
|
153
|
+
* flag: an entry whose target is unchanged keeps its old source hash, because
|
|
154
|
+
* nothing about the translation has actually been redone.
|
|
155
|
+
*/
|
|
156
|
+
export function syncMemory(config: Config, memory: Memory, opts: SyncOptions): SyncResult {
|
|
157
|
+
const source = loadBundle(config, config.sourceLocale);
|
|
158
|
+
const result: SyncResult = { created: 0, updated: 0, keptStale: 0, unchanged: 0, removed: 0 };
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const wanted = opts.locales && opts.locales.length > 0 ? new Set(opts.locales) : null;
|
|
161
|
+
|
|
162
|
+
memory.sourceLocale = config.sourceLocale;
|
|
163
|
+
|
|
164
|
+
for (const locale of config.locales) {
|
|
165
|
+
if (locale === config.sourceLocale) continue;
|
|
166
|
+
if (wanted && !wanted.has(locale)) continue;
|
|
167
|
+
|
|
168
|
+
const target = loadBundle(config, locale);
|
|
169
|
+
const byKey = (memory.entries[locale] ??= {});
|
|
170
|
+
|
|
171
|
+
for (const [key, sourceLeaf] of source.leaves) {
|
|
172
|
+
const targetLeaf = target.leaves.get(key);
|
|
173
|
+
if (!targetLeaf || targetLeaf.value.trim() === '') continue;
|
|
174
|
+
|
|
175
|
+
const entry = byKey[key];
|
|
176
|
+
const sourceHash = hashValue(sourceLeaf.value);
|
|
177
|
+
|
|
178
|
+
if (!entry) {
|
|
179
|
+
byKey[key] = {
|
|
180
|
+
sourceHash,
|
|
181
|
+
value: targetLeaf.value,
|
|
182
|
+
origin: opts.origin,
|
|
183
|
+
reviewed: opts.reviewed,
|
|
184
|
+
updatedAt: now,
|
|
185
|
+
};
|
|
186
|
+
result.created++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const translationChanged = entry.value !== targetLeaf.value;
|
|
191
|
+
|
|
192
|
+
if (translationChanged || opts.force) {
|
|
193
|
+
if (entry.sourceHash === sourceHash && !translationChanged) {
|
|
194
|
+
result.unchanged++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
byKey[key] = {
|
|
198
|
+
sourceHash,
|
|
199
|
+
value: targetLeaf.value,
|
|
200
|
+
origin: translationChanged ? opts.origin : entry.origin,
|
|
201
|
+
reviewed: translationChanged ? opts.reviewed : entry.reviewed,
|
|
202
|
+
updatedAt: now,
|
|
203
|
+
// A sign-off survives a source edit; only a rewritten translation
|
|
204
|
+
// invalidates it.
|
|
205
|
+
...(translationChanged || entry.reviewedAt === undefined
|
|
206
|
+
? {}
|
|
207
|
+
: { reviewedAt: entry.reviewedAt }),
|
|
208
|
+
};
|
|
209
|
+
result.updated++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (entry.sourceHash !== sourceHash) result.keptStale++;
|
|
214
|
+
else result.unchanged++;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Drop bookkeeping for keys that no longer exist in the source.
|
|
218
|
+
for (const key of Object.keys(byKey)) {
|
|
219
|
+
if (!source.leaves.has(key)) {
|
|
220
|
+
delete byKey[key];
|
|
221
|
+
result.removed++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return result;
|
|
227
|
+
}
|