blume 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli/index.js +3290 -254
  3. package/dist/cli/index.js.map +79 -67
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +16 -2
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/link-headers.ts +4 -3
  24. package/src/ai/llms.ts +4 -2
  25. package/src/ai/markdown.ts +34 -1
  26. package/src/ai/mcp/data.ts +10 -4
  27. package/src/ai/mcp/server.ts +74 -3
  28. package/src/ai/mcp/tools.ts +2 -2
  29. package/src/astro/generate.ts +2 -2
  30. package/src/astro/integration.ts +6 -2
  31. package/src/astro/markdown-negotiation.ts +5 -0
  32. package/src/astro/templates.ts +74 -21
  33. package/src/audit/url.ts +5 -10
  34. package/src/cli/commands/build.ts +145 -34
  35. package/src/cli/commands/translate.ts +300 -0
  36. package/src/cli/index.ts +2 -0
  37. package/src/components/Icon.astro +2 -7
  38. package/src/components/content/Step.astro +3 -8
  39. package/src/components/content/Tab.astro +20 -1
  40. package/src/components/layout/LanguageSwitcher.astro +2 -1
  41. package/src/components/layout/Logo.astro +4 -4
  42. package/src/components/layout/PageActions.astro +12 -7
  43. package/src/components/layout/Search.astro +15 -20
  44. package/src/components/layout/search/orama.ts +3 -1
  45. package/src/core/base-path.ts +9 -0
  46. package/src/core/config-input.ts +84 -6
  47. package/src/core/graph.ts +46 -2
  48. package/src/core/i18n-ui.ts +2 -0
  49. package/src/core/i18n.ts +31 -0
  50. package/src/core/nav-diagnostics.ts +13 -34
  51. package/src/core/project-graph.ts +13 -2
  52. package/src/core/schema.ts +174 -74
  53. package/src/core/sources/normalize.ts +25 -12
  54. package/src/core/sources/types.ts +5 -0
  55. package/src/core/types.ts +4 -3
  56. package/src/core/ui-packs/ar.ts +42 -1
  57. package/src/core/ui-packs/bg.ts +42 -1
  58. package/src/core/ui-packs/bn.ts +42 -1
  59. package/src/core/ui-packs/ca.ts +44 -1
  60. package/src/core/ui-packs/cs.ts +42 -1
  61. package/src/core/ui-packs/da.ts +42 -1
  62. package/src/core/ui-packs/de.ts +42 -1
  63. package/src/core/ui-packs/el.ts +44 -1
  64. package/src/core/ui-packs/es.ts +44 -1
  65. package/src/core/ui-packs/fa.ts +42 -1
  66. package/src/core/ui-packs/fi.ts +42 -1
  67. package/src/core/ui-packs/fr.ts +44 -1
  68. package/src/core/ui-packs/he.ts +42 -1
  69. package/src/core/ui-packs/hi.ts +42 -1
  70. package/src/core/ui-packs/hr.ts +42 -1
  71. package/src/core/ui-packs/hu.ts +42 -1
  72. package/src/core/ui-packs/id.ts +42 -1
  73. package/src/core/ui-packs/it.ts +44 -1
  74. package/src/core/ui-packs/ja.ts +44 -1
  75. package/src/core/ui-packs/ko.ts +44 -1
  76. package/src/core/ui-packs/nl.ts +42 -1
  77. package/src/core/ui-packs/no.ts +42 -1
  78. package/src/core/ui-packs/pl.ts +42 -1
  79. package/src/core/ui-packs/pt-br.ts +44 -1
  80. package/src/core/ui-packs/pt.ts +44 -1
  81. package/src/core/ui-packs/ro.ts +42 -1
  82. package/src/core/ui-packs/ru.ts +42 -1
  83. package/src/core/ui-packs/sk.ts +42 -1
  84. package/src/core/ui-packs/sr.ts +42 -1
  85. package/src/core/ui-packs/sv.ts +42 -1
  86. package/src/core/ui-packs/th.ts +44 -1
  87. package/src/core/ui-packs/tr.ts +42 -1
  88. package/src/core/ui-packs/uk.ts +42 -1
  89. package/src/core/ui-packs/vi.ts +44 -1
  90. package/src/core/ui-packs/zh-tw.ts +44 -1
  91. package/src/core/ui-packs/zh.ts +44 -1
  92. package/src/deploy/adapter-output.ts +44 -5
  93. package/src/deploy/cloudflare-negotiation.ts +527 -0
  94. package/src/deploy/redirects.ts +13 -0
  95. package/src/deploy/vercel-negotiation.ts +30 -13
  96. package/src/eval/agents.ts +1 -1
  97. package/src/search/documents.ts +11 -0
  98. package/src/search/facets.ts +33 -0
  99. package/src/search/orama-index.ts +48 -6
  100. package/src/search/popular-icon.ts +33 -0
  101. package/src/theme/fonts.ts +3 -1
  102. package/src/theme/icon-kind.ts +20 -0
  103. package/src/translate/agents.ts +51 -0
  104. package/src/translate/ledger.ts +148 -0
  105. package/src/translate/meta.ts +149 -0
  106. package/src/translate/prompts.ts +95 -0
  107. package/src/translate/report.ts +360 -0
  108. package/src/translate/run.ts +376 -0
  109. package/src/translate/validate.ts +171 -0
  110. package/src/translate/work-list.ts +0 -0
