blume 1.4.3 → 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 (194) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +1621 -576
  3. package/dist/cli/index.js.map +109 -104
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/config-input.d.ts +79 -27
  6. package/dist/types/core/config.d.ts +2 -1
  7. package/dist/types/core/data.d.ts +16 -1
  8. package/dist/types/core/diagnostics.d.ts +5 -1
  9. package/dist/types/core/i18n-ui.d.ts +12 -0
  10. package/dist/types/core/schema.d.ts +112 -15
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +7 -3
  13. package/dist/types/core/types.d.ts +43 -2
  14. package/dist/types/core/ui-packs/index.d.ts +9 -1
  15. package/dist/types/openapi/references.d.ts +6 -5
  16. package/dist/types/seo/x-handle.d.ts +3 -2
  17. package/docs/advanced/api-reference.mdx +8 -6
  18. package/docs/configuration/search.mdx +2 -0
  19. package/docs/configuration/seo.mdx +1 -1
  20. package/docs/content/i18n.mdx +1 -1
  21. package/docs/content/meta.mdx +2 -1
  22. package/docs/content/meta.ts +1 -0
  23. package/docs/content/navigation.mdx +35 -1
  24. package/docs/content/versioning.mdx +106 -0
  25. package/docs/reference/cli.mdx +1 -0
  26. package/docs/reference/frontmatter.mdx +3 -0
  27. package/package.json +3 -1
  28. package/skills/blume-migrate/SKILL.md +2 -2
  29. package/skills/blume-migrate/references/docusaurus.md +1 -1
  30. package/skills/blume-migrate/references/fumadocs.md +1 -1
  31. package/skills/blume-migrate/references/mintlify.md +1 -1
  32. package/src/ai/agent-readability.ts +37 -10
  33. package/src/ai/ask-context.ts +5 -1
  34. package/src/ai/ask.ts +10 -1
  35. package/src/ai/component-markdown.ts +80 -43
  36. package/src/ai/llms.ts +40 -16
  37. package/src/ai/mcp/data.ts +48 -12
  38. package/src/ai/mcp/discovery.ts +28 -11
  39. package/src/ai/mcp/server.ts +183 -38
  40. package/src/ai/mcp/tools.ts +3 -3
  41. package/src/ai/skills.ts +32 -9
  42. package/src/ai/visibility.ts +2 -2
  43. package/src/astro/component-slots.ts +2 -0
  44. package/src/astro/examples.ts +6 -2
  45. package/src/astro/generate.ts +54 -29
  46. package/src/astro/integration.ts +13 -2
  47. package/src/astro/islands.ts +16 -9
  48. package/src/astro/templates.ts +152 -33
  49. package/src/audit/agent.ts +2 -2
  50. package/src/audit/checks/content.ts +26 -11
  51. package/src/audit/checks/dns-aid.ts +3 -0
  52. package/src/audit/checks/indexability.ts +24 -6
  53. package/src/audit/checks/llms.ts +9 -4
  54. package/src/audit/checks/network.ts +2 -0
  55. package/src/audit/checks/social.ts +18 -10
  56. package/src/audit/crawl.ts +37 -9
  57. package/src/audit/report.ts +20 -19
  58. package/src/audit/run.ts +5 -2
  59. package/src/audit/snapshot.ts +2 -4
  60. package/src/audit/types.ts +25 -3
  61. package/src/blume-modules.d.ts +5 -1
  62. package/src/cli/commands/audit.ts +9 -4
  63. package/src/cli/commands/build.ts +15 -9
  64. package/src/cli/commands/dev.ts +2 -0
  65. package/src/cli/commands/doctor.ts +2 -0
  66. package/src/cli/commands/eval.ts +7 -3
  67. package/src/cli/commands/init.ts +9 -9
  68. package/src/cli/commands/mcp-stdio.ts +3 -0
  69. package/src/cli/commands/translate.ts +14 -3
  70. package/src/cli/commands/version.ts +85 -0
  71. package/src/cli/dev-lock.ts +31 -10
  72. package/src/cli/eject-scripts.ts +17 -2
  73. package/src/cli/index.ts +2 -0
  74. package/src/cli/init/questions.ts +1 -1
  75. package/src/cli/init/scaffold.ts +22 -15
  76. package/src/cli/internal-error.ts +1 -0
  77. package/src/components/content/auto-type-table.ts +3 -0
  78. package/src/components/content/diff.ts +9 -5
  79. package/src/components/content/github-info.ts +2 -0
  80. package/src/components/islands/ask-ai.tsx +33 -25
  81. package/src/components/islands/hooks.ts +5 -1
  82. package/src/components/islands/webmcp.ts +49 -12
  83. package/src/components/layout/Header.astro +25 -1
  84. package/src/components/layout/NavSelector.astro +11 -2
  85. package/src/components/layout/NavTree.astro +4 -2
  86. package/src/components/layout/RootLayout.astro +18 -0
  87. package/src/components/layout/Search.astro +77 -13
  88. package/src/components/layout/VersionBanner.astro +39 -0
  89. package/src/components/layout/analytics-client.ts +8 -5
  90. package/src/components/layout/hydration-hint.ts +1 -1
  91. package/src/components/layout/nav-utils.ts +1 -4
  92. package/src/components/layout/overrides.ts +25 -12
  93. package/src/components/layout/search/algolia.ts +18 -5
  94. package/src/components/layout/search/endpoint.ts +3 -0
  95. package/src/components/layout/search/flexsearch.ts +23 -7
  96. package/src/components/layout/search/orama-cloud.ts +1 -1
  97. package/src/components/layout/search/orama.ts +4 -1
  98. package/src/components/layout/search/pagefind.ts +2 -0
  99. package/src/components/layout/search/types.ts +13 -1
  100. package/src/components/layout/search/typesense.ts +19 -3
  101. package/src/components/openapi/ApiOverview.astro +32 -6
  102. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  103. package/src/components/openapi/Bindings.astro +89 -0
  104. package/src/components/openapi/MethodBadge.astro +3 -0
  105. package/src/components/openapi/Operation.astro +7 -2
  106. package/src/components/openapi/PanelTabs.astro +131 -0
  107. package/src/components/openapi/ParametersTable.astro +2 -0
  108. package/src/components/openapi/RequestPanel.astro +12 -119
  109. package/src/components/openapi/async-snippets.ts +174 -0
  110. package/src/components/openapi/async.ts +348 -0
  111. package/src/components/openapi/helpers.ts +52 -20
  112. package/src/components/openapi/security.ts +102 -29
  113. package/src/components/openapi/snippets.ts +11 -11
  114. package/src/core/component-overrides.ts +28 -23
  115. package/src/core/config-input.ts +88 -27
  116. package/src/core/config.ts +20 -7
  117. package/src/core/content.ts +3 -1
  118. package/src/core/data.ts +16 -1
  119. package/src/core/define-components.ts +5 -0
  120. package/src/core/diagnostics.ts +46 -38
  121. package/src/core/frontmatter.ts +33 -7
  122. package/src/core/graph.ts +137 -53
  123. package/src/core/i18n-ui.ts +15 -0
  124. package/src/core/i18n.ts +16 -8
  125. package/src/core/load-module.ts +1 -0
  126. package/src/core/manifest.ts +92 -3
  127. package/src/core/meta.ts +44 -14
  128. package/src/core/nav-diagnostics.ts +3 -3
  129. package/src/core/navigation.ts +247 -67
  130. package/src/core/project-graph.ts +15 -3
  131. package/src/core/schema.ts +213 -67
  132. package/src/core/sources/assets.ts +2 -0
  133. package/src/core/sources/cache.ts +6 -0
  134. package/src/core/sources/github-releases.ts +39 -31
  135. package/src/core/sources/mdx-remote.ts +4 -0
  136. package/src/core/sources/normalize.ts +67 -20
  137. package/src/core/sources/notion.ts +49 -17
  138. package/src/core/sources/portable-text.ts +32 -11
  139. package/src/core/sources/sanity.ts +68 -14
  140. package/src/core/sources/types.ts +4 -0
  141. package/src/core/sources/watch.ts +1 -1
  142. package/src/core/standard-schema.ts +9 -3
  143. package/src/core/text-width.ts +26 -0
  144. package/src/core/tsconfig-aliases.ts +9 -5
  145. package/src/core/types.ts +45 -2
  146. package/src/core/ui-packs/index.ts +9 -1
  147. package/src/core/version-cut.ts +301 -0
  148. package/src/core/version.ts +2 -0
  149. package/src/core/versions.ts +170 -0
  150. package/src/deploy/adapter-output.ts +5 -2
  151. package/src/deploy/cloudflare-negotiation.ts +25 -10
  152. package/src/deploy/sitemap.ts +33 -1
  153. package/src/deploy/vercel-negotiation.ts +11 -4
  154. package/src/eval/report.ts +4 -4
  155. package/src/eval/run.ts +2 -2
  156. package/src/eval/schema.ts +1 -1
  157. package/src/markdown/base-links.ts +6 -6
  158. package/src/markdown/directives.ts +7 -1
  159. package/src/markdown/heading-anchors.ts +17 -6
  160. package/src/markdown/index.ts +73 -24
  161. package/src/markdown/inline-code.ts +14 -2
  162. package/src/markdown/language-icon.ts +6 -2
  163. package/src/markdown/mdast.ts +18 -4
  164. package/src/markdown/package-commands.ts +6 -8
  165. package/src/markdown/table-wrap.ts +4 -1
  166. package/src/markdown/twoslash.ts +2 -0
  167. package/src/og/card.ts +30 -11
  168. package/src/og/derive.ts +43 -27
  169. package/src/openapi/asyncapi.ts +366 -0
  170. package/src/openapi/model.ts +126 -57
  171. package/src/openapi/parse.ts +97 -5
  172. package/src/openapi/references.ts +12 -10
  173. package/src/openapi/render-mdx.ts +73 -34
  174. package/src/openapi/scalar.ts +6 -8
  175. package/src/openapi/source.ts +98 -28
  176. package/src/registry/eject.ts +7 -2
  177. package/src/search/documents.ts +25 -5
  178. package/src/search/facets.ts +7 -5
  179. package/src/search/orama-index.ts +66 -20
  180. package/src/search/popular.ts +10 -5
  181. package/src/search/providers.ts +2 -2
  182. package/src/search/sync/index.ts +2 -0
  183. package/src/search/sync/typesense.ts +4 -2
  184. package/src/seo/jsonld.ts +24 -6
  185. package/src/seo/x-handle.ts +8 -3
  186. package/src/theme/chrome-icons.ts +7 -2
  187. package/src/theme/fonts.ts +8 -4
  188. package/src/theme/icons.ts +4 -2
  189. package/src/theme/palette.ts +22 -14
  190. package/src/translate/meta.ts +15 -6
  191. package/src/translate/report.ts +9 -5
  192. package/src/translate/run.ts +10 -4
  193. package/src/translate/validate.ts +52 -17
  194. package/src/translate/work-list.ts +0 -0
