blume 1.4.2 → 1.5.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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  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 +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -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 +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  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 +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -20,7 +20,6 @@ export interface SecuritySchemeLike {
20
20
  scheme?: string;
21
21
  /** `http` bearer: a hint at the token format, e.g. `JWT`. */
22
22
  bearerFormat?: string;
23
- [key: string]: unknown;
24
23
  }
25
24
 
26
25
  /** One security requirement: scheme name -> required scopes (empty outside OAuth). */
@@ -85,42 +84,115 @@ export const resolveSecurity = (
85
84
  return { alternatives, optional };
86
85
  };
87
86
 
87
+ /**
88
+ * One AsyncAPI 3.x security entry: a `$ref` into
89
+ * `components.securitySchemes`, or an inline scheme object (the shape the
90
+ * official 2.x converter emits for scoped requirements, `scopes` on the
91
+ * scheme itself).
92
+ */
93
+ export interface AsyncApiSecurityEntryLike {
94
+ $ref?: string;
95
+ type?: string;
96
+ scopes?: unknown;
97
+ }
98
+
99
+ const SECURITY_SCHEME_REF = /^#\/components\/securitySchemes\/(?<name>[^/]+)$/u;
100
+
101
+ /** Runtime string check for spec fields a hand-written document may corrupt. */
102
+ const isSpecString = (value: string | undefined): value is string =>
103
+ typeof value === "string";
104
+
105
+ /** Parsed YAML can put anything in a security list; keep only real objects. */
106
+ const isSecurityEntryObject = (
107
+ entry: AsyncApiSecurityEntryLike
108
+ ): entry is AsyncApiSecurityEntryLike =>
109
+ typeof entry === "object" && entry !== null;
110
+
111
+ /** Decode a JSON-pointer token: `kafka~1sasl` -> `kafka/sasl`. */
112
+ const unescapePointer = (token: string): string =>
113
+ token.replaceAll("~1", "/").replaceAll("~0", "~");
114
+
115
+ /**
116
+ * Resolve an AsyncAPI 3.x security list. Unlike OpenAPI's requirement maps,
117
+ * each entry names a single scheme and any one entry satisfies the operation
118
+ * — so every entry becomes its own one-scheme "or" alternative. A `$ref`
119
+ * pointing at an undeclared scheme is kept (with `scheme` undefined),
120
+ * matching {@link resolveSecurity}'s render-don't-drop rule.
121
+ */
122
+ export const resolveAsyncApiSecurity = (
123
+ entries: AsyncApiSecurityEntryLike[],
124
+ schemes: Record<string, SecuritySchemeLike> | undefined
125
+ ): OperationSecurity => {
126
+ const alternatives: ResolvedScheme[][] = [];
127
+ for (const entry of entries) {
128
+ if (!isSecurityEntryObject(entry)) {
129
+ continue;
130
+ }
131
+ const pointer = SECURITY_SCHEME_REF.exec(entry.$ref ?? "")?.groups?.name;
132
+ const name = pointer === undefined ? undefined : unescapePointer(pointer);
133
+ const inline = name === undefined && !isSpecString(entry.$ref);
134
+ const scheme = inline ? entry : schemes?.[name ?? ""];
135
+ alternatives.push([
136
+ {
137
+ key:
138
+ name ??
139
+ (isSpecString(entry.type) && entry.type !== ""
140
+ ? entry.type
141
+ : "security"),
142
+ scheme,
143
+ scopes:
144
+ inline && Array.isArray(entry.scopes)
145
+ ? entry.scopes.filter(
146
+ (scope): scope is string => typeof scope === "string"
147
+ )
148
+ : [],
149
+ },
150
+ ]);
151
+ }
152
+ return { alternatives, optional: false };
153
+ };
154
+
88
155
  const capitalize = (text: string): string =>
89
156
  text.charAt(0).toUpperCase() + text.slice(1);
90
157
 
