failproofai 0.0.14-beta.0 → 0.0.14-beta.2

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 (219) hide show
  1. package/.next/standalone/.codex/hooks.json +6 -6
  2. package/.next/standalone/.cursor/hooks.json +6 -6
  3. package/.next/standalone/.factory/hooks.json +9 -9
  4. package/.next/standalone/.next/BUILD_ID +1 -1
  5. package/.next/standalone/.next/build-manifest.json +5 -5
  6. package/.next/standalone/.next/prerender-manifest.json +3 -3
  7. package/.next/standalone/.next/required-server-files.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  9. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  25. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  27. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  30. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0--lkk6._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-219ec._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__04_h-a5._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08w4wmd._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g6bbuw._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q0qzx1._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q2nbsl._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xuaoik._.js +3 -3
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__16le-kd._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ffkmds._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1legmza._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qb590j._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1shcqgr._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1xuedea._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  95. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00cz9as._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__030k0c6._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__042d3cd._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__05r_17v._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09q1168._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07qfsfa._.js → [root-of-the-server]__09v9cwb._.js} +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e2l3c1._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h12me5._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hci7t3._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hdsupo._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p84xee._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0twi4fj._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ynf7tx._.js +2 -2
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11rtg6s._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17ai7sy._.js +2 -2
  119. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dky4g0._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1nq7ivq._.js +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +2 -2
  122. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__212nf49._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/_05whahf._.js +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/_1kje4fm._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/app_087bt9w._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/app_1fvisnp._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/app_209u41o._.js +1 -1
  128. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  129. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  131. package/.next/standalone/.next/server/chunks/ssr/lib_0xkhw_v._.js +1 -1
  132. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0g5r64e.js → node_modules_html-to-image_es_index_06xsmte.js} +1 -1
  133. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0-uvagv.js +1 -1
  134. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_03c7gi5.js +1 -1
  135. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_09p-8om.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0v-kfiu.js +1 -1
  137. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0xrgzyz.js +1 -1
  138. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1806lsc.js +1 -1
  139. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1j6dd-e.js +1 -1
  140. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1sa65r-.js +1 -1
  141. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
  142. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  143. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  144. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  145. package/.next/standalone/.next/server/pages/404.html +1 -1
  146. package/.next/standalone/.next/server/pages/500.html +1 -1
  147. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  148. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  149. package/.next/standalone/.next/static/chunks/{13f1kmjea-0md.js → 02jcotghcxrbu.js} +1 -1
  150. package/.next/standalone/.next/static/chunks/{1374x9bv_s_vg.js → 0h-6ymo9v86b0.js} +2 -2
  151. package/.next/standalone/.next/static/chunks/0pndjhcxfu-0t.js +1 -0
  152. package/.next/standalone/.next/static/chunks/1-33f82_l32or.js +1 -0
  153. package/.next/standalone/.next/static/chunks/{003uz088wbmz7.js → 1bg_g3g359c_d.js} +1 -1
  154. package/.next/standalone/.next/static/chunks/{454dyizqik6t9.js → 1gg-8e9q_m__9.js} +2 -2
  155. package/.next/standalone/.next/static/chunks/{2ufuwhv03yvtb.js → 29zkom4s9de2d.js} +1 -1
  156. package/.next/standalone/.next/static/chunks/{17sdztgbcc0fi.js → 2llkdafnaejw_.js} +1 -1
  157. package/.next/standalone/.next/static/chunks/{0iinhn4417nk1.js → 3-ogr79xqbw0h.js} +1 -1
  158. package/.next/standalone/.next/static/chunks/36j05kb2x323i.css +2 -0
  159. package/.next/standalone/.next/static/chunks/{43weu8f59tlix.js → 388hgfqye67xa.js} +1 -1
  160. package/.next/standalone/.next/static/chunks/396764jgd7-9j.js +1 -0
  161. package/.next/standalone/.next/static/chunks/3w7g0n0iwwnbs.js +1 -0
  162. package/.next/standalone/.next/static/chunks/{turbopack-0vavqo2d-tlt3.js → turbopack-10hj8t5pf33tu.js} +1 -1
  163. package/.next/standalone/.opencode/plugins/failproofai.mjs +30 -11
  164. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +11 -2
  165. package/.next/standalone/app/components/copy-button.tsx +22 -6
  166. package/.next/standalone/app/components/global-error-listeners.tsx +27 -0
  167. package/.next/standalone/components/reach-developers.tsx +1 -1
  168. package/.next/standalone/docker-hook-sync/Dockerfile +8 -4
  169. package/.next/standalone/integration-suite/Dockerfile +47 -0
  170. package/.next/standalone/integration-suite/README.md +87 -0
  171. package/.next/standalone/integration-suite/canary-policies.mjs +45 -0
  172. package/.next/standalone/integration-suite/capture-tokens.sh +48 -0
  173. package/.next/standalone/integration-suite/ci-entrypoint.sh +164 -0
  174. package/.next/standalone/integration-suite/inject-tokens.sh +44 -0
  175. package/.next/standalone/integration-suite/install-clis.sh +91 -0
  176. package/.next/standalone/integration-suite/probe-cli.sh +208 -0
  177. package/.next/standalone/integration-suite/report.js +74 -0
  178. package/.next/standalone/integration-suite/run.sh +107 -0
  179. package/.next/standalone/lib/error-origin.ts +67 -0
  180. package/.next/standalone/lib/format-duration.ts +23 -9
  181. package/.next/standalone/lib/install-check.ts +231 -0
  182. package/.next/standalone/package.json +1 -3
  183. package/.next/standalone/server.js +1 -1
  184. package/README.md +1 -17
  185. package/bin/failproofai.mjs +11 -1
  186. package/dist/cli.mjs +818 -320
  187. package/lib/error-origin.ts +67 -0
  188. package/lib/format-duration.ts +23 -9
  189. package/lib/install-check.ts +231 -0
  190. package/package.json +1 -3
  191. package/scripts/dev-hook.mjs +292 -0
  192. package/scripts/install-diagnosis.mjs +5 -8
  193. package/scripts/install-telemetry.mjs +12 -4
  194. package/scripts/translate-docs/cli.ts +53 -1
  195. package/scripts/translate-docs/mdx-translator.ts +158 -18
  196. package/scripts/translate-docs/readme-translator.ts +36 -18
  197. package/scripts/translate-docs/translator.ts +180 -9
  198. package/scripts/translate-docs/types.ts +6 -0
  199. package/scripts/translate-docs/validate-translation.ts +132 -0
  200. package/scripts/validate-mdx.ts +92 -15
  201. package/src/audit/cli.ts +53 -15
  202. package/src/auth/cli.ts +14 -5
  203. package/src/hooks/builtin-policies.ts +168 -50
  204. package/src/hooks/configure-wizard.ts +238 -22
  205. package/src/hooks/custom-hooks-loader.ts +70 -4
  206. package/src/hooks/handler.ts +4 -1
  207. package/src/hooks/policy-types.ts +8 -0
  208. package/src/hooks/tui.ts +96 -29
  209. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ge8e7w._.js +0 -3
  210. package/.next/standalone/.next/static/chunks/0405htbxay9vd.js +0 -1
  211. package/.next/standalone/.next/static/chunks/2m_tbl39fcedn.js +0 -1
  212. package/.next/standalone/.next/static/chunks/2o3ql0n5owqsw.js +0 -1
  213. package/.next/standalone/.next/static/chunks/35-90m9py64d4.js +0 -1
  214. package/.next/standalone/.next/static/chunks/3r--yyjkp9_7b.css +0 -2
  215. package/scripts/postinstall.mjs +0 -242
  216. package/scripts/preuninstall.mjs +0 -131
  217. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_buildManifest.js +0 -0
  218. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_clientMiddlewareManifest.js +0 -0
  219. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_ssgManifest.js +0 -0
