blume 1.6.0 → 1.6.2

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 (97) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +1318 -270
  3. package/dist/cli/index.js.map +68 -61
  4. package/dist/types/core/config-input.d.ts +9 -0
  5. package/dist/types/core/data.d.ts +12 -1
  6. package/dist/types/core/i18n-ui.d.ts +4 -0
  7. package/dist/types/core/schema.d.ts +7 -0
  8. package/dist/types/core/types.d.ts +6 -0
  9. package/dist/types/openapi/references.d.ts +5 -0
  10. package/docs/07-faq.mdx +9 -9
  11. package/docs/advanced/api-reference.mdx +10 -1
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/graphql.mdx +1 -1
  14. package/docs/configuration/ai.mdx +76 -7
  15. package/docs/configuration/seo.mdx +3 -3
  16. package/docs/configuration/theming.mdx +6 -0
  17. package/docs/content/components.mdx +8 -1
  18. package/docs/index.mdx +2 -2
  19. package/package.json +53 -53
  20. package/skills/blume/SKILL.md +2 -2
  21. package/src/ai/agent-readability.ts +60 -17
  22. package/src/ai/api/handlers.ts +273 -0
  23. package/src/ai/api/paths.ts +14 -0
  24. package/src/ai/api/problem.ts +63 -0
  25. package/src/ai/api/spec.ts +681 -0
  26. package/src/ai/api-catalog.ts +11 -1
  27. package/src/ai/link-headers.ts +12 -3
  28. package/src/ai/llms.ts +9 -2
  29. package/src/ai/mcp/query.ts +390 -0
  30. package/src/ai/mcp/server.ts +32 -352
  31. package/src/astro/examples.ts +29 -2
  32. package/src/astro/generate.ts +256 -64
  33. package/src/astro/index.ts +7 -0
  34. package/src/astro/markdown-negotiation.ts +1 -1
  35. package/src/astro/runtime-modules.ts +196 -0
  36. package/src/astro/templates.ts +398 -38
  37. package/src/cli/commands/build.ts +9 -1
  38. package/src/cli/commands/dev.ts +6 -3
  39. package/src/cli/host-args.ts +18 -0
  40. package/src/cli/index.ts +2 -1
  41. package/src/components/copy-feedback.ts +93 -9
  42. package/src/components/islands/ask-ai.tsx +4 -1
  43. package/src/components/islands/hooks.ts +3 -1
  44. package/src/components/layout/PageActions.astro +25 -14
  45. package/src/core/config-input.ts +9 -0
  46. package/src/core/data.ts +17 -2
  47. package/src/core/define-components.ts +2 -0
  48. package/src/core/i18n-ui.ts +3 -0
  49. package/src/core/includes.ts +2 -1
  50. package/src/core/manifest.ts +10 -0
  51. package/src/core/schema.ts +20 -5
  52. package/src/core/types.ts +6 -0
  53. package/src/core/ui-packs/ar.ts +1 -0
  54. package/src/core/ui-packs/bg.ts +1 -0
  55. package/src/core/ui-packs/bn.ts +1 -0
  56. package/src/core/ui-packs/ca.ts +1 -0
  57. package/src/core/ui-packs/cs.ts +1 -0
  58. package/src/core/ui-packs/da.ts +1 -0
  59. package/src/core/ui-packs/de.ts +1 -0
  60. package/src/core/ui-packs/el.ts +1 -0
  61. package/src/core/ui-packs/es.ts +1 -0
  62. package/src/core/ui-packs/fa.ts +1 -0
  63. package/src/core/ui-packs/fi.ts +1 -0
  64. package/src/core/ui-packs/fr.ts +1 -0
  65. package/src/core/ui-packs/he.ts +1 -0
  66. package/src/core/ui-packs/hi.ts +1 -0
  67. package/src/core/ui-packs/hr.ts +1 -0
  68. package/src/core/ui-packs/hu.ts +1 -0
  69. package/src/core/ui-packs/id.ts +1 -0
  70. package/src/core/ui-packs/it.ts +1 -0
  71. package/src/core/ui-packs/ja.ts +1 -0
  72. package/src/core/ui-packs/ko.ts +1 -0
  73. package/src/core/ui-packs/nl.ts +1 -0
  74. package/src/core/ui-packs/no.ts +1 -0
  75. package/src/core/ui-packs/pl.ts +1 -0
  76. package/src/core/ui-packs/pt-br.ts +1 -0
  77. package/src/core/ui-packs/pt.ts +1 -0
  78. package/src/core/ui-packs/ro.ts +1 -0
  79. package/src/core/ui-packs/ru.ts +1 -0
  80. package/src/core/ui-packs/sk.ts +1 -0
  81. package/src/core/ui-packs/sr.ts +1 -0
  82. package/src/core/ui-packs/sv.ts +1 -0
  83. package/src/core/ui-packs/th.ts +1 -0
  84. package/src/core/ui-packs/tr.ts +1 -0
  85. package/src/core/ui-packs/uk.ts +1 -0
  86. package/src/core/ui-packs/vi.ts +1 -0
  87. package/src/core/ui-packs/zh-tw.ts +1 -0
  88. package/src/core/ui-packs/zh.ts +1 -0
  89. package/src/core/version-cut.ts +5 -3
  90. package/src/deploy/vercel-negotiation.ts +97 -6
  91. package/src/og/card.ts +1 -1
  92. package/src/openapi/references.ts +8 -0
  93. package/src/openapi/render-mdx.ts +18 -4
  94. package/src/openapi/scalar.ts +0 -4
  95. package/src/registry/eject.ts +36 -17
  96. package/src/theme/entry.ts +2 -2
  97. package/src/theme/sources.ts +49 -0