@@ -1,4 +1,5 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import type { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js";
2
3
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
4
  import {
4
5
  CallToolRequestSchema,
@@ -33,7 +34,7 @@ const MAX_SEARCH_LIMIT = 20;
33
34
  /** Excerpt length when a page has no description. */
34
35
  const EXCERPT_LENGTH = 200;
35
36
 
36
- const CORS_HEADERS: Record<string, string> = {
37
+ const CORS_HEADERS = {
37
38
  "Access-Control-Allow-Headers":
38
39
  "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
39
40
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -65,11 +66,15 @@ const contentTypesField = z.preprocess((value) => {
65
66
  * The optional facet filter `search_docs` and `list_pages` share. Only
66
67
  * string-valued entries survive; an empty `{}` means "no filter".
67
68
  */
69
+ /** Accepts any plain object, so the string-valued entries can be sifted out. */
70
+ const looseFacetObject = z.record(z.string(), z.unknown());
71
+
68
72
  const filtersField = z.preprocess((value) => {
69
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
73
+ const candidate = looseFacetObject.safeParse(value);
74
+ if (!candidate.success) {
70
75
  return;
71
76
  }
72
- const entries = Object.entries(value).filter(
77
+ const entries = Object.entries(candidate.data).filter(
73
78
  (entry): entry is [string, string] => typeof entry[1] === "string"
74
79
  );
75
80
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
@@ -78,7 +83,9 @@ const filtersField = z.preprocess((value) => {
78
83
  /** Clamped into range rather than rejected; non-numeric means the default. */
79
84
  const limitField = z.preprocess(
80
85
  (value) => {
81
- const num = typeof value === "number" ? value : Number(value);
86
+ // `Number` is the identity on numbers, so one conversion covers both the
87
+ // well-typed call and a numeric string.
88
+ const num = Number(value);
82
89
  return Number.isFinite(num)
83
90
  ? Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT)
84
91
  : undefined;
@@ -93,26 +100,55 @@ const limitField = z.preprocess(
93
100
 
94
101
  /** A required text field; a missing or non-string value coerces to "". */
95
102
  const textField = (description: string) =>
96
- z.preprocess(
97
- (value) => (typeof value === "string" ? value : ""),
98
- z.string().describe(description)
99
- );
103
+ z.preprocess((value) => {
104
+ const parsed = z.string().safeParse(value);
105
+ return parsed.success ? parsed.data : "";
106
+ }, z.string().describe(description));
107
+
108
+ /** An optional trimmed text field; blank or non-string means "absent". */
109
+ const optionalTextField = (description: string) =>
110
+ z.preprocess((value) => {
111
+ const parsed = z.string().safeParse(value);
112
+ const trimmed = parsed.success ? parsed.data.trim() : "";
113
+ return trimmed || undefined;
114
+ }, z.string().optional().describe(description));
115
+
116
+ /** The optional locale filter `search_docs` and `list_pages` share. */
117
+ const localeField = optionalTextField(
118
+ "Only include pages in this locale (e.g. `fr`). Omit for every language."
119
+ );
120
+
121
+ /** The optional docs-version scope `search_docs` and `list_pages` share. */
122
+ const versionField = optionalTextField(
123
+ 'Docs version to scope to on a versioned site: `"latest"` (the default — current docs only), `"all"` (every version), or an archived version id (e.g. `"v1.0"`). Ignored when the site is unversioned.'
124
+ );
100
125
 
101
126
  /** Every tool's input schema — the runtime parse and tools/list source. */
102
127
  const TOOL_INPUTS = {
103
- get_navigation: z.object({}),
128
+ get_navigation: z.object({
129
+ locale: optionalTextField(
130
+ "Locale whose navigation tree to return (defaults to the default locale)."
131
+ ),
132
+ version: optionalTextField(
133
+ "Archived version id whose tree to return (defaults to the current docs)."
134
+ ),
135
+ }),
104
136
  get_page: z.object({
105
137
  route: textField("The page route, e.g. `/guides/install`."),
106
138
  }),
107
139
  list_pages: z.object({
108
140
  contentTypes: contentTypesField,
109
141
  filters: filtersField,
142
+ locale: localeField,
143
+ version: versionField,
110
144
  }),
111
145
  search_docs: z.object({
112
146
  contentTypes: contentTypesField,
113
147
  filters: filtersField,
114
148
  limit: limitField,
149
+ locale: localeField,
115
150
  query: textField("The search query."),
151
+ version: versionField,
116
152
  }),
117
153
  };
118
154
 
@@ -122,7 +158,7 @@ const TOOL_INPUTS = {
122
158
  * runtime strips unknown keys rather than rejecting them, and the advertised
123
159
  * schema shouldn't promise stricter validation than the server performs.
124
160
  */
125
- const inputSchemaFor = (schema: z.ZodType): Record<string, unknown> => {
161
+ const inputSchemaFor = (schema: z.ZodType) => {
126
162
  const {
127
163
  $schema: _dialect,
128
164
  additionalProperties: _closed,
@@ -136,12 +172,37 @@ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
136
172
  annotations: tool.annotations,
137
173
  description: tool.description,
138
174
  inputSchema: inputSchemaFor(
175
+ // SAFETY: TOOL_INPUTS declares a schema for every MCP_TOOLS name; the two
176
+ // lists are maintained together so names and descriptions never drift.
139
177
  TOOL_INPUTS[tool.name as keyof typeof TOOL_INPUTS]
140
178
  ),
141
179
  name: tool.name,
142
180
  title: tool.title,
143
181
  }));
144
182
 
183
+ /** One `search_docs` result entry; `version` only appears on versioned sites. */
184
+ interface SearchHitPayload {
185
+ contentType: string | undefined;
186
+ excerpt: string;
187
+ facets: Record<string, string> | undefined;
188
+ route: string;
189
+ title: string;
190
+ url: string;
191
+ version?: string;
192
+ }
193
+
194
+ /** One `list_pages` entry; `version` only appears on versioned sites. */
195
+ interface PageListingPayload {
196
+ contentType: string;
197
+ description: string | undefined;
198
+ facets: Record<string, string> | undefined;
199
+ lastModified: string | null;
200
+ route: string;
201
+ title: string;
202
+ url: string;
203
+ version?: string;
204
+ }
205
+
145
206
  /** Whether a page's facet values satisfy every requested filter entry. */
146
207
  const matchesFacets = (
147
208
  facets: Record<string, string> | undefined,
@@ -149,6 +210,47 @@ const matchesFacets = (
149
210
  ): boolean =>
150
211
  Object.entries(filters).every(([key, value]) => facets?.[key] === value);
151
212
 
213
+ /**
214
+ * Resolve the `version` scope on a versioned site: `undefined` disables the
215
+ * filter (`"all"`), `""` is the current docs (the default — agents almost
216
+ * always want the live documentation), and anything else is an archived id
217
+ * (an unknown id simply matches nothing). On an unversioned site the input is
218
+ * ignored entirely. The input arrives pre-trimmed (blank coerced to absent)
219
+ * from the tool's input schema.
220
+ */
221
+ const asVersionScope = (
222
+ value: string | undefined,
223
+ data: McpData
224
+ ): string | undefined => {
225
+ if (!data.archivedVersions) {
226
+ return;
227
+ }
228
+ if (value === "all") {
229
+ return;
230
+ }
231
+ if (value === undefined || value === "latest" || value === "current") {
232
+ return "";
233
+ }
234
+ return value;
235
+ };
236
+
237
+ /**
238
+ * Error message for a `get_navigation` version id that isn't a configured
239
+ * archived version, or `null` when the id is valid (or the site is
240
+ * unversioned, where the id is ignored like the other tools' scopes). Unlike
241
+ * `asVersionScope`'s match-nothing filters, a bad id here would otherwise
242
+ * silently return the *current* tree posing as the requested snapshot.
243
+ */
244
+ const unknownVersionError = (
245
+ versionId: string | undefined,
246
+ data: McpData
247
+ ): string | null =>
248
+ versionId &&
249
+ data.archivedVersions &&
250
+ !data.archivedVersions.includes(versionId)
251
+ ? `Unknown version "${versionId}". Archived versions: ${data.archivedVersions.join(", ")}.`
252
+ : null;
253
+
152
254
  /**
153
255
  * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
154
256
  * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
@@ -196,10 +298,11 @@ const excerptFor = (doc: OramaDoc): string => {
196
298
  return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
197
299
  };
198
300
 
199
- const text = (value: string, isError = false) => ({
200
- content: [{ text: value, type: "text" as const }],
201
- ...(isError ? { isError: true } : {}),
202
- });
301
+ /** A tool call's text result, marked as an error when `isError` is set. */
302
+ const text = (value: string, isError = false) => {
303
+ const content = [{ text: value, type: "text" as const }];
304
+ return isError ? { content, isError: true } : { content };
305
+ };
203
306
 
204
307
  /** Lazily builds the Orama index over a snapshot's documents, once. */
205
308
  export type OramaIndexProvider = () => Promise<
@@ -227,12 +330,12 @@ export const buildServer = (
227
330
  data: McpData,
228
331
  index: OramaIndexProvider
229
332
  ): Server => {
333
+ const serverOptions: ServerOptions = data.instructions
334
+ ? { capabilities: { tools: {} }, instructions: data.instructions }
335
+ : { capabilities: { tools: {} } };
230
336
  const server = new Server(
231
337
  { name: data.name, version: data.version },
232
- {
233
- capabilities: { tools: {} },
234
- ...(data.instructions ? { instructions: data.instructions } : {}),
235
- }
338
+ serverOptions
236
339
  );
237
340
 
238
341
  server.setRequestHandler(ListToolsRequestSchema, () => ({
@@ -252,18 +355,26 @@ export const buildServer = (
252
355
  {
253
356
  contentTypes: input.contentTypes,
254
357
  facets: input.filters,
358
+ locale: input.locale,
359
+ version: asVersionScope(input.version, data),
255
360
  }
256
361
  );
257
362
  // `route` is the key `get_page` takes (the tool descriptions promise
258
363
  // it); `url` is where the page is served.
259
- const results = hits.map((doc: OramaDoc) => ({
260
- contentType: doc.contentType,
261
- excerpt: excerptFor(doc),
262
- facets: doc.facets,
263
- route: doc.route,
264
- title: doc.title,
265
- url: urlFor(doc.route, data),
266
- }));
364
+ const results = hits.map((doc: OramaDoc) => {
365
+ const hit: SearchHitPayload = {
366
+ contentType: doc.contentType,
367
+ excerpt: excerptFor(doc),
368
+ facets: doc.facets,
369
+ route: doc.route,
370
+ title: doc.title,
371
+ url: urlFor(doc.route, data),
372
+ };
373
+ if (data.archivedVersions) {
374
+ hit.version = doc.version ?? "";
375
+ }
376
+ return hit;
377
+ });
267
378
  return text(JSON.stringify(results, null, 2));
268
379
  }
269
380
 
@@ -281,23 +392,33 @@ export const buildServer = (
281
392
  }
282
393
 
283
394
  if (name === "list_pages") {
284
- const { contentTypes, filters } = TOOL_INPUTS.list_pages.parse(args);
395
+ const input = TOOL_INPUTS.list_pages.parse(args);
396
+ const { contentTypes, filters, locale } = input;
397
+ const versionScope = asVersionScope(input.version, data);
285
398
  const routes = data.routes.filter(
286
399
  (route) =>
287
400
  (!contentTypes || contentTypes.includes(route.contentType)) &&
288
- (!filters || matchesFacets(route.facets, filters))
401
+ (!filters || matchesFacets(route.facets, filters)) &&
402
+ (!locale || route.locale === locale) &&
403
+ (versionScope === undefined || route.version === versionScope)
289
404
  );
290
405
  return text(
291
406
  JSON.stringify(
292
- routes.map((route) => ({
293
- contentType: route.contentType,
294
- description: route.description,
295
- facets: route.facets,
296
- lastModified: route.lastModified,
297
- route: route.route,
298
- title: route.title,
299
- url: urlFor(route.route, data),
300
- })),
407
+ routes.map((route) => {
408
+ const listing: PageListingPayload = {
409
+ contentType: route.contentType,
410
+ description: route.description,
411
+ facets: route.facets,
412
+ lastModified: route.lastModified,
413
+ route: route.route,
414
+ title: route.title,
415
+ url: urlFor(route.route, data),
416
+ };
417
+ if (data.archivedVersions) {
418
+ listing.version = route.version;
419
+ }
420
+ return listing;
421
+ }),
301
422
  null,
302
423
  2
303
424
  )
@@ -305,7 +426,31 @@ export const buildServer = (
305
426
  }
306
427
 
307
428
  if (name === "get_navigation") {
308
- return text(JSON.stringify(data.navigation, null, 2));
429
+ // A version id selects the snapshot's tree; a locale selects its
430
+ // language (falling back through the default locale to any tree the
431
+ // snapshot has). Without a version, a locale selects the current docs'
432
+ // localized tree. An unknown id on a versioned site is an error — the
433
+ // current tree would silently masquerade as the requested snapshot.
434
+ const { locale, version: versionId } =
435
+ TOOL_INPUTS.get_navigation.parse(args);
436
+ const unknownVersion = unknownVersionError(versionId, data);
437
+ if (unknownVersion) {
438
+ return text(unknownVersion, true);
439
+ }
440
+ let { navigation } = data;
441
+ const byLocale = versionId
442
+ ? data.navigationByVersion?.[versionId]
443
+ : undefined;
444
+ if (byLocale) {
445
+ navigation =
446
+ (locale ? byLocale[locale] : undefined) ??
447
+ byLocale[data.defaultLocale ?? ""] ??
448
+ Object.values(byLocale)[0] ??
449
+ navigation;
450
+ } else if (locale && data.navigationByLocale?.[locale]) {
451
+ navigation = data.navigationByLocale[locale];
452
+ }
453
+ return text(JSON.stringify(navigation, null, 2));
309
454
  }
310
455
 
311
456
  return text(`Unknown tool: ${name}`, true);
@@ -19,7 +19,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
19
19
  {
20
20
  annotations: READ_ONLY,
21
21
  description:
22
- 'Full-text search across the documentation. Returns matching pages with their title, route, content type, and a short excerpt; pass `contentTypes` to search only pages of certain types (e.g. `rfc`, `changelog`), and `filters` to require facet values the site declares per type (e.g. `{"status": "enforced"}`). Use this first to discover relevant pages, then `get_page` to read one in full.',
22
+ 'Full-text search across the documentation. Returns matching pages with their title, route, content type, and a short excerpt; pass `contentTypes` to search only pages of certain types (e.g. `rfc`, `changelog`), `filters` to require facet values the site declares per type (e.g. `{"status": "enforced"}`), and `locale` to search one language. On a versioned site results default to the current docs — pass `version` to search an archived version (e.g. `"v1.0"`) or `"all"` for every version. Use this first to discover relevant pages, then `get_page` to read one in full.',
23
23
  name: "search_docs",
24
24
  title: "Search documentation",
25
25
  },
@@ -33,14 +33,14 @@ export const MCP_TOOLS: McpToolMeta[] = [
33
33
  {
34
34
  annotations: READ_ONLY,
35
35
  description:
36
- "List every documentation page with its route, title, description, content type, and any declared facet values; pass `contentTypes` and/or `filters` to narrow the list. Useful for enumerating the docs, discovering the types and facets in use, or finding a page when search is too narrow.",
36
+ 'List every documentation page with its route, title, description, content type, and any declared facet values; pass `contentTypes`, `filters`, and/or `locale` to narrow the list. On a versioned site the list defaults to the current docs — pass `version` for an archived version or `"all"`. Useful for enumerating the docs, discovering the types and facets in use, or finding a page when search is too narrow.',
37
37
  name: "list_pages",
38
38
  title: "List pages",
39
39
  },
40
40
  {
41
41
  annotations: READ_ONLY,
42
42
  description:
43
- "Return the documentation navigation tree (header tabs and the sidebar hierarchy), reflecting how the docs are organized for readers.",
43
+ "Return the documentation navigation tree (header tabs and the sidebar hierarchy), reflecting how the docs are organized for readers. Pass `locale` for a language's tree and, on a versioned site, `version` for an archived snapshot's tree.",
44
44
  name: "get_navigation",
45
45
  title: "Get navigation",
46
46
  },
package/src/ai/skills.ts CHANGED
@@ -89,23 +89,46 @@ const collectEntries = async (
89
89
  return entries;
90
90
  };
91
91
 
92
+ /** The two SKILL.md frontmatter fields the discovery index publishes. */
93
+ interface SkillMeta {
94
+ description: string;
95
+ name: string;
96
+ }
97
+
98
+ interface SkillMetaResult {
99
+ meta: SkillMeta | null;
100
+ warning?: string;
101
+ }
102
+
103
+ /**
104
+ * What js-yaml can put in a SKILL.md frontmatter field. Rich scalars (Dates)
105
+ * ride along as the object arm; only strings are accepted below anyway.
106
+ */
107
+ type FrontmatterField =
108
+ | string
109
+ | number
110
+ | boolean
111
+ | null
112
+ | undefined
113
+ | FrontmatterField[]
114
+ | { [key: string]: FrontmatterField };
115
+
116
+ const isString = (value: FrontmatterField): value is string =>
117
+ typeof value === "string";
118
+
92
119
  /** Frontmatter of a SKILL.md, or null with a warning when unusable. */
93
- const skillMeta = (
94
- raw: string,
95
- dirName: string
96
- ): { meta: { description: string; name: string } | null; warning?: string } => {
97
- let data: Record<string, unknown>;
120
+ const skillMeta = (raw: string, dirName: string): SkillMetaResult => {
121
+ let data: { description?: FrontmatterField; name?: FrontmatterField };
98
122
  try {
99
- ({ data } = matter(raw) as unknown as { data: Record<string, unknown> });
123
+ ({ data } = matter(raw));
100
124
  } catch {
101
125
  return {
102
126
  meta: null,
103
127
  warning: `Skill "${dirName}" has unparsable SKILL.md frontmatter; skipped.`,
104
128
  };
105
129
  }
106
- const name = typeof data.name === "string" ? data.name : "";
107
- const description =
108
- typeof data.description === "string" ? data.description : "";
130
+ const name = isString(data.name) ? data.name : "";
131
+ const description = isString(data.description) ? data.description : "";
109
132
  if (!(name && description)) {
110
133
  return {
111
134
  meta: null,
@@ -19,10 +19,10 @@ const visibilityBlock = (audience: VisibilityAudience): RegExp =>
19
19
  "gu"
20
20
  );
21
21
 
22
- const BLOCKS: Record<VisibilityAudience, RegExp> = {
22
+ const BLOCKS = {
23
23
  agents: visibilityBlock("agents"),
24
24
  web: visibilityBlock("web"),
25
- };
25
+ } satisfies Record<VisibilityAudience, RegExp>;
26
26
 
27
27
  /**
28
28
  * Resolve `<Visibility>` blocks for one audience: blocks addressed to the
@@ -77,6 +77,8 @@ const importClause = (variable: string, name: string, path: string): string =>
77
77
 
78
78
  /** A wrapper `.astro` that statically imports a component and hydrates it. */
79
79
  const wrapperContent = (override: NormalizedOverride): string => {
80
+ // SAFETY: the only caller guards `if (!source)` and bails before invoking
81
+ // this, so the override always carries a resolved source here.
80
82
  const { name, path } = override.source as NonNullable<
81
83
  NormalizedOverride["source"]
82
84
  >;
@@ -33,7 +33,11 @@ export interface ExampleDiscovery {
33
33
  }
34
34
 
35
35
  /** Example extensions mapped to the framework that renders them. */
36
- const FRAMEWORK_BY_EXT: Record<string, ExampleFramework> = {
36
+ interface FrameworkByExtension {
37
+ [extension: string]: ExampleFramework;
38
+ }
39
+
40
+ const FRAMEWORK_BY_EXT: FrameworkByExtension = {
37
41
  astro: "astro",
38
42
  jsx: "react",
39
43
  svelte: "svelte",
@@ -62,7 +66,7 @@ const GLOB_MAGIC = /[!*?[\]{}]/u;
62
66
  * discovered files can be keyed relative to that prefix (e.g.
63
67
  * `registry/x/**\/examples/*` → `{ base: "registry/x", rest: "**\/examples/*" }`).
64
68
  */
65
- const splitGlobBase = (pattern: string): { base: string; rest: string } => {
69
+ const splitGlobBase = (pattern: string) => {
66
70
  const segments = pattern.split("/");
67
71
  // Only called when the pattern contains glob magic (see the caller), and `/`
68
72
  // is never magic, so the magic char always lands in a segment — `findIndex`
@@ -489,12 +489,14 @@ export const ensureDepsLink = async (
489
489
  * (`<build.server>/.prerender/`) output — so it fires for exactly that build.
490
490
  * Inert in dev, where there is no build and `writeBundle` never runs.
491
491
  */
492
- export const prerenderDepsPlugin = (
493
- pkgDir: string = packageRoot()
494
- ): {
492
+ export interface PrerenderDepsPlugin {
495
493
  name: string;
496
494
  writeBundle: (options: { dir?: string }) => Promise<void>;
497
- } => ({
495
+ }
496
+
497
+ export const prerenderDepsPlugin = (
498
+ pkgDir: string = packageRoot()
499
+ ): PrerenderDepsPlugin => ({
498
500
  name: "blume:prerender-deps",
499
501
  writeBundle: async (options) => {
500
502
  if (!options.dir || basename(options.dir) !== ".prerender") {
@@ -528,14 +530,16 @@ interface ServerAppResolveContext {
528
530
  * Stripping the spurious `.js` and delegating back to Astro's resolver lets the
529
531
  * reload complete cleanly, so the renamed route resolves without a restart.
530
532
  */
531
- export const serverAppResolvePlugin = (): {
533
+ export interface ServerAppResolvePlugin {
532
534
  enforce: "pre";
533
535
  name: string;
534
536
  resolveId: (
535
537
  this: ServerAppResolveContext,
536
538
  id: string
537
539
  ) => Promise<string | null>;
538
- } => ({
540
+ }
541
+
542
+ export const serverAppResolvePlugin = (): ServerAppResolvePlugin => ({
539
543
  enforce: "pre",
540
544
  name: "blume:server-app-resolve",
541
545
  async resolveId(id) {
@@ -548,20 +552,20 @@ export const serverAppResolvePlugin = (): {
548
552
  });
549
553
 
550
554
  /** Astro integration package each non-React island framework needs installed. */
551
- const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
552
- svelte: "@astrojs/svelte",
553
- vue: "@astrojs/vue",
554
- };
555
+ const ISLAND_FRAMEWORK_DEPS = new Map([
556
+ ["svelte", "@astrojs/svelte"],
557
+ ["vue", "@astrojs/vue"],
558
+ ]);
555
559
 
556
560
  /**
557
561
  * Adapter package the project must install itself for each deployment
558
562
  * platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
559
563
  * so they never need this.
560
564
  */
561
- const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
562
- cloudflare: "@astrojs/cloudflare",
563
- netlify: "@astrojs/netlify",
564
- };
565
+ const DEPLOYMENT_ADAPTER_DEPS = new Map([
566
+ ["cloudflare", "@astrojs/cloudflare"],
567
+ ["netlify", "@astrojs/netlify"],
568
+ ]);
565
569
 
566
570
  /**
567
571
  * Warn when a Vue/Svelte island is present but its Astro integration isn't
@@ -574,7 +578,7 @@ const islandFrameworkWarnings = (
574
578
  ): string[] => {
575
579
  const warnings: string[] = [];
576
580
  for (const framework of frameworks) {
577
- const dep = ISLAND_FRAMEWORK_DEPS[framework];
581
+ const dep = ISLAND_FRAMEWORK_DEPS.get(framework);
578
582
  if (dep && !canResolveFrom(root, dep)) {
579
583
  warnings.push(
580
584
  `Islands use ${framework}, which needs "${dep}". Install it (e.g. \`npm install ${dep} ${framework}\`).`
@@ -599,7 +603,7 @@ const deploymentAdapterWarnings = (
599
603
  ): string[] => {
600
604
  const dep =
601
605
  deployment.output === "server" && deployment.adapter
602
- ? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
606
+ ? DEPLOYMENT_ADAPTER_DEPS.get(deployment.adapter)
603
607
  : undefined;
604
608
  if (
605
609
  dep &&
@@ -906,6 +910,10 @@ const readLogoSvg = (
906
910
  return file ? readFileSync(file, "utf-8") : undefined;
907
911
  };
908
912
 
913
+ /** Narrows a config union's string shorthand from its object form. */
914
+ const isStringShorthand = <T>(value: T | string): value is string =>
915
+ typeof value === "string";
916
+
909
917
  /**
910
918
  * Resolve the configured logo. A single SVG is read and inlined so a
911
919
  * `currentColor` logo follows the theme; other images keep their URL for an
@@ -916,12 +924,12 @@ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
916
924
  if (!logo) {
917
925
  return null;
918
926
  }
919
- const config = typeof logo === "string" ? { image: logo } : logo;
927
+ const config = isStringShorthand(logo) ? { image: logo } : logo;
920
928
  // `text` is passed through verbatim: `undefined` lets the brand fall back to
921
929
  // the site title, `""` renders the mark alone (a logo with the wordmark baked
922
930
  // in).
923
931
  const { href, image: source, text } = config;
924
- const image = typeof source === "string" ? { light: source } : source;
932
+ const image = isStringShorthand(source) ? { light: source } : source;
925
933
  const light = image?.light ?? image?.dark;
926
934
  const dark = image?.dark ?? image?.light;
927
935
  const alt = image?.alt ?? "";
@@ -957,18 +965,18 @@ const FAVICON_CANDIDATES = [
957
965
  ];
958
966
 
959
967
  /** `<link type>` MIME for the favicon extensions we recognize. */
960
- const FAVICON_TYPES: Record<string, string> = {
961
- ico: "image/x-icon",
962
- jpeg: "image/jpeg",
963
- jpg: "image/jpeg",
964
- png: "image/png",
965
- svg: "image/svg+xml",
966
- };
968
+ const FAVICON_TYPES = new Map([
969
+ ["ico", "image/x-icon"],
970
+ ["jpeg", "image/jpeg"],
971
+ ["jpg", "image/jpeg"],
972
+ ["png", "image/png"],
973
+ ["svg", "image/svg+xml"],
974
+ ]);
967
975
 
968
976
  /** Infer the `<link type>` MIME from a filename, when we recognize the extension. */
969
977
  const faviconType = (name: string): string | undefined => {
970
978
  const ext = name.split(".").pop()?.toLowerCase();
971
- return ext ? FAVICON_TYPES[ext] : undefined;
979
+ return ext ? FAVICON_TYPES.get(ext) : undefined;
972
980
  };
973
981
 
974
982
  /** Read a file and encode it as a `data:` URI of the given MIME type. */
@@ -1041,7 +1049,7 @@ const resolveBanner = (config: ResolvedConfig): BlumeBanner | null => {
1041
1049
  if (!banner) {
1042
1050
  return null;
1043
1051
  }
1044
- if (typeof banner === "string") {
1052
+ if (isStringShorthand(banner)) {
1045
1053
  return { content: banner, dismissible: false, key: banner };
1046
1054
  }
1047
1055
  return {
@@ -1234,6 +1242,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1234
1242
  theme: config.theme,
1235
1243
  title: config.title,
1236
1244
  toc: config.toc,
1245
+ versions: config.versions ?? null,
1237
1246
  webmcp: {
1238
1247
  enabled: config.ai.webmcp,
1239
1248
  llms: config.ai.llmsTxt.enabled,
@@ -1250,6 +1259,19 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1250
1259
  navigation: withRepoUrl(graph.navigation),
1251
1260
  // Per-locale navigation; the catch-all selects the active locale's tree.
1252
1261
  navigationByLocale,
1262
+ // Per-archived-version navigation; the catch-all selects by the route's
1263
+ // version, then locale.
1264
+ navigationByVersion: Object.fromEntries(
1265
+ Object.entries(graph.navigationByVersion).map(([id, byLocale]) => [
1266
+ id,
1267
+ Object.fromEntries(
1268
+ Object.entries(byLocale).map(([code, nav]) => [
1269
+ code,
1270
+ withRepoUrl(nav),
1271
+ ])
1272
+ ),
1273
+ ])
1274
+ ),
1253
1275
  routes: manifest.routes.map((route) => ({
1254
1276
  alternates: route.alternates,
1255
1277
  collection: route.collection,
@@ -1264,6 +1286,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1264
1286
  locale: route.locale,
1265
1287
  path: route.path,
1266
1288
  title: route.title,
1289
+ version: route.version,
1290
+ versionAlternates: route.versionAlternates,
1267
1291
  })),
1268
1292
  // Default-locale chrome strings (English baseline when not under i18n).
1269
1293
  ui: defaultUi,
@@ -1861,8 +1885,9 @@ export const generateRuntime = async (
1861
1885
  ]);
1862
1886
  }
1863
1887
 
1864
- // API/AsyncAPI reference pages (Scalar). One self-contained page per source,
1865
- // mounted on its configured route and regenerated each run.
1888
+ // Scalar-rendered API/AsyncAPI reference pages (`renderer: "scalar"`). One
1889
+ // self-contained page per source, mounted on its configured route and
1890
+ // regenerated each run.
1866
1891
  const warnings: string[] = [
1867
1892
  ...(depsLinkWarning ? [depsLinkWarning] : []),
1868
1893
  ...reactCompilerWarnings(config, needsReact, reactCompilerPath),