blume 1.4.3 → 1.5.1

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 (204) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +1784 -633
  4. package/dist/cli/index.js.map +111 -106
  5. package/dist/types/ai/component-markdown.d.ts +14 -4
  6. package/dist/types/core/config-input.d.ts +80 -28
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +19 -3
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +112 -15
  12. package/dist/types/core/sources/types.d.ts +3 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +6 -5
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/dist/types/theme/fonts.d.ts +11 -2
  19. package/docs/advanced/api-reference.mdx +8 -6
  20. package/docs/advanced/custom-pages.mdx +5 -1
  21. package/docs/configuration/index.mdx +1 -1
  22. package/docs/configuration/search.mdx +2 -0
  23. package/docs/configuration/seo.mdx +1 -1
  24. package/docs/configuration/theming.mdx +4 -2
  25. package/docs/content/i18n.mdx +1 -1
  26. package/docs/content/meta.mdx +2 -1
  27. package/docs/content/meta.ts +1 -0
  28. package/docs/content/navigation.mdx +35 -1
  29. package/docs/content/versioning.mdx +106 -0
  30. package/docs/reference/cli.mdx +2 -1
  31. package/docs/reference/frontmatter.mdx +3 -0
  32. package/package.json +3 -1
  33. package/skills/blume-migrate/SKILL.md +2 -2
  34. package/skills/blume-migrate/references/docusaurus.md +1 -1
  35. package/skills/blume-migrate/references/fumadocs.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +1 -1
  37. package/src/ai/agent-readability.ts +37 -10
  38. package/src/ai/ask-context.ts +5 -1
  39. package/src/ai/ask.ts +10 -1
  40. package/src/ai/component-markdown.ts +80 -43
  41. package/src/ai/llms.ts +40 -16
  42. package/src/ai/mcp/data.ts +48 -12
  43. package/src/ai/mcp/discovery.ts +28 -11
  44. package/src/ai/mcp/server.ts +183 -38
  45. package/src/ai/mcp/tools.ts +3 -3
  46. package/src/ai/skills.ts +32 -9
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +6 -2
  50. package/src/astro/generate.ts +64 -34
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +16 -9
  53. package/src/astro/templates.ts +181 -40
  54. package/src/audit/agent.ts +2 -2
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +37 -9
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +5 -2
  64. package/src/audit/snapshot.ts +2 -4
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +9 -4
  68. package/src/cli/commands/build.ts +15 -9
  69. package/src/cli/commands/dev.ts +2 -0
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eval.ts +7 -3
  72. package/src/cli/commands/init.ts +9 -9
  73. package/src/cli/commands/mcp-stdio.ts +3 -0
  74. package/src/cli/commands/translate.ts +14 -3
  75. package/src/cli/commands/version.ts +85 -0
  76. package/src/cli/dev-lock.ts +31 -10
  77. package/src/cli/eject-scripts.ts +17 -2
  78. package/src/cli/index.ts +2 -0
  79. package/src/cli/init/questions.ts +1 -1
  80. package/src/cli/init/scaffold.ts +22 -15
  81. package/src/cli/internal-error.ts +1 -0
  82. package/src/components/content/auto-type-table.ts +3 -0
  83. package/src/components/content/diff.ts +9 -5
  84. package/src/components/content/github-info.ts +2 -0
  85. package/src/components/islands/ask-ai.tsx +33 -25
  86. package/src/components/islands/hooks.ts +5 -1
  87. package/src/components/islands/webmcp.ts +49 -12
  88. package/src/components/layout/Fonts.astro +23 -3
  89. package/src/components/layout/Header.astro +25 -1
  90. package/src/components/layout/NavSelector.astro +11 -2
  91. package/src/components/layout/NavTree.astro +4 -2
  92. package/src/components/layout/PageLayout.astro +72 -3
  93. package/src/components/layout/ReferenceLayout.astro +2 -1
  94. package/src/components/layout/RootLayout.astro +20 -1
  95. package/src/components/layout/Search.astro +77 -13
  96. package/src/components/layout/VersionBanner.astro +39 -0
  97. package/src/components/layout/analytics-client.ts +8 -5
  98. package/src/components/layout/hydration-hint.ts +1 -1
  99. package/src/components/layout/nav-utils.ts +1 -4
  100. package/src/components/layout/overrides.ts +25 -12
  101. package/src/components/layout/search/algolia.ts +18 -5
  102. package/src/components/layout/search/endpoint.ts +3 -0
  103. package/src/components/layout/search/flexsearch.ts +23 -7
  104. package/src/components/layout/search/orama-cloud.ts +1 -1
  105. package/src/components/layout/search/orama.ts +4 -1
  106. package/src/components/layout/search/pagefind.ts +2 -0
  107. package/src/components/layout/search/types.ts +13 -1
  108. package/src/components/layout/search/typesense.ts +19 -3
  109. package/src/components/openapi/ApiOverview.astro +32 -6
  110. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  111. package/src/components/openapi/Bindings.astro +89 -0
  112. package/src/components/openapi/MethodBadge.astro +3 -0
  113. package/src/components/openapi/Operation.astro +7 -2
  114. package/src/components/openapi/PanelTabs.astro +131 -0
  115. package/src/components/openapi/ParametersTable.astro +2 -0
  116. package/src/components/openapi/RequestPanel.astro +12 -119
  117. package/src/components/openapi/async-snippets.ts +174 -0
  118. package/src/components/openapi/async.ts +348 -0
  119. package/src/components/openapi/helpers.ts +52 -20
  120. package/src/components/openapi/security.ts +102 -29
  121. package/src/components/openapi/snippets.ts +11 -11
  122. package/src/core/component-overrides.ts +28 -23
  123. package/src/core/config-input.ts +89 -28
  124. package/src/core/config.ts +20 -7
  125. package/src/core/content.ts +3 -1
  126. package/src/core/data.ts +19 -3
  127. package/src/core/define-components.ts +5 -0
  128. package/src/core/diagnostics.ts +46 -38
  129. package/src/core/frontmatter.ts +33 -7
  130. package/src/core/graph.ts +137 -53
  131. package/src/core/i18n-ui.ts +15 -0
  132. package/src/core/i18n.ts +16 -8
  133. package/src/core/last-modified.ts +49 -0
  134. package/src/core/load-module.ts +1 -0
  135. package/src/core/manifest.ts +92 -3
  136. package/src/core/meta.ts +44 -14
  137. package/src/core/nav-diagnostics.ts +3 -3
  138. package/src/core/navigation.ts +247 -67
  139. package/src/core/project-graph.ts +26 -3
  140. package/src/core/schema.ts +214 -68
  141. package/src/core/sources/assets.ts +2 -0
  142. package/src/core/sources/cache.ts +6 -0
  143. package/src/core/sources/github-releases.ts +39 -31
  144. package/src/core/sources/mdx-remote.ts +4 -0
  145. package/src/core/sources/normalize.ts +67 -20
  146. package/src/core/sources/notion.ts +49 -17
  147. package/src/core/sources/portable-text.ts +32 -11
  148. package/src/core/sources/sanity.ts +68 -14
  149. package/src/core/sources/types.ts +4 -0
  150. package/src/core/sources/watch.ts +1 -1
  151. package/src/core/standard-schema.ts +9 -3
  152. package/src/core/text-width.ts +26 -0
  153. package/src/core/tsconfig-aliases.ts +9 -5
  154. package/src/core/types.ts +45 -2
  155. package/src/core/ui-packs/index.ts +9 -1
  156. package/src/core/version-cut.ts +301 -0
  157. package/src/core/version.ts +2 -0
  158. package/src/core/versions.ts +170 -0
  159. package/src/deploy/adapter-output.ts +5 -2
  160. package/src/deploy/cloudflare-negotiation.ts +25 -10
  161. package/src/deploy/sitemap.ts +33 -1
  162. package/src/deploy/vercel-negotiation.ts +45 -18
  163. package/src/eval/report.ts +4 -4
  164. package/src/eval/run.ts +2 -2
  165. package/src/eval/schema.ts +1 -1
  166. package/src/markdown/base-links.ts +6 -6
  167. package/src/markdown/directives.ts +7 -1
  168. package/src/markdown/heading-anchors.ts +17 -6
  169. package/src/markdown/index.ts +73 -24
  170. package/src/markdown/inline-code.ts +14 -2
  171. package/src/markdown/language-icon.ts +6 -2
  172. package/src/markdown/mdast.ts +18 -4
  173. package/src/markdown/package-commands.ts +6 -8
  174. package/src/markdown/table-wrap.ts +4 -1
  175. package/src/markdown/twoslash.ts +2 -0
  176. package/src/og/card.ts +33 -12
  177. package/src/og/derive.ts +43 -27
  178. package/src/openapi/asyncapi.ts +366 -0
  179. package/src/openapi/model.ts +126 -57
  180. package/src/openapi/parse.ts +97 -5
  181. package/src/openapi/references.ts +12 -10
  182. package/src/openapi/render-mdx.ts +73 -34
  183. package/src/openapi/scalar.ts +6 -8
  184. package/src/openapi/source.ts +98 -28
  185. package/src/registry/eject.ts +7 -2
  186. package/src/search/documents.ts +25 -5
  187. package/src/search/facets.ts +7 -5
  188. package/src/search/orama-index.ts +66 -20
  189. package/src/search/popular.ts +10 -5
  190. package/src/search/providers.ts +2 -2
  191. package/src/search/sync/index.ts +2 -0
  192. package/src/search/sync/typesense.ts +4 -2
  193. package/src/seo/jsonld.ts +24 -6
  194. package/src/seo/x-handle.ts +8 -3
  195. package/src/theme/chrome-icons.ts +7 -2
  196. package/src/theme/entry.ts +24 -2
  197. package/src/theme/fonts.ts +83 -7
  198. package/src/theme/icons.ts +4 -2
  199. package/src/theme/palette.ts +22 -14
  200. package/src/translate/meta.ts +15 -6
  201. package/src/translate/report.ts +9 -5
  202. package/src/translate/run.ts +10 -4
  203. package/src/translate/validate.ts +52 -17
  204. package/src/translate/work-list.ts +0 -0