@@ -0,0 +1,376 @@
1
+ import { existsSync } from "node:fs";
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+
12
+ import { dirname, join } from "pathe";
13
+
14
+ import { AGENTS, WINDOWS_COMMAND_NOT_FOUND } from "../audit/agent.ts";
15
+ import type { AgentKind } from "../audit/agent.ts";
16
+ import type { BlumeProject } from "../core/project-graph.ts";
17
+ import type { LocaleConfig } from "../core/schema.ts";
18
+ import type { Diagnostic } from "../core/types.ts";
19
+ import { readAgentOutput, runAgentHeadless } from "../eval/agents.ts";
20
+ import type { AgentOutput, HeadlessRunner } from "../eval/agents.ts";
21
+ import { DEFAULT_TRANSLATE_TIMEOUT_MS, translateAgentArgs } from "./agents.ts";
22
+ import { hashSource, stampLedger } from "./ledger.ts";
23
+ import type { TranslationLedger } from "./ledger.ts";
24
+ import { generateMetaModule } from "./meta.ts";
25
+ import { metaPrompt, pagePrompt } from "./prompts.ts";
26
+ import { parseMetaTitles, validateTranslation } from "./validate.ts";
27
+ import type {
28
+ MetaWorkItem,
29
+ PageWorkItem,
30
+ TranslateWorkList,
31
+ WorkItem,
32
+ } from "./work-list.ts";
33
+
34
+ export type TranslateItemStatus = "failed" | "partial" | "translated";
35
+
36
+ export interface TranslateItemResult {
37
+ costUsd?: number;
38
+ /** Why the item failed (agent error, validation failure, missing titles). */
39
+ detail?: string;
40
+ durationMs: number;
41
+ item: WorkItem;
42
+ status: TranslateItemStatus;
43
+ }
44
+
45
+ export type TranslateProgress =
46
+ | {
47
+ kind: "item-end";
48
+ index: number;
49
+ result: TranslateItemResult;
50
+ total: number;
51
+ }
52
+ | { kind: "item-start"; index: number; item: WorkItem; total: number };
53
+
54
+ export interface TranslateRunOptions {
55
+ agent: AgentKind;
56
+ /** Parallel agent sessions. Defaults to 1 (serial). */
57
+ concurrency?: number;
58
+ /** Mutated in place: every validated write stamps its entry immediately. */
59
+ ledger: TranslationLedger;
60
+ onProgress?: (event: TranslateProgress) => void;
61
+ /**
62
+ * Called after each finished item to flush the ledger to disk, so an
63
+ * interrupted run keeps everything already translated. Calls are serialized
64
+ * here — concurrent workers finishing together never race the same file.
65
+ */
66
+ persistLedger?: () => Promise<unknown>;
67
+ project: BlumeProject;
68
+ /** The spawn function — injectable so tests never launch a real agent. */
69
+ run?: HeadlessRunner;
70
+ timeoutMs?: number;
71
+ workList: TranslateWorkList;
72
+ }
73
+
74
+ export interface TranslateResult {
75
+ agent: AgentKind;
76
+ /** Total spend, when the agent CLI reports it (claude does, codex doesn't). */
77
+ costUsd?: number;
78
+ counts: Record<TranslateItemStatus, number>;
79
+ diagnostics: Diagnostic[];
80
+ durationMs: number;
81
+ results: TranslateItemResult[];
82
+ }
83
+
84
+ interface RunContext {
85
+ bin: string;
86
+ dir: string;
87
+ kind: AgentKind;
88
+ run: HeadlessRunner;
89
+ source: LocaleConfig;
90
+ targets: Map<string, LocaleConfig>;
91
+ timeoutMs: number;
92
+ }
93
+
94
+ /** The root directory's key in a meta-titles prompt (an empty key is opaque). */
95
+ const metaDirKey = (dir: string): string => (dir === "" ? "." : dir);
96
+
97
+ /** Write atomically (temp + rename) so a watcher never sees a partial file. */
98
+ const writeFileAtomic = async (path: string, text: string): Promise<void> => {
99
+ await mkdir(dirname(path), { recursive: true });
100
+ const tmp = `${path}.${process.pid}.tmp`;
101
+ await writeFile(tmp, text, "utf-8");
102
+ try {
103
+ await rename(tmp, path);
104
+ } catch (error) {
105
+ await rm(tmp, { force: true });
106
+ throw error;
107
+ }
108
+ };
109
+
110
+ /**
111
+ * One headless agent call. A Windows shell launch reports a missing executable
112
+ * through exit code 9009 instead of a spawn error, so that is normalized to
113
+ * the ENOENT rejection the command layer already turns into an install hint.
114
+ */
115
+ const invokeAgent = async (
116
+ context: RunContext,
117
+ prompt: string,
118
+ index: number
119
+ ): Promise<AgentOutput> => {
120
+ const messagePath = join(context.dir, `message-${index}.txt`);
121
+ const result = await context.run(
122
+ context.bin,
123
+ translateAgentArgs(context.kind, messagePath),
124
+ { cwd: context.dir, prompt, timeoutMs: context.timeoutMs }
125
+ );
126
+ if (!result.timedOut && result.code === WINDOWS_COMMAND_NOT_FOUND) {
127
+ const missing = new Error(
128
+ `${context.bin} was not found on PATH`
129
+ ) as NodeJS.ErrnoException;
130
+ missing.code = "ENOENT";
131
+ throw missing;
132
+ }
133
+ return await readAgentOutput(context.kind, result, messagePath);
134
+ };
135
+
136
+ const runPageItem = async (
137
+ item: PageWorkItem,
138
+ index: number,
139
+ context: RunContext,
140
+ ledger: TranslationLedger
141
+ ): Promise<TranslateItemResult> => {
142
+ const started = performance.now();
143
+ const done = (
144
+ status: TranslateItemStatus,
145
+ detail?: string,
146
+ costUsd?: number
147
+ ): TranslateItemResult => ({
148
+ costUsd,
149
+ detail,
150
+ durationMs: Math.round(performance.now() - started),
151
+ item,
152
+ status,
153
+ });
154
+
155
+ const sourceText = await readFile(item.sourcePath, "utf-8");
156
+ const target = context.targets.get(item.locale) as LocaleConfig;
157
+ // A hand-authored translation can live at a non-canonical name (see
158
+ // WorkStatus); the disk probe finds only canonical targets, and a miss just
159
+ // means the prompt goes out without a style precedent.
160
+ const previousTranslation = existsSync(item.targetPath)
161
+ ? await readFile(item.targetPath, "utf-8")
162
+ : undefined;
163
+ const output = await invokeAgent(
164
+ context,
165
+ pagePrompt(sourceText, target, context.source, previousTranslation),
166
+ index
167
+ );
168
+ if (output.isError) {
169
+ return done("failed", output.detail ?? "agent failed", output.costUsd);
170
+ }
171
+ const validated = validateTranslation(sourceText, output.text);
172
+ if (!validated.ok) {
173
+ return done("failed", validated.reason, output.costUsd);
174
+ }
175
+ await writeFileAtomic(item.targetPath, validated.text);
176
+ stampLedger(ledger, item.sourceRel, item.locale, hashSource(sourceText));
177
+ return done("translated", undefined, output.costUsd);
178
+ };
179
+
180
+ const runMetaItem = async (
181
+ item: MetaWorkItem,
182
+ index: number,
183
+ context: RunContext,
184
+ ledger: TranslationLedger
185
+ ): Promise<TranslateItemResult> => {
186
+ const started = performance.now();
187
+ const done = (
188
+ status: TranslateItemStatus,
189
+ detail?: string,
190
+ costUsd?: number
191
+ ): TranslateItemResult => ({
192
+ costUsd,
193
+ detail,
194
+ durationMs: Math.round(performance.now() - started),
195
+ item,
196
+ status,
197
+ });
198
+
199
+ const titles = Object.fromEntries(
200
+ item.entries.map((entry) => [metaDirKey(entry.meta.dir), entry.meta.title])
201
+ );
202
+ const target = context.targets.get(item.locale) as LocaleConfig;
203
+ const output = await invokeAgent(
204
+ context,
205
+ metaPrompt(titles, target, context.source),
206
+ index
207
+ );
208
+ if (output.isError) {
209
+ return done("failed", output.detail ?? "agent failed", output.costUsd);
210
+ }
211
+
212
+ const parsed = parseMetaTitles(output.text, Object.keys(titles));
213
+ for (const entry of item.entries) {
214
+ const translated = parsed.titles[metaDirKey(entry.meta.dir)];
215
+ if (translated === undefined) {
216
+ continue;
217
+ }
218
+ // Each entry writes and stamps independently, so a partially usable reply
219
+ // still lands the titles it did translate.
220
+ // oxlint-disable-next-line no-await-in-loop
221
+ await writeFileAtomic(
222
+ entry.targetPath,
223
+ generateMetaModule(entry.meta.data, translated)
224
+ );
225
+ stampLedger(
226
+ ledger,
227
+ entry.meta.sourceRel,
228
+ item.locale,
229
+ hashSource(entry.meta.raw)
230
+ );
231
+ }
232
+
233
+ if (parsed.missing.length === item.entries.length) {
234
+ return done("failed", "reply contained no usable titles", output.costUsd);
235
+ }
236
+ if (parsed.missing.length > 0) {
237
+ return done(
238
+ "partial",
239
+ `no translation for: ${parsed.missing.join(", ")}`,
240
+ output.costUsd
241
+ );
242
+ }
243
+ return done("translated", undefined, output.costUsd);
244
+ };
245
+
246
+ const itemDiagnostic = (result: TranslateItemResult): Diagnostic => {
247
+ const { item } = result;
248
+ const site =
249
+ item.kind === "page"
250
+ ? { file: item.sourcePath, subject: item.sourceRel }
251
+ : {
252
+ file: item.entries[0]?.meta.file,
253
+ subject: `meta titles (${item.entries.length})`,
254
+ };
255
+ return {
256
+ code:
257
+ result.status === "partial"
258
+ ? "BLUME_TRANSLATE_META_PARTIAL"
259
+ : "BLUME_TRANSLATE_FAILED",
260
+ file: site.file,
261
+ message: `Translating ${site.subject} into "${item.locale}" ${
262
+ result.status === "partial" ? "partially failed" : "failed"
263
+ }: ${result.detail ?? "unknown error"}.`,
264
+ severity: result.status === "partial" ? "warning" : "error",
265
+ };
266
+ };
267
+
268
+ /**
269
+ * Run every work item through the agent, `concurrency` at a time: each worker
270
+ * is a serial lane pulling the next unclaimed item, so results stay indexed
271
+ * by item and progress events interleave but never duplicate. A failed item
272
+ * never writes or stamps; its lane continues. After every finished item the
273
+ * ledger is flushed via `persistLedger` (serialized across lanes), so an
274
+ * interrupted run resumes from what already landed instead of from scratch.
275
+ */
276
+ export const runTranslate = async (
277
+ options: TranslateRunOptions
278
+ ): Promise<TranslateResult> => {
279
+ const started = performance.now();
280
+ const { i18n } = options.project.config;
281
+ if (!i18n) {
282
+ throw new Error("blume translate requires i18n to be configured");
283
+ }
284
+ const source = i18n.locales.find(
285
+ (locale) => locale.code === i18n.defaultLocale
286
+ ) as LocaleConfig;
287
+
288
+ const context: RunContext = {
289
+ bin: AGENTS[options.agent].bin,
290
+ dir: await mkdtemp(join(tmpdir(), "blume-translate-")),
291
+ kind: options.agent,
292
+ run: options.run ?? runAgentHeadless,
293
+ source,
294
+ targets: new Map(i18n.locales.map((locale) => [locale.code, locale])),
295
+ timeoutMs: options.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS,
296
+ };
297
+
298
+ const { items } = options.workList;
299
+ const results: TranslateItemResult[] = Array.from({ length: items.length });
300
+ const concurrency = Math.max(
301
+ 1,
302
+ Math.min(options.concurrency ?? 1, items.length || 1)
303
+ );
304
+
305
+ // The persist chain: whichever lane finishes next appends its flush after
306
+ // the previous one, so two lanes never write the ledger file concurrently.
307
+ let persisting: Promise<unknown> = Promise.resolve();
308
+ const persist = (): Promise<unknown> => {
309
+ // The chain is the mutex: appending with .then() serializes flushes.
310
+ // oxlint-disable-next-line promise/prefer-await-to-then
311
+ persisting = persisting.then(() => options.persistLedger?.());
312
+ return persisting;
313
+ };
314
+
315
+ let nextIndex = 0;
316
+ const worker = async (): Promise<void> => {
317
+ while (nextIndex < items.length) {
318
+ const index = nextIndex;
319
+ nextIndex += 1;
320
+ const item = items[index] as WorkItem;
321
+ options.onProgress?.({
322
+ index,
323
+ item,
324
+ kind: "item-start",
325
+ total: items.length,
326
+ });
327
+ // oxlint-disable-next-line no-await-in-loop -- each worker is a serial lane
328
+ const result = await (item.kind === "page"
329
+ ? runPageItem(item, index, context, options.ledger)
330
+ : runMetaItem(item, index, context, options.ledger));
331
+ results[index] = result;
332
+ // Flush this item's stamps before claiming the next one, so a kill
333
+ // loses at most the in-flight items.
334
+ // oxlint-disable-next-line no-await-in-loop
335
+ await persist();
336
+ options.onProgress?.({
337
+ index,
338
+ kind: "item-end",
339
+ result,
340
+ total: items.length,
341
+ });
342
+ }
343
+ };
344
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
345
+
346
+ const diagnostics: Diagnostic[] = [];
347
+ for (const result of results) {
348
+ if (result.status !== "translated") {
349
+ diagnostics.push(itemDiagnostic(result));
350
+ }
351
+ }
352
+
353
+ const counts: Record<TranslateItemStatus, number> = {
354
+ failed: 0,
355
+ partial: 0,
356
+ translated: 0,
357
+ };
358
+ for (const result of results) {
359
+ counts[result.status] += 1;
360
+ }
361
+ const costs = results.flatMap((result) =>
362
+ result.costUsd === undefined ? [] : [result.costUsd]
363
+ );
364
+
365
+ return {
366
+ agent: options.agent,
367
+ costUsd:
368
+ costs.length > 0
369
+ ? costs.reduce((total, cost) => total + cost, 0)
370
+ : undefined,
371
+ counts,
372
+ diagnostics,
373
+ durationMs: Math.round(performance.now() - started),
374
+ results,
375
+ };
376
+ };
@@ -0,0 +1,171 @@
1
+ import matter from "../core/frontmatter.ts";
2
+ import { TRANSLATABLE_KEY_PATHS } from "./prompts.ts";
3
+
4
+ /**
5
+ * Structural validation of agent output. The agent is never trusted for
6
+ * structure: the final frontmatter is *reconstructed* from the source (clone
7
+ * the source data, overlay only the translatable string values), so invented
8
+ * keys are dropped, deleted keys are restored, and slugs/icons/orders/dates
9
+ * stay source-verbatim by construction. Only then is the file written.
10
+ */
11
+
12
+ export type ValidationResult =
13
+ | { ok: true; text: string }
14
+ | { ok: false; reason: string };
15
+
16
+ const FRONTMATTER_OPEN = /^---\r?\n/u;
17
+ const FENCE_LINE = /^\s*(?:```|~~~)/u;
18
+
19
+ /**
20
+ * Strip exactly one symmetric outer code fence (any info string) — the one
21
+ * wrapper agents add despite being told not to. Anything else (no fence, or a
22
+ * fence that isn't the entire output) is returned trimmed and untouched.
23
+ */
24
+ export const stripOuterFence = (text: string): string => {
25
+ const trimmed = text.trim();
26
+ const match = trimmed.match(
27
+ /^(?<fence>`{3,}|~{3,})[^\n]*\n(?<inner>[\s\S]*?)\n\k<fence>\s*$/u
28
+ );
29
+ return match?.groups?.inner ?? trimmed;
30
+ };
31
+
32
+ /** Lines opening or closing a code fence; their count must survive translation. */
33
+ const countFenceLines = (text: string): number =>
34
+ text.split("\n").filter((line) => FENCE_LINE.test(line)).length;
35
+
36
+ const getPath = (data: unknown, path: readonly string[]): unknown => {
37
+ let value: unknown = data;
38
+ for (const key of path) {
39
+ if (typeof value !== "object" || value === null) {
40
+ return;
41
+ }
42
+ value = (value as Record<string, unknown>)[key];
43
+ }
44
+ return value;
45
+ };
46
+
47
+ /** Set `path` on `data`; only called for paths whose parents exist in `data`. */
48
+ const setPath = (
49
+ data: Record<string, unknown>,
50
+ path: readonly string[],
51
+ value: string
52
+ ): void => {
53
+ let parent = data;
54
+ for (const key of path.slice(0, -1)) {
55
+ parent = parent[key] as Record<string, unknown>;
56
+ }
57
+ parent[path.at(-1) as string] = value;
58
+ };
59
+
60
+ const ensureTrailingNewline = (text: string): string =>
61
+ text.endsWith("\n") ? text : `${text}\n`;
62
+
63
+ /**
64
+ * Validate one translated page against its source and reassemble the file to
65
+ * write. Fails (no write happens) on empty output, unparseable or missing
66
+ * frontmatter, an empty body, or a changed code-fence count.
67
+ */
68
+ export const validateTranslation = (
69
+ sourceText: string,
70
+ agentText: string
71
+ ): ValidationResult => {
72
+ const candidate = stripOuterFence(agentText);
73
+ if (candidate === "") {
74
+ return { ok: false, reason: "agent returned empty output" };
75
+ }
76
+
77
+ const sourceHasFrontmatter = FRONTMATTER_OPEN.test(sourceText);
78
+ const source = matter(sourceText);
79
+
80
+ if (sourceHasFrontmatter && !FRONTMATTER_OPEN.test(candidate)) {
81
+ return {
82
+ ok: false,
83
+ reason: "translation dropped the frontmatter (must start with ---)",
84
+ };
85
+ }
86
+
87
+ let parsed: { content: string; data: Record<string, unknown> };
88
+ try {
89
+ parsed = matter(candidate);
90
+ } catch {
91
+ return { ok: false, reason: "frontmatter does not parse as YAML" };
92
+ }
93
+
94
+ const body = ensureTrailingNewline(parsed.content.replace(/^\r?\n/u, ""));
95
+ if (body.trim() === "") {
96
+ return { ok: false, reason: "translation has an empty body" };
97
+ }
98
+
99
+ const sourceFences = countFenceLines(source.content);
100
+ const candidateFences = countFenceLines(body);
101
+ if (sourceFences !== candidateFences) {
102
+ return {
103
+ ok: false,
104
+ reason: `code fence count changed (source has ${sourceFences}, translation has ${candidateFences})`,
105
+ };
106
+ }
107
+
108
+ if (!sourceHasFrontmatter) {
109
+ // A frontmatter-less source writes the body alone; any frontmatter the
110
+ // agent invented is dropped with it.
111
+ return { ok: true, text: body };
112
+ }
113
+
114
+ // Reconciliation by reconstruction: start from the SOURCE data and overlay
115
+ // only the translatable key paths where both sides hold a string and the
116
+ // translation is non-empty.
117
+ const data = structuredClone(source.data) as Record<string, unknown>;
118
+ for (const path of TRANSLATABLE_KEY_PATHS) {
119
+ const original = getPath(source.data, path);
120
+ const translated = getPath(parsed.data, path);
121
+ if (
122
+ typeof original === "string" &&
123
+ typeof translated === "string" &&
124
+ translated.trim() !== ""
125
+ ) {
126
+ setPath(data, path, translated);
127
+ }
128
+ }
129
+
130
+ return {
131
+ ok: true,
132
+ text: ensureTrailingNewline(matter.stringify(body, data)),
133
+ };
134
+ };
135
+
136
+ /**
137
+ * Extract translated sidebar titles from a meta reply: tolerant first-`{`
138
+ * to-last-`}` extraction (the eval `parseVerdict` idiom). Keys missing or
139
+ * non-string in the reply land in `missing`, so a batch can partially succeed.
140
+ */
141
+ export const parseMetaTitles = (
142
+ agentText: string,
143
+ expectedKeys: readonly string[]
144
+ ): { titles: Record<string, string>; missing: string[] } => {
145
+ const start = agentText.indexOf("{");
146
+ const end = agentText.lastIndexOf("}");
147
+ let parsed: unknown;
148
+ if (start !== -1 && end > start) {
149
+ try {
150
+ parsed = JSON.parse(agentText.slice(start, end + 1));
151
+ } catch {
152
+ parsed = undefined;
153
+ }
154
+ }
155
+ const record =
156
+ typeof parsed === "object" && parsed !== null
157
+ ? (parsed as Record<string, unknown>)
158
+ : {};
159
+
160
+ const titles: Record<string, string> = {};
161
+ const missing: string[] = [];
162
+ for (const key of expectedKeys) {
163
+ const value = record[key];
164
+ if (typeof value === "string" && value.trim() !== "") {
165
+ titles[key] = value;
166
+ } else {
167
+ missing.push(key);
168
+ }
169
+ }
170
+ return { missing, titles };
171
+ };
Binary file