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
@@ -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`