@@ -87,8 +87,8 @@ const headerValues = (
87
87
  params: ParamLike[],
88
88
  schemas: Record<string, SchemaLike>,
89
89
  auth: SampleAuth | undefined
90
- ): Record<string, string> => {
91
- const headers: Record<string, string> = { ...auth?.headers };
90
+ ) => {
91
+ const headers = { ...auth?.headers };
92
92
  for (const param of params) {
93
93
  if (param.in === "header" && param.required && param.name) {
94
94
  headers[param.name] = String(
@@ -230,14 +230,14 @@ const LANGUAGES: SampleLanguage[] = [
230
230
  { build: pythonSnippet, id: "python", label: "Python", lang: "python" },
231
231
  ];
232
232
 
233
- const ALIASES: Record<string, string> = {
234
- bash: "curl",
235
- javascript: "js",
236
- node: "js",
237
- py: "python",
238
- shell: "curl",
239
- typescript: "js",
240
- };
233
+ const ALIASES = new Map([
234
+ ["bash", "curl"],
235
+ ["javascript", "js"],
236
+ ["node", "js"],
237
+ ["py", "python"],
238
+ ["shell", "curl"],
239
+ ["typescript", "js"],
240
+ ]);
241
241
 
242
242
  /** The sample languages to render, resolved from config ids (unknown ids dropped). */
243
243
  export const sampleLanguages = (ids: string[]): SampleLanguage[] => {
@@ -246,7 +246,7 @@ export const sampleLanguages = (ids: string[]): SampleLanguage[] => {
246
246
  const out: SampleLanguage[] = [];
247
247
  const seen = new Set<string>();
248
248
  for (const raw of wanted) {
249
- const id = ALIASES[raw.toLowerCase()] ?? raw.toLowerCase();
249
+ const id = ALIASES.get(raw.toLowerCase()) ?? raw.toLowerCase();
250
250
  const language = byId.get(id);
251
251
  if (language && !seen.has(id)) {
252
252
  seen.add(id);
@@ -65,18 +65,20 @@ const GROUPS = ["mdx", "layout", "islands"] as const;
65
65
  const GROUP_SET = new Set<string>(GROUPS);
66
66
  type Group = (typeof GROUPS)[number];
67
67
 
68
- const FRAMEWORK_BY_EXT: Record<string, OverrideFramework> = {
69
- jsx: "react",
70
- svelte: "svelte",
71
- tsx: "react",
72
- vue: "vue",
73
- };
68
+ const isGroup = (name: string): name is Group => GROUP_SET.has(name);
69
+
70
+ const FRAMEWORK_BY_EXT = new Map<string, OverrideFramework>([
71
+ ["jsx", "react"],
72
+ ["svelte", "svelte"],
73
+ ["tsx", "react"],
74
+ ["vue", "vue"],
75
+ ]);
74
76
 
75
- const FRAMEWORK_LABEL: Record<OverrideFramework, string> = {
77
+ const FRAMEWORK_LABEL = {
76
78
  react: "React",
77
79
  svelte: "Svelte",
78
80
  vue: "Vue",
79
- };
81
+ } satisfies Record<OverrideFramework, string>;
80
82
 
81
83
  /** Extensions probed (in order) when a specifier omits one. */
82
84
  const COMPONENT_EXTS = [
@@ -90,7 +92,7 @@ const COMPONENT_EXTS = [
90
92
  "svelte",
91
93
  ];
92
94
 
93
- const HYDRATION_MODES = new Set<HydrationMode>([
95
+ const HYDRATION_MODES: ReadonlySet<string> = new Set<HydrationMode>([
94
96
  "idle",
95
97
  "load",
96
98
  "media",
@@ -98,6 +100,9 @@ const HYDRATION_MODES = new Set<HydrationMode>([
98
100
  "visible",
99
101
  ]);
100
102
 
103
+ const isHydrationMode = (value: string): value is HydrationMode =>
104
+ HYDRATION_MODES.has(value);
105
+
101
106
  interface ImportBinding {
102
107
  /** Exported name: `"default"` or a named export. */
103
108
  imported: string;
@@ -226,7 +231,7 @@ const toImport = (
226
231
  }
227
232
  }
228
233
  return {
229
- framework: FRAMEWORK_BY_EXT[extension] ?? null,
234
+ framework: FRAMEWORK_BY_EXT.get(extension) ?? null,
230
235
  name: imported,
231
236
  path,
232
237
  };
@@ -270,9 +275,9 @@ const applyDescriptorProperty = (
270
275
  } else if (
271
276
  name === "client" &&
272
277
  ts.isStringLiteral(init) &&
273
- HYDRATION_MODES.has(init.text as HydrationMode)
278
+ isHydrationMode(init.text)
274
279
  ) {
275
- descriptor.client = init.text as HydrationMode;
280
+ descriptor.client = init.text;
276
281
  } else if (name === "media" && ts.isStringLiteral(init)) {
277
282
  descriptor.media = init.text;
278
283
  }
@@ -334,13 +339,14 @@ const finalize = (
334
339
  );
335
340
  }
336
341
 
337
- return {
338
- identifier,
339
- key,
340
- ...(client ? { client } : {}),
341
- ...(media ? { media } : {}),
342
- source,
343
- };
342
+ const normalized: NormalizedOverride = { identifier, key, source };
343
+ if (client) {
344
+ normalized.client = client;
345
+ }
346
+ if (media) {
347
+ normalized.media = media;
348
+ }
349
+ return normalized;
344
350
  };
345
351
 
346
352
  const normalizeEntry = (
@@ -446,22 +452,21 @@ const collectGroupOverrides = (
446
452
  }
447
453
  const name = propName(property.name);
448
454
  if (
449
- !(name && GROUP_SET.has(name)) ||
455
+ !(name && isGroup(name)) ||
450
456
  !ts.isObjectLiteralExpression(property.initializer)
451
457
  ) {
452
458
  return;
453
459
  }
454
- const group = name as Group;
455
460
  for (const entry of property.initializer.properties) {
456
461
  const normalized = normalizeEntry(
457
462
  entry,
458
- group,
463
+ name,
459
464
  imports,
460
465
  dir,
461
466
  result.warnings
462
467
  );
463
468
  if (normalized) {
464
- result[group].push(normalized);
469
+ result[name].push(normalized);
465
470
  }
466
471
  }
467
472
  };
@@ -510,7 +510,7 @@ export type FontInput =
510
510
  export interface FontsConfig {
511
511
  /** Body / prose font. Defaults to `inter`. */
512
512
  body?: FontInput;
513
- /** Display / heading font. Defaults to `inter-tight`. */
513
+ /** Display / heading font. Defaults to `inter` (shared with the body). */
514
514
  display?: FontInput;
515
515
  /** Monospace / code font. Defaults to `ibm-plex-mono`. */
516
516
  mono?: FontInput;
@@ -761,6 +761,7 @@ export interface AiConfig {
761
761
  /** Web Bot Auth signature directory. Off until at least one key is listed. */
762
762
  export interface WebBotAuthConfig {
763
763
  /** Public JWKs to publish (e.g. an Ed25519 key: `kty: "OKP"`, `crv: "Ed25519"`, `x: …`). */
764
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- mirrors the schema's `z.record(z.unknown())` (the drift guard requires it); JWK parameters are validated at parse time, not typed.
764
765
  keys?: Record<string, unknown>[];
765
766
  }
766
767
 
@@ -838,6 +839,59 @@ export interface I18nConfig {
838
839
  ui?: Record<string, Record<string, Record<string, string>>>;
839
840
  }
840
841
 
842
+ // ---------------------------------------------------------------------------
843
+ // Versions
844
+ // ---------------------------------------------------------------------------
845
+
846
+ /** A frozen documentation snapshot: a directory under the content root. */
847
+ export interface ArchivedVersionInput {
848
+ /**
849
+ * The "you're viewing an old version" notice: `true` (default) for the
850
+ * built-in message, a string for custom copy, `false` to hide it.
851
+ */
852
+ banner?: boolean | string;
853
+ /**
854
+ * Where this version's pages point their canonical URL. `latest` (default)
855
+ * targets the same page in the current docs when it still exists (self
856
+ * otherwise); `self` keeps every page authoritative.
857
+ */
858
+ canonical?: "latest" | "self";
859
+ /**
860
+ * Directory name under the content root, and the URL segment. Must start
861
+ * with a letter (e.g. `v1.0`).
862
+ */
863
+ id: string;
864
+ /** Switcher label; defaults to the id. */
865
+ label?: string;
866
+ /** Emit `noindex` on every page of this version. Defaults to `false`. */
867
+ noindex?: boolean;
868
+ }
869
+
870
+ /**
871
+ * Docs versioning. Opt-in: the latest docs live at the content root with
872
+ * unprefixed URLs, and each archived version is a frozen snapshot directory
873
+ * (`content/docs/<id>/`) cut with `blume version <id>`. Archived means frozen:
874
+ * snapshots carry their own translations and are never retranslated.
875
+ */
876
+ export interface VersionsConfig {
877
+ /** Frozen snapshots, newest first — this order is the switcher order. */
878
+ archived?: ArchivedVersionInput[];
879
+ /** Labels the unprefixed tree (the latest docs) in the switcher. */
880
+ current: {
881
+ /** Small tag rendered next to the label (e.g. `Latest`). */
882
+ badge?: string;
883
+ label: string;
884
+ };
885
+ switcher?: {
886
+ /**
887
+ * Where switching lands when the page has no equivalent in the target
888
+ * version: `same-page` (default) goes to the equivalent when it exists
889
+ * (version root otherwise); `root` always goes to the version root.
890
+ */
891
+ redirect?: "same-page" | "root";
892
+ };
893
+ }
894
+
841
895
  // ---------------------------------------------------------------------------
842
896
  // Deployment & redirects
843
897
  // ---------------------------------------------------------------------------
@@ -1112,28 +1166,24 @@ export interface ReactConfig {
1112
1166
  // ---------------------------------------------------------------------------
1113
1167
 
1114
1168
  /**
1115
- * OpenAPI reference. By default (`renderer: "blume"`) Blume renders its own UI:
1116
- * one real page per operation, grouped by tag in the sidebar and included in
1117
- * search, llms.txt, and OG. Set `renderer: "scalar"` for the embedded Scalar
1118
- * SPA (a single self-contained route).
1169
+ * The shared shape of both API-reference blocks (`openapi`, `asyncapi`). Only
1170
+ * the per-block defaults differ; those are documented on the extending
1171
+ * interfaces.
1119
1172
  */
1120
- export interface OpenApiConfig {
1121
- /** Code-sample languages shown per operation (Blume renderer). */
1122
- codeSamples?: string[];
1173
+ interface ReferenceConfig {
1123
1174
  /** Turn the reference on. Defaults to `false`. */
1124
1175
  enabled?: boolean;
1125
1176
  /** Start nested schema rows expanded (Blume renderer). Defaults to `false`. */
1126
1177
  expandSchemas?: boolean;
1127
1178
  /** Who renders the reference. Defaults to `blume`. */
1128
1179
  renderer?: "blume" | "scalar";
1129
- /** Where the reference mounts. Defaults to `/reference`. */
1130
- route?: string;
1131
1180
  /**
1132
1181
  * Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`
1133
1182
  * (Scalar renderer only) — e.g. `localization`, `agent`,
1134
1183
  * `hideTestRequestButton`, `orderSchemaPropertiesBy`. These win over Blume's
1135
1184
  * derived spec/theme config, so it's a full escape hatch to Scalar's API.
1136
1185
  */
1186
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- mirrors the schema's `z.record(z.unknown())` (the drift guard requires it); the values are Scalar's own API surface, deliberately unmodeled.
1137
1187
  scalar?: Record<string, unknown>;
1138
1188
  /** One or more specs; each renders on its own route by default. */
1139
1189
  sources?: OpenApiSource[];
@@ -1144,27 +1194,36 @@ export interface OpenApiConfig {
1144
1194
  }
1145
1195
 
1146
1196
  /**
1147
- * AsyncAPI reference, rendered via the embedded Scalar SPA (which auto-detects
1148
- * the document type). Same shape as {@link OpenApiConfig}; only the default
1149
- * `route` differs.
1197
+ * OpenAPI reference. By default (`renderer: "blume"`) Blume renders its own UI:
1198
+ * one real page per operation, grouped by tag in the sidebar and included in
1199
+ * search, llms.txt, and OG. Set `renderer: "scalar"` for the embedded Scalar
1200
+ * SPA (a single self-contained route).
1150
1201
  */
1151
- export interface AsyncApiConfig {
1152
- /** Turn the reference on. Defaults to `false`. */
1153
- enabled?: boolean;
1154
- /** Where the reference mounts. Defaults to `/events`. */
1202
+ export interface OpenApiConfig extends ReferenceConfig {
1203
+ /**
1204
+ * Code-sample languages shown per operation (Blume renderer). Defaults to
1205
+ * `["curl", "js", "python"]`.
1206
+ */
1207
+ codeSamples?: string[];
1208
+ /** Where the reference mounts. Defaults to `/reference`. */
1155
1209
  route?: string;
1210
+ }
1211
+
1212
+ /**
1213
+ * AsyncAPI reference. Same shape as {@link OpenApiConfig}: by default
1214
+ * (`renderer: "blume"`) Blume normalizes the spec to AsyncAPI 3.x and renders
1215
+ * its own UI — one real page per operation, grouped by tag (or channel) in the
1216
+ * sidebar and included in search, llms.txt, and OG. Set `renderer: "scalar"`
1217
+ * for the embedded Scalar SPA (a single self-contained route).
1218
+ */
1219
+ export interface AsyncApiConfig extends ReferenceConfig {
1156
1220
  /**
1157
- * Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`.
1158
- * These win over Blume's derived spec/theme config — a full escape hatch to
1159
- * Scalar's API.
1221
+ * Code-sample tools shown per operation (Blume renderer). Defaults to every
1222
+ * tool appropriate to the operation's protocol binding.
1160
1223
  */
1161
- scalar?: Record<string, unknown>;
1162
- /** One or more specs. */
1163
- sources?: OpenApiSource[];
1164
- /** Shorthand for a single source. */
1165
- spec?: string;
1166
- /** Scalar theme name. */
1167
- theme?: string;
1224
+ codeSamples?: string[];
1225
+ /** Where the reference mounts. Defaults to `/events`. */
1226
+ route?: string;
1168
1227
  }
1169
1228
 
1170
1229
  // ---------------------------------------------------------------------------
@@ -1302,7 +1361,7 @@ export interface BlumeConfig {
1302
1361
  ai?: AiConfig;
1303
1362
  /** Analytics providers (PostHog, Vercel, or arbitrary scripts). */
1304
1363
  analytics?: AnalyticsConfig;
1305
- /** AsyncAPI reference (embedded Scalar renderer). */
1364
+ /** AsyncAPI reference (native renderer by default, Scalar opt-out). */
1306
1365
  asyncapi?: AsyncApiConfig;
1307
1366
  /** Site-wide announcement banner shown above the header. */
1308
1367
  banner?: BannerConfig;
@@ -1375,6 +1434,8 @@ export interface BlumeConfig {
1375
1434
  title?: string;
1376
1435
  /** On-page table of contents. Defaults to on (H2–H3). */
1377
1436
  toc?: TocConfig;
1437
+ /** Docs versioning (opt-in frozen snapshots with a version switcher). */
1438
+ versions?: VersionsConfig;
1378
1439
  }
1379
1440
 
1380
1441
  // ---------------------------------------------------------------------------
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
+ import { z } from "zod";
4
+
3
5
  import type { BlumeConfig } from "./config-input.ts";
4
6
  import { applyDeploymentEnv } from "./deployment-env.ts";
5
7
  import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
@@ -68,7 +70,8 @@ import type { Diagnostic } from "./types.ts";
68
70
  * **Reference docs**
69
71
  * - `openapi` — native OpenAPI reference: one real page per operation, woven
70
72
  * into the sidebar and search. Point `sources`/`spec` at your spec.
71
- * - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
73
+ * - `asyncapi` — native AsyncAPI reference with the same treatment; 2.x specs
74
+ * are normalized to 3.x automatically.
72
75
  *
73
76
  * **Search & AI**
74
77
  * - `search` — search backend `provider` (`orama` by default; `pagefind`,
@@ -161,6 +164,15 @@ export interface ConfigLoadResult {
161
164
 
162
165
  const importConfigModule = createModuleLoader();
163
166
 
167
+ /**
168
+ * The slice of a user config module probed before schema defaults apply:
169
+ * whether `theme.fonts` was actually set. `looseObject` keeps every other key
170
+ * out of scope; a non-object at either level simply fails the probe.
171
+ */
172
+ const themeFontsProbeSchema = z.looseObject({
173
+ theme: z.looseObject({ fonts: z.unknown() }).optional(),
174
+ });
175
+
164
176
  /**
165
177
  * Load and validate the project config. When no config file exists, schema
166
178
  * defaults produce a fully resolved config so the zero-boilerplate path works.
@@ -176,11 +188,14 @@ export const loadConfig = async (
176
188
  ): Promise<ConfigLoadResult> => {
177
189
  const configFile = findConfigFile(root);
178
190
 
179
- let raw: unknown = {};
191
+ let raw: unknown;
180
192
  if (configFile) {
181
193
  try {
182
194
  raw = await importConfigModule(configFile);
183
195
  } catch (error) {
196
+ // SAFETY: the module loader rejects with the thrown load/parse failure,
197
+ // which Node surfaces as an Error; a non-Error rejection only degrades
198
+ // the interpolated message.
184
199
  throw new BlumeError({
185
200
  code: "BLUME_CONFIG_LOAD_FAILED",
186
201
  file: configFile,
@@ -191,11 +206,9 @@ export const loadConfig = async (
191
206
  }
192
207
 
193
208
  // Read before parsing: schema defaults erase the set-vs-defaulted distinction.
194
- const themeFontsConfigured = Boolean(
195
- raw &&
196
- typeof raw === "object" &&
197
- (raw as { theme?: { fonts?: unknown } }).theme?.fonts !== undefined
198
- );
209
+ const probe = themeFontsProbeSchema.safeParse(raw);
210
+ const themeFontsConfigured =
211
+ probe.success && probe.data.theme?.fonts !== undefined;
199
212
 
200
213
  const parsed = blumeConfigSchema.safeParse(raw ?? {});
201
214
  if (!parsed.success) {
@@ -1,4 +1,4 @@
1
- import type { ResolvedI18nConfig } from "./schema.ts";
1
+ import type { ResolvedI18nConfig, ResolvedVersionsConfig } from "./schema.ts";
2
2
  import { filesystemSource } from "./sources/filesystem.ts";
3
3
  import { normalizeEntry } from "./sources/normalize.ts";
4
4
  import type { Diagnostic, PageRecord } from "./types.ts";
@@ -24,6 +24,7 @@ export const discoverContent = async (options: {
24
24
  defaultType: string;
25
25
  basePath?: string;
26
26
  i18n?: ResolvedI18nConfig;
27
+ versions?: ResolvedVersionsConfig;
27
28
  }): Promise<{ pages: PageRecord[]; diagnostics: Diagnostic[] }> => {
28
29
  const source = filesystemSource({
29
30
  exclude: options.exclude,
@@ -43,6 +44,7 @@ export const discoverContent = async (options: {
43
44
  defaultType: options.defaultType,
44
45
  i18n: options.i18n,
45
46
  source: { name: source.name, prefix: source.prefix, staged: false },
47
+ versions: options.versions,
46
48
  });
47
49
  pages.push(...normalized.pages);
48
50
  diagnostics.push(...normalized.diagnostics);
package/src/core/data.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import type { FontHead } from "../theme/fonts.ts";
1
2
  import type { UIStrings } from "./i18n-ui.ts";
2
3
  import type { ResolvedConfig, SearchProvider } from "./schema.ts";
3
- import type { Navigation, RouteAlternate } from "./types.ts";
4
+ import type { Navigation, RouteAlternate, VersionAlternate } from "./types.ts";
4
5
 
5
6
  /**
6
7
  * The shape of the `blume:data` virtual module — the resolved, serializable
@@ -85,6 +86,14 @@ export interface BlumeRoute {
85
86
  locale: string;
86
87
  path: string;
87
88
  title: string;
89
+ /** Resolved docs version (`""` for the current docs). */
90
+ version: string;
91
+ /**
92
+ * Versions this logical page exists in within this route's locale — the
93
+ * current version first, then archived versions in configured order. Empty
94
+ * when versioning is off.
95
+ */
96
+ versionAlternates: VersionAlternate[];
88
97
  }
89
98
 
90
99
  /** Site-wide settings derived from `blume.config` — the `config` field of {@link BlumeData}. */
@@ -161,6 +170,8 @@ export interface BlumeDataConfig {
161
170
  * WebMCP in-page tools (`ai.webmcp`), plus whether llms.txt exists for the
162
171
  * list tool to fetch (`ai.llmsTxt.enabled`).
163
172
  */
173
+ /** Docs versioning config; `null` when the site is unversioned. */
174
+ versions: NonNullable<ResolvedConfig["versions"]> | null;
164
175
  webmcp: { enabled: boolean; llms: boolean };
165
176
  /** X (Twitter) attribution: the site's account, and a default creator. */
166
177
  x: { creator?: string; handle?: string };
@@ -182,12 +193,17 @@ export interface BlumeClientData {
182
193
  export interface BlumeData {
183
194
  config: BlumeDataConfig;
184
195
  feeds: BlumeFeed[];
185
- /** CSS variable names for the configured fonts (Astro `<Font>` integration). */
186
- fontCssVars: string[];
196
+ /** Configured fonts for the head: CSS variable + preload weights per family. */
197
+ fontCssVars: FontHead[];
187
198
  /** Sidebar + tab tree for the default locale. */
188
199
  navigation: Navigation;
189
200
  /** Per-locale navigation trees, keyed by locale code (empty without i18n). */
190
201
  navigationByLocale: Record<string, Navigation>;
202
+ /**
203
+ * Per-archived-version navigation trees, keyed by version id and then locale
204
+ * code (`""` on a single-locale site). Empty when versioning is off.
205
+ */
206
+ navigationByVersion: Record<string, Record<string, Navigation>>;
191
207
  routes: BlumeRoute[];
192
208
  /** Resolved UI strings for the default locale. */
193
209
  ui: UIStrings;
@@ -18,6 +18,10 @@ export interface IslandDescriptor {
18
18
  export type ComponentOverride = ComponentReference | IslandDescriptor;
19
19
 
20
20
  /** User-authored component overrides, grouped by surface. */
21
+ // oxlint-disable anti-slop/no-unsafe-dictionary-type -- `ComponentOverride` is untyped by
22
+ // design: user configs pass imported components from any framework (React
23
+ // functions, Svelte classes, Vue SFC objects), which share no structural type.
24
+ // `resolveSlot` and the generated components map are the runtime boundary.
21
25
  export interface ComponentOverrides {
22
26
  /**
23
27
  * Interactive framework components made available in every `.mdx` page. Like
@@ -31,6 +35,7 @@ export interface ComponentOverrides {
31
35
  /** MDX component map overrides (`Callout`, `Card`, ...). */
32
36
  mdx?: Record<string, ComponentOverride>;
33
37
  }
38
+ // oxlint-enable anti-slop/no-unsafe-dictionary-type
34
39
 
35
40
  /**
36
41
  * Identity helper for authoring `components.ts`. Provides type inference and a
@@ -27,41 +27,44 @@ const DOCS_CONTENT_SOURCES = "/docs/content/sources";
27
27
  const DOCS_CONTENT_NAVIGATION = "/docs/content/navigation";
28
28
 
29
29
  /** Diagnostic code → the docs page that explains it. */
30
- const DOCS_PATHS: Record<string, string> = {
31
- BLUME_ADAPTER_REQUIRED: DOCS_DEPLOYMENT,
32
- BLUME_ASSETS_UNCHECKED: DOCS_REFERENCE_CLI,
33
- BLUME_ASSET_FETCH_FAILED: DOCS_CONTENT_SOURCES,
34
- BLUME_BROKEN_ANCHOR: DOCS_REFERENCE_CLI,
35
- BLUME_BROKEN_ASSET: DOCS_REFERENCE_CLI,
36
- BLUME_BROKEN_LINK: DOCS_REFERENCE_CLI,
37
- BLUME_CONFIG_INVALID: "/docs/configuration",
38
- BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
39
- BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
40
- BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
41
- BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
42
- BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
43
- BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
44
- BLUME_META_INVALID: "/docs/content/meta",
45
- BLUME_META_LOAD_FAILED: "/docs/content/meta",
46
- BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
47
- BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
48
- BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
49
- BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
50
- BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
51
- BLUME_NODE_VERSION: "/docs/quickstart",
52
- BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
53
- BLUME_SOURCE_FETCH_FAILED: DOCS_CONTENT_SOURCES,
54
- BLUME_SOURCE_MISCONFIGURED: DOCS_CONTENT_SOURCES,
55
- BLUME_SOURCE_OFFLINE: DOCS_CONTENT_SOURCES,
56
- BLUME_SOURCE_SDK_MISSING: DOCS_CONTENT_SOURCES,
57
- BLUME_SOURCE_UNAVAILABLE: DOCS_CONTENT_SOURCES,
58
- BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
59
- BLUME_UNKNOWN_ICON: DOCS_CONTENT_NAVIGATION,
60
- };
30
+ const DOCS_PATHS = new Map(
31
+ Object.entries({
32
+ BLUME_ADAPTER_REQUIRED: DOCS_DEPLOYMENT,
33
+ BLUME_ASSETS_UNCHECKED: DOCS_REFERENCE_CLI,
34
+ BLUME_ASSET_FETCH_FAILED: DOCS_CONTENT_SOURCES,
35
+ BLUME_BROKEN_ANCHOR: DOCS_REFERENCE_CLI,
36
+ BLUME_BROKEN_ASSET: DOCS_REFERENCE_CLI,
37
+ BLUME_BROKEN_LINK: DOCS_REFERENCE_CLI,
38
+ BLUME_CONFIG_INVALID: "/docs/configuration",
39
+ BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
40
+ BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
41
+ BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
42
+ BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
43
+ BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
44
+ BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
45
+ BLUME_META_INVALID: "/docs/content/meta",
46
+ BLUME_META_LOAD_FAILED: "/docs/content/meta",
47
+ BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
48
+ BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
49
+ BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
50
+ BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
51
+ BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
52
+ BLUME_NODE_VERSION: "/docs/quickstart",
53
+ BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
54
+ BLUME_SIDEBAR_DISPLAY_IGNORED: DOCS_CONTENT_NAVIGATION,
55
+ BLUME_SOURCE_FETCH_FAILED: DOCS_CONTENT_SOURCES,
56
+ BLUME_SOURCE_MISCONFIGURED: DOCS_CONTENT_SOURCES,
57
+ BLUME_SOURCE_OFFLINE: DOCS_CONTENT_SOURCES,
58
+ BLUME_SOURCE_SDK_MISSING: DOCS_CONTENT_SOURCES,
59
+ BLUME_SOURCE_UNAVAILABLE: DOCS_CONTENT_SOURCES,
60
+ BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
61
+ BLUME_UNKNOWN_ICON: DOCS_CONTENT_NAVIGATION,
62
+ })
63
+ );
61
64
 
62
65
  /** The docs URL that explains a diagnostic code, if one is mapped. */
63
66
  export const resolveDocsUrl = (code: string): string | undefined => {
64
- const path = DOCS_PATHS[code];
67
+ const path = DOCS_PATHS.get(code);
65
68
  return path ? `${DOCS_BASE}${path}` : undefined;
66
69
  };
67
70
 
@@ -75,6 +78,10 @@ const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
75
78
  const escapeRegExp = (value: string): string =>
76
79
  value.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
77
80
 
81
+ /** A key segment scans the source text; an array index has no key to find. */
82
+ const isKeySegment = (segment: string | number): segment is string =>
83
+ typeof segment === "string";
84
+
78
85
  /**
79
86
  * Best-effort source position for a Zod issue path (e.g. `["seo", "title"]`) in
80
87
  * the raw config / frontmatter text. Narrows key-by-key — finding each string
@@ -86,9 +93,9 @@ const stepSegment = (
86
93
  source: string,
87
94
  segment: string | number,
88
95
  cursor: number
89
- ): { index: number; next: number; stop: boolean } => {
96
+ ) => {
90
97
  // A non-string path segment (array index) is skipped without moving on.
91
- if (typeof segment !== "string") {
98
+ if (!isKeySegment(segment)) {
92
99
  return { index: -1, next: cursor, stop: false };
93
100
  }
94
101
  // The negative lookbehind keeps a segment like `title` from matching the
@@ -247,10 +254,11 @@ export const formatDiagnostic = (
247
254
  export const hasErrors = (diagnostics: Diagnostic[]): boolean =>
248
255
  diagnostics.some((d) => d.severity === "error");
249
256
 
250
- export const countBySeverity = (
251
- diagnostics: Diagnostic[]
252
- ): Record<Diagnostic["severity"], number> => {
253
- const counts = { error: 0, info: 0, warning: 0 };
257
+ export const countBySeverity = (diagnostics: Diagnostic[]) => {
258
+ const counts = { error: 0, info: 0, warning: 0 } satisfies Record<
259
+ Diagnostic["severity"],
260
+ number
261
+ >;
254
262
  for (const diagnostic of diagnostics) {
255
263
  counts[diagnostic.severity] += 1;
256
264
  }