blume 1.6.1 → 1.6.3
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 +12 -0
- package/dist/cli/index.js +907 -152
- package/dist/cli/index.js.map +20 -16
- package/dist/types/core/config-input.d.ts +9 -0
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +2 -0
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/configuration/ai.mdx +72 -7
- package/docs/content/components.mdx +1 -1
- package/docs/index.mdx +2 -2
- package/package.json +1 -1
- 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/generate.ts +166 -12
- package/src/astro/templates.ts +157 -0
- package/src/cli/commands/build.ts +8 -6
- package/src/components/content/Component.astro +5 -9
- package/src/components/content/Tabs.astro +24 -9
- package/src/components/layout/RootLayout.astro +105 -36
- package/src/core/config-input.ts +9 -0
- package/src/core/data.ts +7 -1
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/schema.ts +7 -0
- package/src/deploy/vercel-negotiation.ts +56 -8
- package/src/theme/code-block-padding.ts +0 -8
- package/src/theme/entry.ts +14 -25
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { withBasePath } from "../../core/base-path.ts";
|
|
2
|
+
import { absoluteUrl } from "../../core/site-url.ts";
|
|
3
|
+
import type { Navigation } from "../../core/types.ts";
|
|
4
|
+
import type { McpData, McpRoute } from "../mcp/data.ts";
|
|
5
|
+
import {
|
|
6
|
+
createIndexProvider,
|
|
7
|
+
getPageMarkdown,
|
|
8
|
+
searchDocs,
|
|
9
|
+
TOOL_INPUTS,
|
|
10
|
+
urlFor,
|
|
11
|
+
} from "../mcp/query.ts";
|
|
12
|
+
import type { SearchHitPayload } from "../mcp/query.ts";
|
|
13
|
+
import {
|
|
14
|
+
API_BASE,
|
|
15
|
+
API_PAGES_PATH,
|
|
16
|
+
API_SEARCH_PATH,
|
|
17
|
+
OPENAPI_PATH,
|
|
18
|
+
} from "./paths.ts";
|
|
19
|
+
import { problemResponse } from "./problem.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The JSON docs API: the REST twin of the MCP tools, over the same snapshot
|
|
23
|
+
* and the same operations (`mcp/query.ts`). The page index, per-page JSON,
|
|
24
|
+
* and navigation are prerendered, so a static site serves them from files;
|
|
25
|
+
* search is a live endpoint and exists on server output only. Errors are RFC
|
|
26
|
+
* 9457 problem details (`problem.ts`). The generated endpoints under
|
|
27
|
+
* `.blume/src/pages/api/docs/` are thin wrappers around these.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One page in the index; `version` only appears on versioned sites. */
|
|
31
|
+
export interface ApiPageSummary {
|
|
32
|
+
contentType: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
facets?: Record<string, string>;
|
|
35
|
+
/** The page's JSON representation (this API's `getPage`). */
|
|
36
|
+
json: string;
|
|
37
|
+
lastModified: string | null;
|
|
38
|
+
locale: string;
|
|
39
|
+
/** The page's raw-Markdown mirror (`{route}.md`). */
|
|
40
|
+
markdownUrl: string;
|
|
41
|
+
route: string;
|
|
42
|
+
title: string;
|
|
43
|
+
/** Where the rendered page is served. */
|
|
44
|
+
url: string;
|
|
45
|
+
version?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The `pages.json` document. */
|
|
49
|
+
export interface ApiPagesIndex {
|
|
50
|
+
count: number;
|
|
51
|
+
generator: string;
|
|
52
|
+
pages: ApiPageSummary[];
|
|
53
|
+
site: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A page's JSON representation: its index entry plus the agent Markdown. */
|
|
57
|
+
export interface ApiPage extends ApiPageSummary {
|
|
58
|
+
markdown: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The search endpoint's document. */
|
|
62
|
+
export interface ApiSearchResponse {
|
|
63
|
+
count: number;
|
|
64
|
+
query: string;
|
|
65
|
+
results: SearchHitPayload[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The site + base an endpoint needs to build absolute URLs. */
|
|
69
|
+
export interface ApiSiteContext {
|
|
70
|
+
base: string;
|
|
71
|
+
site: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What the API serializes: one of its documents, or the navigation tree. */
|
|
75
|
+
export type ApiPayload =
|
|
76
|
+
| ApiPage
|
|
77
|
+
| ApiPagesIndex
|
|
78
|
+
| ApiSearchResponse
|
|
79
|
+
| Navigation;
|
|
80
|
+
|
|
81
|
+
/** A `Response` carrying JSON, pretty-printed for the humans who curl it. */
|
|
82
|
+
export const jsonResponse = (payload: ApiPayload, status = 200): Response =>
|
|
83
|
+
new Response(`${JSON.stringify(payload, null, 2)}\n`, {
|
|
84
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
85
|
+
status,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** The `pages/{route}.json` path segment for a route (`index` for home). */
|
|
89
|
+
export const pageParam = (route: string): string =>
|
|
90
|
+
route === "/" ? "index" : route.slice(1);
|
|
91
|
+
|
|
92
|
+
/** The absolute (or root-relative) URL for a base-less path. */
|
|
93
|
+
const siteUrl = (path: string, context: ApiSiteContext): string => {
|
|
94
|
+
const based = withBasePath(context.base, path);
|
|
95
|
+
return context.site ? absoluteUrl(context.site, based) : based;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const summarize = (route: McpRoute, data: McpData): ApiPageSummary => {
|
|
99
|
+
const summary: ApiPageSummary = {
|
|
100
|
+
contentType: route.contentType,
|
|
101
|
+
json: siteUrl(`${API_BASE}/pages/${pageParam(route.route)}.json`, data),
|
|
102
|
+
lastModified: route.lastModified,
|
|
103
|
+
locale: route.locale,
|
|
104
|
+
markdownUrl: siteUrl(`/${pageParam(route.route)}.md`, data),
|
|
105
|
+
route: route.route,
|
|
106
|
+
title: route.title,
|
|
107
|
+
url: urlFor(route.route, data),
|
|
108
|
+
};
|
|
109
|
+
if (route.description !== undefined) {
|
|
110
|
+
summary.description = route.description;
|
|
111
|
+
}
|
|
112
|
+
if (route.facets) {
|
|
113
|
+
summary.facets = route.facets;
|
|
114
|
+
}
|
|
115
|
+
if (data.archivedVersions) {
|
|
116
|
+
summary.version = route.version;
|
|
117
|
+
}
|
|
118
|
+
return summary;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Every non-hidden page, in manifest order; the index is unfiltered. */
|
|
122
|
+
export const buildPagesIndex = (data: McpData): ApiPagesIndex => {
|
|
123
|
+
const pages = data.routes.map((route) => summarize(route, data));
|
|
124
|
+
return {
|
|
125
|
+
count: pages.length,
|
|
126
|
+
generator: `blume@${data.version}`,
|
|
127
|
+
pages,
|
|
128
|
+
site: data.site,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const pagesIndexResponse = (data: McpData): Response =>
|
|
133
|
+
jsonResponse(buildPagesIndex(data));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `getStaticPaths` entries for the per-page endpoint: one per route that has
|
|
137
|
+
* agent Markdown to serve (a landing page without a mirror has no JSON twin
|
|
138
|
+
* either).
|
|
139
|
+
*/
|
|
140
|
+
export const pageParams = (
|
|
141
|
+
data: McpData
|
|
142
|
+
): { params: { route: string }; props: { route: string } }[] =>
|
|
143
|
+
data.routes
|
|
144
|
+
.filter((route) => getPageMarkdown(data, route.route) !== undefined)
|
|
145
|
+
.map((route) => ({
|
|
146
|
+
params: { route: pageParam(route.route) },
|
|
147
|
+
props: { route: route.route },
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
/** A page's JSON document, or null when no page has the route. */
|
|
151
|
+
export const buildPage = (data: McpData, route: string): ApiPage | null => {
|
|
152
|
+
const entry = data.routes.find((candidate) => candidate.route === route);
|
|
153
|
+
const markdown = getPageMarkdown(data, route);
|
|
154
|
+
if (!entry || markdown === undefined) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return { ...summarize(entry, data), markdown };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const pageResponse = (data: McpData, route: string): Response => {
|
|
161
|
+
const page = buildPage(data, route);
|
|
162
|
+
if (!page) {
|
|
163
|
+
return problemResponse({
|
|
164
|
+
code: "PAGE_NOT_FOUND",
|
|
165
|
+
detail: `No documentation page has the route "${route}".`,
|
|
166
|
+
instance: siteUrl(`${API_BASE}/pages/${pageParam(route)}.json`, data),
|
|
167
|
+
resolution: `List every page at ${siteUrl(API_PAGES_PATH, data)}, or discover the API through ${siteUrl(OPENAPI_PATH, data)}.`,
|
|
168
|
+
status: 404,
|
|
169
|
+
title: "Page not found",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return jsonResponse(page);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The default navigation tree (default locale, current docs). */
|
|
176
|
+
export const buildNavigation = (data: McpData): Navigation => data.navigation;
|
|
177
|
+
|
|
178
|
+
export const navigationResponse = (data: McpData): Response =>
|
|
179
|
+
jsonResponse(buildNavigation(data));
|
|
180
|
+
|
|
181
|
+
/** Repeated and comma-separated values of a list query parameter. */
|
|
182
|
+
const listParam = (
|
|
183
|
+
params: URLSearchParams,
|
|
184
|
+
key: string
|
|
185
|
+
): string[] | undefined => {
|
|
186
|
+
const values = params
|
|
187
|
+
.getAll(key)
|
|
188
|
+
.flatMap((value) => value.split(","))
|
|
189
|
+
.map((value) => value.trim())
|
|
190
|
+
.filter((value) => value.length > 0);
|
|
191
|
+
return values.length > 0 ? values : undefined;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const FILTER_PARAM = /^filters\[(?<key>.+)\]$/u;
|
|
195
|
+
|
|
196
|
+
/** The `filters[key]=value` (OpenAPI deepObject) facet filters. */
|
|
197
|
+
const filtersParam = (
|
|
198
|
+
params: URLSearchParams
|
|
199
|
+
): Record<string, string> | undefined => {
|
|
200
|
+
const entries: [string, string][] = [];
|
|
201
|
+
for (const [key, value] of params) {
|
|
202
|
+
const facet = FILTER_PARAM.exec(key)?.groups?.key;
|
|
203
|
+
if (facet) {
|
|
204
|
+
entries.push([facet, value]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The live search endpoint: `GET /api/docs/search?q=…`. Runs the same query
|
|
212
|
+
* `search_docs` runs, over an index built once per snapshot and shared across
|
|
213
|
+
* requests. A missing or blank `q` is a 400 problem.
|
|
214
|
+
*/
|
|
215
|
+
export const createSearchHandler = (
|
|
216
|
+
data: McpData
|
|
217
|
+
): ((request: Request) => Promise<Response>) => {
|
|
218
|
+
const index = createIndexProvider(data.documents, data.defaultLocale);
|
|
219
|
+
return async (request: Request): Promise<Response> => {
|
|
220
|
+
const url = new URL(request.url);
|
|
221
|
+
const params = url.searchParams;
|
|
222
|
+
const query = (params.get("q") ?? "").trim();
|
|
223
|
+
if (!query) {
|
|
224
|
+
return problemResponse({
|
|
225
|
+
code: "MISSING_QUERY",
|
|
226
|
+
detail: 'The "q" query parameter is required and must not be blank.',
|
|
227
|
+
instance: url.pathname,
|
|
228
|
+
resolution: `Repeat the request with ?q=<search terms>, e.g. ${siteUrl(API_SEARCH_PATH, data)}?q=install.`,
|
|
229
|
+
status: 400,
|
|
230
|
+
title: "Missing search query",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const input = TOOL_INPUTS.search_docs.parse({
|
|
234
|
+
contentTypes: listParam(params, "contentTypes"),
|
|
235
|
+
filters: filtersParam(params),
|
|
236
|
+
limit: params.get("limit") ?? undefined,
|
|
237
|
+
locale: params.get("locale") ?? undefined,
|
|
238
|
+
query,
|
|
239
|
+
version: params.get("version") ?? undefined,
|
|
240
|
+
});
|
|
241
|
+
const results = await searchDocs(data, index, input);
|
|
242
|
+
const payload: ApiSearchResponse = {
|
|
243
|
+
count: results.length,
|
|
244
|
+
query,
|
|
245
|
+
results,
|
|
246
|
+
};
|
|
247
|
+
return jsonResponse(payload);
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The 404 for anything under `/api/` that no endpoint answers — the catch-all
|
|
253
|
+
* behind every live route on server output, so an agent probing the API
|
|
254
|
+
* namespace gets a problem document instead of the HTML not-found page.
|
|
255
|
+
*/
|
|
256
|
+
export const apiNotFoundResponse = (
|
|
257
|
+
request: Request,
|
|
258
|
+
context: ApiSiteContext
|
|
259
|
+
): Response => {
|
|
260
|
+
const { pathname } = new URL(request.url);
|
|
261
|
+
return problemResponse({
|
|
262
|
+
code: "API_ROUTE_NOT_FOUND",
|
|
263
|
+
detail: `No API route exists at ${pathname}.`,
|
|
264
|
+
instance: pathname,
|
|
265
|
+
links: [
|
|
266
|
+
{ href: siteUrl(OPENAPI_PATH, context), label: "OpenAPI description" },
|
|
267
|
+
{ href: siteUrl(API_PAGES_PATH, context), label: "Page index" },
|
|
268
|
+
],
|
|
269
|
+
resolution: `Discover the available operations through the OpenAPI description at ${siteUrl(OPENAPI_PATH, context)}, or list every page at ${siteUrl(API_PAGES_PATH, context)}.`,
|
|
270
|
+
status: 404,
|
|
271
|
+
title: "API route not found",
|
|
272
|
+
});
|
|
273
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the JSON docs API and its OpenAPI description are served. Base-less
|
|
3
|
+
* (like every route Blume emits); callers layer `deployment.base` on top.
|
|
4
|
+
* Under `/api/` alongside the Ask AI endpoint (`/api/ask`) so the namespace a
|
|
5
|
+
* Blume site reserves for live endpoints stays one prefix, and under its own
|
|
6
|
+
* `docs` segment so a search provider's proxy at `/api/search` never collides.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const OPENAPI_PATH = "/openapi.json";
|
|
10
|
+
export const API_BASE = "/api/docs";
|
|
11
|
+
export const API_PAGES_PATH = `${API_BASE}/pages.json`;
|
|
12
|
+
export const API_PAGE_PATH = `${API_BASE}/pages/{route}.json`;
|
|
13
|
+
export const API_NAVIGATION_PATH = `${API_BASE}/navigation.json`;
|
|
14
|
+
export const API_SEARCH_PATH = `${API_BASE}/search`;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9457 problem details — the one error shape every Blume JSON endpoint
|
|
3
|
+
* returns, so an agent that hits a missing page, a bad query, or an unknown
|
|
4
|
+
* API route always gets a stable machine-readable `code`, a human-readable
|
|
5
|
+
* `detail`, and a `resolution` telling it where to go next.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PROBLEM_TYPE = "application/problem+json";
|
|
9
|
+
|
|
10
|
+
/** A recovery link carried on a problem (the 404's "where to look next"). */
|
|
11
|
+
export interface ProblemLink {
|
|
12
|
+
href: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Problem {
|
|
17
|
+
/** Stable, screaming-snake error code for programmatic handling. */
|
|
18
|
+
code: string;
|
|
19
|
+
/** Human-readable explanation specific to this occurrence. */
|
|
20
|
+
detail: string;
|
|
21
|
+
/** The request path the problem occurred on, when known. */
|
|
22
|
+
instance?: string;
|
|
23
|
+
/** Recovery links, when the problem has somewhere useful to send the caller. */
|
|
24
|
+
links?: ProblemLink[];
|
|
25
|
+
/** What to do next — the hint agents act on. */
|
|
26
|
+
resolution: string;
|
|
27
|
+
status: number;
|
|
28
|
+
title: string;
|
|
29
|
+
/** Problem type URI; `about:blank` when the status code says it all. */
|
|
30
|
+
type: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The problem's members, with `type` defaulting to `about:blank`. */
|
|
34
|
+
export const problem = (
|
|
35
|
+
input: Omit<Problem, "type"> & { type?: string }
|
|
36
|
+
): Problem => {
|
|
37
|
+
const body: Problem = {
|
|
38
|
+
code: input.code,
|
|
39
|
+
detail: input.detail,
|
|
40
|
+
resolution: input.resolution,
|
|
41
|
+
status: input.status,
|
|
42
|
+
title: input.title,
|
|
43
|
+
type: input.type ?? "about:blank",
|
|
44
|
+
};
|
|
45
|
+
if (input.instance !== undefined) {
|
|
46
|
+
body.instance = input.instance;
|
|
47
|
+
}
|
|
48
|
+
if (input.links !== undefined) {
|
|
49
|
+
body.links = input.links;
|
|
50
|
+
}
|
|
51
|
+
return body;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A `Response` carrying the problem as `application/problem+json`. */
|
|
55
|
+
export const problemResponse = (
|
|
56
|
+
input: Omit<Problem, "type"> & { type?: string }
|
|
57
|
+
): Response => {
|
|
58
|
+
const body = problem(input);
|
|
59
|
+
return new Response(`${JSON.stringify(body, null, 2)}\n`, {
|
|
60
|
+
headers: { "Content-Type": `${PROBLEM_TYPE}; charset=utf-8` },
|
|
61
|
+
status: body.status,
|
|
62
|
+
});
|
|
63
|
+
};
|