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,360 @@
1
+ import { AGENTS } from "../audit/agent.ts";
2
+ import type { AgentKind } from "../audit/agent.ts";
3
+ import { countBySeverity } from "../core/diagnostics.ts";
4
+ import type { Diagnostic } from "../core/types.ts";
5
+ import type {
6
+ TranslateItemResult,
7
+ TranslateItemStatus,
8
+ TranslateResult,
9
+ } from "./run.ts";
10
+ import type {
11
+ MetaWorkEntry,
12
+ TranslateWorkList,
13
+ WorkItem,
14
+ WorkStatus,
15
+ } from "./work-list.ts";
16
+
17
+ /**
18
+ * The live progress UI, hand-rolled ANSI (no new dependencies), matching the
19
+ * eval report's palette. Render functions are pure; the renderer takes an
20
+ * injectable `write`/`now` so tests never touch a real TTY or clock. The
21
+ * command sends everything here to stderr via the raw `write` — never
22
+ * `logger.info`, which consola drops in test and CI environments.
23
+ */
24
+
25
+ const ESC = String.fromCodePoint(27);
26
+ const COLORS = {
27
+ bold: `${ESC}[1m`,
28
+ cyan: `${ESC}[36m`,
29
+ dim: `${ESC}[2m`,
30
+ green: `${ESC}[32m`,
31
+ red: `${ESC}[31m`,
32
+ reset: `${ESC}[0m`,
33
+ yellow: `${ESC}[33m`,
34
+ };
35
+
36
+ const GLYPH: Record<TranslateItemStatus, string> = {
37
+ failed: "✖",
38
+ partial: "!",
39
+ translated: "✔",
40
+ };
41
+
42
+ const STATUS_COLOR: Record<TranslateItemStatus, string> = {
43
+ failed: COLORS.red,
44
+ partial: COLORS.yellow,
45
+ translated: COLORS.green,
46
+ };
47
+
48
+ export const SPINNER_FRAMES = [
49
+ "⠋",
50
+ "⠙",
51
+ "⠹",
52
+ "⠸",
53
+ "⠼",
54
+ "⠴",
55
+ "⠦",
56
+ "⠧",
57
+ "⠇",
58
+ "⠏",
59
+ ];
60
+
61
+ /** How often the TTY spinner advances (one frame per interval). */
62
+ export const SPINNER_INTERVAL_MS = 80;
63
+
64
+ /** The clear-to-start-of-line prefix every TTY rewrite uses. */
65
+ const REWRITE = `\r${ESC}[K`;
66
+
67
+ const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
68
+
69
+ const money = (cost: number | undefined): string =>
70
+ cost === undefined ? "" : `$${cost.toFixed(2)}`;
71
+
72
+ const duration = (ms: number): string => {
73
+ if (ms < 60_000) {
74
+ return seconds(ms);
75
+ }
76
+ const minutes = Math.floor(ms / 60_000);
77
+ const rest = Math.round((ms % 60_000) / 1000);
78
+ return `${minutes}m ${rest}s`;
79
+ };
80
+
81
+ /** `docs/guides/install.mdx → fr`, or the batched meta call's label. */
82
+ export const itemLabel = (item: WorkItem): string =>
83
+ item.kind === "page"
84
+ ? `${item.sourceRel} → ${item.locale}`
85
+ : `meta title${item.entries.length === 1 ? "" : "s"} (${item.entries.length}) → ${item.locale}`;
86
+
87
+ /**
88
+ * The in-flight spinner line a TTY rewrites in place: the oldest active
89
+ * item's label, how many more lanes are running, and the run's progress.
90
+ */
91
+ export const spinnerLine = (
92
+ active: WorkItem[],
93
+ done: number,
94
+ total: number,
95
+ frame: number
96
+ ): string => {
97
+ const first = active[0] as WorkItem;
98
+ const more = active.length > 1 ? ` (+${active.length - 1} more)` : "";
99
+ return ` ${COLORS.cyan}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]}${COLORS.reset} ${itemLabel(first)}${more} ${COLORS.dim}${done}/${total}${COLORS.reset}`;
100
+ };
101
+
102
+ /** The permanent line printed when an item finishes. */
103
+ export const itemEndLine = (result: TranslateItemResult): string => {
104
+ const color = STATUS_COLOR[result.status];
105
+ const glyph = `${color}${GLYPH[result.status]}${COLORS.reset}`;
106
+ const label = itemLabel(result.item);
107
+ if (result.status === "translated") {
108
+ const cells = [seconds(result.durationMs), money(result.costUsd)]
109
+ .filter((cell) => cell !== "")
110
+ .join(" ");
111
+ return ` ${glyph} ${label} ${COLORS.dim}${cells}${COLORS.reset}`;
112
+ }
113
+ const word = result.status === "partial" ? "partial" : "failed";
114
+ return ` ${glyph} ${label} ${color}${word}${COLORS.reset}${
115
+ result.detail ? `${COLORS.dim}: ${result.detail}${COLORS.reset}` : ""
116
+ }`;
117
+ };
118
+
119
+ /** The header line the command prints before the first item runs. */
120
+ export const translateHeaderLine = (
121
+ itemCount: number,
122
+ localeCount: number,
123
+ agent: AgentKind
124
+ ): string =>
125
+ `${COLORS.bold}blume translate${COLORS.reset} ${itemCount} item(s) · ${localeCount} locale(s) · ${AGENTS[agent].name}`;
126
+
127
+ /**
128
+ * `Translated 11 files into 2 locales · 1 failed · 2 adopted · 8 already up to
129
+ * date · 4m 12s · $0.41` (cost only when the agent reports one).
130
+ */
131
+ export const translateSummaryLine = (
132
+ result: TranslateResult,
133
+ workList: TranslateWorkList
134
+ ): string => {
135
+ const { counts } = result;
136
+ const parts = [
137
+ `Translated ${counts.translated} file${counts.translated === 1 ? "" : "s"} into ${workList.targetLocales.length} locale${workList.targetLocales.length === 1 ? "" : "s"}`,
138
+ counts.failed > 0 ? `${counts.failed} failed` : "",
139
+ counts.partial > 0 ? `${counts.partial} partial` : "",
140
+ workList.untracked.length > 0 ? `${workList.untracked.length} adopted` : "",
141
+ workList.upToDate > 0 ? `${workList.upToDate} already up to date` : "",
142
+ duration(result.durationMs),
143
+ money(result.costUsd),
144
+ ].filter((part) => part !== "");
145
+ return parts.join(" · ");
146
+ };
147
+
148
+ /** Dim warnings for work-list diagnostics (e.g. a factory-form meta file). */
149
+ export const diagnosticLines = (diagnostics: Diagnostic[]): string[] =>
150
+ diagnostics.map(
151
+ (diagnostic) =>
152
+ ` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.dim}${diagnostic.message}${COLORS.reset}`
153
+ );
154
+
155
+ /** Every (source, locale, status) drift row in a work list, pages then meta. */
156
+ const driftRows = (
157
+ workList: TranslateWorkList
158
+ ): { locale: string; sourceRel: string; status: WorkStatus }[] =>
159
+ workList.items.flatMap((item) =>
160
+ item.kind === "page"
161
+ ? [
162
+ {
163
+ locale: item.locale,
164
+ sourceRel: item.sourceRel,
165
+ status: item.status,
166
+ },
167
+ ]
168
+ : item.entries.map((entry: MetaWorkEntry) => ({
169
+ locale: item.locale,
170
+ sourceRel: entry.meta.sourceRel,
171
+ status: entry.status,
172
+ }))
173
+ );
174
+
175
+ /** One line per missing/stale pair, plus dim lines for untracked adoptions. */
176
+ export const checkLines = (workList: TranslateWorkList): string[] => [
177
+ ...driftRows(workList).map(
178
+ (row) =>
179
+ ` ${COLORS.red}✖${COLORS.reset} ${row.sourceRel} → ${row.locale} ${COLORS.dim}${row.status}${COLORS.reset}`
180
+ ),
181
+ ...workList.untracked.map(
182
+ (entry) =>
183
+ ` ${COLORS.dim}⊘ ${entry.sourceRel} → ${entry.locale} untracked (adopted by the next translate run)${COLORS.reset}`
184
+ ),
185
+ ];
186
+
187
+ /** The `--check` totals: `2 missing · 1 stale · 1 untracked · 14 up to date`. */
188
+ export const checkSummaryLine = (workList: TranslateWorkList): string => {
189
+ const rows = driftRows(workList);
190
+ const missing = rows.filter((row) => row.status === "missing").length;
191
+ const stale = rows.filter((row) => row.status === "stale").length;
192
+ const parts = [
193
+ missing > 0 ? `${missing} missing` : "",
194
+ stale > 0 ? `${stale} stale` : "",
195
+ workList.untracked.length > 0
196
+ ? `${workList.untracked.length} untracked`
197
+ : "",
198
+ `${workList.upToDate} up to date`,
199
+ ].filter((part) => part !== "");
200
+ return parts.join(" · ");
201
+ };
202
+
203
+ /** Whether a work list fails the `--check` gate (untracked never does). */
204
+ export const hasDrift = (workList: TranslateWorkList): boolean =>
205
+ workList.items.length > 0;
206
+
207
+ /**
208
+ * The machine-readable `--check` report. The `diagnostics` + `summary` shape
209
+ * matches `blume validate/audit/eval --json` exactly, with the drift report
210
+ * under `translate`.
211
+ */
212
+ export const checkReportJson = (workList: TranslateWorkList): string => {
213
+ const locales: Record<
214
+ string,
215
+ { missing: string[]; stale: string[]; untracked: string[] }
216
+ > = {};
217
+ for (const locale of workList.targetLocales) {
218
+ locales[locale] = { missing: [], stale: [], untracked: [] };
219
+ }
220
+ for (const row of driftRows(workList)) {
221
+ locales[row.locale]?.[row.status].push(row.sourceRel);
222
+ }
223
+ for (const entry of workList.untracked) {
224
+ locales[entry.locale]?.untracked.push(entry.sourceRel);
225
+ }
226
+ return `${JSON.stringify(
227
+ {
228
+ diagnostics: workList.diagnostics,
229
+ summary: countBySeverity(workList.diagnostics),
230
+ translate: { locales, upToDate: workList.upToDate },
231
+ },
232
+ null,
233
+ 2
234
+ )}\n`;
235
+ };
236
+
237
+ /** One run result lowered to JSON-friendly, root-relative fields. */
238
+ const resultJson = (result: TranslateItemResult): Record<string, unknown> => ({
239
+ costUsd: result.costUsd,
240
+ detail: result.detail,
241
+ durationMs: result.durationMs,
242
+ kind: result.item.kind,
243
+ locale: result.item.locale,
244
+ status: result.status,
245
+ ...(result.item.kind === "page"
246
+ ? { source: result.item.sourceRel, target: result.item.targetRel }
247
+ : {
248
+ sources: result.item.entries.map((entry) => entry.meta.sourceRel),
249
+ }),
250
+ });
251
+
252
+ /** The machine-readable report for a translation run (`--json`). */
253
+ export const translateReportJson = (
254
+ result: TranslateResult,
255
+ workList: TranslateWorkList
256
+ ): string => {
257
+ const diagnostics = [...workList.diagnostics, ...result.diagnostics];
258
+ return `${JSON.stringify(
259
+ {
260
+ diagnostics,
261
+ summary: countBySeverity(diagnostics),
262
+ translate: {
263
+ adopted: workList.untracked.length,
264
+ agent: result.agent,
265
+ costUsd: result.costUsd,
266
+ counts: result.counts,
267
+ durationMs: result.durationMs,
268
+ results: result.results.map(resultJson),
269
+ upToDate: workList.upToDate,
270
+ },
271
+ },
272
+ null,
273
+ 2
274
+ )}\n`;
275
+ };
276
+
277
+ export interface ProgressRenderer {
278
+ onProgress: (
279
+ event:
280
+ | {
281
+ kind: "item-end";
282
+ index: number;
283
+ result: TranslateItemResult;
284
+ total: number;
285
+ }
286
+ | { kind: "item-start"; index: number; item: WorkItem; total: number }
287
+ ) => void;
288
+ stop: () => void;
289
+ }
290
+
291
+ /**
292
+ * Live progress: on a TTY the in-flight items render as one spinner line
293
+ * rewritten in place (`\r\x1B[K`) — a concurrent run shows the oldest active
294
+ * item plus a `(+n more)` count — and each completion prints its permanent
295
+ * line above it; off-TTY (CI) there is no interval and only the permanent
296
+ * per-item lines print.
297
+ */
298
+ export const createProgressRenderer = (options: {
299
+ isTTY: boolean;
300
+ write: (chunk: string) => void;
301
+ now?: () => number;
302
+ }): ProgressRenderer => {
303
+ const now = options.now ?? (() => performance.now());
304
+ let timer: ReturnType<typeof setInterval> | undefined;
305
+ const active = new Map<number, WorkItem>();
306
+ let done = 0;
307
+ let total = 0;
308
+ let startedAt = 0;
309
+
310
+ const paint = (): void => {
311
+ if (active.size > 0) {
312
+ const frame = Math.floor((now() - startedAt) / SPINNER_INTERVAL_MS);
313
+ options.write(
314
+ `${REWRITE}${spinnerLine([...active.values()], done, total, frame)}`
315
+ );
316
+ }
317
+ };
318
+ const clearTimer = (): void => {
319
+ if (timer) {
320
+ clearInterval(timer);
321
+ timer = undefined;
322
+ }
323
+ };
324
+
325
+ return {
326
+ onProgress(event) {
327
+ ({ total } = event);
328
+ if (event.kind === "item-start") {
329
+ active.set(event.index, event.item);
330
+ if (!options.isTTY) {
331
+ return;
332
+ }
333
+ if (!timer) {
334
+ startedAt = now();
335
+ timer = setInterval(paint, SPINNER_INTERVAL_MS);
336
+ timer.unref?.();
337
+ }
338
+ paint();
339
+ return;
340
+ }
341
+ active.delete(event.index);
342
+ done += 1;
343
+ const line = itemEndLine(event.result);
344
+ if (!options.isTTY) {
345
+ options.write(`${line}\n`);
346
+ return;
347
+ }
348
+ options.write(`${REWRITE}${line}\n`);
349
+ if (active.size > 0) {
350
+ paint();
351
+ } else {
352
+ clearTimer();
353
+ }
354
+ },
355
+ stop() {
356
+ clearTimer();
357
+ active.clear();
358
+ },
359
+ };
360
+ };