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
|
@@ -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 =
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
141
|
-
existsSync(join(
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
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
|
-
|
|
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"
|