blume 1.3.1 → 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 (104) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/cli/index.js +3221 -201
  3. package/dist/cli/index.js.map +73 -61
  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 +15 -1
  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/mcp/data.ts +10 -4
  24. package/src/ai/mcp/server.ts +74 -3
  25. package/src/ai/mcp/tools.ts +2 -2
  26. package/src/astro/integration.ts +3 -1
  27. package/src/astro/markdown-negotiation.ts +5 -0
  28. package/src/astro/templates.ts +66 -18
  29. package/src/audit/url.ts +5 -10
  30. package/src/cli/commands/build.ts +129 -24
  31. package/src/cli/commands/translate.ts +300 -0
  32. package/src/cli/index.ts +2 -0
  33. package/src/components/Icon.astro +2 -7
  34. package/src/components/content/Step.astro +3 -8
  35. package/src/components/content/Tab.astro +20 -1
  36. package/src/components/layout/LanguageSwitcher.astro +2 -1
  37. package/src/components/layout/Logo.astro +4 -4
  38. package/src/components/layout/PageActions.astro +12 -7
  39. package/src/components/layout/Search.astro +15 -20
  40. package/src/components/layout/search/orama.ts +3 -1
  41. package/src/core/base-path.ts +9 -0
  42. package/src/core/config-input.ts +84 -6
  43. package/src/core/graph.ts +46 -2
  44. package/src/core/i18n-ui.ts +2 -0
  45. package/src/core/i18n.ts +31 -0
  46. package/src/core/nav-diagnostics.ts +13 -34
  47. package/src/core/project-graph.ts +13 -2
  48. package/src/core/schema.ts +174 -74
  49. package/src/core/sources/normalize.ts +25 -12
  50. package/src/core/sources/types.ts +5 -0
  51. package/src/core/types.ts +4 -3
  52. package/src/core/ui-packs/ar.ts +42 -1
  53. package/src/core/ui-packs/bg.ts +42 -1
  54. package/src/core/ui-packs/bn.ts +42 -1
  55. package/src/core/ui-packs/ca.ts +44 -1
  56. package/src/core/ui-packs/cs.ts +42 -1
  57. package/src/core/ui-packs/da.ts +42 -1
  58. package/src/core/ui-packs/de.ts +42 -1
  59. package/src/core/ui-packs/el.ts +44 -1
  60. package/src/core/ui-packs/es.ts +44 -1
  61. package/src/core/ui-packs/fa.ts +42 -1
  62. package/src/core/ui-packs/fi.ts +42 -1
  63. package/src/core/ui-packs/fr.ts +44 -1
  64. package/src/core/ui-packs/he.ts +42 -1
  65. package/src/core/ui-packs/hi.ts +42 -1
  66. package/src/core/ui-packs/hr.ts +42 -1
  67. package/src/core/ui-packs/hu.ts +42 -1
  68. package/src/core/ui-packs/id.ts +42 -1
  69. package/src/core/ui-packs/it.ts +44 -1
  70. package/src/core/ui-packs/ja.ts +44 -1
  71. package/src/core/ui-packs/ko.ts +44 -1
  72. package/src/core/ui-packs/nl.ts +42 -1
  73. package/src/core/ui-packs/no.ts +42 -1
  74. package/src/core/ui-packs/pl.ts +42 -1
  75. package/src/core/ui-packs/pt-br.ts +44 -1
  76. package/src/core/ui-packs/pt.ts +44 -1
  77. package/src/core/ui-packs/ro.ts +42 -1
  78. package/src/core/ui-packs/ru.ts +42 -1
  79. package/src/core/ui-packs/sk.ts +42 -1
  80. package/src/core/ui-packs/sr.ts +42 -1
  81. package/src/core/ui-packs/sv.ts +42 -1
  82. package/src/core/ui-packs/th.ts +44 -1
  83. package/src/core/ui-packs/tr.ts +42 -1
  84. package/src/core/ui-packs/uk.ts +42 -1
  85. package/src/core/ui-packs/vi.ts +44 -1
  86. package/src/core/ui-packs/zh-tw.ts +44 -1
  87. package/src/core/ui-packs/zh.ts +44 -1
  88. package/src/deploy/adapter-output.ts +44 -5
  89. package/src/deploy/cloudflare-negotiation.ts +527 -0
  90. package/src/deploy/redirects.ts +13 -0
  91. package/src/eval/agents.ts +1 -1
  92. package/src/search/documents.ts +11 -0
  93. package/src/search/facets.ts +33 -0
  94. package/src/search/orama-index.ts +48 -6
  95. package/src/search/popular-icon.ts +33 -0
  96. package/src/theme/icon-kind.ts +20 -0
  97. package/src/translate/agents.ts +51 -0
  98. package/src/translate/ledger.ts +148 -0
  99. package/src/translate/meta.ts +149 -0
  100. package/src/translate/prompts.ts +95 -0
  101. package/src/translate/report.ts +360 -0
  102. package/src/translate/run.ts +376 -0
  103. package/src/translate/validate.ts +171 -0
  104. package/src/translate/work-list.ts +0 -0
@@ -17,6 +17,11 @@
17
17
  * bare `/` normalizes to `""`, so an unset/`"/"` base is a clean no-op.
18
18
  */
19
19
  export declare const normalizeBasePath: (input?: string) => string;
