blume 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli/index.js +3290 -254
  3. package/dist/cli/index.js.map +79 -67
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +16 -2
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/link-headers.ts +4 -3
  24. package/src/ai/llms.ts +4 -2
  25. package/src/ai/markdown.ts +34 -1
  26. package/src/ai/mcp/data.ts +10 -4
  27. package/src/ai/mcp/server.ts +74 -3
  28. package/src/ai/mcp/tools.ts +2 -2
  29. package/src/astro/generate.ts +2 -2
  30. package/src/astro/integration.ts +6 -2
  31. package/src/astro/markdown-negotiation.ts +5 -0
  32. package/src/astro/templates.ts +74 -21
  33. package/src/audit/url.ts +5 -10
  34. package/src/cli/commands/build.ts +145 -34
  35. package/src/cli/commands/translate.ts +300 -0
  36. package/src/cli/index.ts +2 -0
  37. package/src/components/Icon.astro +2 -7
  38. package/src/components/content/Step.astro +3 -8
  39. package/src/components/content/Tab.astro +20 -1
  40. package/src/components/layout/LanguageSwitcher.astro +2 -1
  41. package/src/components/layout/Logo.astro +4 -4
  42. package/src/components/layout/PageActions.astro +12 -7
  43. package/src/components/layout/Search.astro +15 -20
  44. package/src/components/layout/search/orama.ts +3 -1
  45. package/src/core/base-path.ts +9 -0
  46. package/src/core/config-input.ts +84 -6
  47. package/src/core/graph.ts +46 -2
  48. package/src/core/i18n-ui.ts +2 -0
  49. package/src/core/i18n.ts +31 -0
  50. package/src/core/nav-diagnostics.ts +13 -34
  51. package/src/core/project-graph.ts +13 -2
  52. package/src/core/schema.ts +174 -74
  53. package/src/core/sources/normalize.ts +25 -12
  54. package/src/core/sources/types.ts +5 -0
  55. package/src/core/types.ts +4 -3
  56. package/src/core/ui-packs/ar.ts +42 -1
  57. package/src/core/ui-packs/bg.ts +42 -1
  58. package/src/core/ui-packs/bn.ts +42 -1
  59. package/src/core/ui-packs/ca.ts +44 -1
  60. package/src/core/ui-packs/cs.ts +42 -1
  61. package/src/core/ui-packs/da.ts +42 -1
  62. package/src/core/ui-packs/de.ts +42 -1
  63. package/src/core/ui-packs/el.ts +44 -1
  64. package/src/core/ui-packs/es.ts +44 -1
  65. package/src/core/ui-packs/fa.ts +42 -1
  66. package/src/core/ui-packs/fi.ts +42 -1
  67. package/src/core/ui-packs/fr.ts +44 -1
  68. package/src/core/ui-packs/he.ts +42 -1
  69. package/src/core/ui-packs/hi.ts +42 -1
  70. package/src/core/ui-packs/hr.ts +42 -1
  71. package/src/core/ui-packs/hu.ts +42 -1
  72. package/src/core/ui-packs/id.ts +42 -1
  73. package/src/core/ui-packs/it.ts +44 -1
  74. package/src/core/ui-packs/ja.ts +44 -1
  75. package/src/core/ui-packs/ko.ts +44 -1
  76. package/src/core/ui-packs/nl.ts +42 -1
  77. package/src/core/ui-packs/no.ts +42 -1
  78. package/src/core/ui-packs/pl.ts +42 -1
  79. package/src/core/ui-packs/pt-br.ts +44 -1
  80. package/src/core/ui-packs/pt.ts +44 -1
  81. package/src/core/ui-packs/ro.ts +42 -1
  82. package/src/core/ui-packs/ru.ts +42 -1
  83. package/src/core/ui-packs/sk.ts +42 -1
  84. package/src/core/ui-packs/sr.ts +42 -1
  85. package/src/core/ui-packs/sv.ts +42 -1
  86. package/src/core/ui-packs/th.ts +44 -1
  87. package/src/core/ui-packs/tr.ts +42 -1
  88. package/src/core/ui-packs/uk.ts +42 -1
  89. package/src/core/ui-packs/vi.ts +44 -1
  90. package/src/core/ui-packs/zh-tw.ts +44 -1
  91. package/src/core/ui-packs/zh.ts +44 -1
  92. package/src/deploy/adapter-output.ts +44 -5
  93. package/src/deploy/cloudflare-negotiation.ts +527 -0
  94. package/src/deploy/redirects.ts +13 -0
  95. package/src/deploy/vercel-negotiation.ts +30 -13
  96. package/src/eval/agents.ts +1 -1
  97. package/src/search/documents.ts +11 -0
  98. package/src/search/facets.ts +33 -0
  99. package/src/search/orama-index.ts +48 -6
  100. package/src/search/popular-icon.ts +33 -0
  101. package/src/theme/fonts.ts +3 -1
  102. package/src/theme/icon-kind.ts +20 -0
  103. package/src/translate/agents.ts +51 -0
  104. package/src/translate/ledger.ts +148 -0
  105. package/src/translate/meta.ts +149 -0
  106. package/src/translate/prompts.ts +95 -0
  107. package/src/translate/report.ts +360 -0
  108. package/src/translate/run.ts +376 -0
  109. package/src/translate/validate.ts +171 -0
  110. package/src/translate/work-list.ts +0 -0
