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
@@ -269,20 +269,88 @@ export interface ContentConfig {
269
269
  * Releases, Sanity, Notion, or a custom `ContentSource`.
270
270
  */
271
271
  sources?: ContentSourceInput[];
272
+ /**
273
+ * Per-type content definitions, keyed by the frontmatter `type` they apply
274
+ * to (including `defaultType`, for pages that set none):
275
+ *
276
+ * ```ts
277
+ * import { z } from "zod";
278
+ *
279
+ * content: {
280
+ * types: {
281
+ * rfc: {
282
+ * frontmatter: {
283
+ * domain: z.string(),
284
+ * status: z.enum(["draft", "enforced"]),
285
+ * },
286
+ * },
287
+ * },
288
+ * },
289
+ * ```
290
+ */
291
+ types?: Record<string, ContentTypeConfig>;
292
+ }
293
+
294
+ /**
295
+ * A per-type content definition: configuration that applies only to pages
296
+ * whose resolved frontmatter `type` matches the map key.
297
+ */
298
+ export interface ContentTypeConfig {
299
+ /**
300
+ * Custom frontmatter keys whose values become filterable facets for pages
301
+ * of this type. Faceted values ride along on search documents
302
+ * (`blume-search.json` and the MCP index), and the MCP `search_docs` and
303
+ * `list_pages` tools accept a `filters` input matching against them:
304
+ *
305
+ * ```ts
306
+ * content: {
307
+ * types: {
308
+ * rfc: {
309
+ * facets: ["domain", "status"],
310
+ * frontmatter: { domain: z.string(), status: z.string() },
311
+ * },
312
+ * },
313
+ * },
314
+ * ```
315
+ *
316
+ * Each name must be a custom key declared for the type — in its
317
+ * `frontmatter` map or the site-wide `frontmatter.extend`. String values
318
+ * facet as-is; numbers and booleans are stringified; anything else
319
+ * (objects, arrays, transformed dates) does not facet.
320
+ */
321
+ facets?: string[];
322
+ /**
323
+ * Custom frontmatter keys for pages of this type, layered on top of the
324
+ * site-wide `frontmatter.extend` (a key can be declared in one or the
325
+ * other, not both). Schemas follow the same rules as `extend`: any
326
+ * Standard Schema library works, every declared key is validated on every
327
+ * page of the type — absent ones included — so a required schema enforces
328
+ * the key type-wide (mark it `.optional()` to validate only when present),
329
+ * and validated values land on the page record's `custom` field. Built-in
330
+ * frontmatter fields cannot be redeclared.
331
+ */
332
+ frontmatter?: Record<string, StandardSchema>;
272
333
  }
273
334
 
274
335
  // ---------------------------------------------------------------------------
275
336
  // Navigation
276
337
  // ---------------------------------------------------------------------------
277
338
 
339
+ /**
340
+ * A header label, optionally per locale: a plain string, or a map of locale
341
+ * code to label (`{ en: "Docs", ja: "ドキュメント" }`). The active locale's
342
+ * entry wins, then the default locale's, then the map's first entry.
343
+ */
344
+ export type LocalizableLabel = string | Record<string, string>;
345
+
278
346
  /** A single item inside a header tab's dropdown. */