20
+ /**
21
+ * Normalize a served path for comparison: drop the trailing slash (Astro serves
22
+ * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
23
+ */
24
+ export declare const normalizePath: (path: string) => string;
20
25
  /**
21
26
  * Whether a link target is a root-relative internal path (`/x`) — the only
22
27
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
@@ -226,15 +226,81 @@ export interface ContentConfig {
226
226
  * Releases, Sanity, Notion, or a custom `ContentSource`.
227
227
  */
228
228
  sources?: ContentSourceInput[];
229
+ /**
230
+ * Per-type content definitions, keyed by the frontmatter `type` they apply
231
+ * to (including `defaultType`, for pages that set none):
232
+ *
233
+ * ```ts
234
+ * import { z } from "zod";
235
+ *
236
+ * content: {
237
+ * types: {
238
+ * rfc: {
239
+ * frontmatter: {
240
+ * domain: z.string(),
241
+ * status: z.enum(["draft", "enforced"]),
242
+ * },
243
+ * },
244
+ * },
245
+ * },
246
+ * ```
247
+ */
248
+ types?: Record<string, ContentTypeConfig>;
249
+ }
250
+ /**
251
+ * A per-type content definition: configuration that applies only to pages
252
+ * whose resolved frontmatter `type` matches the map key.
253
+ */
254
+ export interface ContentTypeConfig {
255
+ /**
256
+ * Custom frontmatter keys whose values become filterable facets for pages
257
+ * of this type. Faceted values ride along on search documents
258
+ * (`blume-search.json` and the MCP index), and the MCP `search_docs` and
259
+ * `list_pages` tools accept a `filters` input matching against them:
260
+ *
261
+ * ```ts
262
+ * content: {
263
+ * types: {
264
+ * rfc: {
265
+ * facets: ["domain", "status"],
266
+ * frontmatter: { domain: z.string(), status: z.string() },
267
+ * },
268
+ * },
269
+ * },
270
+ * ```
271
+ *
272
+ * Each name must be a custom key declared for the type — in its
273
+ * `frontmatter` map or the site-wide `frontmatter.extend`. String values
274
+ * facet as-is; numbers and booleans are stringified; anything else
275
+ * (objects, arrays, transformed dates) does not facet.
276
+ */
277
+ facets?: string[];
278
+ /**
279
+ * Custom frontmatter keys for pages of this type, layered on top of the
280
+ * site-wide `frontmatter.extend` (a key can be declared in one or the
281
+ * other, not both). Schemas follow the same rules as `extend`: any
282
+ * Standard Schema library works, every declared key is validated on every
283
+ * page of the type — absent ones included — so a required schema enforces
284
+ * the key type-wide (mark it `.optional()` to validate only when present),
285
+ * and validated values land on the page record's `custom` field. Built-in
286
+ * frontmatter fields cannot be redeclared.
287
+ */
288
+ frontmatter?: Record<string, StandardSchema>;
229
289
  }
290
+ /**
291
+ * A header label, optionally per locale: a plain string, or a map of locale
292
+ * code to label (`{ en: "Docs", ja: "ドキュメント" }`). The active locale's
293
+ * entry wins, then the default locale's, then the map's first entry.
294
+ */
295
+ export type LocalizableLabel = string | Record<string, string>;
230
296
  /** A single item inside a header tab's dropdown. */
231
297
  export interface NavTabItem {
232
298
  /** Secondary line under the label. */
233
299
  description?: string;
234
300
  /** Lucide icon name shown beside the label. */
235
301
  icon?: string;
236
- /** Item label. */
237
- label: string;
302
+ /** Item label, optionally per locale. */
303
+ label: LocalizableLabel;
238
304
  /** Route the item links to. */
239
305
  path: string;
240
306
  /** Short tag/pill (e.g. `New`, `Beta`). */
@@ -255,8 +321,8 @@ export interface NavTab {
255
321
  icon?: string;
256
322
  /** Dropdown items; omit for a plain link tab. */
257
323
  items?: NavTabItem[];
258
- /** Tab label. */
259
- label: string;
324
+ /** Tab label, optionally per locale. */
325
+ label: LocalizableLabel;
260
326
  /** Route the tab links to. */
261
327
  path: string;
262
328
  }
@@ -434,7 +500,10 @@ export interface MixedbreadSearch {
434
500
  export interface SearchPopularLink {
435
501
  /** Internal route or external URL. */
436
502
  href: string;
437
- /** Built-in icon name shown beside the label; defaults to the file glyph. */
503
+ /**
504
+ * Icon shown beside the label — a built-in name, image path/URL, or inline
505
+ * SVG (same as nav icons). Defaults to the file glyph.
506
+ */
438
507
  icon?: string;
439
508
  /** Link label shown in the dialog. */
440
509
  label: string;
@@ -627,6 +696,12 @@ export interface LocaleConfigInput {
627
696
  dir?: "ltr" | "rtl";
628
697
  /** Human-readable name shown in the switcher. */
629
698
  label: string;
699
+ /**
700
+ * Freeform style guidance for `blume translate`, e.g. "Brazilian
701
+ * Portuguese, informal você". Pins register and dialect from the first
702
+ * translation and wins over an existing translation's style on reruns.
703
+ */
704
+ style?: string;
630
705
  }
631
706
  /**
632
707
  * Internationalization. Opt-in: when omitted, Blume is single-locale. The
@@ -991,7 +1066,8 @@ export interface FrontmatterConfig {
991
1066
  * so a required schema enforces the key site-wide; mark it `.optional()`
992
1067
  * to validate only when present. Validated values are preserved on each
993
1068
  * page record's `custom` field. Built-in frontmatter fields cannot be
994
- * redeclared.
1069
+ * redeclared. To scope a key to one content type instead, declare it under
1070
+ * `content.types.<type>.frontmatter`.
995
1071
  */
996
1072
  extend?: Record<string, StandardSchema>;
997
1073
  }
@@ -24,6 +24,7 @@ declare const uiStringsObject: z.ZodObject<{
24
24
  exportEpub: z.ZodDefault<z.ZodString>;
25
25
  exportPdf: z.ZodDefault<z.ZodString>;
26
26
  generating: z.ZodDefault<z.ZodString>;
27
+ openIn: z.ZodDefault<z.ZodString>;
27
28
  openInChat: z.ZodDefault<z.ZodString>;
28
29
  scrollToTop: z.ZodDefault<z.ZodString>;
29
30
  }, z.core.$strip>>;
@@ -125,6 +126,7 @@ export declare const uiStringsSchema: z.ZodPrefault<z.ZodObject<{
125
126
  exportEpub: z.ZodDefault<z.ZodString>;
126
127
  exportPdf: z.ZodDefault<z.ZodString>;
127
128
  generating: z.ZodDefault<z.ZodString>;
129
+ openIn: z.ZodDefault<z.ZodString>;
128
130
  openInChat: z.ZodDefault<z.ZodString>;
129
131
  scrollToTop: z.ZodDefault<z.ZodString>;
130
132
  }, z.core.$strip>>;
@@ -207,6 +207,16 @@ declare const contentSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
207
207
  }, z.core.$strip>], "type">;
