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/api-catalog.ts
CHANGED
|
@@ -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),
|
package/src/ai/link-headers.ts
CHANGED
|
@@ -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"`,
|
|
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).
|
|
12
|
-
*
|
|
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
|
|
45
|
-
* index, the API catalog, the readability manifest, and
|
|
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
|
+
};
|