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.
- package/CHANGELOG.md +59 -0
- package/dist/cli/index.js +3290 -254
- package/dist/cli/index.js.map +79 -67
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +16 -2
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/link-headers.ts +4 -3
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +34 -1
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/generate.ts +2 -2
- package/src/astro/integration.ts +6 -2
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +74 -21
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +145 -34
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/index.ts +2 -0
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/Search.astro +15 -20
- package/src/components/layout/search/orama.ts +3 -1
- package/src/core/base-path.ts +9 -0
- package/src/core/config-input.ts +84 -6
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/types.ts +5 -0
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/deploy/vercel-negotiation.ts +30 -13
- package/src/eval/agents.ts +1 -1
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/fonts.ts +3 -1
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +148 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +360 -0
- package/src/translate/run.ts +376 -0
- package/src/translate/validate.ts +171 -0
- 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
|