208
208
  /** A resolved content-source config entry (post-defaults). */
209
209
  export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
210
+ /**
211
+ * A header label that may localize: a plain string, or a map of locale code to
212
+ * label (`{ en: "Docs", ja: "ドキュメント" }`). Resolved when each locale's
213
+ * navigation is built — the active locale's entry wins, then the default
214
+ * locale's, then the map's first entry — so a single-locale site can keep
215
+ * plain strings and an i18n site can translate its header without forking the
216
+ * config.
217
+ */
218
+ declare const localizableLabelSchema: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
219
+ export type LocalizableLabel = z.infer<typeof localizableLabelSchema>;
210
220
  declare const directoryModeSchema: z.ZodEnum<{
211
221
  none: "none";
212
222
  accordion: "accordion";
@@ -280,6 +290,7 @@ declare const localeSchema: z.ZodObject<{
280
290
  rtl: "rtl";
281
291
  }>>;
282
292
  label: z.ZodString;
293
+ style: z.ZodOptional<z.ZodString>;
283
294
  }, z.core.$strict>;
284
295
  /**
285
296
  * Internationalization. Opt-in: when absent, Blume is single-locale and behaves
@@ -297,6 +308,7 @@ declare const i18nConfigSchema: z.ZodObject<{
297
308
  rtl: "rtl";
298
309
  }>>;
299
310
  label: z.ZodString;
311
+ style: z.ZodOptional<z.ZodString>;
300
312
  }, z.core.$strict>>;
301
313
  parser: z.ZodDefault<z.ZodEnum<{
302
314
  dir: "dir";
@@ -531,6 +543,10 @@ export declare const blumeConfigSchema: z.ZodObject<{
531
543
  source: z.ZodCustom<ContentSource, ContentSource>;
532
544
  type: z.ZodLiteral<"custom">;
533
545
  }, z.core.$strip>], "type">>>;
546
+ types: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
547
+ facets: z.ZodDefault<z.ZodArray<z.ZodString>>;
548
+ frontmatter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodCustom<StandardSchema<unknown, unknown>, StandardSchema<unknown, unknown>>>>;
549
+ }, z.core.$strict>>>;
534
550
  }, z.core.$strict>>;
535
551
  dateFormat: z.ZodDefault<z.ZodObject<{
536
552
  calendar: z.ZodOptional<z.ZodString>;
@@ -624,6 +640,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
624
640
  rtl: "rtl";
625
641
  }>>;
626
642
  label: z.ZodString;
643
+ style: z.ZodOptional<z.ZodString>;
627
644
  }, z.core.$strict>>;
628
645
  parser: z.ZodDefault<z.ZodEnum<{
629
646
  dir: "dir";
@@ -713,11 +730,11 @@ export declare const blumeConfigSchema: z.ZodObject<{
713
730
  items: z.ZodOptional<z.ZodArray<z.ZodObject<{
714
731
  description: z.ZodOptional<z.ZodString>;
715
732
  icon: z.ZodOptional<z.ZodString>;
716
- label: z.ZodString;
733
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
717
734
  path: z.ZodString;
718
735
  tag: z.ZodOptional<z.ZodString>;
719
736
  }, z.core.$strict>>>;
720
- label: z.ZodString;
737
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
721
738
  path: z.ZodString;
722
739
  }, z.core.$strict>>>;
723
740
  }, z.core.$strict>>;
@@ -114,4 +114,9 @@ export interface NormalizeContext {
114
114
  /** Opt-in custom frontmatter keys (`frontmatter.extend`), schema per key. */