@@ -2,6 +2,7 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
3
  import { absoluteUrl } from "../core/site-url.ts";
4
4
  import { resolveReferences } from "../openapi/references.ts";
5
+ import { API_BASE, OPENAPI_PATH } from "./api/paths.ts";
5
6
 
6
7
  /**
7
8
  * RFC 9727 API catalog: a linkset (RFC 9264) at `/.well-known/api-catalog`
@@ -11,7 +12,8 @@ import { resolveReferences } from "../openapi/references.ts";
11
12
  * generated, never hand-written: one entry per API, anchored at its docs
12
13
  * route (the RFC's own examples anchor on developer-portal pages), with
13
14
  * `service-doc` pointing at the rendered reference and `service-desc` at the
14
- * spec when it lives at a fetchable URL.
15
+ * spec when it lives at a fetchable URL. The site's own JSON docs API is an
16
+ * entry too, described by its generated `/openapi.json`.
15
17
  */
16
18
 
17
19
  export const API_CATALOG_PATH = "/.well-known/api-catalog";
@@ -55,6 +57,14 @@ const linksetEntries = (config: ResolvedConfig): LinksetEntry[] => {
55
57
  entries.push(entry);
56
58
  }
57
59
 
60
+ if (config.ai.api) {
61
+ entries.push({
62
+ anchor: abs(API_BASE),
63
+ "service-desc": [{ href: abs(OPENAPI_PATH), type: "application/json" }],
64
+ "service-doc": [{ href: abs("/"), type: "text/html" }],
65
+ });
66
+ }
67
+
58
68
  if (config.ai.mcp.enabled) {
59
69
  entries.push({
60
70
  anchor: abs(config.ai.mcp.route),
@@ -1,15 +1,17 @@
1
1
  import { normalizeBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
3
  import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
4
+ import { OPENAPI_PATH } from "./api/paths.ts";
4
5
 
5
6
  /**
6
7
  * The homepage `Link` response header (RFC 8288) — agent discovery for the
7
8
  * machine-readable surface Blume already publishes. Agents probing a site read
8
9
  * this header off `GET /` to find the resources without scraping HTML:
9
- * `agent-readability.json` and `llms.txt` as `rel="describedby"`, and the
10
+ * `agent-readability.json` and `llms.txt` as `rel="describedby"`, the OpenAPI
11
+ * description of the JSON docs API as `rel="service-desc"`, and the
10
12
  * homepage's raw-Markdown mirror as `rel="alternate"` (only when the home
11
- * route is a content page — a user landing page has no mirror). Both rel
12
- * values are IANA-registered, which agent-readiness checkers require.
13
+ * route is a content page — a user landing page has no mirror). Every rel
14
+ * value is IANA-registered, which agent-readiness checkers require.
13
15
  *
14
16
  * The header is homepage-only by design: the root response is what agents
15
17
  * probe, and `agent-readability.json` indexes the rest of the surface (the
@@ -35,6 +37,13 @@ export const buildHomeLinkHeader = (
35
37
  `<${deployBase}${API_CATALOG_PATH}>; rel="api-catalog"; type="application/linkset+json"`
36
38
  );
37
39
  }
40
+ // RFC 8631: `service-desc` is the relation for a machine-readable
41
+ // description of the service — the JSON docs API's OpenAPI document.
42
+ if (config.ai.api) {
43
+ links.push(
44
+ `<${deployBase}${OPENAPI_PATH}>; rel="service-desc"; type="application/json"`
45
+ );
46
+ }
38
47
  if (config.seo.agentReadability) {
39
48
  links.push(
40
49
  `<${deployBase}/agent-readability.json>; rel="describedby"; type="application/json"`
package/src/ai/llms.ts CHANGED
@@ -7,6 +7,7 @@ import { readExpandedEntryText } from "../core/sources/read.ts";
7
7
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
8
8
  import { buildRssFeeds } from "../deploy/rss.ts";
9
9
  import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
10
+ import { API_PAGES_PATH, OPENAPI_PATH } from "./api/paths.ts";
10
11
  import {
11
12
  downlevelComponents,
12
13
  exampleComponentSerializers,
@@ -41,8 +42,9 @@ const oneLine = (text: string): string => text.replaceAll(/\s+/gu, " ").trim();
41
42
  /**
42
43
  * The "Agent resources" section: every machine-readable artifact the site
43
44
  * publishes, so an agent that only reads llms.txt still finds the full
44
- * Markdown dump, the per-page Markdown mirrors, the MCP server, the skills
45
- * index, the API catalog, the readability manifest, and the sitemap. The
45
+ * Markdown dump, the per-page Markdown mirrors, the JSON docs API, the MCP
46
+ * server, the skills index, the API catalog, the readability manifest, and
47
+ * the sitemap. The
46
48
  * same artifact set `agent-readability.json` indexes, in prose an agent can
47
49
  * act on without a second fetch.
48
50
  */
@@ -55,6 +57,11 @@ const agentResourceLines = (project: BlumeProject): string[] => {
55
57
  `- [llms-full.txt](${url("/llms-full.txt")}): The full Markdown of every page in one file.`,
56
58
  `- [Page Markdown](${url("/index.md")}): Append \`.md\` to any page URL to fetch that page as raw Markdown.`,
57
59
  ];
60
+ if (config.ai.api) {
61
+ lines.push(
62
+ `- [JSON API](${url(API_PAGES_PATH)}): Page index of the JSON docs API; each entry links the page's JSON and Markdown forms. Described by the OpenAPI document at ${url(OPENAPI_PATH)}.`
63
+ );
64
+ }
58
65
  if (config.ai.mcp.enabled) {
59
66
  lines.push(
60
67
  `- [MCP server](${url(config.ai.mcp.route)}): Streamable HTTP Model Context Protocol server with search_docs, get_page, list_pages, and get_navigation tools, plus every page as a resource. Discovery document: ${url("/.well-known/mcp.json")}`
@@ -0,0 +1,390 @@
1
+ import { z } from "zod";
2
+
3
+ import {
4
+ normalizeRoute as normalizePageRoute,
5
+ stripBasePath,
6
+ withBasePath,
7
+ } from "../../core/base-path.ts";
8
+ import { absoluteUrl } from "../../core/site-url.ts";
9
+ import { trimEnd } from "../../core/trim.ts";
10
+ import type { Navigation } from "../../core/types.ts";
11
+ import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
12
+ import type { OramaDoc } from "../../search/orama-index.ts";
13
+ import type { McpData } from "./data.ts";
14
+
15
+ /**
16
+ * The read-only docs operations behind the agent-facing surfaces — search,
17
+ * page lookup, the page listing, and the navigation tree — over an
18
+ * {@link McpData} snapshot. The MCP server (`server.ts`) and the JSON docs API
19
+ * (`ai/api/handlers.ts`) are both thin transports over these, so a tool call
20
+ * and its REST twin can never answer differently. SDK-free on purpose: the
21
+ * prerendered API endpoints import this module without pulling the MCP SDK
22
+ * into their bundle.
23
+ *
24
+ * Each input is defined once in Zod: the runtime parse and the JSON Schema
25
+ * advertised by `tools/list` (via `z.toJSONSchema`) derive from the same
26
+ * definition, so they cannot drift.
27
+ */
28
+
29
+ /** Default and maximum number of hits returned by a search. */
30
+ export const DEFAULT_SEARCH_LIMIT = 8;
31
+ export const MAX_SEARCH_LIMIT = 20;
32
+ /** Excerpt length when a page has no description. */
33
+ const EXCERPT_LENGTH = 200;
34
+
35
+ // Each field is a preprocess pipe: the input side accepts the sloppy shapes
36
+ // LLM callers actually send (a bare string for an array field, `[]`/`{}`
37
+ // meaning "no filter", out-of-range limits clamped rather than rejected), and
38
+ // the pipe's *output* side is the clean shape — which is exactly what
39
+ // `z.toJSONSchema` emits for `tools/list`. No coercion can ever fail, so a
40
+ // tool call is never rejected on argument shape, matching the previous
41
+ // hand-rolled coercions.
42
+
43
+ /**
44
+ * The optional content-type filter `search_docs` and `list_pages` share.
45
+ * `[]` or no usable strings mean "no filter", not "match nothing"; a bare
46
+ * string is accepted as a one-element list.
47
+ */
48
+ const contentTypesField = z.preprocess((value) => {
49
+ const list = (Array.isArray(value) ? value : [value]).filter(
50
+ (entry): entry is string => typeof entry === "string"
51
+ );
52
+ return list.length > 0 ? list : undefined;
53
+ }, z.array(z.string()).optional().describe('Only include pages of these content types (frontmatter `type`, e.g. `["doc", "rfc"]`). `list_pages` shows each page\'s type. Omit to include every type.'));
54
+
55
+ /**
56
+ * The optional facet filter `search_docs` and `list_pages` share. Only
57
+ * string-valued entries survive; an empty `{}` means "no filter".
58
+ */
59
+ /** Accepts any plain object, so the string-valued entries can be sifted out. */
60
+ const looseFacetObject = z.record(z.string(), z.unknown());
61
+
62
+ const filtersField = z.preprocess((value) => {
63
+ const candidate = looseFacetObject.safeParse(value);
64
+ if (!candidate.success) {
65
+ return;
66
+ }
67
+ const entries = Object.entries(candidate.data).filter(
68
+ (entry): entry is [string, string] => typeof entry[1] === "string"
69
+ );
70
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
71
+ }, z.record(z.string(), z.string()).optional().describe('Only include pages matching every facet, key → required value (e.g. `{"status": "enforced"}`). Facets are metadata the site declares per content type; `list_pages` shows each page\'s facet values. Omit for no facet filtering.'));
72
+
73
+ /** Clamped into range rather than rejected; non-numeric means the default. */
74
+ const limitField = z.preprocess(
75
+ (value) => {
76
+ // `Number` is the identity on numbers, so one conversion covers both the
77
+ // well-typed call and a numeric string.
78
+ const num = Number(value);
79
+ return Number.isFinite(num)
80
+ ? Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT)
81
+ : undefined;
82
+ },
83
+ z
84
+ .int()
85
+ .min(1)
86
+ .max(MAX_SEARCH_LIMIT)
87
+ .optional()
88
+ .describe(`Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`)
89
+ );
90
+
91
+ /** A required text field; a missing or non-string value coerces to "". */
92
+ const textField = (description: string) =>
93
+ z.preprocess((value) => {
94
+ const parsed = z.string().safeParse(value);
95
+ return parsed.success ? parsed.data : "";
96
+ }, z.string().describe(description));
97
+
98
+ /** An optional trimmed text field; blank or non-string means "absent". */
99
+ const optionalTextField = (description: string) =>
100
+ z.preprocess((value) => {
101
+ const parsed = z.string().safeParse(value);
102
+ const trimmed = parsed.success ? parsed.data.trim() : "";
103
+ return trimmed || undefined;
104
+ }, z.string().optional().describe(description));
105
+
106
+ /** The optional locale filter `search_docs` and `list_pages` share. */
107
+ const localeField = optionalTextField(
108
+ "Only include pages in this locale (e.g. `fr`). Omit for every language."
109
+ );
110
+
111
+ /** The optional docs-version scope `search_docs` and `list_pages` share. */
112
+ const versionField = optionalTextField(
113
+ '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.'
114
+ );
115
+
116
+ /** Every tool's input schema — the runtime parse and tools/list source. */
117
+ export const TOOL_INPUTS = {
118
+ get_navigation: z.object({
119
+ locale: optionalTextField(
120
+ "Locale whose navigation tree to return (defaults to the default locale)."
121
+ ),
122
+ version: optionalTextField(
123
+ "Archived version id whose tree to return (defaults to the current docs)."
124
+ ),
125
+ }),
126
+ get_page: z.object({
127
+ route: textField("The page route, e.g. `/guides/install`."),
128
+ }),
129
+ list_pages: z.object({
130
+ contentTypes: contentTypesField,
131
+ filters: filtersField,
132
+ locale: localeField,
133
+ version: versionField,
134
+ }),
135
+ search_docs: z.object({
136
+ contentTypes: contentTypesField,
137
+ filters: filtersField,
138
+ limit: limitField,
139
+ locale: localeField,
140
+ query: textField("The search query."),
141
+ version: versionField,
142
+ }),
143
+ };
144
+
145
+ /** One `search_docs` result entry; `version` only appears on versioned sites. */
146
+ export interface SearchHitPayload {
147
+ contentType: string | undefined;
148
+ excerpt: string;
149
+ facets: Record<string, string> | undefined;
150
+ route: string;
151
+ title: string;
152
+ url: string;
153
+ version?: string;
154
+ }
155
+
156
+ /** One `list_pages` entry; `version` only appears on versioned sites. */
157
+ export interface PageListingPayload {
158
+ contentType: string;
159
+ description: string | undefined;
160
+ facets: Record<string, string> | undefined;
161
+ lastModified: string | null;
162
+ route: string;
163
+ title: string;
164
+ url: string;
165
+ version?: string;
166
+ }
167
+
168
+ /** Whether a page's facet values satisfy every requested filter entry. */
169
+ const matchesFacets = (
170
+ facets: Record<string, string> | undefined,
171
+ filters: Record<string, string>
172
+ ): boolean =>
173
+ Object.entries(filters).every(([key, value]) => facets?.[key] === value);
174
+
175
+ /**
176
+ * Resolve the `version` scope on a versioned site: `undefined` disables the
177
+ * filter (`"all"`), `""` is the current docs (the default — agents almost
178
+ * always want the live documentation), and anything else is an archived id
179
+ * (an unknown id simply matches nothing). On an unversioned site the input is
180
+ * ignored entirely. The input arrives pre-trimmed (blank coerced to absent)
181
+ * from the tool's input schema.
182
+ */
183
+ const asVersionScope = (
184
+ value: string | undefined,
185
+ data: McpData
186
+ ): string | undefined => {
187
+ if (!data.archivedVersions) {
188
+ return;
189
+ }
190
+ if (value === "all") {
191
+ return;
192
+ }
193
+ if (value === undefined || value === "latest" || value === "current") {
194
+ return "";
195
+ }
196
+ return value;
197
+ };
198
+
199
+ /**
200
+ * Error message for a `get_navigation` version id that isn't a configured
201
+ * archived version, or `null` when the id is valid (or the site is
202
+ * unversioned, where the id is ignored like the other tools' scopes). Unlike
203
+ * `asVersionScope`'s match-nothing filters, a bad id here would otherwise
204
+ * silently return the *current* tree posing as the requested snapshot.
205
+ */
206
+ const unknownVersionError = (
207
+ versionId: string | undefined,
208
+ data: McpData
209
+ ): string | null =>
210
+ versionId &&
211
+ data.archivedVersions &&
212
+ !data.archivedVersions.includes(versionId)
213
+ ? `Unknown version "${versionId}". Archived versions: ${data.archivedVersions.join(", ")}.`
214
+ : null;
215
+
216
+ /**
217
+ * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
218
+ * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
219
+ * `site` + `deployment.base`, and an agent following "pass a route from
220
+ * `search_docs`" will hand one straight back — reducing it to its base-less,
221
+ * percent-decoded path.
222
+ */
223
+ export const normalizeRoute = (input: string, data: McpData): string => {
224
+ let value = input.trim();
225
+ if (/^https?:\/\//iu.test(value)) {
226
+ try {
227
+ value = new URL(value).pathname;
228
+ } catch {
229
+ // Not parseable as a URL after all; treat it as a path.
230
+ }
231
+ }
232
+ try {
233
+ value = decodeURI(value);
234
+ } catch {
235
+ // Malformed percent sequence — compare it as written.
236
+ }
237
+ // Trailing slashes come off before the suffix so `/a/b.md/` still loses its
238
+ // `.md`; normalizePageRoute then settles the leading slash.
239
+ const noSuffix = trimEnd(value, "/").replace(/\.mdx?$/u, "");
240
+ return stripBasePath(data.base, normalizePageRoute(noSuffix));
241
+ };
242
+
243
+ /** Build the absolute (or root-relative) URL for a route. */
244
+ export const urlFor = (route: string, data: McpData): string => {
245
+ // Routes are base-less manifest paths; layer `deployment.base` on top so the
246
+ // URL matches where the page is served (the sitemap/llms.txt convention).
247
+ const path = withBasePath(data.base, route);
248
+ // Concatenate rather than `new URL(path, site)` — a root-absolute path
249
+ // would drop the base path of a subpath deployment (`acme.com/docs`).
250
+ return data.site ? absoluteUrl(data.site, path) : path;
251
+ };
252
+
253
+ /** A hit's excerpt: its description, else the head of its content with an
254
+ * ellipsis only when something was actually cut off. */
255
+ const excerptFor = (doc: OramaDoc): string => {
256
+ if (doc.description) {
257
+ return doc.description;
258
+ }
259
+ const head = doc.content.slice(0, EXCERPT_LENGTH).trim();
260
+ return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
261
+ };
262
+
263
+ /** Lazily builds the Orama index over a snapshot's documents, once. */
264
+ export type OramaIndexProvider = () => Promise<
265
+ Awaited<ReturnType<typeof buildOramaIndex>>
266
+ >;
267
+
268
+ /**
269
+ * Memoize the search index so every server built from a snapshot shares it.
270
+ * `locale` is the snapshot's `defaultLocale`, forwarded so non-Latin scripts
271
+ * (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
272
+ * get a word-segmenting tokenizer.
273
+ */
274
+ export const createIndexProvider = (
275
+ documents: OramaDoc[],
276
+ locale?: string
277
+ ): OramaIndexProvider => {
278
+ let dbPromise: ReturnType<OramaIndexProvider> | null = null;
279
+ return function provideIndex() {
280
+ dbPromise ??= buildOramaIndex(documents, locale);
281
+ return dbPromise;
282
+ };
283
+ };
284
+
285
+ /** Full-text search over the snapshot; the `search_docs` operation. */
286
+ export const searchDocs = async (
287
+ data: McpData,
288
+ index: OramaIndexProvider,
289
+ input: z.output<typeof TOOL_INPUTS.search_docs>
290
+ ): Promise<SearchHitPayload[]> => {
291
+ const db = await index();
292
+ const hits = await queryOramaIndex(
293
+ db,
294
+ input.query,
295
+ input.limit ?? DEFAULT_SEARCH_LIMIT,
296
+ {
297
+ contentTypes: input.contentTypes,
298
+ facets: input.filters,
299
+ locale: input.locale,
300
+ version: asVersionScope(input.version, data),
301
+ }
302
+ );
303
+ // `route` is the key `get_page` takes (the tool descriptions promise
304
+ // it); `url` is where the page is served.
305
+ return hits.map((doc: OramaDoc) => {
306
+ const hit: SearchHitPayload = {
307
+ contentType: doc.contentType,
308
+ excerpt: excerptFor(doc),
309
+ facets: doc.facets,
310
+ route: doc.route,
311
+ title: doc.title,
312
+ url: urlFor(doc.route, data),
313
+ };
314
+ if (data.archivedVersions) {
315
+ hit.version = doc.version ?? "";
316
+ }
317
+ return hit;
318
+ });
319
+ };
320
+
321
+ /** A page's agent Markdown, or `undefined` when no page has the route. */
322
+ export const getPageMarkdown = (
323
+ data: McpData,
324
+ route: string
325
+ ): string | undefined => data.pages[route];
326
+
327
+ /** Every non-hidden route matching the filters; the `list_pages` operation. */
328
+ export const listPages = (
329
+ data: McpData,
330
+ input: z.output<typeof TOOL_INPUTS.list_pages>
331
+ ): PageListingPayload[] => {
332
+ const { contentTypes, filters, locale } = input;
333
+ const versionScope = asVersionScope(input.version, data);
334
+ return data.routes
335
+ .filter(
336
+ (route) =>
337
+ (!contentTypes || contentTypes.includes(route.contentType)) &&
338
+ (!filters || matchesFacets(route.facets, filters)) &&
339
+ (!locale || route.locale === locale) &&
340
+ (versionScope === undefined || route.version === versionScope)
341
+ )
342
+ .map((route) => {
343
+ const listing: PageListingPayload = {
344
+ contentType: route.contentType,
345
+ description: route.description,
346
+ facets: route.facets,
347
+ lastModified: route.lastModified,
348
+ route: route.route,
349
+ title: route.title,
350
+ url: urlFor(route.route, data),
351
+ };
352
+ if (data.archivedVersions) {
353
+ listing.version = route.version;
354
+ }
355
+ return listing;
356
+ });
357
+ };
358
+
359
+ /**
360
+ * The navigation tree for a locale and version; the `get_navigation`
361
+ * operation. A version id selects the snapshot's tree; a locale selects its
362
+ * language (falling back through the default locale to any tree the snapshot
363
+ * has). Without a version, a locale selects the current docs' localized
364
+ * tree. An unknown id on a versioned site is an error string — the current
365
+ * tree would silently masquerade as the requested snapshot.
366
+ */
367
+ export const getNavigation = (
368
+ data: McpData,
369
+ input: z.output<typeof TOOL_INPUTS.get_navigation>
370
+ ): { error: string } | { navigation: Navigation } => {
371
+ const { locale, version: versionId } = input;
372
+ const unknownVersion = unknownVersionError(versionId, data);
373
+ if (unknownVersion) {
374
+ return { error: unknownVersion };
375
+ }
376
+ let { navigation } = data;
377
+ const byLocale = versionId
378
+ ? data.navigationByVersion?.[versionId]
379
+ : undefined;
380
+ if (byLocale) {
381
+ navigation =
382
+ (locale ? byLocale[locale] : undefined) ??
383
+ byLocale[data.defaultLocale ?? ""] ??
384
+ Object.values(byLocale)[0] ??
385
+ navigation;
386
+ } else if (locale && data.navigationByLocale?.[locale]) {
387
+ navigation = data.navigationByLocale[locale];
388
+ }
389
+ return { navigation };
390
+ };