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
@@ -14,6 +14,12 @@ import {
14
14
  } from "../../ai/api-catalog.ts";
15
15
  import { buildHomeLinkHeader } from "../../ai/link-headers.ts";
16
16
  import { buildLlmsFiles } from "../../ai/llms.ts";
17
+ import {
18
+ agentMarkdown,
19
+ buildRawMarkdown,
20
+ markdownRoutePaths,
21
+ markdownTokenCount,
22
+ } from "../../ai/markdown.ts";
17
23
  import {
18
24
  AGENT_SKILLS_DIR,
19
25
  buildSkillsIndex,
@@ -32,14 +38,20 @@ import type { ProjectContext } from "../../core/types.ts";
32
38
  import {
33
39
  ADAPTER_IGNORE_DIRS,
34
40
  deployStaticDir,
41
+ readsHeaderFiles,
42
+ servesClientSubdir,
35
43
  surfaceAdapterOutput,
36
44
  } from "../../deploy/adapter-output.ts";
45
+ import {
46
+ injectWorkerNegotiation,
47
+ NEGOTIATION_WORKER_FILE,
48
+ } from "../../deploy/cloudflare-negotiation.ts";
37
49
  import { buildNetlifyHeaders } from "../../deploy/headers.ts";
38
50
  import {
39
- applyBaseToPlatformRedirects,
40
51
  buildNetlifyRedirects,
41
52
  buildRedirectManifest,
42
53
  buildVercelConfig,
54
+ platformRedirects,
43
55
  } from "../../deploy/redirects.ts";
44
56
  import { buildRobots } from "../../deploy/robots.ts";
45
57
  import { buildSitemap } from "../../deploy/sitemap.ts";
@@ -93,11 +105,7 @@ const emitRedirectFiles = async (
93
105
  config: ResolvedConfig,
94
106
  distDir: string
95
107
  ): Promise<void> => {
96
- const redirects = applyBaseToPlatformRedirects(
97
- config.redirects,
98
- config.basePath,
99
- config.deployment.base ?? ""
100
- );
108
+ const redirects = platformRedirects(config);
101
109
  if (redirects.length === 0 || config.deployment.output !== "static") {
102
110
  return;
103
111
  }
@@ -121,36 +129,61 @@ const emitRedirectFiles = async (
121
129
  };
122
130
 
123
131
  /**
124
- * Emit a `_headers` file for a static build so Netlify / Cloudflare static
125
- * hosts serve the raw AI-ready endpoints (`*.md`, `*.mdx`, `*.txt`) with an
126
- * explicit `charset=utf-8`. Without it those hosts send `text/markdown` /
127
- * `text/plain` with no charset and browsers fall back to Windows-1252, garbling
128
- * any non-ASCII docs (#82). The same file also stamps the homepage
129
- * agent-discovery `Link` header (RFC 8288, see `ai/link-headers.ts`). A
130
- * `_headers` shipped in `public/` (copied into dist by Astro before this runs)
131
- * wins, exactly like `_redirects`. Server adapters set the Content-Type on the
132
- * Response directly, so this is static-only.
132
+ * Emit a `_headers` file so Netlify / Cloudflare serve the raw AI-ready
133
+ * endpoints (`*.md`, `*.mdx`, `*.txt`) with an explicit `charset=utf-8`. Without
134
+ * it those hosts send `text/markdown` / `text/plain` with no charset and
135
+ * browsers fall back to Windows-1252, garbling any non-ASCII docs (#82).
136
+ *
137
+ * The same file carries the rest of the agent-discovery surface that only a
138
+ * response header can express: the homepage `Link` header (RFC 8288, see
139
+ * `ai/link-headers.ts`), and the registered media types for the extensionless
140
+ * well-known files `application/linkset+json` for the API catalog, the
141
+ * signatures directory, and the Agent Skills archives. A static host serves
142
+ * those as `octet-stream` or nothing at all without a rule.
143
+ *
144
+ * A `_headers` shipped in `public/` wins, exactly like `_redirects` — the opt-out
145
+ * is checked at its source rather than in `dist`, because on Cloudflare the file
146
+ * in `dist` is not necessarily the user's: `@astrojs/cloudflare` writes its own
147
+ * `_headers` (an immutable `Cache-Control` rule for `/_astro/*`) during the
148
+ * build, before this runs. Testing `dist` therefore read an adapter-generated
149
+ * file as a user opt-out and skipped silently. When both exist, the adapter's
150
+ * rules are preserved and ours are appended.
151
+ *
152
+ * Gated on {@link readsHeaderFiles}, not on `output === "static"`. A **Cloudflare
153
+ * server** build serves `dist/client` through the Worker's ASSETS binding, and
154
+ * Workers static assets honor `_headers` from that directory — so the file
155
+ * applies there too, and skipping it left every Cloudflare server build with no
156
+ * `Link` header and no media type on its own discovery files. The charset half
157
+ * of this file *is* redundant on a server build, because the runtime endpoint
158
+ * sets Content-Type on the Response itself; the `Link` and well-known halves are
159
+ * not, and one conclusion about the first was applied to all three.
160
+ *
161
+ * Exported for the test suite, which exercises it in a subprocess like the
162
+ * other command helpers.
133
163
  */
134
- const emitHeaderFiles = async (
164
+ export const emitHeaderFiles = async (
135
165
  project: BlumeProject,
136
166
  distDir: string
137
167
  ): Promise<void> => {
138
168
  const { config } = project;
139
169
  if (
140
- config.deployment.output !== "static" ||
141
- existsSync(join(distDir, "_headers"))
170
+ !readsHeaderFiles(config.deployment) ||
171
+ existsSync(join(project.context.root, "public", "_headers"))
142
172
  ) {
143
173
  return;
144
174
  }
175
+ const ours = buildNetlifyHeaders(
176
+ config,
177
+ buildHomeLinkHeader(config, markdownRoutePaths(project))
178
+ );
179
+ // An adapter may have written its own rules here already (Cloudflare adds an
180
+ // immutable Cache-Control for /_astro/*). Keep them and append ours: both
181
+ // sets are wanted, and `_headers` has no merge semantics beyond order.
182
+ const target = join(distDir, "_headers");
183
+ const existing = existsSync(target) ? await readFile(target, "utf-8") : "";
145
184
  await writeFile(
146
- join(distDir, "_headers"),
147
- buildNetlifyHeaders(
148
- config,
149
- buildHomeLinkHeader(
150
- config,
151
- project.manifest.routes.map((route) => route.path)
152
- )
153
- ),
185
+ target,
186
+ existing ? `${existing.trimEnd()}\n${ours}` : ours,
154
187
  "utf-8"
155
188
  );
156
189
  logger.success(
@@ -253,10 +286,11 @@ const emitWellKnownFiles = async (
253
286
  * straight to the project root (see `withAdapterRoot`).
254
287
  */
255
288
  const emitVercelNegotiation = async (
256
- config: ResolvedConfig,
289
+ project: BlumeProject,
257
290
  routePaths: string[],
258
291
  root: string
259
292
  ): Promise<void> => {
293
+ const { config } = project;
260
294
  const configPath = join(root, ".vercel", "output", "config.json");
261
295
  if (!existsSync(configPath)) {
262
296
  return;
@@ -269,11 +303,17 @@ const emitVercelNegotiation = async (
269
303
  ? { [SIGNATURES_DIRECTORY_PATH.slice(1)]: SIGNATURES_DIRECTORY_TYPE }
270
304
  : {}),
271
305
  };
306
+ // The homepage rewrite serves `/index.md` from the static layer, so its
307
+ // `x-markdown-tokens` estimate has to ride the routing config; the runtime
308
+ // endpoint stamps it on dev/server-rendered responses itself.
309
+ const rawMarkdown = await buildRawMarkdown(project);
310
+ const home = rawMarkdown["/"];
272
311
  const injected = injectNegotiationRoutes(
273
312
  await readFile(configPath, "utf-8"),
274
313
  routePaths,
275
314
  buildHomeLinkHeader(config, routePaths),
276
- overrides
315
+ overrides,
316
+ home ? markdownTokenCount(agentMarkdown(home)) : undefined
277
317
  );
278
318
  if (injected === null) {
279
319
  logger.warn(
@@ -287,6 +327,72 @@ const emitVercelNegotiation = async (
287
327
  );
288
328
  };
289
329
 
330
+ const warnCloudflareNegotiationSkipped = (): void =>
331
+ logger.warn(
332
+ "Could not wire Accept: text/markdown negotiation into dist/server/wrangler.json — raw Markdown stays available at the .md URLs."
333
+ );
334
+
335
+ /**
336
+ * Wire `Accept: text/markdown` negotiation into a Cloudflare server build. The
337
+ * ASSETS binding serves the prerendered content pages before the Worker runs —
338
+ * and even a request that reaches the Worker is answered by the adapter's
339
+ * handler from that binding, ahead of the only place middleware runs — so the
340
+ * negotiation lives in a generated wrapper Worker, routed to by
341
+ * `assets.run_worker_first` (see `deploy/cloudflare-negotiation.ts`). Both
342
+ * pieces are spliced into the adapter's emitted `dist/server` bundle.
343
+ */
344
+ const emitCloudflareNegotiation = async (
345
+ project: BlumeProject,
346
+ routePaths: string[]
347
+ ): Promise<void> => {
348
+ const { config, context } = project;
349
+ const serverDir = join(
350
+ context.distDir ?? join(context.root, "dist"),
351
+ "server"
352
+ );
353
+ const wranglerPath = join(serverDir, "wrangler.json");
354
+ if (!existsSync(wranglerPath)) {
355
+ warnCloudflareNegotiationSkipped();
356
+ return;
357
+ }
358
+ // The homepage mirror is served from the static layer, so its
359
+ // `x-markdown-tokens` estimate rides the wrapper Worker, mirroring the
360
+ // Vercel routing config.
361
+ const rawMarkdown = await buildRawMarkdown(project);
362
+ const home = rawMarkdown["/"];
363
+ const injected = injectWorkerNegotiation(
364
+ await readFile(wranglerPath, "utf-8"),
365
+ {
366
+ base: config.deployment.base,
367
+ // The manifest routes guard the wrapper's redirect table; `routePaths`
368
+ // also carries the synthesized homepage mirror, which must not block a
369
+ // configured root redirect.
370
+ contentRoutePaths: project.manifest.routes.map((route) => route.path),
371
+ homeLinkHeader: buildHomeLinkHeader(config, routePaths),
372
+ homeTokens: home ? markdownTokenCount(agentMarkdown(home)) : undefined,
373
+ // The wrapper Worker matches full served URLs, so the redirects are
374
+ // based the same way the platform files are — it answers any the
375
+ // worker-first rules claim, where `_redirects` is never consulted and
376
+ // Astro would default their status.
377
+ redirects: platformRedirects(config),
378
+ routePaths,
379
+ }
380
+ );
381
+ if (injected === null) {
382
+ warnCloudflareNegotiationSkipped();
383
+ return;
384
+ }
385
+ await writeFile(
386
+ join(serverDir, NEGOTIATION_WORKER_FILE),
387
+ injected.worker,
388
+ "utf-8"
389
+ );
390
+ await writeFile(wranglerPath, injected.wrangler, "utf-8");
391
+ logger.success(
392
+ "Wired Accept: text/markdown negotiation into the Cloudflare Worker"
393
+ );
394
+ };
395
+
290
396
  const formatBytes = (bytes: number): string => {
291
397
  if (bytes < 1024) {
292
398
  return `${bytes} B`;
@@ -425,6 +531,8 @@ export const isolatedOutputDir = (
425
531
  * bundle to the project root — a Vercel server build's static output stays at
426
532
  * `<runtime>/.vercel/output/static`, where `deployStaticDir` would instead
427
533
  * point at the project-root copy (a previous real build's assets, or nothing).
534
+ * Node and Cloudflare server builds serve one level down, at `client/` — see
535
+ * {@link servesClientSubdir}.
428
536
  */
429
537
  export const isolatedStaticDir = (
430
538
  config: ResolvedConfig,
@@ -435,7 +543,7 @@ export const isolatedStaticDir = (
435
543
  if (output === "server" && adapter === "vercel") {
436
544
  return join(outputDir, "static");
437
545
  }
438
- if (output === "server" && adapter === "node") {
546
+ if (servesClientSubdir(config.deployment)) {
439
547
  return join(outputDir, "client");
440
548
  }
441
549
  return outputDir;
@@ -709,11 +817,14 @@ export const buildCommand = defineCommand({
709
817
  }
710
818
 
711
819
  if (project.config.deployment.output === "server" && adapter === "vercel") {
712
- await emitVercelNegotiation(
713
- project.config,
714
- project.manifest.routes.map((route) => route.path),
715
- root
716
- );
820
+ await emitVercelNegotiation(project, markdownRoutePaths(project), root);
821
+ }
822
+
823
+ if (
824
+ project.config.deployment.output === "server" &&
825
+ adapter === "cloudflare"
826
+ ) {
827
+ await emitCloudflareNegotiation(project, markdownRoutePaths(project));
717
828
  }
718
829
 
719
830
  await publishBuildArtifacts(
@@ -0,0 +1,300 @@
1
+ import { defineCommand } from "citty";
2
+
3
+ import { AGENTS } from "../../audit/agent.ts";
4
+ import type { AgentKind } from "../../audit/agent.ts";
5
+ import { BlumeError } from "../../core/diagnostics.ts";
6
+ import { i18nEnabled, localeCodes } from "../../core/i18n.ts";
7
+ import { scanProject } from "../../core/project-graph.ts";
8
+ import type { ResolvedI18nConfig } from "../../core/schema.ts";
9
+ import { DEFAULT_TRANSLATE_TIMEOUT_MS } from "../../translate/agents.ts";
10
+ import {
11
+ pruneLedger,
12
+ readLedger,
13
+ stampLedger,
14
+ writeLedger,
15
+ } from "../../translate/ledger.ts";
16
+ import {
17
+ checkLines,
18
+ checkReportJson,
19
+ checkSummaryLine,
20
+ createProgressRenderer,
21
+ diagnosticLines,
22
+ hasDrift,
23
+ translateHeaderLine,
24
+ translateReportJson,
25
+ translateSummaryLine,
26
+ } from "../../translate/report.ts";
27
+ import { runTranslate } from "../../translate/run.ts";
28
+ import { computeWorkList } from "../../translate/work-list.ts";
29
+ import { reportInternalError } from "../internal-error.ts";
30
+ import { flushStdout, logger } from "../log.ts";
31
+
32
+ /** Wall-clock ceiling per file, in seconds. */
33
+ const DEFAULT_TIMEOUT_S = DEFAULT_TRANSLATE_TIMEOUT_MS / 1000;
34
+
35
+ /** Parallel agent sessions per run; each one is a full agent process. */
36
+ const DEFAULT_CONCURRENCY = 4;
37
+ const MAX_CONCURRENCY = 16;
38
+
39
+ interface TranslateFlags {
40
+ check?: boolean;
41
+ claude?: boolean;
42
+ codex?: boolean;
43
+ concurrency?: string;
44
+ force?: boolean;
45
+ json?: boolean;
46
+ locale?: string;
47
+ timeout?: string;
48
+ }
49
+
50
+ /** Validate the flag surface, exiting with a message on the first offense. */
51
+ const parseFlags = (
52
+ args: TranslateFlags
53
+ ): { agent: AgentKind | undefined; concurrency: number; timeoutS: number } => {
54
+ const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
55
+ (kind) => args[kind]
56
+ );
57
+ if (agents.length > 1) {
58
+ logger.error("Pass exactly one of --claude or --codex.");
59
+ process.exit(1);
60
+ }
61
+ if (args.check && agents.length > 0) {
62
+ logger.error(
63
+ "--check is read-only and never runs an agent; drop --claude/--codex."
64
+ );
65
+ process.exit(1);
66
+ }
67
+ if (!args.check && agents.length === 0) {
68
+ logger.error(
69
+ "Pass --claude or --codex to choose the agent CLI that translates."
70
+ );
71
+ process.exit(1);
72
+ }
73
+ const timeoutS =
74
+ args.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(args.timeout);
75
+ if (!Number.isInteger(timeoutS) || timeoutS <= 0) {
76
+ logger.error(`Invalid --timeout "${args.timeout}" (whole seconds).`);
77
+ process.exit(1);
78
+ }
79
+ const concurrency =
80
+ args.concurrency === undefined
81
+ ? DEFAULT_CONCURRENCY
82
+ : Number(args.concurrency);
83
+ if (
84
+ !Number.isInteger(concurrency) ||
85
+ concurrency < 1 ||
86
+ concurrency > MAX_CONCURRENCY
87
+ ) {
88
+ logger.error(
89
+ `Invalid --concurrency "${args.concurrency}" (use 1..${MAX_CONCURRENCY}).`
90
+ );
91
+ process.exit(1);
92
+ }
93
+ return { agent: agents[0], concurrency, timeoutS };
94
+ };
95
+
96
+ /**
97
+ * Resolve `--locale` against the configured locales: comma-separated,
98
+ * case-insensitive, adopting the configured casing. Unknown codes and the
99
+ * default locale (the translation source) are errors.
100
+ */
101
+ const parseLocales = (
102
+ value: string | undefined,
103
+ i18n: ResolvedI18nConfig
104
+ ): string[] | undefined => {
105
+ if (value === undefined) {
106
+ return;
107
+ }
108
+ const configured = new Map(
109
+ i18n.locales.map((locale) => [locale.code.toLowerCase(), locale.code])
110
+ );
111
+ const resolved: string[] = [];
112
+ for (const part of value.split(",")) {
113
+ const code = configured.get(part.trim().toLowerCase());
114
+ if (code === undefined) {
115
+ logger.error(
116
+ `Unknown --locale "${part.trim()}" (configured: ${localeCodes(i18n).join(", ")}).`
117
+ );
118
+ process.exit(1);
119
+ }
120
+ if (code === i18n.defaultLocale) {
121
+ logger.error(
122
+ `--locale "${code}" is the default locale — it is the translation source, not a target.`
123
+ );
124
+ process.exit(1);
125
+ }
126
+ resolved.push(code);
127
+ }
128
+ return resolved;
129
+ };
130
+
131
+ const notInstalled = (agent: AgentKind): never => {
132
+ const cli = AGENTS[agent];
133
+ logger.error(
134
+ `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
135
+ );
136
+ return process.exit(1);
137
+ };
138
+
139
+ export const translateCommand = defineCommand({
140
+ args: {
141
+ check: {
142
+ description:
143
+ "Report missing/stale translations without writing anything; exits 1 on drift (for CI).",
144
+ type: "boolean",
145
+ },
146
+ claude: {
147
+ description: "Translate with Claude Code.",
148
+ type: "boolean",
149
+ },
150
+ codex: {
151
+ description: "Translate with Codex.",
152
+ type: "boolean",
153
+ },
154
+ concurrency: {
155
+ description: `Parallel agent sessions. Defaults to ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY}.`,
156
+ type: "string",
157
+ },
158
+ force: {
159
+ description:
160
+ "Retranslate everything, up-to-date and hand-authored files included.",
161
+ type: "boolean",
162
+ },
163
+ json: {
164
+ description: "Emit the report as JSON on stdout (for CI/editors).",
165
+ type: "boolean",
166
+ },
167
+ locale: {
168
+ description:
169
+ "Comma-separated target locale codes; defaults to every non-default locale.",
170
+ type: "string",
171
+ },
172
+ timeout: {
173
+ description: `Agent time limit per file, in seconds. Defaults to ${DEFAULT_TIMEOUT_S}.`,
174
+ type: "string",
175
+ },
176
+ },
177
+ meta: {
178
+ description:
179
+ "Translate docs into the configured locales with a local agent CLI.",
180
+ name: "translate",
181
+ },
182
+ async run({ args }) {
183
+ const root = process.cwd();
184
+ const { agent, concurrency, timeoutS } = parseFlags(args);
185
+
186
+ try {
187
+ // `scanProject`, not `prepareProject`: translation reads the content
188
+ // tree and writes source files, never `.blume/`, so it doesn't contend
189
+ // with a running dev server. Same reasoning as `blume audit`/`eval`.
190
+ const project = await scanProject(root, { mode: "build" });
191
+ if (!i18nEnabled(project.config)) {
192
+ logger.error(
193
+ "i18n is not configured — add `i18n.locales` to blume.config to use `blume translate`."
194
+ );
195
+ process.exit(1);
196
+ }
197
+ const { i18n } = project.config;
198
+ if (localeCodes(i18n).every((code) => code === i18n.defaultLocale)) {
199
+ logger.error(
200
+ "i18n.locales has no locale besides the default — nothing to translate into."
201
+ );
202
+ process.exit(1);
203
+ }
204
+ const locales = parseLocales(args.locale, i18n);
205
+ const ledger = await readLedger(root);
206
+ const workList = await computeWorkList(project, ledger, {
207
+ force: !args.check && Boolean(args.force),
208
+ locales,
209
+ });
210
+
211
+ if (args.check) {
212
+ const lines = [
213
+ ...diagnosticLines(workList.diagnostics),
214
+ ...checkLines(workList),
215
+ "",
216
+ ` ${checkSummaryLine(workList)}`,
217
+ "",
218
+ ];
219
+ process.stderr.write(`${lines.join("\n")}\n`);
220
+ if (args.json) {
221
+ process.stdout.write(checkReportJson(workList));
222
+ }
223
+ if (hasDrift(workList)) {
224
+ await flushStdout();
225
+ process.exit(1);
226
+ }
227
+ return;
228
+ }
229
+
230
+ const kind = agent as AgentKind;
231
+ process.stderr.write(
232
+ `${translateHeaderLine(workList.items.length, workList.targetLocales.length, kind)}\n\n`
233
+ );
234
+
235
+ // Adopt pre-existing hand-authored translations (stamp, never rewrite)
236
+ // and persist BEFORE the agents run, so adoption survives an interrupt.
237
+ for (const entry of workList.untracked) {
238
+ stampLedger(ledger, entry.sourceRel, entry.locale, entry.hash);
239
+ }
240
+ await writeLedger(root, ledger);
241
+
242
+ const renderer = createProgressRenderer({
243
+ isTTY: process.stderr.isTTY === true,
244
+ write: (chunk) => process.stderr.write(chunk),
245
+ });
246
+ const result = await runTranslate({
247
+ agent: kind,
248
+ concurrency,
249
+ ledger,
250
+ onProgress: (event) => renderer.onProgress(event),
251
+ // Flush after every finished item, so stopping a long run mid-way
252
+ // keeps everything already translated out of the next work list.
253
+ persistLedger: () => writeLedger(root, ledger),
254
+ project,
255
+ timeoutMs: timeoutS * 1000,
256
+ workList,
257
+ });
258
+ renderer.stop();
259
+
260
+ // Prune entries whose source or locale no longer exists. Pruning spans
261
+ // ALL non-default locales — a `--locale fr` run must not drop the other
262
+ // locales' stamps.
263
+ const knownLocales = new Set(
264
+ localeCodes(i18n).filter((code) => code !== i18n.defaultLocale)
265
+ );
266
+ await writeLedger(
267
+ root,
268
+ pruneLedger(ledger, workList.knownSources, knownLocales)
269
+ );
270
+
271
+ const tail = [
272
+ "",
273
+ ...diagnosticLines(workList.diagnostics),
274
+ ` ${translateSummaryLine(result, workList)}`,
275
+ "",
276
+ ];
277
+ process.stderr.write(tail.join("\n"));
278
+
279
+ if (args.json) {
280
+ process.stdout.write(translateReportJson(result, workList));
281
+ }
282
+ if (result.counts.failed > 0 || result.counts.partial > 0) {
283
+ // The ledger write above already persisted every success, so a failed
284
+ // rerun only retries what actually failed.
285
+ await flushStdout();
286
+ process.exit(1);
287
+ }
288
+ } catch (error) {
289
+ if (error instanceof BlumeError) {
290
+ logger.error(error.diagnostic.message);
291
+ process.exit(1);
292
+ }
293
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT" && agent) {
294
+ notInstalled(agent);
295
+ }
296
+ reportInternalError(error);
297
+ process.exit(1);
298
+ }
299
+ },
300
+ });
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@ import { initCommand } from "./commands/init.ts";
13
13
  import { mcpStdioCommand } from "./commands/mcp-stdio.ts";
14
14
  import { previewCommand } from "./commands/preview.ts";
15
15
  import { syncCommand } from "./commands/sync.ts";
16
+ import { translateCommand } from "./commands/translate.ts";
16
17
  import { validateCommand } from "./commands/validate.ts";
17
18
  import { loadEnvFiles } from "./env.ts";
18
19
  import { reportInternalError } from "./internal-error.ts";
@@ -36,6 +37,7 @@ const main = defineCommand({
36
37
  "mcp-stdio": mcpStdioCommand,
37
38
  preview: previewCommand,
38
39
  sync: syncCommand,
40
+ translate: translateCommand,
39
41
  validate: validateCommand,
40
42
  },
41
43
  });
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
2
3
  import { resolveIcon } from "../theme/icons.ts";
3
4
  import { withBase } from "./islands/base-path.ts";
4
5
 
@@ -27,13 +28,7 @@ const customIcon =
27
28
  ? rawIcon
28
29
  : null;
29
30
  const iconName = typeof rawIcon === "string" ? rawIcon : null;
30
- const rawSvg =
31
- iconName && /^\s*<svg[\s\S]*<\/svg>\s*$/u.test(iconName)
32
- ? iconName.trim()
33
- : null;
34
- const isImageIcon = (value: string): boolean =>
35
- /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)/u.test(value) ||
36
- /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
31
+ const rawSvg = iconName && isInlineSvg(iconName) ? iconName.trim() : null;
37
32
  const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
38
33
  const resolvedIcon =
39
34
  iconName && !(imageSrc || rawSvg) ? resolveIcon(iconName) : null;
@@ -1,6 +1,7 @@
1
1
  ---
2
- import Icon from "../Icon.astro";
2
+ import { isAssetIcon } from "../../theme/icon-kind.ts";
3
3
  import { hasIcon } from "../../theme/icons.ts";
4
+ import Icon from "../Icon.astro";
4
5
 
5
6
  interface Props {
6
7
  icon?: string;
@@ -10,14 +11,8 @@ interface Props {
10
11
  const { icon, title } = Astro.props;
11
12
  const markerClass =
12
13
  "absolute top-0 -start-[2.75rem] flex size-6 items-center justify-center rounded-full bg-accent text-center font-semibold text-accent-foreground text-xs";
13
- const isImageIcon = (value: string): boolean =>
14
- /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)/u.test(value) ||
15
- /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
16
14
  const canRenderIcon =
17
- icon !== undefined &&
18
- (/^\s*<svg[\s\S]*<\/svg>\s*$/u.test(icon) ||
19
- isImageIcon(icon) ||
20
- hasIcon(icon));
15
+ icon !== undefined && (isAssetIcon(icon) || hasIcon(icon));
21
16
  ---
22
17
 
23
18
  <div
@@ -21,9 +21,28 @@ const slugify = (value: string): string =>
21
21
  const tabId = id ?? slugify(title);
22
22
  ---
23
23
 
24
+ {/*
25
+ Drop the inset only when the panel is *nothing but* code fences: a fence owns
26
+ its own frame, so a code-only panel would double-inset it. A panel holding a
27
+ fence AND prose is real prose content and keeps the inset, matching every
28
+ other code-block host (CodeGroup, Steps, Callout, Card, Accordion) — see the
29
+ scroll-container note in theme/entry.ts. Previously the bare `:has(>pre)`
30
+ also matched mixed panels, stripping padding from the prose too; with
31
+ `[&>:last-child]:mb-0!` a trailing paragraph sat flush against the border.
32
+
33
+ The `template` exclusion is load-bearing, not defensive: when `icon` is set
34
+ this component renders a `<template data-blume-tab-icon>` that stays a
35
+ permanent child, because the tabs script *clones* its content into the
36
+ trigger (`icon.content.cloneNode(true)`) rather than moving the node. So the
37
+ panel legitimately has a non-`pre` child even when it is code-only, and
38
+ `:has(>pre:only-child)` would stop matching every icon tab — restoring the
39
+ double inset. `:only-of-type` survives the single-fence case but breaks a
40
+ code-only panel holding two fences. Excluding `template` handles both. Do
41
+ not "simplify" this to those.
42
+ */}
24
43
  <div
25
44
  class:list={[
26
- "px-4 py-3 [&:has(>pre)]:p-0 [&>:first-child]:mt-0! [&>:last-child]:mb-0!",
45
+ "px-4 py-3 [&:has(>pre):not(:has(>:not(pre,template)))]:p-0 [&>:first-child]:mt-0! [&>:last-child]:mb-0!",
27
46
  className,
28
47
  ]}
29
48
  data-blume-tab-panel
@@ -26,7 +26,8 @@ const menuRowClass =
26
26
  class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
27
27
  >
28
28
  <Icon name="globe" size={16} />
29
- <span class="max-sm:hidden">{current?.label ?? label}</span>
29
+ {/* Collapses to the globe icon below `lg`, matching the search field. */}
30
+ <span class="max-lg:hidden">{current?.label ?? label}</span>
30
31
  <Icon
31
32
  class="transition-transform group-open:rotate-180"
32
33
  name="chevron-down"