279
347
  export interface NavTabItem {
280
348
  /** Secondary line under the label. */
281
349
  description?: string;
282
350
  /** Lucide icon name shown beside the label. */
283
351
  icon?: string;
284
- /** Item label. */
285
- label: string;
352
+ /** Item label, optionally per locale. */
353
+ label: LocalizableLabel;
286
354
  /** Route the item links to. */
287
355
  path: string;
288
356
  /** Short tag/pill (e.g. `New`, `Beta`). */
@@ -304,8 +372,8 @@ export interface NavTab {
304
372
  icon?: string;
305
373
  /** Dropdown items; omit for a plain link tab. */
306
374
  items?: NavTabItem[];
307
- /** Tab label. */
308
- label: string;
375
+ /** Tab label, optionally per locale. */
376
+ label: LocalizableLabel;
309
377
  /** Route the tab links to. */
310
378
  path: string;
311
379
  }
@@ -512,7 +580,10 @@ export interface MixedbreadSearch {
512
580
  export interface SearchPopularLink {
513
581
  /** Internal route or external URL. */
514
582
  href: string;
515
- /** Built-in icon name shown beside the label; defaults to the file glyph. */
583
+ /**
584
+ * Icon shown beside the label — a built-in name, image path/URL, or inline
585
+ * SVG (same as nav icons). Defaults to the file glyph.
586
+ */
516
587
  icon?: string;
517
588
  /** Link label shown in the dialog. */
518
589
  label: string;
@@ -728,6 +799,12 @@ export interface LocaleConfigInput {
728
799
  dir?: "ltr" | "rtl";
729
800
  /** Human-readable name shown in the switcher. */
730
801
  label: string;
802
+ /**
803
+ * Freeform style guidance for `blume translate`, e.g. "Brazilian
804
+ * Portuguese, informal você". Pins register and dialect from the first
805
+ * translation and wins over an existing translation's style on reruns.
806
+ */
807
+ style?: string;
731
808
  }
732
809
 
733
810
  /**
@@ -1145,7 +1222,8 @@ export interface FrontmatterConfig {
1145
1222
  * so a required schema enforces the key site-wide; mark it `.optional()`
1146
1223
  * to validate only when present. Validated values are preserved on each
1147
1224
  * page record's `custom` field. Built-in frontmatter fields cannot be
1148
- * redeclared.
1225
+ * redeclared. To scope a key to one content type instead, declare it under
1226
+ * `content.types.<type>.frontmatter`.
1149
1227
  */
1150
1228
  extend?: Record<string, StandardSchema>;
1151
1229
  }
package/src/core/graph.ts CHANGED
@@ -4,6 +4,7 @@ import { validateNavIcons, validateNavStructure } from "./nav-diagnostics.ts";
4
4
  import { buildNavigation } from "./navigation.ts";
5
5
  import type {
6
6
  FolderMeta,
7
+ LocalizableLabel,
7
8
  ResolvedConfig,
8
9
  ResolvedI18nConfig,
9
10
  } from "./schema.ts";
@@ -11,6 +12,7 @@ import type {
11
12
  ContentGraph,
12
13
  Diagnostic,
13
14
  Navigation,
15
+ NavTab,
14
16
  PageRecord,
15
17
  } from "./types.ts";
16
18
 
@@ -79,6 +81,42 @@ const localePagesFor = (
79
81
  return [...real, ...filled];
80
82
  };
81
83
 
84
+ /**
85
+ * Resolve a possibly-per-locale header label to the string a locale renders:
86
+ * the active locale's entry, else the default locale's, else the map's first
87
+ * entry (which is also what a single-locale site gets).
88
+ */
89
+ const resolveLabel = (
90
+ label: LocalizableLabel,
91
+ locale: string,
92
+ defaultLocale?: string
93
+ ): string => {
94
+ if (typeof label === "string") {
95
+ return label;
96
+ }
97
+ return (
98
+ label[locale] ??
99
+ (defaultLocale === undefined ? undefined : label[defaultLocale]) ??
100
+ Object.values(label)[0] ??
101
+ ""
102
+ );
103
+ };
104
+
105
+ /** Resolve every localizable label in the configured tabs for one locale. */
106
+ const resolveTabLabels = (
107
+ tabs: BuildContentGraphOptions["navigation"]["tabs"],
108
+ locale: string,
109
+ defaultLocale?: string
110
+ ): NavTab[] =>
111
+ (tabs ?? []).map((tab) => ({
112
+ ...tab,
113
+ items: tab.items?.map((item) => ({
114
+ ...item,
115
+ label: resolveLabel(item.label, locale, defaultLocale),
116
+ })),
117
+ label: resolveLabel(tab.label, locale, defaultLocale),
118
+ }));
119
+
82
120
  /** Build one locale's navigation tree from its own pages and folder meta. */
83
121
  const buildLocaleNavigation = (
84
122
  code: string,
@@ -95,7 +133,11 @@ const buildLocaleNavigation = (
95
133
  // selector's items intentionally target specific locales.
96
134
  const localizePath = (path: string): string =>
97
135
  path.startsWith("/") ? localizeRoute(path, code, i18n) : path;
98
- const tabs = options.navigation.tabs?.map((tab) => ({
136
+ const tabs = resolveTabLabels(
137
+ options.navigation.tabs,
138
+ code,
139
+ i18n.defaultLocale
140
+ ).map((tab) => ({
99
141
  ...tab,
100
142
  ...(tab.href ? { href: localizePath(tab.href) } : {}),
101
143
  items: tab.items?.map((item) => ({
@@ -220,7 +262,9 @@ export const buildContentGraph = (
220
262
  selectors: options.navigation.selectors,
221
263
  sharedFolderMeta: options.sharedFolderMeta,
222
264
  sidebar: options.navigation.sidebar.items,
223
- tabs: options.navigation.tabs,
265
+ // No locale to prefer: a per-locale label map resolves to its first
266
+ // entry on a single-locale site.
267
+ tabs: resolveTabLabels(options.navigation.tabs, ""),
224
268
  }),
225
269
  navigationByLocale: {} as Record<string, Navigation>,
226
270
  };
@@ -28,6 +28,8 @@ const uiStringsObject = z.object({
28
28
  exportEpub: z.string().default("Export to EPUB"),
29
29
  exportPdf: z.string().default("Export to PDF"),
30
30
  generating: z.string().default("Generating…"),
31
+ // `{name}` is replaced with the provider's brand name at render time.
32
+ openIn: z.string().default("Open in {name}"),
31
33
  openInChat: z.string().default("Open in chat"),
32
34
  scrollToTop: z.string().default("Scroll to top"),
33
35
  })
package/src/core/i18n.ts CHANGED
@@ -148,6 +148,37 @@ export const localePlacement = (
148
148
  return { locales: [locale], navPath: rest.join("/") };
149
149
  };
150
150
 
151
+ /**
152
+ * The inverse of {@link localePlacement}: where a default-locale file's
153
+ * translation into `locale` lives, by parser:
154
+ * - `dir`: a leading locale directory (`guides/x.mdx` → `fr/guides/x.mdx`)
155
+ * - `dot`: a filename suffix (`guides/x.mdx` → `guides/x.fr.mdx`)
156
+ *
157
+ * Under `dot`, a source authored with an explicit default-locale suffix
158
+ * (`x.en.mdx`) swaps it for the target's (`x.fr.mdx`) — same case-insensitive
159
+ * last-dot-inside-filename logic as `localePlacement`, so both spellings of a
160
+ * default-locale file resolve to one canonical target.
161
+ */
162
+ export const localeTargetPath = (
163
+ rel: string,
164
+ ext: string,
165
+ locale: string,
166
+ i18n: ResolvedI18nConfig
167
+ ): string => {
168
+ if (i18n.parser === "dot") {
169
+ let base = rel.slice(0, rel.length - ext.length);
170
+ const lastDot = base.lastIndexOf(".");
171
+ if (lastDot > base.lastIndexOf("/")) {
172
+ const suffix = base.slice(lastDot + 1).toLowerCase();
173
+ if (i18n.locales.some((entry) => entry.code.toLowerCase() === suffix)) {
174
+ base = base.slice(0, lastDot);
175
+ }
176
+ }
177
+ return `${base}.${locale}${ext}`;
178
+ }
179
+ return `${locale}/${rel}`;
180
+ };
181
+
151
182
  /**
152
183
  * Warn about top-level content folders that look like a locale (a code Blume
153
184
  * recognizes) but aren't declared in `i18n.locales`. Without this they're
@@ -1,3 +1,4 @@
1
+ import { isAssetIcon } from "../theme/icon-kind.ts";
1
2
  import { hasIcon } from "../theme/icons.ts";
2
3
  import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
3
4
 
@@ -8,12 +9,8 @@ import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
8
9
  * covers every source (config, folder meta, frontmatter) at once.
9
10
  */
10
11
 
11
- const IMAGE_ICON =
12
- /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
13
-
14
- /** Whether an icon string is an asset (image/URL/inline SVG), not a set name. */
15
- const isAssetIcon = (value: string): boolean =>
16
- value.startsWith("<") || IMAGE_ICON.test(value);
12
+ const ICON_SHAPE_HINT =
13
+ "Use a built-in icon name, an image path/URL, or inline SVG markup.";
17
14
 
18
15
  /** Flatten a sidebar tree to every node, descending into groups. */
19
16
  const flattenNodes = (nodes: NavNode[]): NavNode[] =>
@@ -66,9 +63,14 @@ const unknownIconDiagnostics = (
66
63
  continue;
67
64
  }
68
65
  seen.add(icon);
66
+ // Markup that isn't a complete <svg> element (an <img> tag, a truncated
67
+ // svg) is a shape problem, not a set-name typo — say so.
68
+ const message = icon.trimStart().startsWith("<")
69
+ ? `Icon markup "${icon}" (${where}) isn't a complete inline <svg> element, so it won't render.`
70
+ : `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`;
69
71
  diagnostics.push({
70
72
  code: "BLUME_UNKNOWN_ICON",
71
- message: `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`,
73
+ message,
72
74
  severity: "warning",
73
75
  suggestion,
74
76
  });
@@ -78,16 +80,11 @@ const unknownIconDiagnostics = (
78
80
 
79
81
  /** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
80
82
  export const validateNavIcons = (navigation: Navigation): Diagnostic[] =>
81
- unknownIconDiagnostics(
82
- collectIcons(navigation),
83
- "Use a built-in icon name, an image path/URL, or inline SVG markup."
84
- );
83
+ unknownIconDiagnostics(collectIcons(navigation), ICON_SHAPE_HINT);
85
84
 
86
85
  /**
87
- * Warn about unknown icons on curated `search.popular` links. Separate from
88
- * {@link validateNavIcons} because these live under `search`, not the built
89
- * navigation — and unlike nav icons they resolve in a *client* island, so only
90
- * set names work (an image/SVG icon quietly falls back to the file glyph).
86
+ * Warn about unknown icons on curated `search.popular` links. Same accepted
87
+ * input shapes as nav icons resolved to markup on the server for the island.
91
88
  */
92
89
  export const validateSearchPopularIcons = (
93
90
  popular: { icon?: string; label: string }[]
@@ -97,25 +94,7 @@ export const validateSearchPopularIcons = (
97
94
  ? [{ icon: link.icon, where: `popular link "${link.label}"` }]
98
95
  : []
99
96
  );
100
- // Asset icons are valid in the nav, so the shared helper skips them — but
101
- // here they are exactly the silent failure this validator exists to catch.
102
- const diagnostics: Diagnostic[] = [];
103
- const seen = new Set<string>();
104
- for (const { icon, where } of icons) {
105
- if (isAssetIcon(icon) && !seen.has(icon)) {
106
- seen.add(icon);
107
- diagnostics.push({
108
- code: "BLUME_UNKNOWN_ICON",
109
- message: `Icon "${icon}" (${where}) is an image or inline SVG — popular links render in the client search island, where only built-in icon names resolve, so it falls back to the file glyph.`,
110
- severity: "warning",
111
- suggestion: "Use a built-in icon name.",
112
- });
113
- }
114
- }
115
- return [
116
- ...diagnostics,
117
- ...unknownIconDiagnostics(icons, "Use a built-in icon name."),
118
- ];
97
+ return unknownIconDiagnostics(icons, ICON_SHAPE_HINT);
119
98
  };
120
99
 
121
100
  /** Whether an internal path resolves to a page or a section that has pages. */
@@ -143,12 +143,22 @@ const normalizeLoadedEntries = (
143
143
  loaded: ({ source: ContentSource } & SourceLoadResult)[],
144
144
  config: ResolvedConfig
145
145
  ): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
146
- // Only thread `frontmatter.extend` through when a project opts in, so the
147
- // known-key split in `normalizeEntry` stays off the default path.
146
+ // Only thread `frontmatter.extend` / `content.types` through when a project
147
+ // opts in, so the known-key split in `normalizeEntry` stays off the default
148
+ // path.
148
149
  const frontmatterExtend =
149
150
  Object.keys(config.frontmatter.extend).length > 0
150
151
  ? config.frontmatter.extend
151
152
  : undefined;
153
+ const declaredTypes = Object.entries(config.content.types).filter(
154
+ ([, type]) => Object.keys(type.frontmatter).length > 0
155
+ );
156
+ const typeFrontmatter =
157
+ declaredTypes.length > 0
158
+ ? Object.fromEntries(
159
+ declaredTypes.map(([name, type]) => [name, type.frontmatter])
160
+ )
161
+ : undefined;
152
162
 
153
163
  const pages: PageRecord[] = [];
154
164
  const allDiagnostics: Diagnostic[] = [];
@@ -166,6 +176,7 @@ const normalizeLoadedEntries = (
166
176
  prefix: source.prefix,
167
177
  staged: source.staged,
168
178
  },
179
+ typeFrontmatter,
169
180
  });
170
181
  if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
171
182
  droppedPages += 1;