@@ -80,12 +80,20 @@ function getInstanceId() {
80
80
 
81
81
  /**
82
82
  * Track a named event to PostHog. No-op when telemetry is disabled.
83
- * Uses process.env.npm_package_version (set automatically by npm in lifecycle scripts).
83
+ *
84
+ * `opts.version` must be supplied by callers outside an npm lifecycle script:
85
+ * npm only sets npm_package_version while running one, and these events now
86
+ * fire from the CLI (see lib/install-check.ts) because package managers block
87
+ * install scripts by default.
88
+ *
89
+ * @param {string} event
90
+ * @param {Record<string, unknown>} [properties]
91
+ * @param {{ version?: string, timeoutMs?: number }} [opts]
84
92
  */
85
- export async function trackInstallEvent(event, properties = {}) {
93
+ export async function trackInstallEvent(event, properties = {}, opts = {}) {
86
94
  if (process.env.FAILPROOFAI_TELEMETRY_DISABLED === "1") return;
87
95
 
88
- const version = process.env.npm_package_version ?? "unknown";
96
+ const version = opts.version ?? process.env.npm_package_version ?? "unknown";
89
97
  const body = JSON.stringify({
90
98
  api_key: process.env.FAILPROOFAI_POSTHOG_KEY ?? API_KEY,
91
99
  event,
@@ -106,7 +114,7 @@ export async function trackInstallEvent(event, properties = {}) {
106
114
  method: "POST",
107
115
  headers: { "Content-Type": "application/json" },
108
116
  body,
109
- signal: AbortSignal.timeout(5000),
117
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
110
118
  }
111
119
  );
112
120
  }
@@ -9,7 +9,11 @@ import {
9
9
  getLanguageByCode,
10
10
  getModelForTier,
11
11
  } from "./config";
12
- import { getEnglishMdxPages, translateMdxPage } from "./mdx-translator";
12
+ import {
13
+ getEnglishMdxPages,
14
+ translateMdxPage,
15
+ pruneOrphanedTranslations,
16
+ } from "./mdx-translator";
13
17
  import { translateReadme } from "./readme-translator";
14
18
  import {
15
19
  getNavigationPageReferences,
@@ -33,6 +37,8 @@ const { values: args } = parseArgs({
33
37
  force: { type: "boolean", short: "f", default: false },
34
38
  "update-nav": { type: "boolean", default: false },
35
39
  validate: { type: "boolean", default: false },
40
+ prune: { type: "boolean", default: false },
41
+ "no-prune": { type: "boolean", default: false },
36
42
  model: { type: "string", short: "m" },
37
43
  help: { type: "boolean", short: "h", default: false },
38
44
  },
@@ -53,6 +59,8 @@ Options:
53
59
  -f, --force Ignore cache, re-translate everything
54
60
  --update-nav Regenerate docs.json navigation after translation
55
61
  --validate Check all nav references resolve to files
62
+ --prune Only delete translations whose English source is gone
63
+ --no-prune Skip the prune step during a normal translation run
56
64
  -m, --model <model> Claude model override (default: Sonnet for Tier 1, Haiku for Tier 2/3)
57
65
  -h, --help Show this help
58
66
 
@@ -65,6 +73,7 @@ Examples:
65
73
  bun scripts/translate-docs/cli.ts --dry-run --tier 3 # Preview all translations
66
74
  bun scripts/translate-docs/cli.ts --validate # Check nav references
67
75
  bun scripts/translate-docs/cli.ts --update-nav # Regenerate docs.json
76
+ bun scripts/translate-docs/cli.ts --prune --tier 3 # Drop orphaned translations
68
77
  `);
69
78
  process.exit(0);
70
79
  }
@@ -132,6 +141,26 @@ async function main() {
132
141
  return;
133
142
  }
134
143
 
144
+ // Prune-only mode
145
+ if (args.prune) {
146
+ const langCodes = resolveLanguages();
147
+ const cache = readCache();
148
+ const pruned = pruneOrphanedTranslations(langCodes, {
149
+ dryRun: args["dry-run"],
150
+ cache,
151
+ });
152
+ for (const file of pruned) {
153
+ console.log(
154
+ ` ${args["dry-run"] ? "would remove" : "removed"}: ${file}`,
155
+ );
156
+ }
157
+ if (pruned.length > 0 && !args["dry-run"]) writeCache(cache);
158
+ console.log(
159
+ `\n${pruned.length} orphaned translation(s)${args["dry-run"] ? " would be" : ""} removed.`,
160
+ );
161
+ return;
162
+ }
163
+
135
164
  const langCodes = resolveLanguages();
136
165
  const isDryRun = args["dry-run"];
137
166
  const isForce = args.force;
@@ -196,6 +225,23 @@ async function main() {
196
225
 
197
226
  // Translate docs
198
227
  if (!args["readme-only"]) {
228
+ // Drop translations orphaned by an upstream English deletion before
229
+ // translating, so a stale locale page can never outlive its source.
230
+ if (!args["no-prune"]) {
231
+ const pruned = pruneOrphanedTranslations(langCodes, {
232
+ dryRun: isDryRun,
233
+ cache,
234
+ });
235
+ for (const file of pruned) {
236
+ console.log(` ${isDryRun ? "would prune" : "pruned"}: ${file}`);
237
+ }
238
+ if (pruned.length > 0) {
239
+ console.log(
240
+ `${pruned.length} orphaned translation(s)${isDryRun ? " would be" : ""} removed.`,
241
+ );
242
+ }
243
+ }
244
+
199
245
  const pages = getEnglishMdxPages();
200
246
  const filteredPages = args.pages
201
247
  ? pages.filter((p) => {
@@ -373,6 +419,12 @@ async function main() {
373
419
  if (totalInput > 0) {
374
420
  console.log(`Total tokens: ${totalInput} input + ${totalOutput} output`);
375
421
  }
422
+ // Surface pages that needed a re-translation to pass validation, so a run
423
+ // that quietly retried does not read as a clean one in the job log.
424
+ const retried = translated.filter((r) => (r.attempts ?? 1) > 1);
425
+ if (retried.length > 0) {
426
+ console.log(`Retried: ${retried.length}`);
427
+ }
376
428
 
377
429
  if (errors.length > 0) {
378
430
  process.exit(1);
@@ -4,12 +4,21 @@ import {
4
4
  mkdirSync,
5
5
  readdirSync,
6
6
  statSync,
7
+ existsSync,
8
+ rmSync,
7
9
  } from "node:fs";
8
10
  import { dirname, join, relative } from "node:path";
9
11
  import { fileURLToPath } from "node:url";
10
12
  import { getLanguageByCode } from "./config";
11
- import { translateContent } from "./translator";
12
- import { readCache, writeCache, isCached, setCacheEntry } from "./cache";
13
+ import { translateValidated } from "./translator";
14
+ import { findTranslationError } from "./validate-translation";
15
+ import {
16
+ readCache,
17
+ writeCache,
18
+ isCached,
19
+ setCacheEntry,
20
+ getCacheKey,
21
+ } from "./cache";
13
22
  import type { TranslationResult, TranslationCache } from "./types";
14
23
 
15
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -94,6 +103,60 @@ export function stripStrayTrailingFence(content: string): string {
94
103
  return lines.join("\n");
95
104
  }
96
105
 
106
+ /**
107
+ * Convert HTML comments (`<!-- ... -->`) into MDX brace-slash-star comments.
108
+ *
109
+ * Mintlify parses every page as MDX, where a top-level HTML comment is a hard
110
+ * syntax error ("Unexpected character `!` (U+0021) before name …") that fails
111
+ * the whole deployment — the remedy Mintlify itself suggests is switching to
112
+ * MDX's own comment form. The root README keeps HTML-comment syntax (GitHub
113
+ * renders it invisibly), so its translated copies under docs/i18n/ have to be
114
+ * rewritten to the MDX form or the docs deploy breaks.
115
+ *
116
+ * Comments inside fenced code blocks are left untouched — there `<!-- -->` is
117
+ * literal sample text (e.g. the plist snippet in the AgentEye collector docs),
118
+ * not a comment to convert. Any `*`+`/` sequence inside a body is broken up so
119
+ * the generated JS block comment can't be terminated early.
120
+ */
121
+ export function convertHtmlComments(content: string): string {
122
+ // Map out fenced-code ranges so comments inside them stay literal. Per
123
+ // CommonMark, a fence opens with ≥3 backticks or tildes and closes only on a
124
+ // later line using the SAME character and at least the same length. A naive
125
+ // "any ``` toggles" counter misfires on a ```` block that embeds ``` or on
126
+ // mixed ```/~~~ fences — the toggle desyncs and a real top-level comment
127
+ // after the block would be left unconverted (breaking the deploy we fix here).
128
+ const fenceRanges: Array<[number, number]> = [];
129
+ const fenceRe = /^[ \t]*(`{3,}|~{3,})/gm;
130
+ let fenceMatch: RegExpExecArray | null;
131
+ let open: { char: string; length: number; start: number } | null = null;
132
+ while ((fenceMatch = fenceRe.exec(content)) !== null) {
133
+ const marker = fenceMatch[1];
134
+ if (!open) {
135
+ open = { char: marker[0], length: marker.length, start: fenceMatch.index };
136
+ } else if (marker[0] === open.char && marker.length >= open.length) {
137
+ const lineEnd = content.indexOf("\n", fenceRe.lastIndex);
138
+ fenceRanges.push([open.start, lineEnd === -1 ? content.length : lineEnd]);
139
+ open = null;
140
+ }
141
+ // A different char or shorter marker while a fence is open is inner content.
142
+ }
143
+ // An unterminated fence runs to the end of the document.
144
+ if (open) fenceRanges.push([open.start, content.length]);
145
+
146
+ const isInsideFence = (offset: number): boolean =>
147
+ fenceRanges.some(([start, end]) => offset >= start && offset < end);
148
+
149
+ return content.replace(
150
+ /<!--([\s\S]*?)-->/g,
151
+ (match: string, body: string, offset: number) => {
152
+ if (isInsideFence(offset)) return match;
153
+ // Neutralize any `*/` so it can't close the JS block comment early.
154
+ const safeBody = body.replace(/\*\//g, "* /");
155
+ return `{/*${safeBody}*/}`;
156
+ },
157
+ );
158
+ }
159
+
97
160
  /**
98
161
  * Rewrite internal doc links to include the language prefix.
99
162
  * e.g. href="/built-in-policies" -> href="/es/built-in-policies"
@@ -131,10 +194,13 @@ export async function translateMdxPage(
131
194
  dryRun?: boolean;
132
195
  model?: string;
133
196
  cache?: TranslationCache;
197
+ /** Override the docs root. Tests point this at a fixture tree. */
198
+ docsDir?: string;
134
199
  } = {},
135
200
  ): Promise<TranslationResult> {
136
- const relPath = relative(DOCS_DIR, sourcePath);
137
- const outputPath = join(DOCS_DIR, lang, relPath);
201
+ const docsDir = options.docsDir ?? DOCS_DIR;
202
+ const relPath = relative(docsDir, sourcePath);
203
+ const outputPath = join(docsDir, lang, relPath);
138
204
  const sourceContent = readFileSync(sourcePath, "utf-8");
139
205
 
140
206
  const langConfig = getLanguageByCode(lang);
@@ -166,23 +232,31 @@ export async function translateMdxPage(
166
232
  };
167
233
  }
168
234
 
169
- // Translate
170
- const { translated, inputTokens, outputTokens } = await translateContent(
171
- sourceContent,
172
- lang,
173
- langConfig.name,
174
- options.model,
175
- );
176
-
177
- // Strip stray quote artifacts from JSX attribute values, drop any
178
- // unmatched trailing code fence the model sometimes hallucinates, then
179
- // rewrite links.
180
- const sanitized = stripStrayTrailingFence(sanitizeJsxAttributes(translated));
181
- const withLinks = rewriteInternalLinks(sanitized, lang);
235
+ // Translate and validate the exact bytes we will write. The render callback
236
+ // reproduces the historical sanitize + link-rewrite chain byte-for-byte
237
+ // (strip stray JSX-attribute quotes, drop an unmatched trailing fence,
238
+ // convert HTML comments to MDX, then add the language prefix to links), so
239
+ // the validated bytes ARE the written bytes and a pass here equals a pass in
240
+ // the deploy. On exhaustion translateValidated throws before we reach the
241
+ // write, so an invalid page is never written or cached.
242
+ const { rendered, inputTokens, outputTokens, attempts } =
243
+ await translateValidated({
244
+ source: sourceContent,
245
+ lang,
246
+ langName: langConfig.name,
247
+ model: options.model,
248
+ label: `${relPath} [${lang}]`,
249
+ render: (raw) =>
250
+ rewriteInternalLinks(
251
+ convertHtmlComments(stripStrayTrailingFence(sanitizeJsxAttributes(raw))),
252
+ lang,
253
+ ),
254
+ validate: (bytes) => findTranslationError(bytes, sourceContent),
255
+ });
182
256
 
183
257
  // Write output
184
258
  mkdirSync(dirname(outputPath), { recursive: true });
185
- writeFileSync(outputPath, withLinks);
259
+ writeFileSync(outputPath, rendered);
186
260
 
187
261
  // Update cache — skip if caller manages the cache (batch write)
188
262
  if (!options.cache) {
@@ -205,6 +279,7 @@ export async function translateMdxPage(
205
279
  inputTokens,
206
280
  outputTokens,
207
281
  cached: false,
282
+ attempts,
208
283
  };
209
284
  }
210
285
 
@@ -234,6 +309,71 @@ export function getEnglishMdxPages(): string[] {
234
309
  return results.sort();
235
310
  }
236
311
 
312
+ /** Every `.mdx` file under `dir`, recursively. */
313
+ function collectMdxFiles(dir: string): string[] {
314
+ const results: string[] = [];
315
+ for (const entry of readdirSync(dir)) {
316
+ const full = join(dir, entry);
317
+ if (statSync(full).isDirectory()) {
318
+ results.push(...collectMdxFiles(full));
319
+ } else if (entry.endsWith(".mdx")) {
320
+ results.push(full);
321
+ }
322
+ }
323
+ return results;
324
+ }
325
+
326
+ /**
327
+ * Delete translated pages whose English source no longer exists, and drop
328
+ * their cache entries.
329
+ *
330
+ * Translation only ever moves forward: `getEnglishMdxPages()` drives what gets
331
+ * written, so when an English page is deleted upstream (the agenteye sync does
332
+ * this routinely) its 14 translations are simply never revisited. They linger
333
+ * on disk and `--update-nav` drops them from `docs.json`, which hides them from
334
+ * the sidebar but does *not* unpublish them — Mintlify still serves and indexes
335
+ * any `.mdx` present, so non-English readers can land on a page documenting a
336
+ * removed feature with no navigation out. Left unpruned these also accumulate
337
+ * as permanent `validate:mdx` surface area for content no English source can
338
+ * ever correct.
339
+ *
340
+ * Returns the docs-dir-relative paths that were (or, when `dryRun`, would be)
341
+ * removed.
342
+ */
343
+ export function pruneOrphanedTranslations(
344
+ langCodes: string[],
345
+ options: {
346
+ dryRun?: boolean;
347
+ cache?: TranslationCache;
348
+ /** Override the docs root. Tests point this at a fixture tree. */
349
+ docsDir?: string;
350
+ } = {},
351
+ ): string[] {
352
+ const docsDir = options.docsDir ?? DOCS_DIR;
353
+ const removed: string[] = [];
354
+
355
+ for (const lang of langCodes) {
356
+ const langDir = join(docsDir, lang);
357
+ if (!existsSync(langDir)) continue;
358
+
359
+ for (const file of collectMdxFiles(langDir)) {
360
+ // docs/zh/agenteye/foo.mdx -> agenteye/foo.mdx -> docs/agenteye/foo.mdx
361
+ const relPath = relative(langDir, file);
362
+ if (existsSync(join(docsDir, relPath))) continue;
363
+
364
+ removed.push(relative(docsDir, file));
365
+ if (!options.dryRun) {
366
+ rmSync(file);
367
+ if (options.cache) {
368
+ delete options.cache.translations[getCacheKey(relPath, lang)];
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ return removed.sort();
375
+ }
376
+
237
377
  function isLanguageDir(name: string): boolean {
238
378
  const langCodes = [
239
379
  "zh",
@@ -2,8 +2,13 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { LANGUAGES, getLanguageByCode } from "./config";
5
- import { translateContent } from "./translator";
6
- import { stripStrayTrailingFence } from "./mdx-translator";
5
+ import { translateValidated } from "./translator";
6
+ import {
7
+ stripStrayTrailingFence,
8
+ convertHtmlComments,
9
+ sanitizeJsxAttributes,
10
+ } from "./mdx-translator";
11
+ import { findTranslationError } from "./validate-translation";
7
12
  import { readCache, writeCache, isCached, setCacheEntry } from "./cache";
8
13
  import type { TranslationResult, TranslationCache } from "./types";
9
14
 
@@ -86,15 +91,12 @@ export async function translateReadme(
86
91
  };
87
92
  }
88
93
 
89
- // Translate
90
- const { translated, inputTokens, outputTokens } = await translateContent(
91
- sourceContent,
92
- lang,
93
- langConfig.name,
94
- options.model,
95
- );
96
-
97
- // Build the final output with header
94
+ // Compute the wrapper (disclaimer, language selector, RTL <div>) up front so
95
+ // the render callback can assemble the FINAL bytes on every attempt. The
96
+ // assembled bytes — not the raw model output — are what gets validated: the
97
+ // swallowed-`</div>` class is introduced by the wrapper AFTER the model
98
+ // returns, and `mintlify validate` never sees the README at all, so this gate
99
+ // is the only thing standing between a broken README and the deploy.
98
100
  const disclaimer = langConfig.rtl
99
101
  ? `> **\u26a0\ufe0f** \u0647\u0630\u0647 \u062a\u0631\u062c\u0645\u0629 \u0622\u0644\u064a\u0629. \u0644\u0644\u0627\u0637\u0644\u0627\u0639 \u0639\u0644\u0649 \u0623\u062d\u062f\u062b \u0625\u0635\u062f\u0627\u0631\u060c \u0631\u0627\u062c\u0639 [English README](../../README.md).`
100
102
  : `> **\u26a0\ufe0f** This is an auto-generated translation. For the latest version, see the [English README](../../README.md). Community corrections welcome!`;
@@ -103,16 +105,31 @@ export async function translateReadme(
103
105
  const rtlOpen = langConfig.rtl ? `<div dir="rtl">\n\n` : "";
104
106
  const rtlClose = langConfig.rtl ? `\n\n</div>` : "";
105
107
 
106
- // Drop any stray trailing fence the model hallucinated would otherwise
107
- // open an unclosed code block that swallows the wrapping `</div>` for RTL
108
- // pages and break Mintlify's MDX parser.
109
- const cleaned = stripStrayTrailingFence(translated);
110
-
111
- const output = `${disclaimer}\n\n${langSelector}\n\n---\n${rtlOpen}\n${cleaned}\n${rtlClose}`;
108
+ // Translate and validate the assembled bytes, re-translating on failure.
109
+ const { rendered, inputTokens, outputTokens, attempts } =
110
+ await translateValidated({
111
+ source: sourceContent,
112
+ lang,
113
+ langName: langConfig.name,
114
+ model: options.model,
115
+ label: `README.${lang}.md`,
116
+ render: (raw) => {
117
+ // Same MDX sanitizers as translateMdxPage — the README emits JSX (the
118
+ // logo table), so strip stray attribute quotes, drop any unmatched
119
+ // trailing code fence (which would swallow the RTL `</div>`), and
120
+ // convert HTML comments to MDX — then wrap in disclaimer + selector +
121
+ // RTL div.
122
+ const cleaned = convertHtmlComments(
123
+ stripStrayTrailingFence(sanitizeJsxAttributes(raw)),
124
+ );
125
+ return `${disclaimer}\n\n${langSelector}\n\n---\n${rtlOpen}\n${cleaned}\n${rtlClose}`;
126
+ },
127
+ validate: (bytes) => findTranslationError(bytes, sourceContent),
128
+ });
112
129
 
113
130
  // Write output
114
131
  mkdirSync(I18N_DIR, { recursive: true });
115
- writeFileSync(outputPath, output);
132
+ writeFileSync(outputPath, rendered);
116
133
 
117
134
  // Update cache — skip if caller manages the cache (batch write)
118
135
  if (!options.cache) {
@@ -128,6 +145,7 @@ export async function translateReadme(
128
145
  inputTokens,
129
146
  outputTokens,
130
147
  cached: false,
148
+ attempts,
131
149
  };
132
150
  }
133
151
 
@@ -3,6 +3,47 @@ import { DO_NOT_TRANSLATE } from "./config";
3
3
 
4
4
  let client: Anthropic | null = null;
5
5
 
6
+ // Output-token ceiling for a single translation. The largest English docs
7
+ // (e.g. agenteye/kubernetes-deployment.mdx at ~1400 lines) translate to well
8
+ // beyond the old 16384 cap for verbose target languages. When a response hit
9
+ // that cap it was silently truncated mid-MDX — leaving an unbalanced `{` or an
10
+ // unterminated JSX expression — and the partial output was written to disk and
11
+ // the cache, only to fail `mintlify validate` later in the consolidate job
12
+ // (or, for the very largest pages, to trip the proxy with a "stream ended"
13
+ // error). 64000 is Claude Haiku 4.5's max output (Tier 2/3 languages) and well
14
+ // inside Claude Sonnet 4.6's 128000 (Tier 1), with headroom over the largest
15
+ // observed translation; max_tokens is only a ceiling, so smaller pages still
16
+ // stop at `end_turn` and cost the same. Streaming (below) is what keeps an
17
+ // output this large safe from the SDK's HTTP timeout.
18
+ // Override via TRANSLATE_MAX_TOKENS (integer >= 1; otherwise default).
19
+ const parsedMaxTokens = Number.parseInt(
20
+ process.env.TRANSLATE_MAX_TOKENS ?? "",
21
+ 10,
22
+ );
23
+ const MAX_TOKENS =
24
+ Number.isInteger(parsedMaxTokens) && parsedMaxTokens > 0
25
+ ? parsedMaxTokens
26
+ : 64000;
27
+
28
+ // Maximum TOTAL validation attempts per page: 1 initial translation plus up to
29
+ // MAX_ATTEMPTS-1 re-translations when the rendered output fails validation
30
+ // (see translateValidated below). Distinct from the SDK transport budget
31
+ // TRANSLATE_MAX_RETRIES (default 5, in getClient): that retries a single HTTP
32
+ // request on a connection error; this re-translates a page whose *content*
33
+ // failed the docs-build checks. They multiply — at most
34
+ // MAX_ATTEMPTS x (1 + maxRetries) HTTP requests for one page in the worst case.
35
+ // 3 collapses the observed per-page failure rate to negligible while costing
36
+ // wall-clock (a serial retry inside one worker slot), not peak concurrency.
37
+ // Override via TRANSLATE_MAX_ATTEMPTS (integer >= 1; 1 disables retries).
38
+ const parsedMaxAttempts = Number.parseInt(
39
+ process.env.TRANSLATE_MAX_ATTEMPTS ?? "",
40
+ 10,
41
+ );
42
+ const MAX_ATTEMPTS =
43
+ Number.isInteger(parsedMaxAttempts) && parsedMaxAttempts > 0
44
+ ? parsedMaxAttempts
45
+ : 3;
46
+
6
47
  function getClient(): Anthropic {
7
48
  if (!client) {
8
49
  // Default 5 retries (up from SDK default of 2) so transient
@@ -22,7 +63,7 @@ const SYSTEM_PROMPT = `You are a professional technical documentation translator
22
63
 
23
64
  1. **Preserve all code blocks exactly as-is** — never translate content inside backtick-fenced code blocks (\`\`\`...\`\`\`) or inline code (\`...\`).
24
65
  2. **Preserve MDX component syntax** — tags like <Card>, <CardGroup>, <CodeGroup>, <Steps>, <Step>, <Note>, <Tip>, <Tabs>, <Tab>, <Warning> must remain unchanged. Their attribute names (title, icon, href, cols) must remain in English. Only translate the text content of the \`title\` attribute and the text body between tags. **Never put an ASCII straight \`"\` inside a \`title="…"\` (or any JSX attribute value)** — it terminates the attribute and breaks MDX parsing. If the target language would normally wrap a word in quotation marks (e.g. German „…", Japanese 「…」), drop the inner quotes inside attribute values and rely on the surrounding tag for emphasis.
25
- 3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged.
66
+ 3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged. Never rename, add, or drop a frontmatter key. The \`title\` and \`description\` values are wrapped in double quotes: **never put an unescaped ASCII \`"\` inside them** — it terminates the YAML string and breaks the frontmatter parse (exactly as an ASCII \`"\` breaks a JSX attribute in rule 2). If the source value contains an escaped quote (\`\\"\`), keep it escaped in the same form; if the target language would quote a phrase, use typographic quotes (e.g. „…", «…», 「…」) or rephrase to avoid the inner quote.
26
67
  4. **Preserve all URLs and paths** — never modify href values, image paths, or links.
27
68
  5. **Preserve Markdown structure** — headers (#, ##), lists (-, *), tables (|), bold (**), italic (*), links ([text](url)) must keep their Markdown formatting.
28
69
  6. **Preserve badge/shield URLs** — any [![...](https://img.shields.io/...)](url) pattern must remain completely unchanged.
@@ -43,11 +84,40 @@ ${DO_NOT_TRANSLATE.map((t) => `- ${t}`).join("\n")}
43
84
 
44
85
  Return ONLY the translated content. Do not add explanations, notes, or commentary.`;
45
86
 
87
+ export interface RetryFeedback {
88
+ attempt: number;
89
+ maxAttempts: number;
90
+ /** The previous attempt's validation error — model-actionable text. */
91
+ error: string;
92
+ }
93
+
94
+ /**
95
+ * The repair note appended to the user turn on a retry. The failed attempt is
96
+ * NOT fed back as an assistant turn: a fresh re-translate keeps the input flat
97
+ * across attempts. Conversational repair would grow the input every attempt and
98
+ * push a large README toward the max_tokens ceiling on exactly the retry where
99
+ * a truncation would be worst — and the error is self-locating (it carries the
100
+ * caret excerpt / body snippet), so the model needs the note, not its own prior
101
+ * output, to fix the defect.
102
+ */
103
+ function buildRetryFeedback(f: RetryFeedback): string {
104
+ return (
105
+ `[Retry ${f.attempt} of ${f.maxAttempts}. The source document above is unchanged.]\n\n` +
106
+ "Your previous translation of this document was REJECTED by the docs build " +
107
+ "and discarded. It failed validation with:\n\n" +
108
+ `${f.error}\n\n` +
109
+ "Translate the source again from the beginning and return ONLY the " +
110
+ "corrected translation — do not comment on this note. Do not reproduce that " +
111
+ "defect. Every rule above still applies."
112
+ );
113
+ }
114
+
46
115
  export async function translateContent(
47
116
  content: string,
48
117
  targetLang: string,
49
118
  targetLangName: string,
50
119
  model: string = "claude-sonnet-4-6",
120
+ feedback?: RetryFeedback,
51
121
  ): Promise<{ translated: string; inputTokens: number; outputTokens: number }> {
52
122
  const anthropic = getClient();
53
123
 
@@ -59,20 +129,36 @@ export async function translateContent(
59
129
  // surfaces to the SDK as `APIConnectionError ("Connection error.")`.
60
130
  // `messages.stream(...).finalMessage()` returns the same Message shape
61
131
  // as `messages.create(...)`, so the rest of the pipeline is unchanged.
132
+ const base = `Translate the following documentation content into ${targetLangName} (${targetLang}).\n\n---\n\n${content}`;
133
+ // On a retry, append the repair note after the source in the SAME single user
134
+ // turn — no assistant turn is fed back, so the request stays flat-input.
135
+ const userContent = feedback
136
+ ? `${base}\n\n---\n\n${buildRetryFeedback(feedback)}`
137
+ : base;
138
+
62
139
  const response = await anthropic.messages.stream({
63
140
  model,
64
- max_tokens: 16384,
141
+ max_tokens: MAX_TOKENS,
65
142
  system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }],
66
- messages: [
67
- {
68
- role: "user",
69
- content: `Translate the following documentation content into ${targetLangName} (${targetLang}).\n\n---\n\n${content}`,
70
- },
71
- ],
143
+ messages: [{ role: "user", content: userContent }],
72
144
  }).finalMessage();
73
145
 
146
+ // A truncated translation is worse than a failed one: the model stops
147
+ // mid-MDX, and that partial would otherwise be written to disk and cached as
148
+ // if complete, surfacing only as an unbalanced-brace parse error in
149
+ // `mintlify validate`. Fail loudly instead so the caller (cli.ts) records an
150
+ // error, never caches the partial, and excludes this language from the
151
+ // consolidate publish step. If a page ever legitimately needs more than
152
+ // MAX_TOKENS, raise TRANSLATE_MAX_TOKENS or split the source.
153
+ if (response.stop_reason === "max_tokens") {
154
+ throw new Error(
155
+ `translation truncated at max_tokens=${MAX_TOKENS} ` +
156
+ `(output ${response.usage.output_tokens} tokens) — source too large to translate in one request`,
157
+ );
158
+ }
159
+
74
160
  const translated =
75
- response.content[0].type === "text" ? response.content[0].text : "";
161
+ response.content[0]?.type === "text" ? response.content[0].text : "";
76
162
 
77
163
  return {
78
164
  translated,
@@ -80,3 +166,88 @@ export async function translateContent(
80
166
  outputTokens: response.usage.output_tokens,
81
167
  };
82
168
  }
169
+
170
+ /**
171
+ * Translate `source`, render it to the exact bytes that will be written, and
172
+ * validate those bytes — re-translating with the validation error fed back
173
+ * until it passes or MAX_ATTEMPTS is reached.
174
+ *
175
+ * The loop lives here, not in translateContent, because each caller renders
176
+ * different final bytes (the MDX pages add rewriteInternalLinks; the README
177
+ * wraps the body in a disclaimer + RTL `<div>`). Validating the RENDERED bytes —
178
+ * not the raw model output — is what makes a pass here equal to a pass in
179
+ * `mintlify validate` and the deploy.
180
+ *
181
+ * On exhaustion it THROWS (never returns a partial), so the caller's write is
182
+ * unreachable and no invalid page is ever written or cached — the same
183
+ * fail-loud contract translateContent already enforces for max_tokens
184
+ * truncation. Transport/auth/max_tokens errors from translateContent propagate
185
+ * unchanged and consume no attempt: only *validity* failures retry.
186
+ */
187
+ export async function translateValidated(opts: {
188
+ source: string;
189
+ lang: string;
190
+ langName: string;
191
+ model?: string;
192
+ /** Label for the per-attempt warning line, e.g. `agenteye/cli.mdx [de]`. */
193
+ label: string;
194
+ /** Turn raw model output into the exact bytes that will be written. */
195
+ render: (raw: string) => string;
196
+ /** Validate the rendered bytes; return an error message, or null if valid. */
197
+ validate: (rendered: string) => Promise<string | null>;
198
+ }): Promise<{
199
+ rendered: string;
200
+ inputTokens: number;
201
+ outputTokens: number;
202
+ attempts: number;
203
+ }> {
204
+ let inputTokens = 0;
205
+ let outputTokens = 0;
206
+ let lastError = "";
207
+
208
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
209
+ const feedback: RetryFeedback | undefined =
210
+ attempt > 1
211
+ ? { attempt, maxAttempts: MAX_ATTEMPTS, error: lastError }
212
+ : undefined;
213
+
214
+ // No try/catch: a transport/auth/max_tokens throw is not a validity
215
+ // failure — let it propagate so it is never silently retried as one.
216
+ const result = await translateContent(
217
+ opts.source,
218
+ opts.lang,
219
+ opts.langName,
220
+ opts.model,
221
+ feedback,
222
+ );
223
+ inputTokens += result.inputTokens;
224
+ outputTokens += result.outputTokens;
225
+
226
+ if (result.translated.trim() === "") {
227
+ // An empty response is a validity failure, not a usable page — resample
228
+ // rather than write a blank file.
229
+ lastError =
230
+ "The translation was empty. Return the full translated document.";
231
+ console.warn(
232
+ ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} produced empty output; retrying`,
233
+ );
234
+ continue;
235
+ }
236
+
237
+ const rendered = opts.render(result.translated);
238
+ const error = await opts.validate(rendered);
239
+ if (error === null) {
240
+ return { rendered, inputTokens, outputTokens, attempts: attempt };
241
+ }
242
+
243
+ lastError = error;
244
+ console.warn(
245
+ ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} failed validation: ${error.split("\n")[0]}`,
246
+ );
247
+ }
248
+
249
+ throw new Error(
250
+ `translation into ${opts.langName} (${opts.lang}) still fails validation ` +
251
+ `after ${MAX_ATTEMPTS} attempt(s): ${lastError.split("\n")[0]}`,
252
+ );
253
+ }