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.
- package/CHANGELOG.md +21 -0
- package/dist/cli/index.js +1318 -270
- package/dist/cli/index.js.map +68 -61
- package/dist/types/core/config-input.d.ts +9 -0
- package/dist/types/core/data.d.ts +12 -1
- package/dist/types/core/i18n-ui.d.ts +4 -0
- package/dist/types/core/schema.d.ts +7 -0
- package/dist/types/core/types.d.ts +6 -0
- package/dist/types/openapi/references.d.ts +5 -0
- package/docs/07-faq.mdx +9 -9
- package/docs/advanced/api-reference.mdx +10 -1
- package/docs/advanced/custom-pages.mdx +3 -1
- package/docs/advanced/graphql.mdx +1 -1
- package/docs/configuration/ai.mdx +76 -7
- package/docs/configuration/seo.mdx +3 -3
- package/docs/configuration/theming.mdx +6 -0
- package/docs/content/components.mdx +8 -1
- package/docs/index.mdx +2 -2
- package/package.json +53 -53
- package/skills/blume/SKILL.md +2 -2
- package/src/ai/agent-readability.ts +60 -17
- package/src/ai/api/handlers.ts +273 -0
- package/src/ai/api/paths.ts +14 -0
- package/src/ai/api/problem.ts +63 -0
- package/src/ai/api/spec.ts +681 -0
- package/src/ai/api-catalog.ts +11 -1
- package/src/ai/link-headers.ts +12 -3
- package/src/ai/llms.ts +9 -2
- package/src/ai/mcp/query.ts +390 -0
- package/src/ai/mcp/server.ts +32 -352
- package/src/astro/examples.ts +29 -2
- package/src/astro/generate.ts +256 -64
- package/src/astro/index.ts +7 -0
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/runtime-modules.ts +196 -0
- package/src/astro/templates.ts +398 -38
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/dev.ts +6 -3
- package/src/cli/host-args.ts +18 -0
- package/src/cli/index.ts +2 -1
- package/src/components/copy-feedback.ts +93 -9
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/hooks.ts +3 -1
- package/src/components/layout/PageActions.astro +25 -14
- package/src/core/config-input.ts +9 -0
- package/src/core/data.ts +17 -2
- package/src/core/define-components.ts +2 -0
- package/src/core/i18n-ui.ts +3 -0
- package/src/core/includes.ts +2 -1
- package/src/core/manifest.ts +10 -0
- package/src/core/schema.ts +20 -5
- package/src/core/types.ts +6 -0
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +1 -0
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/core/version-cut.ts +5 -3
- package/src/deploy/vercel-negotiation.ts +97 -6
- package/src/og/card.ts +1 -1
- package/src/openapi/references.ts +8 -0
- package/src/openapi/render-mdx.ts +18 -4
- package/src/openapi/scalar.ts +0 -4
- package/src/registry/eject.ts +36 -17
- package/src/theme/entry.ts +2 -2
- package/src/theme/sources.ts +49 -0
package/src/ai/mcp/server.ts
CHANGED
|
@@ -10,32 +10,32 @@ import {
|
|
|
10
10
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
normalizeRoute as normalizePageRoute,
|
|
15
|
-
stripBasePath,
|
|
16
|
-
withBasePath,
|
|
17
|
-
} from "../../core/base-path.ts";
|
|
18
|
-
import { absoluteUrl } from "../../core/site-url.ts";
|
|
19
|
-
import { trimEnd } from "../../core/trim.ts";
|
|
20
|
-
import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
|
|
21
|
-
import type { OramaDoc } from "../../search/orama-index.ts";
|
|
13
|
+
import { withBasePath } from "../../core/base-path.ts";
|
|
22
14
|
import type { McpData } from "./data.ts";
|
|
15
|
+
import {
|
|
16
|
+
createIndexProvider,
|
|
17
|
+
getNavigation,
|
|
18
|
+
getPageMarkdown,
|
|
19
|
+
listPages,
|
|
20
|
+
normalizeRoute,
|
|
21
|
+
searchDocs,
|
|
22
|
+
TOOL_INPUTS,
|
|
23
|
+
urlFor,
|
|
24
|
+
} from "./query.ts";
|
|
25
|
+
import type { OramaIndexProvider } from "./query.ts";
|
|
23
26
|
import { MCP_TOOLS } from "./tools.ts";
|
|
24
27
|
|
|
28
|
+
export { createIndexProvider } from "./query.ts";
|
|
29
|
+
export type { OramaIndexProvider } from "./query.ts";
|
|
30
|
+
|
|
25
31
|
/**
|
|
26
32
|
* The low-level SDK `Server` is used (rather than the high-level `McpServer`)
|
|
27
33
|
* because the latter's `registerTool` is generic over the caller's Zod instance;
|
|
28
34
|
* Blume's zod and the SDK's may resolve to different copies, whose types don't
|
|
29
|
-
* unify.
|
|
30
|
-
*
|
|
31
|
-
* derive from the same definition, so they cannot drift.
|
|
35
|
+
* unify. The operations themselves live in `query.ts`, shared with the JSON
|
|
36
|
+
* docs API; this module is the MCP transport over them.
|
|
32
37
|
*/
|
|
33
38
|
|
|
34
|
-
/** Default and maximum number of hits returned by `search_docs`. */
|
|
35
|
-
const DEFAULT_SEARCH_LIMIT = 8;
|
|
36
|
-
const MAX_SEARCH_LIMIT = 20;
|
|
37
|
-
/** Excerpt length when a page has no description. */
|
|
38
|
-
const EXCERPT_LENGTH = 200;
|
|
39
39
|
/** Every page resource is the page's agent Markdown. */
|
|
40
40
|
const RESOURCE_MIME_TYPE = "text/markdown";
|
|
41
41
|
/** The MCP spec's JSON-RPC code for an unknown resource URI. */
|
|
@@ -51,116 +51,6 @@ const CORS_HEADERS = {
|
|
|
51
51
|
"Access-Control-Expose-Headers": "Mcp-Session-Id",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
// Each field is a preprocess pipe: the input side accepts the sloppy shapes
|
|
55
|
-
// LLM callers actually send (a bare string for an array field, `[]`/`{}`
|
|
56
|
-
// meaning "no filter", out-of-range limits clamped rather than rejected), and
|
|
57
|
-
// the pipe's *output* side is the clean shape — which is exactly what
|
|
58
|
-
// `z.toJSONSchema` emits for `tools/list`. No coercion can ever fail, so a
|
|
59
|
-
// tool call is never rejected on argument shape, matching the previous
|
|
60
|
-
// hand-rolled coercions.
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The optional content-type filter `search_docs` and `list_pages` share.
|
|
64
|
-
* `[]` or no usable strings mean "no filter", not "match nothing"; a bare
|
|
65
|
-
* string is accepted as a one-element list.
|
|
66
|
-
*/
|
|
67
|
-
const contentTypesField = z.preprocess((value) => {
|
|
68
|
-
const list = (Array.isArray(value) ? value : [value]).filter(
|
|
69
|
-
(entry): entry is string => typeof entry === "string"
|
|
70
|
-
);
|
|
71
|
-
return list.length > 0 ? list : undefined;
|
|
72
|
-
}, 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.'));
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The optional facet filter `search_docs` and `list_pages` share. Only
|
|
76
|
-
* string-valued entries survive; an empty `{}` means "no filter".
|
|
77
|
-
*/
|
|
78
|
-
/** Accepts any plain object, so the string-valued entries can be sifted out. */
|
|
79
|
-
const looseFacetObject = z.record(z.string(), z.unknown());
|
|
80
|
-
|
|
81
|
-
const filtersField = z.preprocess((value) => {
|
|
82
|
-
const candidate = looseFacetObject.safeParse(value);
|
|
83
|
-
if (!candidate.success) {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const entries = Object.entries(candidate.data).filter(
|
|
87
|
-
(entry): entry is [string, string] => typeof entry[1] === "string"
|
|
88
|
-
);
|
|
89
|
-
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
90
|
-
}, 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.'));
|
|
91
|
-
|
|
92
|
-
/** Clamped into range rather than rejected; non-numeric means the default. */
|
|
93
|
-
const limitField = z.preprocess(
|
|
94
|
-
(value) => {
|
|
95
|
-
// `Number` is the identity on numbers, so one conversion covers both the
|
|
96
|
-
// well-typed call and a numeric string.
|
|
97
|
-
const num = Number(value);
|
|
98
|
-
return Number.isFinite(num)
|
|
99
|
-
? Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT)
|
|
100
|
-
: undefined;
|
|
101
|
-
},
|
|
102
|
-
z
|
|
103
|
-
.int()
|
|
104
|
-
.min(1)
|
|
105
|
-
.max(MAX_SEARCH_LIMIT)
|
|
106
|
-
.optional()
|
|
107
|
-
.describe(`Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`)
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
/** A required text field; a missing or non-string value coerces to "". */
|
|
111
|
-
const textField = (description: string) =>
|
|
112
|
-
z.preprocess((value) => {
|
|
113
|
-
const parsed = z.string().safeParse(value);
|
|
114
|
-
return parsed.success ? parsed.data : "";
|
|
115
|
-
}, z.string().describe(description));
|
|
116
|
-
|
|
117
|
-
/** An optional trimmed text field; blank or non-string means "absent". */
|
|
118
|
-
const optionalTextField = (description: string) =>
|
|
119
|
-
z.preprocess((value) => {
|
|
120
|
-
const parsed = z.string().safeParse(value);
|
|
121
|
-
const trimmed = parsed.success ? parsed.data.trim() : "";
|
|
122
|
-
return trimmed || undefined;
|
|
123
|
-
}, z.string().optional().describe(description));
|
|
124
|
-
|
|
125
|
-
/** The optional locale filter `search_docs` and `list_pages` share. */
|
|
126
|
-
const localeField = optionalTextField(
|
|
127
|
-
"Only include pages in this locale (e.g. `fr`). Omit for every language."
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
/** The optional docs-version scope `search_docs` and `list_pages` share. */
|
|
131
|
-
const versionField = optionalTextField(
|
|
132
|
-
'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.'
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
/** Every tool's input schema — the runtime parse and tools/list source. */
|
|
136
|
-
const TOOL_INPUTS = {
|
|
137
|
-
get_navigation: z.object({
|
|
138
|
-
locale: optionalTextField(
|
|
139
|
-
"Locale whose navigation tree to return (defaults to the default locale)."
|
|
140
|
-
),
|
|
141
|
-
version: optionalTextField(
|
|
142
|
-
"Archived version id whose tree to return (defaults to the current docs)."
|
|
143
|
-
),
|
|
144
|
-
}),
|
|
145
|
-
get_page: z.object({
|
|
146
|
-
route: textField("The page route, e.g. `/guides/install`."),
|
|
147
|
-
}),
|
|
148
|
-
list_pages: z.object({
|
|
149
|
-
contentTypes: contentTypesField,
|
|
150
|
-
filters: filtersField,
|
|
151
|
-
locale: localeField,
|
|
152
|
-
version: versionField,
|
|
153
|
-
}),
|
|
154
|
-
search_docs: z.object({
|
|
155
|
-
contentTypes: contentTypesField,
|
|
156
|
-
filters: filtersField,
|
|
157
|
-
limit: limitField,
|
|
158
|
-
locale: localeField,
|
|
159
|
-
query: textField("The search query."),
|
|
160
|
-
version: versionField,
|
|
161
|
-
}),
|
|
162
|
-
};
|
|
163
|
-
|
|
164
54
|
/**
|
|
165
55
|
* A tool's advertised JSON Schema. The dialect key is dropped (noise in a
|
|
166
56
|
* tools/list payload), as is the root `additionalProperties: false` — the
|
|
@@ -189,17 +79,6 @@ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
|
|
|
189
79
|
title: tool.title,
|
|
190
80
|
}));
|
|
191
81
|
|
|
192
|
-
/** One `search_docs` result entry; `version` only appears on versioned sites. */
|
|
193
|
-
interface SearchHitPayload {
|
|
194
|
-
contentType: string | undefined;
|
|
195
|
-
excerpt: string;
|
|
196
|
-
facets: Record<string, string> | undefined;
|
|
197
|
-
route: string;
|
|
198
|
-
title: string;
|
|
199
|
-
url: string;
|
|
200
|
-
version?: string;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
82
|
/** One `resources/list` entry: a page served as `text/markdown`. */
|
|
204
83
|
interface PageResource {
|
|
205
84
|
description?: string;
|
|
@@ -209,103 +88,6 @@ interface PageResource {
|
|
|
209
88
|
uri: string;
|
|
210
89
|
}
|
|
211
90
|
|
|
212
|
-
/** One `list_pages` entry; `version` only appears on versioned sites. */
|
|
213
|
-
interface PageListingPayload {
|
|
214
|
-
contentType: string;
|
|
215
|
-
description: string | undefined;
|
|
216
|
-
facets: Record<string, string> | undefined;
|
|
217
|
-
lastModified: string | null;
|
|
218
|
-
route: string;
|
|
219
|
-
title: string;
|
|
220
|
-
url: string;
|
|
221
|
-
version?: string;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Whether a page's facet values satisfy every requested filter entry. */
|
|
225
|
-
const matchesFacets = (
|
|
226
|
-
facets: Record<string, string> | undefined,
|
|
227
|
-
filters: Record<string, string>
|
|
228
|
-
): boolean =>
|
|
229
|
-
Object.entries(filters).every(([key, value]) => facets?.[key] === value);
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Resolve the `version` scope on a versioned site: `undefined` disables the
|
|
233
|
-
* filter (`"all"`), `""` is the current docs (the default — agents almost
|
|
234
|
-
* always want the live documentation), and anything else is an archived id
|
|
235
|
-
* (an unknown id simply matches nothing). On an unversioned site the input is
|
|
236
|
-
* ignored entirely. The input arrives pre-trimmed (blank coerced to absent)
|
|
237
|
-
* from the tool's input schema.
|
|
238
|
-
*/
|
|
239
|
-
const asVersionScope = (
|
|
240
|
-
value: string | undefined,
|
|
241
|
-
data: McpData
|
|
242
|
-
): string | undefined => {
|
|
243
|
-
if (!data.archivedVersions) {
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (value === "all") {
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (value === undefined || value === "latest" || value === "current") {
|
|
250
|
-
return "";
|
|
251
|
-
}
|
|
252
|
-
return value;
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Error message for a `get_navigation` version id that isn't a configured
|
|
257
|
-
* archived version, or `null` when the id is valid (or the site is
|
|
258
|
-
* unversioned, where the id is ignored like the other tools' scopes). Unlike
|
|
259
|
-
* `asVersionScope`'s match-nothing filters, a bad id here would otherwise
|
|
260
|
-
* silently return the *current* tree posing as the requested snapshot.
|
|
261
|
-
*/
|
|
262
|
-
const unknownVersionError = (
|
|
263
|
-
versionId: string | undefined,
|
|
264
|
-
data: McpData
|
|
265
|
-
): string | null =>
|
|
266
|
-
versionId &&
|
|
267
|
-
data.archivedVersions &&
|
|
268
|
-
!data.archivedVersions.includes(versionId)
|
|
269
|
-
? `Unknown version "${versionId}". Archived versions: ${data.archivedVersions.join(", ")}.`
|
|
270
|
-
: null;
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
|
|
274
|
-
* Accepts a full URL too — `search_docs` hits and llms.txt entries carry
|
|
275
|
-
* `site` + `deployment.base`, and an agent following "pass a route from
|
|
276
|
-
* `search_docs`" will hand one straight back — reducing it to its base-less,
|
|
277
|
-
* percent-decoded path.
|
|
278
|
-
*/
|
|
279
|
-
const normalizeRoute = (input: string, data: McpData): string => {
|
|
280
|
-
let value = input.trim();
|
|
281
|
-
if (/^https?:\/\//iu.test(value)) {
|
|
282
|
-
try {
|
|
283
|
-
value = new URL(value).pathname;
|
|
284
|
-
} catch {
|
|
285
|
-
// Not parseable as a URL after all; treat it as a path.
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
try {
|
|
289
|
-
value = decodeURI(value);
|
|
290
|
-
} catch {
|
|
291
|
-
// Malformed percent sequence — compare it as written.
|
|
292
|
-
}
|
|
293
|
-
// Trailing slashes come off before the suffix so `/a/b.md/` still loses its
|
|
294
|
-
// `.md`; normalizePageRoute then settles the leading slash.
|
|
295
|
-
const noSuffix = trimEnd(value, "/").replace(/\.mdx?$/u, "");
|
|
296
|
-
return stripBasePath(data.base, normalizePageRoute(noSuffix));
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
/** Build the absolute (or root-relative) URL for a route. */
|
|
300
|
-
const urlFor = (route: string, data: McpData): string => {
|
|
301
|
-
// Routes are base-less manifest paths; layer `deployment.base` on top so the
|
|
302
|
-
// URL matches where the page is served (the sitemap/llms.txt convention).
|
|
303
|
-
const path = withBasePath(data.base, route);
|
|
304
|
-
// Concatenate rather than `new URL(path, site)` — a root-absolute path
|
|
305
|
-
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
306
|
-
return data.site ? absoluteUrl(data.site, path) : path;
|
|
307
|
-
};
|
|
308
|
-
|
|
309
91
|
/**
|
|
310
92
|
* A page's resource URI. Resource URIs must be absolute, so this is the page's
|
|
311
93
|
* served URL when a site is configured (the same URL `search_docs` and
|
|
@@ -326,44 +108,12 @@ const resourceRoute = (uri: string, data: McpData): string =>
|
|
|
326
108
|
data
|
|
327
109
|
);
|
|
328
110
|
|
|
329
|
-
/** A hit's excerpt: its description, else the head of its content with an
|
|
330
|
-
* ellipsis only when something was actually cut off. */
|
|
331
|
-
const excerptFor = (doc: OramaDoc): string => {
|
|
332
|
-
if (doc.description) {
|
|
333
|
-
return doc.description;
|
|
334
|
-
}
|
|
335
|
-
const head = doc.content.slice(0, EXCERPT_LENGTH).trim();
|
|
336
|
-
return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
|
|
337
|
-
};
|
|
338
|
-
|
|
339
111
|
/** A tool call's text result, marked as an error when `isError` is set. */
|
|
340
112
|
const text = (value: string, isError = false) => {
|
|
341
113
|
const content = [{ text: value, type: "text" as const }];
|
|
342
114
|
return isError ? { content, isError: true } : { content };
|
|
343
115
|
};
|
|
344
116
|
|
|
345
|
-
/** Lazily builds the Orama index over a snapshot's documents, once. */
|
|
346
|
-
export type OramaIndexProvider = () => Promise<
|
|
347
|
-
Awaited<ReturnType<typeof buildOramaIndex>>
|
|
348
|
-
>;
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Memoize the search index so every server built from a snapshot shares it.
|
|
352
|
-
* `locale` is the snapshot's `defaultLocale`, forwarded so non-Latin scripts
|
|
353
|
-
* (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
|
|
354
|
-
* get a word-segmenting tokenizer.
|
|
355
|
-
*/
|
|
356
|
-
export const createIndexProvider = (
|
|
357
|
-
documents: OramaDoc[],
|
|
358
|
-
locale?: string
|
|
359
|
-
): OramaIndexProvider => {
|
|
360
|
-
let dbPromise: ReturnType<OramaIndexProvider> | null = null;
|
|
361
|
-
return function provideIndex() {
|
|
362
|
-
dbPromise ??= buildOramaIndex(documents, locale);
|
|
363
|
-
return dbPromise;
|
|
364
|
-
};
|
|
365
|
-
};
|
|
366
|
-
|
|
367
117
|
/** Construct a fresh MCP server with Blume's read-only docs tools registered. */
|
|
368
118
|
export const buildServer = (
|
|
369
119
|
data: McpData,
|
|
@@ -403,7 +153,7 @@ export const buildServer = (
|
|
|
403
153
|
|
|
404
154
|
server.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
|
405
155
|
const { uri } = request.params;
|
|
406
|
-
const markdown = data
|
|
156
|
+
const markdown = getPageMarkdown(data, resourceRoute(uri, data));
|
|
407
157
|
if (markdown === undefined) {
|
|
408
158
|
throw new McpError(
|
|
409
159
|
RESOURCE_NOT_FOUND,
|
|
@@ -419,42 +169,18 @@ export const buildServer = (
|
|
|
419
169
|
const { arguments: args = {}, name } = request.params;
|
|
420
170
|
|
|
421
171
|
if (name === "search_docs") {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
input.query,
|
|
427
|
-
input.limit ?? DEFAULT_SEARCH_LIMIT,
|
|
428
|
-
{
|
|
429
|
-
contentTypes: input.contentTypes,
|
|
430
|
-
facets: input.filters,
|
|
431
|
-
locale: input.locale,
|
|
432
|
-
version: asVersionScope(input.version, data),
|
|
433
|
-
}
|
|
172
|
+
const results = await searchDocs(
|
|
173
|
+
data,
|
|
174
|
+
index,
|
|
175
|
+
TOOL_INPUTS.search_docs.parse(args)
|
|
434
176
|
);
|
|
435
|
-
// `route` is the key `get_page` takes (the tool descriptions promise
|
|
436
|
-
// it); `url` is where the page is served.
|
|
437
|
-
const results = hits.map((doc: OramaDoc) => {
|
|
438
|
-
const hit: SearchHitPayload = {
|
|
439
|
-
contentType: doc.contentType,
|
|
440
|
-
excerpt: excerptFor(doc),
|
|
441
|
-
facets: doc.facets,
|
|
442
|
-
route: doc.route,
|
|
443
|
-
title: doc.title,
|
|
444
|
-
url: urlFor(doc.route, data),
|
|
445
|
-
};
|
|
446
|
-
if (data.archivedVersions) {
|
|
447
|
-
hit.version = doc.version ?? "";
|
|
448
|
-
}
|
|
449
|
-
return hit;
|
|
450
|
-
});
|
|
451
177
|
return text(JSON.stringify(results, null, 2));
|
|
452
178
|
}
|
|
453
179
|
|
|
454
180
|
if (name === "get_page") {
|
|
455
181
|
const input = TOOL_INPUTS.get_page.parse(args);
|
|
456
182
|
const key = normalizeRoute(input.route, data);
|
|
457
|
-
const markdown = data
|
|
183
|
+
const markdown = getPageMarkdown(data, key);
|
|
458
184
|
if (markdown === undefined) {
|
|
459
185
|
return text(
|
|
460
186
|
`No page found at "${key}". Use list_pages or search_docs to find valid routes.`,
|
|
@@ -465,65 +191,19 @@ export const buildServer = (
|
|
|
465
191
|
}
|
|
466
192
|
|
|
467
193
|
if (name === "list_pages") {
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
const versionScope = asVersionScope(input.version, data);
|
|
471
|
-
const routes = data.routes.filter(
|
|
472
|
-
(route) =>
|
|
473
|
-
(!contentTypes || contentTypes.includes(route.contentType)) &&
|
|
474
|
-
(!filters || matchesFacets(route.facets, filters)) &&
|
|
475
|
-
(!locale || route.locale === locale) &&
|
|
476
|
-
(versionScope === undefined || route.version === versionScope)
|
|
477
|
-
);
|
|
478
|
-
return text(
|
|
479
|
-
JSON.stringify(
|
|
480
|
-
routes.map((route) => {
|
|
481
|
-
const listing: PageListingPayload = {
|
|
482
|
-
contentType: route.contentType,
|
|
483
|
-
description: route.description,
|
|
484
|
-
facets: route.facets,
|
|
485
|
-
lastModified: route.lastModified,
|
|
486
|
-
route: route.route,
|
|
487
|
-
title: route.title,
|
|
488
|
-
url: urlFor(route.route, data),
|
|
489
|
-
};
|
|
490
|
-
if (data.archivedVersions) {
|
|
491
|
-
listing.version = route.version;
|
|
492
|
-
}
|
|
493
|
-
return listing;
|
|
494
|
-
}),
|
|
495
|
-
null,
|
|
496
|
-
2
|
|
497
|
-
)
|
|
498
|
-
);
|
|
194
|
+
const listing = listPages(data, TOOL_INPUTS.list_pages.parse(args));
|
|
195
|
+
return text(JSON.stringify(listing, null, 2));
|
|
499
196
|
}
|
|
500
197
|
|
|
501
198
|
if (name === "get_navigation") {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
TOOL_INPUTS.get_navigation.parse(args);
|
|
509
|
-
const unknownVersion = unknownVersionError(versionId, data);
|
|
510
|
-
if (unknownVersion) {
|
|
511
|
-
return text(unknownVersion, true);
|
|
512
|
-
}
|
|
513
|
-
let { navigation } = data;
|
|
514
|
-
const byLocale = versionId
|
|
515
|
-
? data.navigationByVersion?.[versionId]
|
|
516
|
-
: undefined;
|
|
517
|
-
if (byLocale) {
|
|
518
|
-
navigation =
|
|
519
|
-
(locale ? byLocale[locale] : undefined) ??
|
|
520
|
-
byLocale[data.defaultLocale ?? ""] ??
|
|
521
|
-
Object.values(byLocale)[0] ??
|
|
522
|
-
navigation;
|
|
523
|
-
} else if (locale && data.navigationByLocale?.[locale]) {
|
|
524
|
-
navigation = data.navigationByLocale[locale];
|
|
199
|
+
const result = getNavigation(
|
|
200
|
+
data,
|
|
201
|
+
TOOL_INPUTS.get_navigation.parse(args)
|
|
202
|
+
);
|
|
203
|
+
if ("error" in result) {
|
|
204
|
+
return text(result.error, true);
|
|
525
205
|
}
|
|
526
|
-
return text(JSON.stringify(navigation, null, 2));
|
|
206
|
+
return text(JSON.stringify(result.navigation, null, 2));
|
|
527
207
|
}
|
|
528
208
|
|
|
529
209
|
return text(`Unknown tool: ${name}`, true);
|
package/src/astro/examples.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
3
|
import pMap from "p-map";
|
|
4
|
-
import { join, relative } from "pathe";
|
|
4
|
+
import { isAbsolute, join, relative } from "pathe";
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
6
|
|
|
7
7
|
import type { ExampleLookup } from "../core/types.ts";
|
|
@@ -28,6 +28,11 @@ export interface ExampleSpec {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface ExampleDiscovery {
|
|
31
|
+
/**
|
|
32
|
+
* Absolute directory the examples were discovered under: the configured
|
|
33
|
+
* `examples.source` (or a glob's static prefix) resolved against the root.
|
|
34
|
+
*/
|
|
35
|
+
dir: string;
|
|
31
36
|
examples: ExampleSpec[];
|
|
32
37
|
warnings: string[];
|
|
33
38
|
}
|
|
@@ -56,6 +61,28 @@ const DEFAULT_EXAMPLE_GLOB = "**/*.{astro,jsx,svelte,tsx,vue}";
|
|
|
56
61
|
/** Ceiling on concurrent example-file reads; unbounded fan-out risks EMFILE. */
|
|
57
62
|
const READ_CONCURRENCY = 16;
|
|
58
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Files the preview-frame Tailwind entry scans for utility classes, appended
|
|
66
|
+
* to each directory from `exampleScanRoots`.
|
|
67
|
+
*/
|
|
68
|
+
export const EXAMPLE_SCAN_GLOB = "**/*.{astro,jsx,svelte,ts,tsx,vue}";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Directories the `<Component />` preview sheet scans for utility classes:
|
|
72
|
+
* the project root, plus the examples directory when it lives outside the
|
|
73
|
+
* root (a sibling workspace package, say). Tailwind's `@source` is a file
|
|
74
|
+
* glob, not an import graph, so an out-of-root examples directory would
|
|
75
|
+
* otherwise contribute no utilities and previews would render half-styled.
|
|
76
|
+
*/
|
|
77
|
+
export const exampleScanRoots = (
|
|
78
|
+
root: string,
|
|
79
|
+
examplesDir: string
|
|
80
|
+
): string[] => {
|
|
81
|
+
const path = relative(root, examplesDir);
|
|
82
|
+
const outside = path.startsWith("..") || isAbsolute(path);
|
|
83
|
+
return outside ? [root, examplesDir] : [root];
|
|
84
|
+
};
|
|
85
|
+
|
|
59
86
|
// Glob magic that turns `examples` from a plain directory into a pattern. `()`,
|
|
60
87
|
// `@`, and `+` are excluded so literal path segments (npm scopes, parens) keep
|
|
61
88
|
// resolving as directories; the extglob leads `*?!` still trigger here.
|
|
@@ -153,7 +180,7 @@ export const discoverExamples = async (
|
|
|
153
180
|
collectExample(file, sources[index] ?? "");
|
|
154
181
|
}
|
|
155
182
|
|
|
156
|
-
return { examples, warnings };
|
|
183
|
+
return { dir, examples, warnings };
|
|
157
184
|
};
|
|
158
185
|
|
|
159
186
|
/**
|