158
+ /**
159
+ * Fixed labels for scheme types with no per-scheme variation. Covers both
160
+ * OpenAPI's types and the broker-auth types AsyncAPI adds; `http` is handled
161
+ * separately (its label depends on the scheme/bearerFormat fields).
162
+ */
163
+ const TYPE_LABELS = new Map<string, string>([
164
+ ["X509", "X.509 certificate"],
165
+ ["apiKey", "API key"],
166
+ ["asymmetricEncryption", "Asymmetric encryption"],
167
+ ["gssapi", "SASL/GSSAPI"],
168
+ ["httpApiKey", "API key"],
169
+ ["mutualTLS", "Mutual TLS"],
170
+ ["oauth2", "OAuth2 access token"],
171
+ ["openIdConnect", "OpenID Connect token"],
172
+ ["plain", "SASL/PLAIN"],
173
+ ["scramSha256", "SASL/SCRAM-SHA-256"],
174
+ ["scramSha512", "SASL/SCRAM-SHA-512"],
175
+ ["symmetricEncryption", "Symmetric encryption"],
176
+ ["userPassword", "Username & password"],
177
+ ]);
178
+
91
179
  /** A short human label for a scheme row, e.g. `Bearer token` or `API key`. */
92
180
  export const schemeLabel = (resolved: ResolvedScheme): string => {
93
181
  const { scheme } = resolved;
94
- switch (scheme?.type) {
95
- case "http": {
96
- const kind = (scheme.scheme ?? "").toLowerCase();
97
- if (kind === "bearer") {
98
- return scheme.bearerFormat
99
- ? `Bearer token (${scheme.bearerFormat})`
100
- : "Bearer token";
101
- }
102
- if (kind === "basic") {
103
- return "Basic auth";
104
- }
105
- return kind ? `HTTP ${kind}` : "HTTP auth";
106
- }
107
- case "apiKey": {
108
- return "API key";
182
+ if (scheme?.type === "http") {
183
+ const kind = (scheme.scheme ?? "").toLowerCase();
184
+ if (kind === "bearer") {
185
+ return scheme.bearerFormat
186
+ ? `Bearer token (${scheme.bearerFormat})`
187
+ : "Bearer token";
109
188
  }
110
- case "oauth2": {
111
- return "OAuth2 access token";
112
- }
113
- case "openIdConnect": {
114
- return "OpenID Connect token";
115
- }
116
- case "mutualTLS": {
117
- return "Mutual TLS";
118
- }
119
- default: {
120
- // Unknown scheme ref: the component name is the best label available.
121
- return resolved.key;
189
+ if (kind === "basic") {
190
+ return "Basic auth";
122
191
  }
192
+ return kind ? `HTTP ${kind}` : "HTTP auth";
123
193
  }
194
+ // Unknown scheme ref: the component name is the best label available.
195
+ return TYPE_LABELS.get(scheme?.type ?? "") ?? resolved.key;
124
196
  };
125
197
 
126
198
  /**
@@ -138,7 +210,8 @@ export const schemeCarrier = (
138
210
  case "openIdConnect": {
139
211
  return { in: "header", name: "Authorization" };
140
212
  }
141
- case "apiKey": {
213
+ case "apiKey":
214
+ case "httpApiKey": {
142
215
  return { in: scheme.in ?? "header", name: scheme.name ?? resolved.key };
143
216
  }
144
217
  default: {
@@ -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);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Coalesce a high-frequency event handler (resize, scroll) into at most one
3
+ * call per animation frame — the toc-element scroll pattern, shared. Calls
4
+ * landing while a frame is pending are dropped; the handler runs once on the
5
+ * next frame with the latest state. Layout reads inside `fn` then happen once
6
+ * per frame instead of once per event, without the settle lag a debounce
7
+ * would add to position-tracking handlers.
8
+ */
9
+ export const rafThrottle = (fn: () => void): (() => void) => {
10
+ let ticking = false;
11
+ return () => {
12
+ if (ticking) {
13
+ return;
14
+ }
15
+ ticking = true;
16
+ requestAnimationFrame(() => {
17
+ ticking = false;
18
+ fn();
19
+ });
20
+ };
21
+ };
@@ -0,0 +1,14 @@
1
+ import { slug } from "github-slugger";
2
+
3
+ /**
4
+ * Slug a component's title into a DOM id (accordions, tabs, changelog
5
+ * updates), replacing three identical per-component copies. github-slugger —
6
+ * already what heading anchors use, both here and in Satteri's own
7
+ * heading-ids — so a component id slugs exactly like a heading with the same
8
+ * text (unicode letters kept, `user_id` keeps its underscore, `C#` keeps
9
+ * nothing extra dropped). Stateless on purpose: components render across many
10
+ * pages in one build process, so a stateful slugger would leak duplicate
11
+ * suffixes between pages — same-page duplicates are de-duplicated client-side
12
+ * by each component's own script.
13
+ */
14
+ export const componentSlug = (value: string): string => slug(value);
@@ -12,6 +12,8 @@
12
12
  * `components/islands/base-path.ts` and serves `deployment.base` via `BASE_URL`.
13
13
  */
14
14
 
15
+ import { trimEnd } from "./trim.ts";
16
+
15
17
  /**
16
18
  * Canonicalize a configured base path to either `""` (none) or `/seg[/seg…]`
17
19
  * (leading slash, no trailing slash, collapsed inner slashes). A blank value or
@@ -33,10 +35,25 @@ export const normalizeBasePath = (input?: string): string => {
33
35
  * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
34
36
  */
35
37
  export const normalizePath = (path: string): string => {
36
- const trimmed = path.replace(/\/+$/u, "");
38
+ const trimmed = trimEnd(path, "/");
37
39
  return trimmed === "" ? "/" : trimmed;
38
40
  };
39
41
 
42
+ /**
43
+ * Canonicalize a route-ish string (a configured route, a page path, an agent-
44
+ * supplied route) to `/` or `/seg[/seg…]`: trimmed, exactly one leading slash,
45
+ * no trailing slash. The shared spelling of what openapi/references,
46
+ * ai/ask-context, and ai/mcp/server each hand-rolled with slightly different
47
+ * regexes.
48
+ */
49
+ export const normalizeRoute = (input: string): string => {
50
+ const trimmed = trimEnd(input.trim(), "/");
51
+ if (trimmed === "") {
52
+ return "/";
53
+ }
54
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
55
+ };
56
+
40
57
  /**
41
58
  * Whether a link target is a root-relative internal path (`/x`) — the only
42
59
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
@@ -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
  };
@@ -205,6 +205,8 @@ export interface SanitySource {
205
205
  /** A Notion database; pages become entries, blocks become MDX. */
206
206
  export interface NotionSource {
207
207
  type: "notion";
208
+ /** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */
209
+ concurrency?: number;
208
210
  /** Notion database id. */
209
211
  database: string;
210
212
  /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
@@ -653,6 +655,12 @@ export interface AskConfig {
653
655
  * limiting, and streaming. Accepts an absolute URL or root-relative path.
654
656
  */
655
657
  endpoint?: string;
658
+ /**
659
+ * Extra system-prompt text appended to the built-in instructions — use it
660
+ * for identity, language, or tone. The built-in grounding behavior (answer
661
+ * from the retrieved excerpts, cite pages as Markdown links) is preserved.
662
+ */
663
+ instructions?: string;
656
664
  /** Model id to use. Defaults to `openai/gpt-5.5`. */
657
665
  model?: string;
658
666
  /** Which backend routes the request. Defaults to `gateway`. */
@@ -753,6 +761,7 @@ export interface AiConfig {
753
761
  /** Web Bot Auth signature directory. Off until at least one key is listed. */
754
762
  export interface WebBotAuthConfig {
755
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.
756
765
  keys?: Record<string, unknown>[];
757
766
  }
758
767
 
@@ -830,6 +839,59 @@ export interface I18nConfig {
830
839
  ui?: Record<string, Record<string, Record<string, string>>>;
831
840
  }
832
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
+
833
895
  // ---------------------------------------------------------------------------
834
896
  // Deployment & redirects
835
897
  // ---------------------------------------------------------------------------
@@ -1104,28 +1166,24 @@ export interface ReactConfig {
1104
1166
  // ---------------------------------------------------------------------------
1105
1167
 
1106
1168
  /**
1107
- * OpenAPI reference. By default (`renderer: "blume"`) Blume renders its own UI:
1108
- * one real page per operation, grouped by tag in the sidebar and included in
1109
- * search, llms.txt, and OG. Set `renderer: "scalar"` for the embedded Scalar
1110
- * 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.
1111
1172
  */
1112
- export interface OpenApiConfig {
1113
- /** Code-sample languages shown per operation (Blume renderer). */
1114
- codeSamples?: string[];
1173
+ interface ReferenceConfig {
1115
1174
  /** Turn the reference on. Defaults to `false`. */
1116
1175
  enabled?: boolean;
1117
1176
  /** Start nested schema rows expanded (Blume renderer). Defaults to `false`. */
1118
1177
  expandSchemas?: boolean;
1119
1178
  /** Who renders the reference. Defaults to `blume`. */
1120
1179
  renderer?: "blume" | "scalar";
1121
- /** Where the reference mounts. Defaults to `/reference`. */
1122
- route?: string;
1123
1180
  /**
1124
1181
  * Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`
1125
1182
  * (Scalar renderer only) — e.g. `localization`, `agent`,
1126
1183
  * `hideTestRequestButton`, `orderSchemaPropertiesBy`. These win over Blume's
1127
1184
  * derived spec/theme config, so it's a full escape hatch to Scalar's API.
1128
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.
1129
1187
  scalar?: Record<string, unknown>;
1130
1188
  /** One or more specs; each renders on its own route by default. */
1131
1189
  sources?: OpenApiSource[];
@@ -1136,27 +1194,36 @@ export interface OpenApiConfig {
1136
1194
  }
1137
1195
 
1138
1196
  /**
1139
- * AsyncAPI reference, rendered via the embedded Scalar SPA (which auto-detects
1140
- * the document type). Same shape as {@link OpenApiConfig}; only the default
1141
- * `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).
1142
1201
  */
1143
- export interface AsyncApiConfig {
1144
- /** Turn the reference on. Defaults to `false`. */
1145
- enabled?: boolean;
1146
- /** 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`. */
1147
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 {
1148
1220
  /**
1149
- * Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`.
1150
- * These win over Blume's derived spec/theme config — a full escape hatch to
1151
- * Scalar's API.
1221
+ * Code-sample tools shown per operation (Blume renderer). Defaults to every
1222
+ * tool appropriate to the operation's protocol binding.
1152
1223
  */
1153
- scalar?: Record<string, unknown>;
1154
- /** One or more specs. */
1155
- sources?: OpenApiSource[];
1156
- /** Shorthand for a single source. */
1157
- spec?: string;
1158
- /** Scalar theme name. */
1159
- theme?: string;
1224
+ codeSamples?: string[];
1225
+ /** Where the reference mounts. Defaults to `/events`. */
1226
+ route?: string;
1160
1227
  }
1161
1228
 
1162
1229
  // ---------------------------------------------------------------------------
@@ -1294,7 +1361,7 @@ export interface BlumeConfig {
1294
1361
  ai?: AiConfig;
1295
1362
  /** Analytics providers (PostHog, Vercel, or arbitrary scripts). */
1296
1363
  analytics?: AnalyticsConfig;
1297
- /** AsyncAPI reference (embedded Scalar renderer). */
1364
+ /** AsyncAPI reference (native renderer by default, Scalar opt-out). */
1298
1365
  asyncapi?: AsyncApiConfig;
1299
1366
  /** Site-wide announcement banner shown above the header. */
1300
1367
  banner?: BannerConfig;
@@ -1367,6 +1434,8 @@ export interface BlumeConfig {
1367
1434
  title?: string;
1368
1435
  /** On-page table of contents. Defaults to on (H2–H3). */
1369
1436
  toc?: TocConfig;
1437
+ /** Docs versioning (opt-in frozen snapshots with a version switcher). */
1438
+ versions?: VersionsConfig;
1370
1439
  }
1371
1440
 
1372
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);