@@ -36,7 +36,7 @@ const brandText = logo?.text ?? site.title;
36
36
  logoSvg && (
37
37
  <span
38
38
  aria-hidden="true"
39
- class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
39
+ class="inline-flex h-5 shrink-0 items-center [&>svg]:h-5 [&>svg]:w-auto"
40
40
  set:html={logoSvg}
41
41
  />
42
42
  )
@@ -47,7 +47,7 @@ const brandText = logo?.text ?? site.title;
47
47
  (logoLight === logoDark ? (
48
48
  <img
49
49
  alt={logoAlt}
50
- class="h-5 w-auto"
50
+ class="h-5 w-auto shrink-0"
51
51
  height={logoLightDimensions?.height}
52
52
  src={withBase(logoLight)}
53
53
  width={logoLightDimensions?.width}
@@ -56,14 +56,14 @@ const brandText = logo?.text ?? site.title;
56
56
  <>
57
57
  <img
58
58
  alt={logoAlt}
59
- class="h-5 w-auto dark:hidden"
59
+ class="h-5 w-auto shrink-0 dark:hidden"
60
60
  height={logoLightDimensions?.height}
61
61
  src={withBase(logoLight)}
62
62
  width={logoLightDimensions?.width}
63
63
  />
64
64
  <img
65
65
  alt={logoAlt}
66
- class="hidden h-5 w-auto dark:block"
66
+ class="hidden h-5 w-auto shrink-0 dark:block"
67
67
  height={logoDarkDimensions?.height}
68
68
  src={withBase(logoDark)}
69
69
  width={logoDarkDimensions?.width}
@@ -44,14 +44,19 @@ const LOGOS: Record<string, string> = {
44
44
  v0: '<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 147 70" xmlns="http://www.w3.org/2000/svg"><path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z"/><path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z"/></svg>',
45
45
  };
46
46
 
47
+ // Brand names stay verbatim; the surrounding "Open in" phrase localizes via
48
+ // the `actions.openIn` template.
47
49
  const PROVIDERS = [
48
- { key: "v0", label: "Open in v0" },
49
- { key: "chatgpt", label: "Open in ChatGPT" },
50
- { key: "claude", label: "Open in Claude" },
51
- { key: "t3", label: "Open in T3 Chat" },
52
- { key: "scira", label: "Open in Scira" },
53
- { key: "cursor", label: "Open in Cursor" },
54
- ];
50
+ { key: "v0", name: "v0" },
51
+ { key: "chatgpt", name: "ChatGPT" },
52
+ { key: "claude", name: "Claude" },
53
+ { key: "t3", name: "T3 Chat" },
54
+ { key: "scira", name: "Scira" },
55
+ { key: "cursor", name: "Cursor" },
56
+ ].map((provider) => ({
57
+ ...provider,
58
+ label: a.openIn.replace("{name}", provider.name),
59
+ }));
55
60
 
56
61
  // Top-level actions match the ToC links: flat, no background/padding, color
57
62
  // shift on hover only.
@@ -2,7 +2,7 @@
2
2
  import { EN_UI } from "../../core/i18n-ui.ts";
3
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
4
  import type { Navigation } from "../../core/types.ts";
5
- import { resolveIcon } from "../../theme/icons.ts";
5
+ import { resolvePopularIconMarkup } from "../../search/popular-icon.ts";
6
6
  import Icon from "../Icon.astro";
7
7
  import { flattenPages } from "./nav-utils.ts";
8
8
 
@@ -28,23 +28,15 @@ const { askEnabled = false, navigation, popularPages, strings, locale } =
28
28
  // default, matching the pattern PageActions uses for its own dictionary.
29
29
  const s = { ...EN_UI.search, ...strings };
30
30
 
31
- // Icons resolve to inline SVG here (`theme/icons.ts` is a server-only module —
32
- // far too large to ship to the browser), so the island gets ready-to-render
33
- // markup rather than a name it can't resolve. Same approach as AskAI.astro.
34
- // A name outside the set falls through to the island's own file glyph.
35
- const iconSvg = (name: string | undefined): string | undefined => {
36
- const resolved = name ? resolveIcon(name) : null;
37
- return resolved
38
- ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
39
- : undefined;
40
- };
41
-
42
- // Pages shown in the empty state, before the user has typed anything. Only
43
- // curated entries carry an icon; sidebar-derived rows keep the file glyph.
31
+ // Resolve popular icons to markup here (icon set is server-only). Unknown
32
+ // names fall through to the island's file glyph.
44
33
  const popular =
45
34
  popularPages && popularPages.length > 0
46
35
  ? popularPages.map((page) => ({
47
- icon: iconSvg(page.icon),
36
+ icon: resolvePopularIconMarkup(
37
+ page.icon,
38
+ import.meta.env.BASE_URL ?? "/"
39
+ ),
48
40
  label: page.label,
49
41
  route: page.route,
50
42
  }))
@@ -68,15 +60,18 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
68
60
  data-i18n-results={s.results}
69
61
  data-locale={locale || undefined}
70
62
  >
63
+ {/* The label and shortcut hint wait until `lg`: below it the hamburger and
64
+ inline tab bar share the header row, and a full-width search field would
65
+ press into the language switcher. */}
71
66
  <button
72
67
  aria-label={s.button}
73
- class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground sm:min-w-48"
68
+ class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-48"
74
69
  data-blume-search-open
75
70
  type="button"
76
71
  >
77
72
  <Icon name="search" size={16} />
78
- <span class="flex-1 text-start max-sm:hidden">{s.button}</span>
79
- <kbd class="font-mono text-[0.7rem] max-sm:hidden" data-blume-search-kbd
73
+ <span class="flex-1 text-start max-lg:hidden">{s.button}</span>
74
+ <kbd class="font-mono text-[0.7rem] max-lg:hidden" data-blume-search-kbd
80
75
  >⌘K</kbd
81
76
  >
82
77
  </button>
@@ -626,8 +621,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
626
621
  const href = prefixBase(import.meta.env.BASE_URL, url);
627
622
  el.href = href;
628
623
  el.className = ROW_CLASS;
629
- // `icon` is server-resolved markup from the bundled icon set, not
630
- // author HTML — the label still goes through `escapeHtml`.
624
+ // `icon` is server-resolved markup (built-in Lucide, `<img>`, or
625
+ // config-authored inline SVG) — the label still goes through `escapeHtml`.
631
626
  el.innerHTML = `
632
627
  <span class="mt-0.5 shrink-0 text-muted-foreground">${icon ?? svg("file")}</span>
633
628
  <span class="flex-1">
@@ -23,7 +23,9 @@ export const createSearch = async (opts: {
23
23
  const db = await buildOramaIndex(documents, opts.locale);
24
24
 
25
25
  return async (query, options) => {
26
- const docs = await queryOramaIndex(db, query, RESULT_POOL, options?.locale);
26
+ const docs = await queryOramaIndex(db, query, RESULT_POOL, {
27
+ locale: options?.locale,
28
+ });
27
29
  return buildResult(docs as IndexedDocument[], query, options?.section);
28
30
  };
29
31
  };
@@ -28,6 +28,15 @@ export const normalizeBasePath = (input?: string): string => {
28
28
  return trimmed === "" ? "" : `/${trimmed}`;
29
29
  };
30
30
 
31
+ /**
32
+ * Normalize a served path for comparison: drop the trailing slash (Astro serves
33
+ * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
34
+ */
35
+ export const normalizePath = (path: string): string => {
36
+ const trimmed = path.replace(/\/+$/u, "");
37
+ return trimmed === "" ? "/" : trimmed;
38
+ };
39
+
31
40
  /**
32
41
  * Whether a link target is a root-relative internal path (`/x`) — the only
33
42
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
@@ -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;