115
115
  frontmatterExtend?: FrontmatterExtend;
116
116
  i18n?: ResolvedI18nConfig;
117
+ /**
118
+ * Per-type custom frontmatter keys (`content.types.<type>.frontmatter`),
119
+ * applied to a page only when its resolved `type` matches.
120
+ */
121
+ typeFrontmatter?: Record<string, FrontmatterExtend>;
117
122
  }
@@ -139,9 +139,10 @@ export interface PageRecord {
139
139
  contentType: string;
140
140
  meta: PageMeta;
141
141
  /**
142
- * Custom frontmatter values declared via `frontmatter.extend`, validated by
143
- * the user-supplied schemas (schema output, so transforms apply). Present
144
- * only when the project opts in and the page carries at least one value.
142
+ * Custom frontmatter values declared via `frontmatter.extend` or the page
143
+ * type's `content.types.<type>.frontmatter`, validated by the user-supplied
144
+ * schemas (schema output, so transforms apply). Present only when the
145
+ * project opts in and the page carries at least one value.
145
146
  */
146
147
  custom?: Record<string, unknown>;
147
148
  headings: Heading[];
@@ -101,7 +101,7 @@ On **Vercel**, **Netlify**, and **Cloudflare Pages**, Blume picks the matching a
101
101
 
102
102
  A server build includes everything a static build does, plus any Astro endpoints or middleware you add. The `node` adapter produces a standalone server you can run directly.
103
103
 
104
- On Vercel, a server build also turns on [`Accept: text/markdown` content negotiation](/docs/configuration/ai#content-negotiation): Blume splices header-conditional rewrites into the deploy's routing config, so an agent requesting any content page with that header receives its raw-Markdown mirror at the same URL.
104
+ On Vercel and Cloudflare, a server build also turns on [`Accept: text/markdown` content negotiation](/docs/configuration/ai#content-negotiation), so an agent requesting any content page with that header receives its raw-Markdown mirror at the same URL. On Vercel, Blume splices header-conditional rewrites into the deploy's routing config; on Cloudflare, it generates a small Worker in front of the Astro one and scopes `assets.run_worker_first` to the content routes, since the platform would otherwise serve the prerendered pages before any server code runs — other assets keep their zero-Worker fast path.
105
105
 
106
106
  :::note
107
107
  Server features have their own configuration — for example, Ask AI needs a model API key. See the [AI guide](/docs/configuration/ai) for setup.
@@ -63,7 +63,7 @@ The `.md` variant _downlevels_ components to plain Markdown for consumers that c
63
63
 
64
64
  ### Content negotiation
65
65
 
66
- Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy's routing rules automatically — no configuration needed. The homepage always negotiates, even when it's a custom landing page rather than a content page: its Markdown mirror falls back to the [`llms.txt`](#llmstxt) index, so an agent asking the site root for Markdown gets the machine-readable map of the site. Markdown responses also carry an `x-markdown-tokens` header — an estimated token count (~4 characters per token), following the convention of [Cloudflare's Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) — on every surface where Blume controls response headers: the dev server, server-rendered responses, and the negotiated homepage on Vercel. Other deploy targets serve prerendered pages from a static layer with no request-time hook, so agents there fetch the `.md` URL directly; the [agent readability manifest](#agent-readability) advertises `contentNegotiation` only on deployments that honor the header.
66
+ Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel or Cloudflare server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy automatically — routing rules on Vercel, a generated Worker on Cloudflare — no configuration needed. The homepage always negotiates, even when it's a custom landing page rather than a content page: its Markdown mirror falls back to the [`llms.txt`](#llmstxt) index, so an agent asking the site root for Markdown gets the machine-readable map of the site. Markdown responses also carry an `x-markdown-tokens` header — an estimated token count (~4 characters per token), following the convention of [Cloudflare's Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) — on every surface where Blume controls response headers: the dev server, server-rendered responses, and the negotiated homepage on Vercel and Cloudflare. Other deploy targets serve prerendered pages from a static layer with no request-time hook, so agents there fetch the `.md` URL directly; the [agent readability manifest](#agent-readability) advertises `contentNegotiation` only on deployments that honor the header.
67
67
 
68
68
  ### Custom component serializers
69
69
 
@@ -257,6 +257,20 @@ The server exposes read-only tools — `search_docs`, `get_page`, `list_pages`,
257
257
 
258
258
  `search_docs` runs its own full-text index, so it works regardless of your [search](/docs/configuration/search) provider — and even when search is set to `none`. The MCP server is a separate feature from on-page search.
259
259
 
260
+ `search_docs` and `list_pages` both accept an optional `contentTypes` filter, narrowing results to pages of the given frontmatter [`type`s](/docs/reference/frontmatter) — `["rfc"]`, `["blog", "changelog"]` — so an agent working against a site that mixes docs with RFCs, runbooks, or policies can scope retrieval to the kind of page it needs. Every result names its content type, and `list_pages` output shows the types in use.
261
+
262
+ Both tools also accept a `filters` object matching against the facets a site declares per content type ([`content.types.<type>.facets`](/docs/configuration#frontmatter)) — custom frontmatter keys whose values become filterable metadata:
263
+
264
+ ```json
265
+ {
266
+ "query": "OpenAPI request schemas",
267
+ "contentTypes": ["rfc"],
268
+ "filters": { "domain": "architecture", "status": "enforced" }
269
+ }
270
+ ```
271
+
272
+ Every `filters` entry must match (results carry their facet values, and `list_pages` shows each page's), so a knowledge base can drive progressive-disclosure agent workflows — enumerate the enforced standards, search only within them — without any server of its own.
273
+
260
274
  ### Server output required
261
275
 
262
276
  The MCP server is a live endpoint (`/mcp`), so it can't run on a static build. Switch to server output and pick an adapter:
@@ -189,6 +189,7 @@ content: {
189
189
  | `exclude` | `["**/_*", "**/.*"]` | Globs to ignore (underscore- and dot-files). |
190
190
  | `pages` | `"pages"` | Folder for custom `.astro` pages. |
191
191
  | `defaultType` | `"doc"` | Page `type` used when frontmatter omits it. |
192
+ | `types` | `{}` | Per-type content definitions — custom frontmatter keys scoped to pages of one `type`. See [Frontmatter](#frontmatter). |
192
193
 
193
194
  Static assets live in `public/` — a file at `public/logo.png` is served at `/logo.png`, so a reference like `![](/images/create.png)` resolves against `public/images/create.png`. Images referenced by **relative path** (`![](./diagram.png)`) live next to your content instead, and are [optimized at build time](/docs/content/syntax#links-and-images).
194
195
 
@@ -230,6 +231,31 @@ export default defineConfig({
230
231
 
231
232
  Any [Standard Schema](https://standardschema.dev) library works — Zod (whichever version your project installs), Valibot, ArkType. Keys outside the extension stay strictly validated, so typo-catching is unchanged. See [Custom keys](/docs/reference/frontmatter#custom-keys) for the validation semantics.
232
233
 
234
+ Keys under `extend` apply site-wide. To require keys only on pages of one content type — an RFC's `status`, a runbook's `service` — declare them per type under `content.types` instead:
235
+
236
+ ```ts blume.config.ts lineNumbers
237
+ import { defineConfig } from "blume";
238
+ import { z } from "zod";
239
+
240
+ export default defineConfig({
241
+ content: {
242
+ types: {
243
+ rfc: {
244
+ facets: ["domain", "status"],
245
+ frontmatter: {
246
+ domain: z.string(),
247
+ status: z.enum(["draft", "review", "enforced"]),
248
+ },
249
+ },
250
+ },
251
+ },
252
+ });
253
+ ```
254
+
255
+ A key can be declared site-wide or per-type, not both. See [Per-type keys](/docs/reference/frontmatter#per-type-keys) for how the scoping resolves.
256
+
257
+ `facets` names the custom keys whose values become filterable metadata: they ride along on search documents (`blume-search.json` and the MCP index), and the [MCP tools](/docs/configuration/ai#mcp-server) accept a `filters` input matching against them, so an agent can retrieve, say, only `enforced` RFCs in the `architecture` domain. Each facet must be a declared custom key — per-type or site-wide — and only string (or stringified number/boolean) values facet.
258
+
233
259
  ## GitHub
234
260
 
235
261
  Point Blume at your repository with `github`. It powers the header [repository link](/docs/content/navigation#repository-link) and the **Edit on GitHub** and **Give feedback** [page actions](/docs/content/navigation#page-actions):
@@ -27,9 +27,7 @@ search: {
27
27
  },
28
28
  ```
29
29
 
30
- Each entry takes an `href` (internal route or external URL) and a `label`, plus an optional `icon` — a built-in icon name shown beside the label, defaulting to a file glyph. Omit `popular` or leave it empty to keep the sidebar fallback.
31
-
32
- Unlike icons elsewhere in Blume, `icon` here must be a built-in name: these rows render in a client island, so an image path or inline SVG isn't supported and falls back to the file glyph.
30
+ Each entry takes an `href` (internal route or external URL) and a `label`, plus an optional `icon` — a [built-in icon](/docs/content/components#icon) name, image path/URL, or inline SVG (same _inputs_ as nav icons), defaulting to a file glyph. Omit `popular` or leave it empty to keep the sidebar fallback.
33
31
 
34
32
  Write `href` as if the site were mounted at the root — a `basePath` is applied for you, the same as `navigation.featured`. External URLs pass through untouched.
35
33
 
@@ -20,7 +20,7 @@ i18n: {
20
20
  }
21
21
  ```
22
22
 
23
- Each locale has a `code` (used in URLs), a `label` (shown in the language switcher), and an optional `dir` for right-to-left scripts (`"ltr"` by default).
23
+ Each locale has a `code` (used in URLs), a `label` (shown in the language switcher), and an optional `dir` for right-to-left scripts (`"ltr"` by default). An optional `style` gives [`blume translate`](/docs/reference/translate) freeform guidance for the locale — register, dialect, terminology, e.g. `"Brazilian Portuguese, informal você"` — so the choice is pinned from the very first translation instead of decided by the agent.
24
24
 
25
25
  ## Organize translated content
26
26
 
@@ -83,6 +83,8 @@ i18n: {
83
83
 
84
84
  Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: under the default `dir` parser, put a `meta.ts` under `fr/guides/` to order the French group independently. Under the `dot` parser translations sit next to the originals, so a folder's `meta.ts` applies to every locale. Everything else about [navigation](/docs/content/navigation) works the same, per language.
85
85
 
86
+ Header tabs are configured, not derived from content, so their labels localize in `blume.config.ts`: a tab `label` accepts a per-locale map (`{ en: "Docs", fr: "Documentation" }`) alongside the plain-string form, falling back to the default locale's entry for locales you haven't filled in. See [Tabs](/docs/content/navigation#tabs).
87
+
86
88
  ## Fallbacks
87
89
 
88
90
  When a page isn't translated yet, Blume renders the fallback locale's content at the localized URL — so the link works, the page is fully pre-rendered, and search engines aren't sent to a dead end. The fallback defaults to your `defaultLocale`:
@@ -100,6 +102,16 @@ Fallback pages are excluded from the search index and aren't advertised as real
100
102
  Start by translating your most important pages — the homepage, quickstart, and top guides — and let the rest fall back. You can fill in translations over time without breaking any links.
101
103
  :::
102
104
 
105
+ ## Translating with an agent
106
+
107
+ You don't have to fill in the locales by hand. [`blume translate`](/docs/reference/translate) finds every page that's missing or outdated in each locale and translates it with a local agent CLI ([Claude Code](https://claude.com/claude-code) or [Codex](https://developers.openai.com/codex/cli)):
108
+
109
+ ```bash
110
+ blume translate --claude
111
+ ```
112
+
113
+ Blume validates each result's structure — frontmatter, code fences, links — and writes the files itself; the agent only translates text. A committed ledger (`blume.translations.json`) tracks which source revision each translation came from, so reruns only touch what changed, and translations you wrote by hand are adopted as-is, never overwritten. In CI, `blume translate --check` fails when a source page has drifted ahead of its translations.
114
+
103
115
  ## The language switcher
104
116
 
105
117
  When i18n is on, a language switcher appears in the header automatically, generated from your `locales`. For each page it links the matching translation in every language; where a translation is missing it links the fallback page and marks it as not translated. There's nothing to configure.
@@ -135,6 +135,17 @@ navigation: {
135
135
 
136
136
  This matters for routes that aren't part of the content tree, since the fallback can't see them: the generated [changelog](/docs/advanced/changelog) index, or a [custom page](/docs/advanced/custom-pages) you added under `pages/`. Without `href`, a `/changelog` tab lands on the newest entry rather than the index. Tabs that don't set `href` are unaffected.
137
137
 
138
+ On an [i18n](/docs/content/i18n) site, a tab's `label` (and a dropdown item's) can be a per-locale map instead of a string — the active locale's entry wins, then the default locale's:
139
+
140
+ ```ts blume.config.ts
141
+ navigation: {
142
+ tabs: [
143
+ { label: { en: "Docs", fr: "Documentation" }, path: "/docs" },
144
+ { label: "CLI", path: "/cli" }, // a plain string renders as-is everywhere
145
+ ],
146
+ }
147
+ ```
148
+
138
149
  Tabs also **scope the sidebar**: when the current route falls under a tab's `path`, the sidebar shows only that section's pages — so `/adapters/*` lists the adapters and nothing else. The folder at a tab's `path` becomes the section, so this needs no extra config beyond the tabs themselves; structure your content into a folder per tab and point each tab at it.
139
150
 
140
151
  On a route under no tab (or a tab whose `path` is `/`), the sidebar shows the pages that _don't_ belong to a tab — each tab's folder is hidden from it, since that section already has its own tab in the header. So a root landing page lists your loose top-level pages while the sectioned content stays behind its tab, mirroring Fumadocs' root folders. If a route has no pages of its own to show this way, the full tree is shown instead, so the sidebar is never left blank.
@@ -23,6 +23,7 @@ blume <command> [options]
23
23
  | `blume validate` | Validate links across your content. |
24
24
  | `blume audit` | Audit the built site for SEO and health issues. |
25
25
  | `blume eval` | Test the docs: an agent answers your questions using only the documentation. |
26
+ | `blume translate` | Translate docs into the configured locales with a local agent CLI. |
26
27
 
27
28
  ## Common flags
28
29
 
@@ -62,6 +63,9 @@ blume <command> [options]
62
63
  - `blume eval` — run the questions in `evals.yaml` through an agent that reads only your docs; see [Evals](/docs/reference/eval).
63
64
  - `blume eval init` — have the agent draft a starter `evals.yaml` from your docs.
64
65
  - `blume eval --agent claude|codex --threshold <0..1> --timeout <seconds> --json --fix --verbose` — see [Evals](/docs/reference/eval) for each flag.
66
+ - `blume translate --claude` / `--codex` — translate missing and outdated pages into your configured locales; see [Translate](/docs/reference/translate).
67
+ - `blume translate --check` — report translation drift and exit non-zero (the CI gate), without running an agent.
68
+ - `blume translate --locale <codes> --concurrency <n> --force --timeout <seconds> --json` — see [Translate](/docs/reference/translate) for each flag.
65
69
 
66
70
  ## Verifying while the dev server runs
67
71
 
@@ -109,6 +109,39 @@ reviewedAt: 2026-06-20
109
109
 
110
110
  Schemas are accepted through the [Standard Schema](https://standardschema.dev) interface, so Zod (whichever version your project installs), Valibot, and ArkType all work. Every declared key is validated on every page — absent ones included — so a required schema enforces the key site-wide; mark it `.optional()` to validate only where present. All other keys stay strictly validated, and built-in fields can't be redeclared.
111
111
 
112
+ ### Per-type keys
113
+
114
+ To require keys only on one content type — an RFC's `status`, an incident report's `severity` — declare them under [`content.types`](/docs/configuration#content) instead, keyed by the frontmatter `type` they apply to:
115
+
116
+ ```ts blume.config.ts lineNumbers
117
+ import { defineConfig } from "blume";
118
+ import { z } from "zod";
119
+
120
+ export default defineConfig({
121
+ content: {
122
+ types: {
123
+ rfc: {
124
+ frontmatter: {
125
+ domain: z.string(),
126
+ status: z.enum(["draft", "review", "enforced"]),
127
+ },
128
+ },
129
+ },
130
+ },
131
+ });
132
+ ```
133
+
134
+ ```yaml rfcs/openapi-request-schemas.mdx
135
+ ---
136
+ title: OpenAPI request schemas
137
+ type: rfc
138
+ domain: architecture
139
+ status: enforced
140
+ ---
141
+ ```
142
+
143
+ Per-type keys follow the same validation rules as `extend`, scoped to pages whose resolved `type` matches — including pages that set no `type`, when the declaration is for [`content.defaultType`](/docs/configuration#content). A key belongs to one declaration, site-wide or per-type, not both. And a key declared only for another type stays unknown elsewhere, so a stray `status` on a plain doc page still fails the build.
144
+
112
145
  A page that fails validation fails `blume build` with a diagnostic naming the file and key. With [`--no-strict`](/docs/reference/cli#common-flags), the build succeeds anyway and the failing pages are dropped from the output — the build summary reports how many.
113
146
 
114
147
  Schemas are exported from `blume/schema` for editor and migration tooling.
@@ -2,6 +2,6 @@ import { defineMeta } from "blume";
2
2
 
3
3
  export default defineMeta({
4
4
  order: 6,
5
- pages: ["frontmatter", "cli", "eval"],
5
+ pages: ["frontmatter", "cli", "eval", "translate"],
6
6
  title: "Reference",
7
7
  });
@@ -0,0 +1,80 @@
1
+ ---
2
+ title: Translate
3
+ description: blume translate fills in your locales with an AI agent — it finds the pages that are missing or outdated in each language, translates them with the agent CLI you already have, and gives CI a gate that fails when translations drift.
4
+ ---
5
+
6
+ Once [i18n](/docs/content/i18n) is on, every edit to a source page quietly outdates its translations. `blume translate` closes that loop: it computes exactly which pages are missing or stale in each locale, translates them headlessly with a local agent CLI, and records what it did in a committed ledger so the next run — and CI — knows what's current.
7
+
8
+ ```bash
9
+ blume translate --claude
10
+ ```
11
+
12
+ ```
13
+ blume translate 3 item(s) · 2 locale(s) · Claude Code
14
+
15
+ ✔ docs/guides/install.mdx → fr 24.2s $0.11
16
+ ✔ docs/guides/install.mdx → de 22.8s $0.10
17
+ ✔ meta titles (2) → de 4.1s $0.01
18
+
19
+ Translated 3 files into 2 locales · 1 adopted · 14 already up to date · 51.1s · $0.22
20
+ ```
21
+
22
+ ## How it works
23
+
24
+ Blume owns the pipeline; the agent only translates text. For each file that needs work, Blume builds a translation prompt, runs the agent CLI headlessly with its file, shell, and web tools disabled, validates the reply's structure, and writes the target file itself — [Claude Code](https://claude.com/claude-code) with `--claude`, or [Codex](https://developers.openai.com/codex/cli) with `--codex`. Blume holds no API keys and calls no model itself.
25
+
26
+ Every validated write is recorded in `blume.translations.json` at the project root: for each source file and locale, a hash of the source at the moment it was translated. **Commit this file.** It's how a rerun knows the difference between "already translated" and "translated, but the source changed since" — and it's what makes the CI gate possible.
27
+
28
+ The ledger is flushed after every finished file, so stopping a long run (Ctrl+C) loses at most the translations that were in flight — the next run picks up where you left off. Files run 4 at a time by default; raise it with `--concurrency` if your machine and the agent's rate limits allow.
29
+
30
+ Reruns are incremental: a source that hasn't changed since its last translation is skipped, so running `blume translate` after editing one page translates one page per locale. When a stale page is retranslated, the agent is shown the existing translation and told to match its register, dialect, and terminology — a one-paragraph source edit produces a one-paragraph translation diff, not a from-scratch rewrite.
31
+
32
+ A first translation has no precedent to match, so pin the choice up front with [`style` on the locale](/docs/content/i18n) (`{ code: "pt", label: "Português", style: "Brazilian Portuguese, informal você" }`). The guidance rides along in every translation prompt, and where an existing translation disagrees with it, `style` wins — so a retranslation also nudges older pages toward the configured style.
33
+
34
+ ## What gets translated
35
+
36
+ - **Pages** — `.md`/`.mdx` files in the default locale. The agent translates the prose and only the human-visible frontmatter values (`title`, `description`, `sidebar.label`, `sidebar.badge`, `seo.title`, `seo.description`). Targets follow your parser: `fr/guides/install.mdx` under `dir`, `guides/install.fr.mdx` under `dot`.
37
+ - **Folder navigation titles** — under the `dir` parser, each locale's needed [`meta.ts`](/docs/content/meta) titles are translated in one batched call, and the generated per-locale `meta.ts` copies every other key (`order`, `pages`, `icon`, `collapsed`) verbatim so the locale's sidebar keeps its ordering.
38
+
39
+ Translations you wrote by hand are **adopted, never overwritten**: a translation that exists but has no ledger entry is stamped as current and left alone. Only `--force` retranslates it.
40
+
41
+ ## Validation
42
+
43
+ The agent is never trusted with structure. Before writing, Blume checks each reply and rebuilds the file from the source:
44
+
45
+ - The frontmatter is reconstructed from the source file's data, with only the six translatable values overlaid — keys the agent invented are dropped, keys it deleted are restored, and `slug`, `icon`, `order`, and dates are source-verbatim by construction.
46
+ - The number of code fences must match the source, the body must be non-empty, and the frontmatter must parse.
47
+
48
+ A reply that fails validation writes nothing — the item is reported as failed and the run moves on. Everything that succeeded stays stamped in the ledger, so a rerun retries only the failures.
49
+
50
+ ## Failing CI
51
+
52
+ `blume translate --check` is the read-only gate: it reports every missing and stale pair and exits non-zero when there's drift, without running an agent or writing anything.
53
+
54
+ ```bash
55
+ blume translate --check # exit 1 when translations are missing or stale
56
+ blume translate --check --json # machine-readable drift report on stdout
57
+ ```
58
+
59
+ ```yaml .github/workflows/translations.yml
60
+ - run: npx blume translate --check
61
+ ```
62
+
63
+ The JSON report carries the same `diagnostics` + `summary` shape as `blume validate --json`, `blume audit --json`, and `blume eval --json`, with the drift grouped per locale. Hand-authored (untracked) translations never fail the gate.
64
+
65
+ ## Limitations
66
+
67
+ - Meta title translation is `dir`-parser only — the `dot` parser has no per-locale `meta.ts` mechanism. A `meta.ts` that default-exports a function is skipped with a warning; author that locale's copy by hand.
68
+ - Remote and CMS-backed sources are skipped: there is no local file to write the translation to.
69
+ - Header tab labels live in `blume.config.ts`, not content — localize them there with [per-locale label maps](/docs/content/navigation#tabs).
70
+ - Translation quality is the agent's. Review the output like any other contribution — the ledger only guarantees freshness, not fluency.
71
+
72
+ ## Flags
73
+
74
+ - `--claude` / `--codex` — which agent CLI translates. Exactly one is required (except with `--check`).
75
+ - `--check` — report drift and exit non-zero, without writing anything.
76
+ - `--concurrency <n>` — parallel agent sessions. Defaults to `4`, max `16`.
77
+ - `--locale <codes>` — comma-separated target locales (defaults to every non-default locale).
78
+ - `--force` — retranslate everything, up-to-date and hand-authored files included.
79
+ - `--timeout <seconds>` — agent time limit per file. Defaults to `600`; the ceiling exists to catch hung agents, so large pages have room to finish.
80
+ - `--json` — emit the report as JSON on stdout, in both modes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -93,12 +93,15 @@ export const buildAgentReadability = (
93
93
 
94
94
  // Advertise `Accept: text/markdown` negotiation only where the deployed site
95
95
  // actually honors it — a Vercel server build, whose routing config gets the
96
- // rewrite rules (see `deploy/vercel-negotiation.ts`). Static builds and other
97
- // adapters serve prerendered pages from a static layer with no request-time
98
- // hook, so agents there should fetch the `.md` pattern directly.
96
+ // rewrite rules (see `deploy/vercel-negotiation.ts`), and a Cloudflare server
97
+ // build, whose deploy bundle gets a wrapper Worker (see
98
+ // `deploy/cloudflare-negotiation.ts`). Static builds and other adapters serve
99
+ // prerendered pages from a static layer with no request-time hook, so agents
100
+ // there should fetch the `.md` pattern directly.
99
101
  const negotiates =
100
102
  config.deployment.output === "server" &&
101
- config.deployment.adapter === "vercel";
103
+ (config.deployment.adapter === "vercel" ||
104
+ config.deployment.adapter === "cloudflare");
102
105
  const artifacts: Record<string, unknown> = {
103
106
  markdown: {
104
107
  ...(negotiates ? { contentNegotiation: "text/markdown" } : {}),
@@ -226,12 +226,9 @@ export const createAskContext = (
226
226
  ? byRoute.get(normalizeRoute(page.path))
227
227
  : undefined;
228
228
  const db = await index();
229
- const hits = await queryOramaIndex(
230
- db,
231
- query,
232
- MAX_RESULTS,
233
- current?.locale || undefined
234
- );
229
+ const hits = await queryOramaIndex(db, query, MAX_RESULTS, {
230
+ locale: current?.locale || undefined,
231
+ });
235
232
 
236
233
  const seen = new Set<string>();
237
234
  const sections: string[] = [];