@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.
Files changed (34) hide show
  1. package/migrations.json +6 -1
  2. package/package.json +1 -1
  3. package/src/migrations/latest/translate-script-whole-file-writes/files/build-system-prompt.ts.fixture +23 -0
  4. package/src/migrations/latest/translate-script-whole-file-writes/files/build-user-prompt.ts.fixture +96 -0
  5. package/src/migrations/latest/translate-script-whole-file-writes/files/config-path.ts.fixture +1 -0
  6. package/src/migrations/latest/translate-script-whole-file-writes/files/file-to-translate.ts.fixture +10 -0
  7. package/src/migrations/latest/translate-script-whole-file-writes/files/get-files-to-translate.ts.fixture +96 -0
  8. package/src/migrations/latest/translate-script-whole-file-writes/files/header.ts.fixture +15 -0
  9. package/src/migrations/latest/translate-script-whole-file-writes/files/log.ts.fixture +18 -0
  10. package/src/migrations/latest/translate-script-whole-file-writes/files/main-call.ts.fixture +6 -0
  11. package/src/migrations/latest/translate-script-whole-file-writes/files/main.ts.fixture +79 -0
  12. package/src/migrations/latest/translate-script-whole-file-writes/files/project-root.ts.fixture +1 -0
  13. package/src/migrations/latest/translate-script-whole-file-writes/files/run-with-concurrency.ts.fixture +57 -0
  14. package/src/migrations/latest/translate-script-whole-file-writes/files/scripts-dir.ts.fixture +1 -0
  15. package/src/migrations/latest/translate-script-whole-file-writes/files/translate-file-for-language.ts.fixture +61 -0
  16. package/src/migrations/latest/translate-script-whole-file-writes/grit/already-migrated.grit +1 -0
  17. package/src/migrations/latest/translate-script-whole-file-writes/grit/build-system-prompt.grit +6 -0
  18. package/src/migrations/latest/translate-script-whole-file-writes/grit/build-user-prompt.grit +6 -0
  19. package/src/migrations/latest/translate-script-whole-file-writes/grit/config-path.grit +1 -0
  20. package/src/migrations/latest/translate-script-whole-file-writes/grit/file-to-translate.grit +11 -0
  21. package/src/migrations/latest/translate-script-whole-file-writes/grit/get-files-to-translate.grit +7 -0
  22. package/src/migrations/latest/translate-script-whole-file-writes/grit/log.grit +3 -0
  23. package/src/migrations/latest/translate-script-whole-file-writes/grit/main-call.grit +1 -0
  24. package/src/migrations/latest/translate-script-whole-file-writes/grit/main.grit +8 -0
  25. package/src/migrations/latest/translate-script-whole-file-writes/grit/project-root.grit +1 -0
  26. package/src/migrations/latest/translate-script-whole-file-writes/grit/run-with-concurrency.grit +6 -0
  27. package/src/migrations/latest/translate-script-whole-file-writes/grit/translate-file-for-language.grit +6 -0
  28. package/src/migrations/latest/translate-script-whole-file-writes/metadata.json +3 -0
  29. package/src/migrations/latest/translate-script-whole-file-writes/migration.d.ts +2 -0
  30. package/src/migrations/latest/translate-script-whole-file-writes/migration.js +103 -0
  31. package/src/migrations/latest/translate-script-whole-file-writes/migration.js.map +1 -0
  32. package/src/migrations/latest/translate-script-whole-file-writes/test-fixtures/released-script-first.ts.fixture +430 -0
  33. package/src/migrations/latest/translate-script-whole-file-writes/test-fixtures/released-script.ts.fixture +431 -0
  34. package/src/ts/astro-docs/files/translation/scripts/translate.ts.template +269 -148
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { getProjects, joinPathFragments } from "@nx/devkit";
7
+ import { TS_ASTRO_DOCS_GENERATOR_INFO } from "../../../ts/astro-docs/generator.js";
8
+ import { insertViaGritQL, matchGritQL } from "../../../utils/ast.js";
9
+ import { formatFilesInSubtree } from "../../../utils/format.js";
10
+ import { isEsmWorkspace } from "../../../utils/module-format.js";
11
+ import { getPackageManagerDisplayCommands } from "../../../utils/pkg-manager.js";
12
+ /**
13
+ * Rewrite the docs translate script so it writes whole translated files.
14
+ *
15
+ * The script used to tell the agent to update an existing translation with
16
+ * `str_replace` edits. A replacement that did not match cleanly duplicated or
17
+ * spliced sections, and nothing checked the result, so the damage was committed.
18
+ * The rewrite also fixes incremental runs, which never matched a changed file in
19
+ * a generated workspace, and raises the Bedrock socket timeout that made every
20
+ * large file fail.
21
+ *
22
+ * Most of the script changed, so it is rewritten a declaration at a time. Each
23
+ * pattern in `grit/` matches one declaration as a previous release wrote it and
24
+ * rewrites it to a placeholder, which `insertViaGritQL` swaps for today's version
25
+ * from `files/`. Routing the replacement through a placeholder keeps it out of the
26
+ * pattern: the script is full of template literals, and a GritQL snippet
27
+ * containing a backtick cannot be parsed.
28
+ *
29
+ * Each pattern recognises its declaration by name *and* by statements the released
30
+ * body contained, so a declaration the user has edited no longer matches. The
31
+ * rewrite is all or nothing — every pattern must match before anything is written
32
+ * — so an edited script keeps its whole file and is reported through `nextSteps`
33
+ * rather than being half-rewritten from two different versions.
34
+ */ /**
35
+ * The declarations that changed, in the order they appear in the script. Each name
36
+ * pairs a `grit/<name>.grit` pattern matching its previous form with a
37
+ * `files/<name>.ts.fixture` holding its replacement, which carries any new
38
+ * declarations that follow it.
39
+ *
40
+ * `grit/already-migrated.grit` stands apart: it recognises today's script rather
41
+ * than rewriting anything.
42
+ */ const REWRITES = [
43
+ 'project-root',
44
+ 'config-path',
45
+ 'file-to-translate',
46
+ 'log',
47
+ 'get-files-to-translate',
48
+ 'build-system-prompt',
49
+ 'build-user-prompt',
50
+ 'translate-file-for-language',
51
+ 'run-with-concurrency',
52
+ 'main',
53
+ 'main-call'
54
+ ];
55
+ /** A leading block comment, which GritQL does not match. */ const HEADER_COMMENT = /^\/\*\*[\s\S]*?\*\/\n/;
56
+ const readGritPattern = (name)=>`language js\n${readFileSync(join(import.meta.dirname, 'grit', `${name}.grit`), 'utf-8').trim()}`;
57
+ const readReplacement = (name)=>readFileSync(join(import.meta.dirname, 'files', `${name}.ts.fixture`), 'utf-8');
58
+ /** Fills in the values the generator interpolates when it vends the script. */ const render = (tree, source, fullyQualifiedName)=>source.replace(/<% if \(esm\) \{ %>(.*?)<% \} else \{ %>(.*?)<% \} %>/g, isEsmWorkspace(tree) ? '$1' : '$2').replace(/<%= pkgMgrCmd %>/g, getPackageManagerDisplayCommands().exec).replace(/<%= fullyQualifiedName %>/g, fullyQualifiedName);
59
+ const editedNextStep = (projectName)=>`${projectName}: its scripts/translate.ts has been customised, so it was left as it is. The script now writes each translation as a whole file rather than editing it in place — see https://awslabs.github.io/nx-plugin-for-aws/guides/astro-docs/ for the version it expects.`;
60
+ export default async function migration(tree) {
61
+ const nextSteps = [];
62
+ for (const [name, project] of getProjects(tree)){
63
+ const metadata = project.metadata;
64
+ if (metadata?.generator !== TS_ASTRO_DOCS_GENERATOR_INFO.id) {
65
+ continue;
66
+ }
67
+ // Absent when the docs site was generated with --noTranslation.
68
+ const scriptPath = joinPathFragments(project.root, 'scripts', 'translate.ts');
69
+ if (!tree.exists(scriptPath)) {
70
+ continue;
71
+ }
72
+ // Already migrated: matched by a declaration only today's script has, so a
73
+ // second run is a no-op rather than a rewrite.
74
+ if (await matchGritQL(tree, scriptPath, readGritPattern('already-migrated'))) {
75
+ continue;
76
+ }
77
+ // All or nothing: every declaration must still be the one a release vended,
78
+ // so a partly edited script is never left half-rewritten.
79
+ const matches = await Promise.all(REWRITES.map((rewrite)=>matchGritQL(tree, scriptPath, readGritPattern(rewrite))));
80
+ if (!matches.every(Boolean)) {
81
+ nextSteps.push(editedNextStep(name));
82
+ continue;
83
+ }
84
+ // The header is a leading comment, which GritQL does not reach. Anchored to
85
+ // the start of the file so the replacement cannot match anything else.
86
+ tree.write(scriptPath, (tree.read(scriptPath, 'utf-8') ?? '').replace(HEADER_COMMENT, render(tree, readReplacement('header'), name)));
87
+ // The first release resolved paths from `__dirname` directly, with no
88
+ // `SCRIPTS_DIR` for the rewrites below to build on.
89
+ const script = tree.read(scriptPath, 'utf-8') ?? '';
90
+ if (!/^const SCRIPTS_DIR /m.test(script)) {
91
+ tree.write(scriptPath, script.replace(/^const PROJECT_ROOT /m, `${render(tree, readReplacement('scripts-dir'), name).trimEnd()}\nconst PROJECT_ROOT `));
92
+ }
93
+ for (const rewrite of REWRITES){
94
+ await insertViaGritQL(tree, scriptPath, readGritPattern(rewrite), render(tree, readReplacement(rewrite), name).trimEnd());
95
+ }
96
+ }
97
+ await formatFilesInSubtree(tree);
98
+ return {
99
+ nextSteps
100
+ };
101
+ }
102
+
103
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../packages/nx-plugin/src/migrations/latest/translate-script-whole-file-writes/migration.ts"],"sourcesContent":["/**\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * SPDX-License-Identifier: Apache-2.0\n */\nimport { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport {\n getProjects,\n joinPathFragments,\n type MigrationReturnObject,\n type Tree,\n} from '@nx/devkit';\nimport { TS_ASTRO_DOCS_GENERATOR_INFO } from '../../../ts/astro-docs/generator';\nimport { insertViaGritQL, matchGritQL } from '../../../utils/ast';\nimport { formatFilesInSubtree } from '../../../utils/format';\nimport { isEsmWorkspace } from '../../../utils/module-format';\nimport { getPackageManagerDisplayCommands } from '../../../utils/pkg-manager';\n\n/**\n * Rewrite the docs translate script so it writes whole translated files.\n *\n * The script used to tell the agent to update an existing translation with\n * `str_replace` edits. A replacement that did not match cleanly duplicated or\n * spliced sections, and nothing checked the result, so the damage was committed.\n * The rewrite also fixes incremental runs, which never matched a changed file in\n * a generated workspace, and raises the Bedrock socket timeout that made every\n * large file fail.\n *\n * Most of the script changed, so it is rewritten a declaration at a time. Each\n * pattern in `grit/` matches one declaration as a previous release wrote it and\n * rewrites it to a placeholder, which `insertViaGritQL` swaps for today's version\n * from `files/`. Routing the replacement through a placeholder keeps it out of the\n * pattern: the script is full of template literals, and a GritQL snippet\n * containing a backtick cannot be parsed.\n *\n * Each pattern recognises its declaration by name *and* by statements the released\n * body contained, so a declaration the user has edited no longer matches. The\n * rewrite is all or nothing — every pattern must match before anything is written\n * — so an edited script keeps its whole file and is reported through `nextSteps`\n * rather than being half-rewritten from two different versions.\n */\n\n/**\n * The declarations that changed, in the order they appear in the script. Each name\n * pairs a `grit/<name>.grit` pattern matching its previous form with a\n * `files/<name>.ts.fixture` holding its replacement, which carries any new\n * declarations that follow it.\n *\n * `grit/already-migrated.grit` stands apart: it recognises today's script rather\n * than rewriting anything.\n */\nconst REWRITES = [\n 'project-root',\n 'config-path',\n 'file-to-translate',\n 'log',\n 'get-files-to-translate',\n 'build-system-prompt',\n 'build-user-prompt',\n 'translate-file-for-language',\n 'run-with-concurrency',\n 'main',\n 'main-call',\n] as const;\n\n/** A leading block comment, which GritQL does not match. */\nconst HEADER_COMMENT = /^\\/\\*\\*[\\s\\S]*?\\*\\/\\n/;\n\nconst readGritPattern = (name: string): string =>\n `language js\\n${readFileSync(join(import.meta.dirname, 'grit', `${name}.grit`), 'utf-8').trim()}`;\n\nconst readReplacement = (name: string): string =>\n readFileSync(\n join(import.meta.dirname, 'files', `${name}.ts.fixture`),\n 'utf-8',\n );\n\n/** Fills in the values the generator interpolates when it vends the script. */\nconst render = (\n tree: Tree,\n source: string,\n fullyQualifiedName: string,\n): string =>\n source\n .replace(\n /<% if \\(esm\\) \\{ %>(.*?)<% \\} else \\{ %>(.*?)<% \\} %>/g,\n isEsmWorkspace(tree) ? '$1' : '$2',\n )\n .replace(/<%= pkgMgrCmd %>/g, getPackageManagerDisplayCommands().exec)\n .replace(/<%= fullyQualifiedName %>/g, fullyQualifiedName);\n\nconst editedNextStep = (projectName: string): string =>\n `${projectName}: its scripts/translate.ts has been customised, so it was left as it is. The script now writes each translation as a whole file rather than editing it in place — see https://awslabs.github.io/nx-plugin-for-aws/guides/astro-docs/ for the version it expects.`;\n\nexport default async function migration(\n tree: Tree,\n): Promise<MigrationReturnObject> {\n const nextSteps: string[] = [];\n\n for (const [name, project] of getProjects(tree)) {\n const metadata = project.metadata as { generator?: string } | undefined;\n if (metadata?.generator !== TS_ASTRO_DOCS_GENERATOR_INFO.id) {\n continue;\n }\n\n // Absent when the docs site was generated with --noTranslation.\n const scriptPath = joinPathFragments(\n project.root,\n 'scripts',\n 'translate.ts',\n );\n if (!tree.exists(scriptPath)) {\n continue;\n }\n\n // Already migrated: matched by a declaration only today's script has, so a\n // second run is a no-op rather than a rewrite.\n if (\n await matchGritQL(tree, scriptPath, readGritPattern('already-migrated'))\n ) {\n continue;\n }\n\n // All or nothing: every declaration must still be the one a release vended,\n // so a partly edited script is never left half-rewritten.\n const matches = await Promise.all(\n REWRITES.map((rewrite) =>\n matchGritQL(tree, scriptPath, readGritPattern(rewrite)),\n ),\n );\n if (!matches.every(Boolean)) {\n nextSteps.push(editedNextStep(name));\n continue;\n }\n\n // The header is a leading comment, which GritQL does not reach. Anchored to\n // the start of the file so the replacement cannot match anything else.\n tree.write(\n scriptPath,\n (tree.read(scriptPath, 'utf-8') ?? '').replace(\n HEADER_COMMENT,\n render(tree, readReplacement('header'), name),\n ),\n );\n\n // The first release resolved paths from `__dirname` directly, with no\n // `SCRIPTS_DIR` for the rewrites below to build on.\n const script = tree.read(scriptPath, 'utf-8') ?? '';\n if (!/^const SCRIPTS_DIR /m.test(script)) {\n tree.write(\n scriptPath,\n script.replace(\n /^const PROJECT_ROOT /m,\n `${render(tree, readReplacement('scripts-dir'), name).trimEnd()}\\nconst PROJECT_ROOT `,\n ),\n );\n }\n\n for (const rewrite of REWRITES) {\n await insertViaGritQL(\n tree,\n scriptPath,\n readGritPattern(rewrite),\n render(tree, readReplacement(rewrite), name).trimEnd(),\n );\n }\n }\n\n await formatFilesInSubtree(tree);\n\n return { nextSteps };\n}\n"],"names":["readFileSync","join","getProjects","joinPathFragments","TS_ASTRO_DOCS_GENERATOR_INFO","insertViaGritQL","matchGritQL","formatFilesInSubtree","isEsmWorkspace","getPackageManagerDisplayCommands","REWRITES","HEADER_COMMENT","readGritPattern","name","dirname","trim","readReplacement","render","tree","source","fullyQualifiedName","replace","exec","editedNextStep","projectName","migration","nextSteps","project","metadata","generator","id","scriptPath","root","exists","matches","Promise","all","map","rewrite","every","Boolean","push","write","read","script","test","trimEnd"],"mappings":"AAAA;;;CAGC,GACD,SAASA,YAAY,QAAQ,UAAU;AACvC,SAASC,IAAI,QAAQ,YAAY;AACjC,SACEC,WAAW,EACXC,iBAAiB,QAGZ,aAAa;AACpB,SAASC,4BAA4B,QAAQ,sCAAmC;AAChF,SAASC,eAAe,EAAEC,WAAW,QAAQ,wBAAqB;AAClE,SAASC,oBAAoB,QAAQ,2BAAwB;AAC7D,SAASC,cAAc,QAAQ,kCAA+B;AAC9D,SAASC,gCAAgC,QAAQ,gCAA6B;AAE9E;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED;;;;;;;;CAQC,GACD,MAAMC,WAAW;IACf;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,0DAA0D,GAC1D,MAAMC,iBAAiB;AAEvB,MAAMC,kBAAkB,CAACC,OACvB,CAAC,aAAa,EAAEb,aAAaC,KAAK,YAAYa,OAAO,EAAE,QAAQ,GAAGD,KAAK,KAAK,CAAC,GAAG,SAASE,IAAI,IAAI;AAEnG,MAAMC,kBAAkB,CAACH,OACvBb,aACEC,KAAK,YAAYa,OAAO,EAAE,SAAS,GAAGD,KAAK,WAAW,CAAC,GACvD;AAGJ,6EAA6E,GAC7E,MAAMI,SAAS,CACbC,MACAC,QACAC,qBAEAD,OACGE,OAAO,CACN,0DACAb,eAAeU,QAAQ,OAAO,MAE/BG,OAAO,CAAC,qBAAqBZ,mCAAmCa,IAAI,EACpED,OAAO,CAAC,8BAA8BD;AAE3C,MAAMG,iBAAiB,CAACC,cACtB,GAAGA,YAAY,gQAAgQ,CAAC;AAElR,eAAe,eAAeC,UAC5BP,IAAU;IAEV,MAAMQ,YAAsB,EAAE;IAE9B,KAAK,MAAM,CAACb,MAAMc,QAAQ,IAAIzB,YAAYgB,MAAO;QAC/C,MAAMU,WAAWD,QAAQC,QAAQ;QACjC,IAAIA,UAAUC,cAAczB,6BAA6B0B,EAAE,EAAE;YAC3D;QACF;QAEA,gEAAgE;QAChE,MAAMC,aAAa5B,kBACjBwB,QAAQK,IAAI,EACZ,WACA;QAEF,IAAI,CAACd,KAAKe,MAAM,CAACF,aAAa;YAC5B;QACF;QAEA,2EAA2E;QAC3E,+CAA+C;QAC/C,IACE,MAAMzB,YAAYY,MAAMa,YAAYnB,gBAAgB,sBACpD;YACA;QACF;QAEA,4EAA4E;QAC5E,0DAA0D;QAC1D,MAAMsB,UAAU,MAAMC,QAAQC,GAAG,CAC/B1B,SAAS2B,GAAG,CAAC,CAACC,UACZhC,YAAYY,MAAMa,YAAYnB,gBAAgB0B;QAGlD,IAAI,CAACJ,QAAQK,KAAK,CAACC,UAAU;YAC3Bd,UAAUe,IAAI,CAAClB,eAAeV;YAC9B;QACF;QAEA,4EAA4E;QAC5E,uEAAuE;QACvEK,KAAKwB,KAAK,CACRX,YACA,AAACb,CAAAA,KAAKyB,IAAI,CAACZ,YAAY,YAAY,EAAC,EAAGV,OAAO,CAC5CV,gBACAM,OAAOC,MAAMF,gBAAgB,WAAWH;QAI5C,sEAAsE;QACtE,oDAAoD;QACpD,MAAM+B,SAAS1B,KAAKyB,IAAI,CAACZ,YAAY,YAAY;QACjD,IAAI,CAAC,uBAAuBc,IAAI,CAACD,SAAS;YACxC1B,KAAKwB,KAAK,CACRX,YACAa,OAAOvB,OAAO,CACZ,yBACA,GAAGJ,OAAOC,MAAMF,gBAAgB,gBAAgBH,MAAMiC,OAAO,GAAG,qBAAqB,CAAC;QAG5F;QAEA,KAAK,MAAMR,WAAW5B,SAAU;YAC9B,MAAML,gBACJa,MACAa,YACAnB,gBAAgB0B,UAChBrB,OAAOC,MAAMF,gBAAgBsB,UAAUzB,MAAMiC,OAAO;QAExD;IACF;IAEA,MAAMvC,qBAAqBW;IAE3B,OAAO;QAAEQ;IAAU;AACrB"}
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Translate documentation files using a Strands agent powered by Claude on
3
+ * Amazon Bedrock.
4
+ *
5
+ * Configuration lives in ./translate.config.json (sibling to this file).
6
+ * Run from the project root:
7
+ *
8
+ * <%= pkgMgrCmd %> nx translate <%= fullyQualifiedName %> -- --all
9
+ * <%= pkgMgrCmd %> nx translate <%= fullyQualifiedName %> # only files changed since last translate commit
10
+ *
11
+ * The driver gathers the set of changed source docs + their git diffs and hands
12
+ * them to an agent that has read/write access (scoped to the docs directory)
13
+ * via a wrapped version of the built-in `fileEditor` tool. The agent decides
14
+ * how to translate each file and writes the output itself.
15
+ */
16
+ import { Command } from 'commander';
17
+ import fs from 'fs-extra';
18
+ import path from 'path';
19
+ import { simpleGit } from 'simple-git';
20
+ import glob from 'fast-glob';
21
+ import { Agent, BeforeToolCallEvent } from '@strands-agents/sdk';
22
+ import { BedrockModel } from '@strands-agents/sdk/models/bedrock';
23
+ import { fileEditor } from '@strands-agents/sdk/vended-tools/file-editor';
24
+
25
+ interface TranslateConfig {
26
+ sourceLanguage: string;
27
+ targetLanguages: string[];
28
+ docsDir: string;
29
+ include: string[];
30
+ exclude: string[];
31
+ modelId: string;
32
+ awsRegion: string;
33
+ /**
34
+ * Maximum number of (file x language) translations running in parallel. Each one
35
+ * is a fresh agent invocation, so this caps concurrent Bedrock requests.
36
+ * Defaults to 5 when omitted.
37
+ */
38
+ concurrency?: number;
39
+ /**
40
+ * Commit message marker used to identify previous translation commits, so
41
+ * incremental runs only re-translate changes since the last translation.
42
+ * Defaults to "docs: update translations".
43
+ */
44
+ translationCommitMessage?: string;
45
+ }
46
+
47
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
48
+ const CONFIG_PATH = path.resolve(__dirname, 'translate.config.json');
49
+ const config: TranslateConfig = JSON.parse(
50
+ fs.readFileSync(CONFIG_PATH, 'utf-8'),
51
+ );
52
+ const DOCS_DIR = path.resolve(PROJECT_ROOT, config.docsDir);
53
+ const TRANSLATION_COMMIT_MESSAGE =
54
+ config.translationCommitMessage ?? 'docs: update translations';
55
+
56
+ /**
57
+ * Reject any `fileEditor` call whose `path` resolves outside the configured
58
+ * docs directory. Runs as a `BeforeToolCallEvent` hook so we reuse the
59
+ * built-in tool's schema/description verbatim — nothing else uses fileEditor in
60
+ * this script, so the check is unconditional.
61
+ */
62
+ function rejectOutsideDocsDir(event: BeforeToolCallEvent): void {
63
+ if (event.toolUse.name !== 'fileEditor') return;
64
+ const input = event.toolUse.input as { path?: unknown };
65
+ if (typeof input.path !== 'string') return;
66
+ const resolved = path.resolve(input.path);
67
+ const docsDirWithSep = DOCS_DIR.endsWith(path.sep)
68
+ ? DOCS_DIR
69
+ : DOCS_DIR + path.sep;
70
+ if (resolved !== DOCS_DIR && !resolved.startsWith(docsDirWithSep)) {
71
+ event.cancel = `Path ${resolved} is outside the docs directory (${DOCS_DIR}); refusing access.`;
72
+ }
73
+ }
74
+
75
+ interface FileToTranslate {
76
+ relativePath: string;
77
+ sourceAbsPath: string;
78
+ sourceContent: string;
79
+ /** Empty when this is a newly-added file. */
80
+ diff: string;
81
+ }
82
+
83
+ const program = new Command();
84
+ program
85
+ .name('translate')
86
+ .description('Translate documentation files using a Strands agent')
87
+ .option('-a, --all', 'Translate all source documentation files')
88
+ .option(
89
+ '-l, --languages <languages>',
90
+ 'Comma-separated list of target languages (overrides translate.config.json)',
91
+ )
92
+ .option(
93
+ '-d, --dry-run',
94
+ 'Show what would be translated without invoking the agent',
95
+ )
96
+ .option('-v, --verbose', 'Show verbose output')
97
+ .parse(process.argv);
98
+ const options = program.opts();
99
+
100
+ const log = {
101
+ info: (m: string) => console.log(`[translate] ${m}`),
102
+ warn: (m: string) => console.warn(`[translate] ${m}`),
103
+ error: (m: string) => console.error(`[translate] ERROR ${m}`),
104
+ verbose: (m: string) =>
105
+ options.verbose && console.log(`[translate] ${m}`),
106
+ };
107
+
108
+ /**
109
+ * Gather the set of source-language files to translate, with their diffs.
110
+ */
111
+ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
112
+ const sourceLangRoot = `${DOCS_DIR}/${config.sourceLanguage}`;
113
+ const includePatterns = config.include.map(
114
+ (p) => `${sourceLangRoot}/${p}`,
115
+ );
116
+ const ignorePatterns = config.exclude.map(
117
+ (p) => `${sourceLangRoot}/${p}`,
118
+ );
119
+
120
+ if (options.all) {
121
+ log.info('Translating all source documentation files');
122
+ const files = await glob(includePatterns, { ignore: ignorePatterns });
123
+ return Promise.all(
124
+ files.map(async (file) => ({
125
+ relativePath: path.relative(sourceLangRoot, file),
126
+ sourceAbsPath: file,
127
+ sourceContent: await fs.readFile(file, 'utf-8'),
128
+ diff: '',
129
+ })),
130
+ );
131
+ }
132
+
133
+ const git = simpleGit();
134
+
135
+ let currentBranch: string;
136
+ let mainBranch: string;
137
+ if (process.env.GITHUB_HEAD_REF) {
138
+ currentBranch = `origin/${process.env.GITHUB_HEAD_REF}`;
139
+ mainBranch = 'origin/main';
140
+ } else {
141
+ currentBranch = (await git.branch()).current;
142
+ mainBranch = 'main';
143
+ }
144
+
145
+ try {
146
+ await git.raw(['rev-parse', '--verify', mainBranch]);
147
+ } catch {
148
+ log.warn(
149
+ `Could not find "${mainBranch}"; falling back to --all behaviour`,
150
+ );
151
+ options.all = true;
152
+ return getFilesToTranslate();
153
+ }
154
+
155
+ const mergeBase = (
156
+ await git.raw(['merge-base', mainBranch, currentBranch])
157
+ ).trim();
158
+
159
+ const translationCommits = (
160
+ await git.log({ from: mergeBase, to: 'HEAD' })
161
+ ).all.filter((c) => c.message.includes(TRANSLATION_COMMIT_MESSAGE));
162
+
163
+ const baseCommit =
164
+ translationCommits.length > 0
165
+ ? translationCommits[0].hash
166
+ : mergeBase;
167
+
168
+ log.info(
169
+ translationCommits.length > 0
170
+ ? `Detecting changed files since last translation commit ${baseCommit.substring(0, 7)}`
171
+ : `Detecting changed files since branch creation ${baseCommit.substring(0, 7)}`,
172
+ );
173
+
174
+ const diffNames = (
175
+ await git.diff([
176
+ `${baseCommit}..HEAD`,
177
+ '--name-only',
178
+ '--diff-filter=d',
179
+ ])
180
+ )
181
+ .split('\n')
182
+ .filter(Boolean);
183
+
184
+ const { files: uncommitted } = await git.status();
185
+ const uncommittedNames = uncommitted.map((f) => f.path);
186
+
187
+ const allCandidates = [
188
+ ...new Set([...diffNames, ...uncommittedNames]),
189
+ ].map((p) => path.resolve(process.cwd(), p));
190
+
191
+ // Filter to files inside the source language dir that match include/exclude
192
+ const includedGlob = await glob(includePatterns, {
193
+ ignore: ignorePatterns,
194
+ });
195
+ const includedSet = new Set(includedGlob.map((p) => path.resolve(p)));
196
+
197
+ const changed = allCandidates.filter((abs) => includedSet.has(abs));
198
+
199
+ if (changed.length === 0) {
200
+ log.warn('No changed source documentation files detected');
201
+ return [];
202
+ }
203
+
204
+ return Promise.all(
205
+ changed.map(async (file) => {
206
+ const sourceContent = await fs.readFile(file, 'utf-8');
207
+ let diff = '';
208
+ try {
209
+ diff = await git.diff([
210
+ `${baseCommit}..HEAD`,
211
+ '--',
212
+ path.relative(process.cwd(), file),
213
+ ]);
214
+ } catch {
215
+ // treat as new file
216
+ }
217
+ return {
218
+ relativePath: path.relative(sourceLangRoot, file),
219
+ sourceAbsPath: file,
220
+ sourceContent,
221
+ diff,
222
+ };
223
+ }),
224
+ );
225
+ }
226
+
227
+ function buildSystemPrompt(targetLang: string): string {
228
+ return `You are an expert technical-documentation translator. Your job is to translate a single MDX documentation file from the source locale \`${config.sourceLanguage}\` into the target locale \`${targetLang}\`.
229
+
230
+ 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.
231
+
232
+ You have one tool available: \`fileEditor\`. Use it to:
233
+ - 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.
234
+ - Read the existing translation if one is provided (\`command: "view"\`).
235
+ - Write the translated file (\`command: "create"\` — it overwrites).
236
+
237
+ Translation rules:
238
+ 1. Translate natural-language prose into the target language. Keep technical accuracy.
239
+ 2. DO NOT translate:
240
+ - Code blocks and inline code (text inside backticks)
241
+ - URLs, link paths, and HTML/JSX/MDX tag names and attributes
242
+ - \`import\` statements, component names, and frontmatter keys
243
+ - Proper names of people, products, or AWS services
244
+ 3. Preserve every aspect of the MDX structure exactly: frontmatter delimiters, headings, lists, code blocks, MDX components, JSX, whitespace, blank lines.
245
+ 4. For frontmatter:
246
+ - Translate only string values for the \`title\` and \`description\` keys.
247
+ - Leave \`date\`, \`authors\`, \`template\`, \`slug\`, and all other keys untouched.
248
+ 5. If any localised link paths embed the source locale (e.g. \`/${config.sourceLanguage}/foo\`), rewrite them to the target locale (\`/${targetLang}/foo\`).
249
+ 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.
250
+ 7. If no existing translation exists, translate the whole file.
251
+ 8. Never wrap the file output in triple backticks.
252
+ 9. Always use absolute paths with \`fileEditor\`.
253
+
254
+ When the translated file has been written, reply with a one-line summary and stop.`;
255
+ }
256
+
257
+ function buildUserPrompt(
258
+ file: FileToTranslate,
259
+ targetLang: string,
260
+ ): string {
261
+ const targetLangRoot = path.join(DOCS_DIR, targetLang);
262
+ const targetAbsPath = path.join(targetLangRoot, file.relativePath);
263
+ const existingTranslationExists = fs.existsSync(targetAbsPath);
264
+
265
+ const diffBlock = file.diff
266
+ ? `Git diff showing what changed in the source since the last translation:\n\`\`\`diff\n${file.diff.slice(0, 40_000)}\n\`\`\``
267
+ : 'There is no prior diff for this file — treat it as new content and translate in full.';
268
+
269
+ const existingBlock = existingTranslationExists
270
+ ? `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.`
271
+ : `No existing translation exists yet — you will create it fresh.`;
272
+
273
+ return `Translate one file from source locale \`${config.sourceLanguage}\` into target locale \`${targetLang}\`.
274
+
275
+ - Source file (read from here): \`${file.sourceAbsPath}\`
276
+ - Target file (write the translation here, absolute path): \`${targetAbsPath}\`
277
+
278
+ ${existingBlock}
279
+
280
+ ${diffBlock}
281
+
282
+ Steps:
283
+ 1. Read the source file. If it is long, read it in slices using \`view_range\`.
284
+ 2. If an existing translation is present, read it first so you can reuse unchanged sections.
285
+ 3. Write the full translated file to the target path using \`command: "create"\`.
286
+ 4. Reply with a single short confirmation line.`;
287
+ }
288
+
289
+ async function translateFileForLanguage(
290
+ file: FileToTranslate,
291
+ targetLang: string,
292
+ ): Promise<void> {
293
+ const targetAbsPath = path.join(
294
+ DOCS_DIR,
295
+ targetLang,
296
+ file.relativePath,
297
+ );
298
+ const beforeMtimeMs = fs.existsSync(targetAbsPath)
299
+ ? (await fs.stat(targetAbsPath)).mtimeMs
300
+ : 0;
301
+
302
+ const agent = new Agent({
303
+ model: new BedrockModel({
304
+ modelId: config.modelId,
305
+ region: process.env.AWS_REGION ?? config.awsRegion,
306
+ maxTokens: 64_000,
307
+ temperature: 0.2,
308
+ }),
309
+ systemPrompt: buildSystemPrompt(targetLang),
310
+ tools: [fileEditor],
311
+ printer: !!options.verbose,
312
+ });
313
+ agent.addHook(BeforeToolCallEvent, rejectOutsideDocsDir);
314
+
315
+ const result = await agent.invoke(buildUserPrompt(file, targetLang));
316
+
317
+ if (result.stopReason !== 'endTurn') {
318
+ log.warn(
319
+ `agent stopped with reason=${result.stopReason} while translating ${file.relativePath} → ${targetLang} — inspect output above`,
320
+ );
321
+ }
322
+
323
+ // Sanity check: the target file should now exist and have been written during this run.
324
+ if (!fs.existsSync(targetAbsPath)) {
325
+ throw new Error(
326
+ `agent did not write target file ${targetAbsPath} for ${file.relativePath} → ${targetLang}`,
327
+ );
328
+ }
329
+ const afterMtimeMs = (await fs.stat(targetAbsPath)).mtimeMs;
330
+ if (afterMtimeMs <= beforeMtimeMs) {
331
+ log.warn(
332
+ `target ${file.relativePath} → ${targetLang} was not updated (mtime unchanged) — the agent may have decided no change was needed`,
333
+ );
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Simple concurrency-limited runner.
339
+ */
340
+ async function runWithConcurrency<T>(
341
+ tasks: Array<() => Promise<T>>,
342
+ limit: number,
343
+ ): Promise<T[]> {
344
+ const results: T[] = new Array(tasks.length);
345
+ let next = 0;
346
+ async function worker() {
347
+ while (true) {
348
+ const i = next++;
349
+ if (i >= tasks.length) return;
350
+ results[i] = await tasks[i]();
351
+ }
352
+ }
353
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker);
354
+ await Promise.all(workers);
355
+ return results;
356
+ }
357
+
358
+ async function main() {
359
+ const requestedLanguages: string[] = options.languages
360
+ ? options.languages
361
+ .split(',')
362
+ .map((l: string) => l.trim())
363
+ .filter(Boolean)
364
+ : config.targetLanguages;
365
+
366
+ const targetLanguages = requestedLanguages.filter(
367
+ (l) => l !== config.sourceLanguage,
368
+ );
369
+
370
+ if (targetLanguages.length === 0) {
371
+ log.error('No target languages configured');
372
+ process.exit(1);
373
+ }
374
+
375
+ log.info(`Source: ${config.sourceLanguage}`);
376
+ log.info(`Targets: ${targetLanguages.join(', ')}`);
377
+
378
+ const files = await getFilesToTranslate();
379
+
380
+ if (files.length === 0) {
381
+ log.info('Nothing to translate.');
382
+ return;
383
+ }
384
+
385
+ log.info(`Files to translate: ${files.length}`);
386
+ for (const f of files) {
387
+ log.verbose(
388
+ ` - ${f.relativePath}${f.diff ? ' (changed)' : ' (full)'}`,
389
+ );
390
+ }
391
+
392
+ if (options.dryRun) {
393
+ for (const lang of targetLanguages) {
394
+ for (const f of files) {
395
+ log.info(`[dry-run] would translate ${f.relativePath} → ${lang}`);
396
+ }
397
+ }
398
+ log.info('Done (dry-run).');
399
+ return;
400
+ }
401
+
402
+ // Build one task per (file × target language) — each is a fresh agent invocation,
403
+ // so context windows stay small no matter how big the docs site is.
404
+ const tasks = targetLanguages.flatMap((lang) =>
405
+ files.map((file) => async () => {
406
+ log.info(` ${file.relativePath} → ${lang}`);
407
+ try {
408
+ await translateFileForLanguage(file, lang);
409
+ } catch (err) {
410
+ log.error(
411
+ `${file.relativePath} → ${lang}: ${err instanceof Error ? err.message : String(err)}`,
412
+ );
413
+ throw err;
414
+ }
415
+ }),
416
+ );
417
+
418
+ const concurrency = Math.max(1, config.concurrency ?? 5);
419
+ log.info(
420
+ `Running ${tasks.length} translation(s) with concurrency=${concurrency}`,
421
+ );
422
+ await runWithConcurrency(tasks, concurrency);
423
+
424
+ log.info('Done.');
425
+ }
426
+
427
+ main().catch((err) => {
428
+ log.error(err instanceof Error ? err.message : String(err));
429
+ process.exit(1);
430
+ });