@aws/nx-plugin 1.0.0-rc.67 → 1.0.0-rc.68
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/migrations.json +6 -1
- package/package.json +1 -1
- package/src/migrations/latest/translate-script-whole-file-writes/files/build-system-prompt.ts.fixture +23 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/build-user-prompt.ts.fixture +96 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/config-path.ts.fixture +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/file-to-translate.ts.fixture +10 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/get-files-to-translate.ts.fixture +96 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/header.ts.fixture +15 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/log.ts.fixture +18 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/main-call.ts.fixture +6 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/main.ts.fixture +79 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/project-root.ts.fixture +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/run-with-concurrency.ts.fixture +57 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/scripts-dir.ts.fixture +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/files/translate-file-for-language.ts.fixture +61 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/already-migrated.grit +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/build-system-prompt.grit +6 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/build-user-prompt.grit +6 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/config-path.grit +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/file-to-translate.grit +11 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/get-files-to-translate.grit +7 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/log.grit +3 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/main-call.grit +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/main.grit +8 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/project-root.grit +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/run-with-concurrency.grit +6 -0
- package/src/migrations/latest/translate-script-whole-file-writes/grit/translate-file-for-language.grit +6 -0
- package/src/migrations/latest/translate-script-whole-file-writes/metadata.json +3 -0
- package/src/migrations/latest/translate-script-whole-file-writes/migration.d.ts +2 -0
- package/src/migrations/latest/translate-script-whole-file-writes/migration.js +103 -0
- package/src/migrations/latest/translate-script-whole-file-writes/migration.js.map +1 -0
- package/src/migrations/latest/translate-script-whole-file-writes/test-fixtures/released-script-first.ts.fixture +430 -0
- package/src/migrations/latest/translate-script-whole-file-writes/test-fixtures/released-script.ts.fixture +431 -0
- package/src/ts/astro-docs/files/translation/scripts/translate.ts.template +269 -148
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* <%= pkgMgrCmd %> nx translate <%= fullyQualifiedName %> -- --all
|
|
9
9
|
* <%= pkgMgrCmd %> nx translate <%= fullyQualifiedName %> # only files changed since last translate commit
|
|
10
10
|
*
|
|
11
|
-
* The driver
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The driver works out which source docs to translate and spawns one Strands
|
|
12
|
+
* agent per (file × target language). Each agent gets the `fileEditor` tool and
|
|
13
|
+
* the paths it needs — the source file, the target file, and a diff of what
|
|
14
|
+
* changed — then reads, translates and writes the file itself.
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from 'commander';
|
|
17
17
|
import fs from 'fs-extra';
|
|
@@ -76,9 +76,12 @@ function rejectOutsideDocsDir(event: BeforeToolCallEvent): void {
|
|
|
76
76
|
interface FileToTranslate {
|
|
77
77
|
relativePath: string;
|
|
78
78
|
sourceAbsPath: string;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Path to a file holding the source diff, or undefined when there is no prior
|
|
81
|
+
* translation to update. Passed to the agent as a path rather than inlined
|
|
82
|
+
* into the prompt, so it reads only as much of it as it needs.
|
|
83
|
+
*/
|
|
84
|
+
diffPath?: string;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
const program = new Command();
|
|
@@ -102,36 +105,42 @@ const log = {
|
|
|
102
105
|
info: (m: string) => console.log(`[translate] ${m}`),
|
|
103
106
|
warn: (m: string) => console.warn(`[translate] ${m}`),
|
|
104
107
|
error: (m: string) => console.error(`[translate] ERROR ${m}`),
|
|
105
|
-
verbose: (m: string) =>
|
|
106
|
-
options.verbose && console.log(`[translate] ${m}`),
|
|
108
|
+
verbose: (m: string) => options.verbose && console.log(`[translate] ${m}`),
|
|
107
109
|
};
|
|
108
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Diffs live under the docs directory so the agent's file access — which is
|
|
113
|
+
* scoped to that directory — can read them. Removed when the run finishes.
|
|
114
|
+
*/
|
|
115
|
+
const DIFF_DIR = path.join(DOCS_DIR, '.translate-diffs');
|
|
116
|
+
|
|
117
|
+
async function writeDiff(relativePath: string, diff: string): Promise<string> {
|
|
118
|
+
const diffPath = path.join(DIFF_DIR, `${relativePath}.diff`);
|
|
119
|
+
await fs.outputFile(diffPath, diff, 'utf-8');
|
|
120
|
+
return diffPath;
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
/**
|
|
110
124
|
* Gather the set of source-language files to translate, with their diffs.
|
|
111
125
|
*/
|
|
112
126
|
async function getFilesToTranslate(): Promise<FileToTranslate[]> {
|
|
113
127
|
const sourceLangRoot = `${DOCS_DIR}/${config.sourceLanguage}`;
|
|
114
|
-
const includePatterns = config.include.map(
|
|
115
|
-
|
|
116
|
-
);
|
|
117
|
-
const ignorePatterns = config.exclude.map(
|
|
118
|
-
(p) => `${sourceLangRoot}/${p}`,
|
|
119
|
-
);
|
|
128
|
+
const includePatterns = config.include.map((p) => `${sourceLangRoot}/${p}`);
|
|
129
|
+
const ignorePatterns = config.exclude.map((p) => `${sourceLangRoot}/${p}`);
|
|
120
130
|
|
|
121
131
|
if (options.all) {
|
|
122
132
|
log.info('Translating all source documentation files');
|
|
123
133
|
const files = await glob(includePatterns, { ignore: ignorePatterns });
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
sourceContent: await fs.readFile(file, 'utf-8'),
|
|
129
|
-
diff: '',
|
|
130
|
-
})),
|
|
131
|
-
);
|
|
134
|
+
return files.map((file) => ({
|
|
135
|
+
relativePath: path.relative(sourceLangRoot, file),
|
|
136
|
+
sourceAbsPath: file,
|
|
137
|
+
}));
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
const git = simpleGit();
|
|
141
|
+
// git reports paths relative to the repo root, which is not necessarily the
|
|
142
|
+
// directory this script runs from.
|
|
143
|
+
const repoRoot = (await git.revparse(['--show-toplevel'])).trim();
|
|
135
144
|
|
|
136
145
|
let currentBranch: string;
|
|
137
146
|
let mainBranch: string;
|
|
@@ -146,9 +155,7 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
|
|
|
146
155
|
try {
|
|
147
156
|
await git.raw(['rev-parse', '--verify', mainBranch]);
|
|
148
157
|
} catch {
|
|
149
|
-
log.warn(
|
|
150
|
-
`Could not find "${mainBranch}"; falling back to --all behaviour`,
|
|
151
|
-
);
|
|
158
|
+
log.warn(`Could not find "${mainBranch}"; falling back to --all behaviour`);
|
|
152
159
|
options.all = true;
|
|
153
160
|
return getFilesToTranslate();
|
|
154
161
|
}
|
|
@@ -162,9 +169,7 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
|
|
|
162
169
|
).all.filter((c) => c.message.includes(TRANSLATION_COMMIT_MESSAGE));
|
|
163
170
|
|
|
164
171
|
const baseCommit =
|
|
165
|
-
translationCommits.length > 0
|
|
166
|
-
? translationCommits[0].hash
|
|
167
|
-
: mergeBase;
|
|
172
|
+
translationCommits.length > 0 ? translationCommits[0].hash : mergeBase;
|
|
168
173
|
|
|
169
174
|
log.info(
|
|
170
175
|
translationCommits.length > 0
|
|
@@ -173,11 +178,7 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
|
|
|
173
178
|
);
|
|
174
179
|
|
|
175
180
|
const diffNames = (
|
|
176
|
-
await git.diff([
|
|
177
|
-
`${baseCommit}..HEAD`,
|
|
178
|
-
'--name-only',
|
|
179
|
-
'--diff-filter=d',
|
|
180
|
-
])
|
|
181
|
+
await git.diff([`${baseCommit}..HEAD`, '--name-only', '--diff-filter=d'])
|
|
181
182
|
)
|
|
182
183
|
.split('\n')
|
|
183
184
|
.filter(Boolean);
|
|
@@ -185,9 +186,9 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
|
|
|
185
186
|
const { files: uncommitted } = await git.status();
|
|
186
187
|
const uncommittedNames = uncommitted.map((f) => f.path);
|
|
187
188
|
|
|
188
|
-
const allCandidates = [
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
const allCandidates = [...new Set([...diffNames, ...uncommittedNames])].map(
|
|
190
|
+
(p) => path.resolve(repoRoot, p),
|
|
191
|
+
);
|
|
191
192
|
|
|
192
193
|
// Filter to files inside the source language dir that match include/exclude
|
|
193
194
|
const includedGlob = await glob(includePatterns, {
|
|
@@ -204,135 +205,202 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
|
|
|
204
205
|
|
|
205
206
|
return Promise.all(
|
|
206
207
|
changed.map(async (file) => {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
path.relative(process.cwd(), file),
|
|
214
|
-
]);
|
|
215
|
-
} catch {
|
|
216
|
-
// treat as new file
|
|
217
|
-
}
|
|
208
|
+
const relativePath = path.relative(sourceLangRoot, file);
|
|
209
|
+
const diff = await git.diff([
|
|
210
|
+
`${baseCommit}..HEAD`,
|
|
211
|
+
'--',
|
|
212
|
+
path.relative(repoRoot, file),
|
|
213
|
+
]);
|
|
218
214
|
return {
|
|
219
|
-
relativePath
|
|
215
|
+
relativePath,
|
|
220
216
|
sourceAbsPath: file,
|
|
221
|
-
|
|
222
|
-
diff,
|
|
217
|
+
diffPath: diff ? await writeDiff(relativePath, diff) : undefined,
|
|
223
218
|
};
|
|
224
219
|
}),
|
|
225
220
|
);
|
|
226
221
|
}
|
|
227
222
|
|
|
228
223
|
function buildSystemPrompt(targetLang: string): string {
|
|
229
|
-
return `You are an expert technical-documentation translator.
|
|
230
|
-
|
|
231
|
-
Both values are locale codes (e.g. ISO 639-1 / BCP-47 style, or common short forms like \`jp\`, \`zh\`, \`pt\`). Interpret them yourself and translate naturally into the language they identify. If a code is ambiguous, prefer the most widely used written form.
|
|
232
|
-
|
|
233
|
-
You have one tool available: \`fileEditor\`. Use it to:
|
|
234
|
-
- Read the source file (\`command: "view"\`). For large files you can read a portion at a time using \`view_range\`, then request the next range.
|
|
235
|
-
- Read the existing translation if one is provided (\`command: "view"\`).
|
|
236
|
-
- Write the translated file (\`command: "create"\` — it overwrites).
|
|
237
|
-
|
|
238
|
-
Translation rules:
|
|
239
|
-
1. Translate natural-language prose into the target language. Keep technical accuracy.
|
|
240
|
-
2. DO NOT translate:
|
|
241
|
-
- Code blocks and inline code (text inside backticks)
|
|
242
|
-
- URLs, link paths, and HTML/JSX/MDX tag names and attributes
|
|
243
|
-
- \`import\` statements, component names, and frontmatter keys
|
|
244
|
-
- Proper names of people, products, or AWS services
|
|
245
|
-
3. Preserve every aspect of the MDX structure exactly: frontmatter delimiters, headings, lists, code blocks, MDX components, JSX, whitespace, blank lines.
|
|
246
|
-
4. For frontmatter:
|
|
247
|
-
- Translate only string values for the \`title\` and \`description\` keys.
|
|
248
|
-
- Leave \`date\`, \`authors\`, \`template\`, \`slug\`, and all other keys untouched.
|
|
249
|
-
5. If any localised link paths embed the source locale (e.g. \`/${config.sourceLanguage}/foo\`), rewrite them to the target locale (\`/${targetLang}/foo\`).
|
|
250
|
-
6. Efficiency rule: if an existing translation is provided AND a diff is provided, reuse the existing translation verbatim for any section of the document that is NOT touched by the diff. Only retranslate sections that actually changed. Still emit the complete final file.
|
|
251
|
-
7. If no existing translation exists, translate the whole file.
|
|
252
|
-
8. Never wrap the file output in triple backticks.
|
|
253
|
-
9. Always use absolute paths with \`fileEditor\`.
|
|
254
|
-
|
|
255
|
-
When the translated file has been written, reply with a one-line summary and stop.`;
|
|
256
|
-
}
|
|
224
|
+
return `You are an expert technical-documentation translator. You translate MDX documentation files from the source locale \`${config.sourceLanguage}\` into the target locale \`${targetLang}\`.
|
|
257
225
|
|
|
258
|
-
|
|
259
|
-
file: FileToTranslate,
|
|
260
|
-
targetLang: string,
|
|
261
|
-
): string {
|
|
262
|
-
const targetLangRoot = path.join(DOCS_DIR, targetLang);
|
|
263
|
-
const targetAbsPath = path.join(targetLangRoot, file.relativePath);
|
|
264
|
-
const existingTranslationExists = fs.existsSync(targetAbsPath);
|
|
226
|
+
Both are locale codes (ISO 639-1 / BCP-47 style, or short forms like \`jp\`, \`zh\`, \`pt\`). Translate naturally into the language the target code identifies.
|
|
265
227
|
|
|
266
|
-
|
|
267
|
-
? `Git diff showing what changed in the source since the last translation:\n\`\`\`diff\n${file.diff.slice(0, 40_000)}\n\`\`\``
|
|
268
|
-
: 'There is no prior diff for this file — treat it as new content and translate in full.';
|
|
228
|
+
Use the \`fileEditor\` tool to read the files you are given and to write your translation. Read a long file in slices with \`view_range\`. Use the absolute paths exactly as given — write to the target path verbatim, never to a variation of it.
|
|
269
229
|
|
|
270
|
-
|
|
271
|
-
? `An existing translation for locale \`${targetLang}\` already lives at:\n \`${targetAbsPath}\`\nRead it with \`fileEditor\` (\`command: "view"\`) before writing so you can reuse any sections that have not changed.`
|
|
272
|
-
: `No existing translation exists yet — you will create it fresh.`;
|
|
230
|
+
Translate the prose. Everything else is copied from the source exactly as it is:
|
|
273
231
|
|
|
274
|
-
|
|
232
|
+
- Code blocks. Reproduce each one character for character: the language and \`title\` on the opening fence, and every line of the body. Comments inside code are code — a \`# Creates a new subsegment\` stays in English. Never translate anything between the fences.
|
|
233
|
+
- Inline code, URLs, link paths, \`import\` statements, component names, JSX attributes, and \`<Snippet name="..." />\` values.
|
|
234
|
+
- Product, service, and people's names, and package names such as \`@aws/nx-plugin\` — translate no part of them, even where they appear in prose or in a \`title\`.
|
|
235
|
+
- The MDX structure itself: frontmatter delimiters, heading levels, lists, and the blank lines between blocks. Add none and remove none.
|
|
275
236
|
|
|
276
|
-
|
|
277
|
-
- Target file (write the translation here, absolute path): \`${targetAbsPath}\`
|
|
237
|
+
Two things do change:
|
|
278
238
|
|
|
279
|
-
|
|
239
|
+
- In frontmatter, translate only \`title\` and \`description\`. Leave every other key and value alone. Frontmatter is YAML, so wrap a value in double quotes whenever it would otherwise begin with a character YAML reserves — for example a title starting with \`@aws/nx-plugin\` must be written as \`"@aws/nx-plugin ..."\`.
|
|
240
|
+
- Rewrite link paths that embed the source locale (\`/${config.sourceLanguage}/foo\` becomes \`/${targetLang}/foo\`), and make a \`parentHeading\` match the translated heading it sits under.
|
|
280
241
|
|
|
281
|
-
|
|
242
|
+
Write the finished file in one \`create\` call, and never with \`str_replace\` — a partial replacement silently duplicates or splices sections. Do not wrap the file in triple backticks.
|
|
282
243
|
|
|
283
|
-
|
|
284
|
-
1. Read the source file. If it is long, read it in slices using \`view_range\`.
|
|
285
|
-
2. If an existing translation is present, read it first so you can reuse unchanged sections.
|
|
286
|
-
3. Write the full translated file to the target path using \`command: "create"\`.
|
|
287
|
-
4. Reply with a single short confirmation line.`;
|
|
244
|
+
When the file is written, reply with a one-line summary and stop.`;
|
|
288
245
|
}
|
|
289
246
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
targetLang: string,
|
|
293
|
-
): Promise<void> {
|
|
294
|
-
const targetAbsPath = path.join(
|
|
295
|
-
DOCS_DIR,
|
|
296
|
-
targetLang,
|
|
297
|
-
file.relativePath,
|
|
298
|
-
);
|
|
299
|
-
const beforeMtimeMs = fs.existsSync(targetAbsPath)
|
|
300
|
-
? (await fs.stat(targetAbsPath)).mtimeMs
|
|
301
|
-
: 0;
|
|
302
|
-
|
|
303
|
-
const agent = new Agent({
|
|
304
|
-
model: new BedrockModel({
|
|
305
|
-
modelId: config.modelId,
|
|
306
|
-
region: process.env.AWS_REGION ?? config.awsRegion,
|
|
307
|
-
maxTokens: 64_000,
|
|
308
|
-
temperature: 0.2,
|
|
309
|
-
}),
|
|
310
|
-
systemPrompt: buildSystemPrompt(targetLang),
|
|
311
|
-
tools: [fileEditor],
|
|
312
|
-
printer: !!options.verbose,
|
|
313
|
-
});
|
|
314
|
-
agent.addHook(BeforeToolCallEvent, rejectOutsideDocsDir);
|
|
247
|
+
function buildUserPrompt(file: FileToTranslate, targetLang: string): string {
|
|
248
|
+
const targetAbsPath = path.join(DOCS_DIR, targetLang, file.relativePath);
|
|
315
249
|
|
|
316
|
-
|
|
250
|
+
if (!file.diffPath) {
|
|
251
|
+
return `Translate \`${file.sourceAbsPath}\` into locale \`${targetLang}\` and write it to \`${targetAbsPath}\`.
|
|
317
252
|
|
|
318
|
-
|
|
319
|
-
log.warn(
|
|
320
|
-
`agent stopped with reason=${result.stopReason} while translating ${file.relativePath} → ${targetLang} — inspect output above`,
|
|
321
|
-
);
|
|
253
|
+
Read the source, then write the whole translated file with \`create\`. Always write it, even if a translation is already there — it may be out of date.`;
|
|
322
254
|
}
|
|
323
255
|
|
|
324
|
-
|
|
325
|
-
|
|
256
|
+
return `Update the \`${targetLang}\` translation of \`${file.sourceAbsPath}\`.
|
|
257
|
+
|
|
258
|
+
- Source: \`${file.sourceAbsPath}\`
|
|
259
|
+
- Existing translation to update: \`${targetAbsPath}\`
|
|
260
|
+
- Diff of what changed in the source: \`${file.diffPath}\`
|
|
261
|
+
|
|
262
|
+
Read all three. Retranslate only what the diff touched, keep the existing translated wording everywhere else, then write the whole file back with \`create\`.`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const FENCE_RE = /^\s*(?:`{3,}|~{3,})/;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Split a document into alternating prose and fenced-code segments.
|
|
269
|
+
*/
|
|
270
|
+
function splitOnFences(text: string): { prose: string[]; code: string[] } {
|
|
271
|
+
const prose: string[] = [];
|
|
272
|
+
const code: string[] = [];
|
|
273
|
+
let current: string[] = [];
|
|
274
|
+
let inFence = false;
|
|
275
|
+
for (const line of text.split('\n')) {
|
|
276
|
+
if (FENCE_RE.test(line)) {
|
|
277
|
+
(inFence ? code : prose).push(current.concat(line).join('\n'));
|
|
278
|
+
current = [];
|
|
279
|
+
inFence = !inFence;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
current.push(line);
|
|
283
|
+
}
|
|
284
|
+
(inFence ? code : prose).push(current.join('\n'));
|
|
285
|
+
return { prose, code };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* YAML frontmatter must still parse after translation. A value that begins with
|
|
290
|
+
* a character YAML reserves — most often a title starting with \`@aws/...\` —
|
|
291
|
+
* breaks the docs build, so reject it here and let the retry quote it.
|
|
292
|
+
*/
|
|
293
|
+
function assertFrontmatterIsParseable(text: string): void {
|
|
294
|
+
const lines = text.split('\n');
|
|
295
|
+
if (lines[0]?.trim() !== '---') return;
|
|
296
|
+
for (let i = 1; i < lines.length && lines[i].trim() !== '---'; i++) {
|
|
297
|
+
const value = lines[i].match(/^[A-Za-z0-9_-]+:\s+(\S)/)?.[1];
|
|
298
|
+
if (value && '@*&%!|>'.includes(value)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`frontmatter line ${i + 1} starts with the reserved YAML character "${value}" and must be quoted: ${lines[i].trim()}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Copy the source's code blocks over the translated file's. The prompt tells the
|
|
308
|
+
* agent to reproduce them verbatim, but it occasionally translates a comment
|
|
309
|
+
* anyway, which would ship stale or broken code.
|
|
310
|
+
*
|
|
311
|
+
* A differing block count means the translation lost the document's shape —
|
|
312
|
+
* usually a section duplicated or dropped — so this throws rather than stitching
|
|
313
|
+
* mismatched blocks together, letting the caller retry.
|
|
314
|
+
*/
|
|
315
|
+
async function restoreCodeBlocks(
|
|
316
|
+
sourceAbsPath: string,
|
|
317
|
+
targetAbsPath: string,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
const source = splitOnFences(await fs.readFile(sourceAbsPath, 'utf-8'));
|
|
320
|
+
const target = splitOnFences(await fs.readFile(targetAbsPath, 'utf-8'));
|
|
321
|
+
|
|
322
|
+
if (source.code.length !== target.code.length) {
|
|
326
323
|
throw new Error(
|
|
327
|
-
`
|
|
324
|
+
`translation has ${target.code.length} code block(s) but the source has ${source.code.length}`,
|
|
328
325
|
);
|
|
329
326
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
327
|
+
if (source.code.length === 0) return;
|
|
328
|
+
|
|
329
|
+
const merged = target.prose.flatMap((chunk, i) =>
|
|
330
|
+
i < source.code.length ? [chunk, source.code[i]] : [chunk],
|
|
331
|
+
);
|
|
332
|
+
await fs.writeFile(targetAbsPath, merged.join('\n'), 'utf-8');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Number of times to retry a single (file x language) translation. Large files
|
|
337
|
+
* occasionally hit transient Bedrock streaming errors; a fresh agent invocation
|
|
338
|
+
* re-reads the files from disk, so retries are idempotent.
|
|
339
|
+
*/
|
|
340
|
+
const TRANSLATE_MAX_ATTEMPTS = 4;
|
|
341
|
+
|
|
342
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
343
|
+
|
|
344
|
+
async function translateFileForLanguage(
|
|
345
|
+
file: FileToTranslate,
|
|
346
|
+
targetLang: string,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
const targetAbsPath = path.join(DOCS_DIR, targetLang, file.relativePath);
|
|
349
|
+
|
|
350
|
+
let lastError: unknown;
|
|
351
|
+
for (let attempt = 1; attempt <= TRANSLATE_MAX_ATTEMPTS; attempt++) {
|
|
352
|
+
const agent = new Agent({
|
|
353
|
+
model: new BedrockModel({
|
|
354
|
+
modelId: config.modelId,
|
|
355
|
+
region: process.env.AWS_REGION ?? config.awsRegion,
|
|
356
|
+
maxTokens: 64_000,
|
|
357
|
+
temperature: 0.2,
|
|
358
|
+
// Translating a long file can stream for several minutes, well past the
|
|
359
|
+
// AWS SDK's two-minute default socket timeout.
|
|
360
|
+
clientConfig: { requestHandler: { requestTimeout: 15 * 60_000 } },
|
|
361
|
+
}),
|
|
362
|
+
systemPrompt: buildSystemPrompt(targetLang),
|
|
363
|
+
tools: [fileEditor],
|
|
364
|
+
printer: !!options.verbose,
|
|
365
|
+
});
|
|
366
|
+
agent.addHook(BeforeToolCallEvent, rejectOutsideDocsDir);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const result = await agent.invoke(buildUserPrompt(file, targetLang));
|
|
370
|
+
if (result.stopReason !== 'endTurn') {
|
|
371
|
+
log.warn(
|
|
372
|
+
`agent stopped with reason=${result.stopReason} while translating ${file.relativePath} → ${targetLang} — inspect output above`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
lastError = undefined;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
lastError = err;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Judge the file on disk rather than on whether the agent wrote it: the
|
|
381
|
+
// stream often drops after a successful write, and an agent handed an
|
|
382
|
+
// already-correct translation may rightly decide there is nothing to do.
|
|
383
|
+
if (fs.existsSync(targetAbsPath)) {
|
|
384
|
+
try {
|
|
385
|
+
assertFrontmatterIsParseable(await fs.readFile(targetAbsPath, 'utf-8'));
|
|
386
|
+
await restoreCodeBlocks(file.sourceAbsPath, targetAbsPath);
|
|
387
|
+
return;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
lastError = err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
lastError ??= new Error('no translation exists at the target path');
|
|
393
|
+
|
|
394
|
+
if (attempt < TRANSLATE_MAX_ATTEMPTS) {
|
|
395
|
+
const delayMs = 2000 * attempt;
|
|
396
|
+
log.warn(
|
|
397
|
+
`translation attempt ${attempt}/${TRANSLATE_MAX_ATTEMPTS} for ${file.relativePath} → ${targetLang} failed (${lastError instanceof Error ? lastError.message : String(lastError)}); retrying in ${delayMs}ms`,
|
|
398
|
+
);
|
|
399
|
+
await sleep(delayMs);
|
|
400
|
+
}
|
|
335
401
|
}
|
|
402
|
+
|
|
403
|
+
throw lastError;
|
|
336
404
|
}
|
|
337
405
|
|
|
338
406
|
/**
|
|
@@ -356,6 +424,46 @@ async function runWithConcurrency<T>(
|
|
|
356
424
|
return results;
|
|
357
425
|
}
|
|
358
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Delete translated files whose source document no longer exists. Without this
|
|
429
|
+
* a source file that is renamed or removed leaves its translations behind in
|
|
430
|
+
* every locale, where they still resolve as snippets and pages.
|
|
431
|
+
*/
|
|
432
|
+
async function pruneOrphanedTranslations(
|
|
433
|
+
targetLanguages: string[],
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
const sourceLangRoot = `${DOCS_DIR}/${config.sourceLanguage}`;
|
|
436
|
+
const sourceFiles = new Set(
|
|
437
|
+
(
|
|
438
|
+
await glob(
|
|
439
|
+
config.include.map((p) => `${sourceLangRoot}/${p}`),
|
|
440
|
+
{ ignore: config.exclude.map((p) => `${sourceLangRoot}/${p}`) },
|
|
441
|
+
)
|
|
442
|
+
).map((p) => path.relative(sourceLangRoot, p)),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
for (const lang of targetLanguages) {
|
|
446
|
+
const langRoot = path.join(DOCS_DIR, lang);
|
|
447
|
+
if (!fs.existsSync(langRoot)) continue;
|
|
448
|
+
const translated = await glob(
|
|
449
|
+
config.include.map((p) => `${langRoot}/${p}`),
|
|
450
|
+
{ ignore: config.exclude.map((p) => `${langRoot}/${p}`) },
|
|
451
|
+
);
|
|
452
|
+
for (const abs of translated) {
|
|
453
|
+
const rel = path.relative(langRoot, abs);
|
|
454
|
+
if (sourceFiles.has(rel)) continue;
|
|
455
|
+
if (options.dryRun) {
|
|
456
|
+
log.info(`[dry-run] would delete orphaned ${lang}/${rel}`);
|
|
457
|
+
} else {
|
|
458
|
+
log.info(
|
|
459
|
+
`Deleting orphaned ${lang}/${rel} (no ${config.sourceLanguage} source)`,
|
|
460
|
+
);
|
|
461
|
+
await fs.remove(abs);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
359
467
|
async function main() {
|
|
360
468
|
const requestedLanguages: string[] = options.languages
|
|
361
469
|
? options.languages
|
|
@@ -376,17 +484,19 @@ async function main() {
|
|
|
376
484
|
log.info(`Source: ${config.sourceLanguage}`);
|
|
377
485
|
log.info(`Targets: ${targetLanguages.join(', ')}`);
|
|
378
486
|
|
|
487
|
+
await pruneOrphanedTranslations(targetLanguages);
|
|
488
|
+
|
|
379
489
|
const files = await getFilesToTranslate();
|
|
380
490
|
|
|
381
491
|
if (files.length === 0) {
|
|
382
|
-
log.info('
|
|
492
|
+
log.info('No documentation files to translate.');
|
|
383
493
|
return;
|
|
384
494
|
}
|
|
385
495
|
|
|
386
496
|
log.info(`Files to translate: ${files.length}`);
|
|
387
497
|
for (const f of files) {
|
|
388
498
|
log.verbose(
|
|
389
|
-
` - ${f.relativePath}${f.
|
|
499
|
+
` - ${f.relativePath}${f.diffPath ? ' (changed)' : ' (full)'}`,
|
|
390
500
|
);
|
|
391
501
|
}
|
|
392
502
|
|
|
@@ -401,7 +511,10 @@ async function main() {
|
|
|
401
511
|
}
|
|
402
512
|
|
|
403
513
|
// Build one task per (file × target language) — each is a fresh agent invocation,
|
|
404
|
-
// so context windows stay small no matter how big the docs site is.
|
|
514
|
+
// so context windows stay small no matter how big the docs site is. A single
|
|
515
|
+
// failing translation records its error rather than aborting the whole run, so
|
|
516
|
+
// every other (file × language) still completes and gets written to disk.
|
|
517
|
+
const failures: string[] = [];
|
|
405
518
|
const tasks = targetLanguages.flatMap((lang) =>
|
|
406
519
|
files.map((file) => async () => {
|
|
407
520
|
log.info(` ${file.relativePath} → ${lang}`);
|
|
@@ -411,7 +524,7 @@ async function main() {
|
|
|
411
524
|
log.error(
|
|
412
525
|
`${file.relativePath} → ${lang}: ${err instanceof Error ? err.message : String(err)}`,
|
|
413
526
|
);
|
|
414
|
-
|
|
527
|
+
failures.push(`${file.relativePath} → ${lang}`);
|
|
415
528
|
}
|
|
416
529
|
}),
|
|
417
530
|
);
|
|
@@ -422,10 +535,18 @@ async function main() {
|
|
|
422
535
|
);
|
|
423
536
|
await runWithConcurrency(tasks, concurrency);
|
|
424
537
|
|
|
538
|
+
if (failures.length > 0) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`${failures.length} translation(s) failed after retries:\n ${failures.join('\n ')}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
425
544
|
log.info('Done.');
|
|
426
545
|
}
|
|
427
546
|
|
|
428
|
-
main()
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
547
|
+
main()
|
|
548
|
+
.finally(() => fs.remove(DIFF_DIR))
|
|
549
|
+
.catch((err) => {
|
|
550
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
551
|
+
process.exit(1);
|
|
552
|
+
});
|