blume 1.6.1 → 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 +6 -0
- package/dist/cli/index.js +894 -130
- package/dist/cli/index.js.map +18 -14
- 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/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/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
|
@@ -690,6 +690,15 @@ export interface McpConfig {
|
|
|
690
690
|
* hosted MCP server.
|
|
691
691
|
*/
|
|
692
692
|
export interface AiConfig {
|
|
693
|
+
/**
|
|
694
|
+
* The JSON docs API — the REST twin of the MCP tools. Serves the page index
|
|
695
|
+
* (`/api/docs/pages.json`), each page as JSON (`/api/docs/pages/{route}.json`),
|
|
696
|
+
* and the navigation tree (`/api/docs/navigation.json`) as prerendered
|
|
697
|
+
* files, plus full-text search (`/api/docs/search?q=`) on server output,
|
|
698
|
+
* all described by an OpenAPI document at `/openapi.json`. Errors are RFC
|
|
699
|
+
* 9457 problem details. Defaults to `true`; set `false` to publish none of it.
|
|
700
|
+
*/
|
|
701
|
+
api?: boolean;
|
|
693
702
|
/** The Ask AI chat assistant. */
|
|
694
703
|
ask?: AskConfig;
|
|
695
704
|
/**
|
|
@@ -79,6 +79,7 @@ declare const uiStringsObject: z.ZodObject<{
|
|
|
79
79
|
toggleTheme: z.ZodDefault<z.ZodString>;
|
|
80
80
|
}, z.core.$strip>>;
|
|
81
81
|
notFound: z.ZodPrefault<z.ZodObject<{
|
|
82
|
+
api: z.ZodDefault<z.ZodString>;
|
|
82
83
|
description: z.ZodDefault<z.ZodString>;
|
|
83
84
|
home: z.ZodDefault<z.ZodString>;
|
|
84
85
|
llms: z.ZodDefault<z.ZodString>;
|
|
@@ -192,6 +193,7 @@ export declare const uiStringsSchema: z.ZodPrefault<z.ZodObject<{
|
|
|
192
193
|
toggleTheme: z.ZodDefault<z.ZodString>;
|
|
193
194
|
}, z.core.$strip>>;
|
|
194
195
|
notFound: z.ZodPrefault<z.ZodObject<{
|
|
196
|
+
api: z.ZodDefault<z.ZodString>;
|
|
195
197
|
description: z.ZodDefault<z.ZodString>;
|
|
196
198
|
home: z.ZodDefault<z.ZodString>;
|
|
197
199
|
llms: z.ZodDefault<z.ZodString>;
|
|
@@ -260,6 +260,7 @@ export declare const searchProviders: readonly ["orama", "pagefind", "flexsearch
|
|
|
260
260
|
/** Ask AI backends. `gateway` (default) routes through the Vercel AI Gateway. */
|
|
261
261
|
export declare const askAiProviders: readonly ["gateway", "openrouter", "llmgateway", "inkeep", "openai-compatible"];
|
|
262
262
|
declare const aiConfigSchema: z.ZodObject<{
|
|
263
|
+
api: z.ZodDefault<z.ZodBoolean>;
|
|
263
264
|
ask: z.ZodOptional<z.ZodObject<{
|
|
264
265
|
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
265
266
|
baseUrl: z.ZodOptional<z.ZodURL>;
|
|
@@ -500,6 +501,7 @@ declare const graphqlSourceSchema: z.ZodObject<{
|
|
|
500
501
|
export type GraphqlSource = z.input<typeof graphqlSourceSchema>;
|
|
501
502
|
export declare const blumeConfigSchema: z.ZodObject<{
|
|
502
503
|
ai: z.ZodPrefault<z.ZodObject<{
|
|
504
|
+
api: z.ZodDefault<z.ZodBoolean>;
|
|
503
505
|
ask: z.ZodOptional<z.ZodObject<{
|
|
504
506
|
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
505
507
|
baseUrl: z.ZodOptional<z.ZodURL>;
|
|
@@ -274,7 +274,7 @@ import data from "blume:data";
|
|
|
274
274
|
|
|
275
275
|
Blume ships a default **not found** page out of the box: a centered "404" message wrapped in the site chrome (header, search, theme), served for any unmatched URL. `blume build` writes it to `404.html`, which static hosts serve automatically, and `blume dev` shows it for unknown routes. Under the message, a **Where to look next** list links every top-level section plus the `sitemap.xml` and [`llms.txt`](/docs/configuration/ai#llmstxt) indexes when they exist, so a reader — or an agent that followed a stale URL — has a way back.
|
|
276
276
|
|
|
277
|
-
The page also has a Markdown twin at `/404.md` with the same recovery links (absolute URLs once [`deployment.site`](/docs/deployment) is set). On a [Vercel server build](/docs/deployment#server-rendering), a request for a missing page that sends [`Accept: text/markdown`](/docs/configuration/ai#content-negotiation), or asks for a `.md` URL no page backs, gets
|
|
277
|
+
The page also has a Markdown twin at `/404.md` and a JSON twin at `/404.json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details) with the same recovery links (absolute URLs once [`deployment.site`](/docs/deployment) is set), plus the [`openapi.json`](/docs/configuration/ai#json-api) description when the JSON API is on. On a [Vercel server build](/docs/deployment#server-rendering), a request for a missing page that sends [`Accept: text/markdown`](/docs/configuration/ai#content-negotiation), or asks for a `.md` URL no page backs, gets the Markdown body with the `404` status instead of the HTML shell; one that sends `Accept: application/json`, or asks for a `.json` URL no file backs, gets the problem document — so an agent never has to parse a page of chrome to learn where to go next.
|
|
278
278
|
|
|
279
279
|
To replace it with your own, add a `pages/404.astro`. It owns the `/404` route the same way `pages/changelog.astro` takes over the changelog — your page wins and the default is dropped. Build it like any other custom page, in `PageLayout` or `RootLayout`:
|
|
280
280
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: AI
|
|
3
|
-
description: Make your docs machine-readable with llms.txt, add an optional in-page Ask AI assistant, and expose a hosted MCP server for coding agents.
|
|
3
|
+
description: Make your docs machine-readable with llms.txt and a JSON API described by OpenAPI, add an optional in-page Ask AI assistant, and expose a hosted MCP server for coding agents.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Blume has a few AI features: machine-readable docs for external tools (`llms.txt
|
|
6
|
+
Blume has a few AI features: machine-readable docs for external tools (`llms.txt` and a [JSON API](#json-api) with an OpenAPI description, both on by default), an in-page **Ask AI** assistant, and a hosted **MCP server** for coding agents. Ask AI and MCP are opt-in, and static docs stay fully static until you turn a feature on.
|
|
7
7
|
|
|
8
8
|
## llms.txt
|
|
9
9
|
|
|
@@ -364,6 +364,52 @@ A static build with `ai.mcp.enabled` fails fast with a message telling you to se
|
|
|
364
364
|
claude mcp add --transport http my-docs https://docs.example.com/mcp
|
|
365
365
|
```
|
|
366
366
|
|
|
367
|
+
## JSON API
|
|
368
|
+
|
|
369
|
+
Every Blume site also serves its docs as a small read-only **JSON API** — the REST twin of the [MCP server](#mcp-server)'s tools, over the same page snapshot, for agents and function-calling frameworks that speak plain HTTP rather than MCP. It's on by default and needs no configuration:
|
|
370
|
+
|
|
371
|
+
| Endpoint | Returns |
|
|
372
|
+
| --- | --- |
|
|
373
|
+
| `/api/docs/pages.json` | Every page with its route, title, description, content type, locale, facets, and the URLs of its rendered, Markdown, and JSON forms. |
|
|
374
|
+
| `/api/docs/pages/{route}.json` | One page: its index entry plus the agent Markdown (the same body `get_page` returns). `{route}` is the page route without the leading slash, `index` for home. |
|
|
375
|
+
| `/api/docs/navigation.json` | The navigation tree — header tabs and the sidebar hierarchy. |
|
|
376
|
+
| `/api/docs/search?q=` | Full-text search, with the same `limit`, `contentTypes`, `locale`, `version`, and `filters[key]` scoping as `search_docs`. Server output only. |
|
|
377
|
+
| `/openapi.json` | The OpenAPI 3.1 description of the whole machine-readable surface. |
|
|
378
|
+
|
|
379
|
+
The page index, per-page documents, and navigation are prerendered, so a static site serves them as files from any host. Search is a live endpoint and exists only under [server output](/docs/deployment#server-rendering), where it runs the same index `search_docs` does. Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`application/problem+json`) with a stable `code`, a `detail`, and a `resolution` hint telling the agent where to go next — a missing page, a blank search query, or on server output any `/api/…` URL no endpoint answers:
|
|
380
|
+
|
|
381
|
+
```json
|
|
382
|
+
{
|
|
383
|
+
"code": "API_ROUTE_NOT_FOUND",
|
|
384
|
+
"detail": "No API route exists at /api/nope.",
|
|
385
|
+
"instance": "/api/nope",
|
|
386
|
+
"links": [
|
|
387
|
+
{
|
|
388
|
+
"href": "https://docs.example.com/openapi.json",
|
|
389
|
+
"label": "OpenAPI description"
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
"href": "https://docs.example.com/api/docs/pages.json",
|
|
393
|
+
"label": "Page index"
|
|
394
|
+
}
|
|
395
|
+
],
|
|
396
|
+
"resolution": "Discover the available operations through the OpenAPI description at https://docs.example.com/openapi.json, or list every page at https://docs.example.com/api/docs/pages.json.",
|
|
397
|
+
"status": 404,
|
|
398
|
+
"title": "API route not found",
|
|
399
|
+
"type": "about:blank"
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The **OpenAPI document** at `/openapi.json` is generated per build from your config, so it describes only what the deployed site serves: every JSON endpoint with a unique `operationId`, typed parameters, and response schemas, plus the text surfaces alongside — the [`.md` mirrors](#raw-markdown), [`llms.txt`](#llmstxt) and `llms-full.txt`, [`agent-readability.json`](#agent-readability) — and the [MCP endpoint](#mcp-server) when it's enabled. Frameworks that build tools from an OpenAPI description get the same reach an MCP client has. The document is linked from the [API catalog](#api-catalog), the [readability manifest](#agent-readability), the homepage `Link` header as `rel="service-desc"`, and `llms.txt`.
|
|
404
|
+
|
|
405
|
+
None of this touches your own [API reference](/docs/advanced/api-reference): a documented spec is rendered into pages, never served at `/openapi.json`, and the catalog lists both. A `public/openapi.json` you ship yourself takes over that route (the JSON endpoints stay). The `/api/…` catch-all steps aside when a docs section is served from the `/api` namespace (`content/api/overview.md`) or a custom page owns a rest route under `/api/`, so those pages keep winning. Set `ai.api` to `false` to publish none of it:
|
|
406
|
+
|
|
407
|
+
```ts blume.config.ts lineNumbers
|
|
408
|
+
ai: {
|
|
409
|
+
api: false,
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
367
413
|
## Agent readability
|
|
368
414
|
|
|
369
415
|
Blume writes an **`/agent-readability.json`** manifest at your site root that indexes the agent-facing surface described on this page — so an agent can discover it in a single fetch instead of guessing at conventions or scraping HTML. Like `llms.txt`, it's on by default:
|
|
@@ -374,7 +420,7 @@ seo: {
|
|
|
374
420
|
}
|
|
375
421
|
```
|
|
376
422
|
|
|
377
|
-
The manifest lists only what you've enabled — the [raw Markdown](#raw-markdown) mirror pattern, [`llms.txt`](#llmstxt) and `llms-full.txt`, the [MCP server](#mcp-server) and its discovery document, the [Ask AI](#ask-ai) endpoint, the [sitemap](/docs/configuration/seo#sitemap), and [RSS feeds](/docs/configuration/seo#rss-feeds) — alongside your site name, description, source repository, and the [content-signal](/docs/configuration/seo#content-signals) usage policy. URLs are absolute when [`deployment.site`](/docs/deployment) is set and root-relative otherwise:
|
|
423
|
+
The manifest lists only what you've enabled — the [raw Markdown](#raw-markdown) mirror pattern, the [JSON API](#json-api) and its OpenAPI description, [`llms.txt`](#llmstxt) and `llms-full.txt`, the [MCP server](#mcp-server) and its discovery document, the [Ask AI](#ask-ai) endpoint, the [sitemap](/docs/configuration/seo#sitemap), and [RSS feeds](/docs/configuration/seo#rss-feeds) — alongside your site name, description, source repository, and the [content-signal](/docs/configuration/seo#content-signals) usage policy. URLs are absolute when [`deployment.site`](/docs/deployment) is set and root-relative otherwise:
|
|
378
424
|
|
|
379
425
|
```json agent-readability.json
|
|
380
426
|
{
|
|
@@ -383,6 +429,11 @@ The manifest lists only what you've enabled — the [raw Markdown](#raw-markdown
|
|
|
383
429
|
"contentNegotiation": "text/markdown",
|
|
384
430
|
"pattern": "https://docs.example.com/{route}.md"
|
|
385
431
|
},
|
|
432
|
+
"api": {
|
|
433
|
+
"openapi": "https://docs.example.com/openapi.json",
|
|
434
|
+
"pages": "https://docs.example.com/api/docs/pages.json",
|
|
435
|
+
"search": "https://docs.example.com/api/docs/search"
|
|
436
|
+
},
|
|
386
437
|
"llmsFullTxt": "https://docs.example.com/llms-full.txt",
|
|
387
438
|
"llmsTxt": "https://docs.example.com/llms.txt",
|
|
388
439
|
"mcp": {
|
|
@@ -408,12 +459,14 @@ Set `seo.agentReadability` to `false` to skip it, or ship your own `public/agent
|
|
|
408
459
|
Agents that probe a site don't know to look for the manifest — so Blume also advertises it in an [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) `Link` response header on the homepage, using IANA-registered relation types:
|
|
409
460
|
|
|
410
461
|
```http
|
|
411
|
-
Link:
|
|
462
|
+
Link: </.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json",
|
|
463
|
+
</openapi.json>; rel="service-desc"; type="application/json",
|
|
464
|
+
</agent-readability.json>; rel="describedby"; type="application/json",
|
|
412
465
|
</llms.txt>; rel="describedby"; type="text/plain",
|
|
413
466
|
</index.md>; rel="alternate"; type="text/markdown"
|
|
414
467
|
```
|
|
415
468
|
|
|
416
|
-
Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror — the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page.
|
|
469
|
+
Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror — the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. The `service-desc` link ([RFC 8631](https://www.rfc-editor.org/rfc/rfc8631)) points at the [JSON API](#json-api)'s OpenAPI description, and `api-catalog` at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules.
|
|
417
470
|
|
|
418
471
|
Not every agent enters through the root, though — one following a search result or a shared link lands on a deep page and never sees the homepage header. So every rendered page also carries the same discovery links in its HTML `<head>`, using the same IANA-registered relations:
|
|
419
472
|
|
|
@@ -431,7 +484,7 @@ Here the `alternate` link points at _that page's own_ [raw-Markdown mirror](#raw
|
|
|
431
484
|
|
|
432
485
|
### API catalog
|
|
433
486
|
|
|
434
|
-
When the site publishes APIs, Blume generates an [RFC 9727](https://www.rfc-editor.org/rfc/rfc9727) API catalog at `/.well-known/api-catalog` — a [linkset](https://www.rfc-editor.org/rfc/rfc9264) that lets agents enumerate your APIs from the domain alone, served with its registered `application/linkset+json` media type on every build surface. There's nothing to configure: the catalog is derived from what's already in `blume.config.ts`. Each [OpenAPI or AsyncAPI reference](/docs/advanced/api-reference) becomes an entry anchored at its rendered docs route, with `service-doc` pointing at those docs and `service-desc` at the spec when it lives at a fetchable URL; the [MCP server](#mcp-server) becomes an entry with its discovery document as the service description:
|
|
487
|
+
When the site publishes APIs, Blume generates an [RFC 9727](https://www.rfc-editor.org/rfc/rfc9727) API catalog at `/.well-known/api-catalog` — a [linkset](https://www.rfc-editor.org/rfc/rfc9264) that lets agents enumerate your APIs from the domain alone, served with its registered `application/linkset+json` media type on every build surface. There's nothing to configure: the catalog is derived from what's already in `blume.config.ts`. Each [OpenAPI or AsyncAPI reference](/docs/advanced/api-reference) becomes an entry anchored at its rendered docs route, with `service-doc` pointing at those docs and `service-desc` at the spec when it lives at a fetchable URL; the site's own [JSON API](#json-api) becomes an entry described by its `/openapi.json`; and the [MCP server](#mcp-server) becomes an entry with its discovery document as the service description:
|
|
435
488
|
|
|
436
489
|
```json .well-known/api-catalog
|
|
437
490
|
{
|
|
@@ -443,6 +496,18 @@ When the site publishes APIs, Blume generates an [RFC 9727](https://www.rfc-edit
|
|
|
443
496
|
],
|
|
444
497
|
"service-desc": [{ "href": "https://api.example.com/openapi.json" }]
|
|
445
498
|
},
|
|
499
|
+
{
|
|
500
|
+
"anchor": "https://docs.example.com/api/docs",
|
|
501
|
+
"service-desc": [
|
|
502
|
+
{
|
|
503
|
+
"href": "https://docs.example.com/openapi.json",
|
|
504
|
+
"type": "application/json"
|
|
505
|
+
}
|
|
506
|
+
],
|
|
507
|
+
"service-doc": [
|
|
508
|
+
{ "href": "https://docs.example.com/", "type": "text/html" }
|
|
509
|
+
]
|
|
510
|
+
},
|
|
446
511
|
{
|
|
447
512
|
"anchor": "https://docs.example.com/mcp",
|
|
448
513
|
"service-desc": [
|
|
@@ -459,7 +524,7 @@ When the site publishes APIs, Blume generates an [RFC 9727](https://www.rfc-edit
|
|
|
459
524
|
}
|
|
460
525
|
```
|
|
461
526
|
|
|
462
|
-
A site with no API references
|
|
527
|
+
A site with no API references, no MCP server, and the [JSON API](#json-api) turned off emits no catalog — there'd be nothing in it. As everywhere, a `public/.well-known/api-catalog` file you ship yourself wins over the generated one.
|
|
463
528
|
|
|
464
529
|
### WebMCP
|
|
465
530
|
|
package/docs/index.mdx
CHANGED
|
@@ -31,7 +31,7 @@ Blume builds on Astro and Vite and renders static HTML by default — fast, cach
|
|
|
31
31
|
|
|
32
32
|
### AI-ready out of the box
|
|
33
33
|
|
|
34
|
-
Every Blume site speaks fluent machine. It emits [`llms.txt` and `llms-full.txt`](/docs/configuration/ai), serves any page's raw Markdown by appending `.md` to its URL, and gives readers **Copy as Markdown** and **Open in chat** actions on every page. Add an optional in-page **Ask AI** assistant, or host an [**MCP server**](/docs/configuration/ai#mcp-server) so coding agents like Claude Code and Cursor can search and read your docs directly — no scraping, no hosted service. Your Markdown is the source of truth for both humans and models.
|
|
34
|
+
Every Blume site speaks fluent machine. It emits [`llms.txt` and `llms-full.txt`](/docs/configuration/ai), serves any page's raw Markdown by appending `.md` to its URL, exposes a [JSON API described by OpenAPI](/docs/configuration/ai#json-api), and gives readers **Copy as Markdown** and **Open in chat** actions on every page. Add an optional in-page **Ask AI** assistant, or host an [**MCP server**](/docs/configuration/ai#mcp-server) so coding agents like Claude Code and Cursor can search and read your docs directly — no scraping, no hosted service. Your Markdown is the source of truth for both humans and models.
|
|
35
35
|
|
|
36
36
|
### Zero configuration — even the template
|
|
37
37
|
|
|
@@ -45,7 +45,7 @@ Your [`blume.config.ts`](/docs/configuration) and every [`meta.ts`](/docs/conten
|
|
|
45
45
|
|
|
46
46
|
- **Components** — callouts, cards, steps, tabs, accordions, badges, file trees, and parameter tables, usable in MDX with [no imports](/docs/content/components).
|
|
47
47
|
- **Local search** — Orama works in dev and production; Pagefind is one flag away for large sites. No hosted index.
|
|
48
|
-
- **AI** — [`llms.txt`, raw Markdown URLs, Copy as Markdown, Open in chat, an Ask AI assistant, and a hosted MCP server](/docs/configuration/ai).
|
|
48
|
+
- **AI** — [`llms.txt`, raw Markdown URLs, a JSON API with an OpenAPI description, Copy as Markdown, Open in chat, an Ask AI assistant, and a hosted MCP server](/docs/configuration/ai).
|
|
49
49
|
- **Navigation** — inferred from files, refined with `meta.ts` or config.
|
|
50
50
|
- **SEO** — metadata, Open Graph images, RSS feeds, and JSON-LD, [built in](/docs/configuration/seo).
|
|
51
51
|
- **Customization** — component overrides, React islands, custom pages, theme tokens, and a source-component registry via `blume add`.
|
package/package.json
CHANGED
package/skills/blume/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ The core idea: **the framework _is_ the template.** There's no starter to clone
|
|
|
12
12
|
## What makes it different
|
|
13
13
|
|
|
14
14
|
- **Fast by default** — Static HTML on Astro/Vite. The core theme ships no client framework JS so pages score well on Core Web Vitals out of the box. You opt into server features only when you need them.
|
|
15
|
-
- **AI-ready out of the box** — Emits `llms.txt`/`llms-full.txt`, serves any page's raw Markdown by appending `.md` to its URL, offers **Copy as Markdown** and **Open in chat** on every page, and can host an optional **Ask AI** assistant or an **MCP server** so coding agents read your docs directly.
|
|
15
|
+
- **AI-ready out of the box** — Emits `llms.txt`/`llms-full.txt`, serves any page's raw Markdown by appending `.md` to its URL, publishes a JSON docs API (`/api/docs/…`) described by an OpenAPI document at `/openapi.json`, offers **Copy as Markdown** and **Open in chat** on every page, and can host an optional **Ask AI** assistant or an **MCP server** so coding agents read your docs directly.
|
|
16
16
|
- **Zero configuration — even the template** — A folder of docs is a complete project. Navigation is inferred from files, search works in dev and production with no hosted service, and theming is a handful of tokens.
|
|
17
17
|
- **Type-safe to the core** — `blume.config.ts` and every `meta.ts` are real TypeScript, validated by a schema and authored with `defineConfig` and `defineMeta`. Your editor autocompletes options and catches mistakes before a build.
|
|
18
18
|
|
|
@@ -52,7 +52,7 @@ Navigation, search, and page metadata are inferred from your files as you add th
|
|
|
52
52
|
|
|
53
53
|
- **Components** — callouts, cards, steps, tabs, accordions, badges, file trees, and parameter tables, usable in MDX with no imports.
|
|
54
54
|
- **Local search** — Orama in dev and production; Pagefind is one flag away for large sites. No hosted index.
|
|
55
|
-
- **AI** — `llms.txt`, raw Markdown URLs, Copy as Markdown, Open in chat, an Ask AI assistant, and an MCP server endpoint served by the docs site itself.
|
|
55
|
+
- **AI** — `llms.txt`, raw Markdown URLs, a JSON docs API with an OpenAPI description, Copy as Markdown, Open in chat, an Ask AI assistant, and an MCP server endpoint served by the docs site itself.
|
|
56
56
|
- **Navigation** — inferred from files, refined with `meta.ts` or config.
|
|
57
57
|
- **SEO** — metadata, Open Graph images, RSS feeds, and JSON-LD.
|
|
58
58
|
- **Customization** — component overrides, React islands, custom pages, theme tokens, and a source-component registry via `blume add`.
|
|
@@ -5,6 +5,7 @@ import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
|
|
|
5
5
|
import { absoluteUrl } from "../core/site-url.ts";
|
|
6
6
|
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
7
7
|
import { hasApiCatalog } from "./api-catalog.ts";
|
|
8
|
+
import { API_PAGES_PATH, API_SEARCH_PATH, OPENAPI_PATH } from "./api/paths.ts";
|
|
8
9
|
|
|
9
10
|
/** Token map for the machine-readable content-usage echo. */
|
|
10
11
|
const USAGE_TOKENS: [keyof ContentSignalPolicy, string][] = [
|
|
@@ -55,9 +56,17 @@ interface WellKnownArtifacts {
|
|
|
55
56
|
agentSkills?: string;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/** The JSON docs API's entry points; `search` exists on server output only. */
|
|
60
|
+
interface ApiArtifact {
|
|
61
|
+
openapi: string;
|
|
62
|
+
pages: string;
|
|
63
|
+
search?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
/** The agent-facing artifact index the manifest publishes. */
|
|
59
67
|
interface AgentArtifacts extends WellKnownArtifacts {
|
|
60
68
|
markdown: { contentNegotiation?: string; pattern: string };
|
|
69
|
+
api?: ApiArtifact;
|
|
61
70
|
llmsFullTxt?: string;
|
|
62
71
|
llmsTxt?: string;
|
|
63
72
|
mcp?: { discovery: string; url: string };
|
|
@@ -77,6 +86,47 @@ export interface AgentReadabilityManifest {
|
|
|
77
86
|
repository?: string;
|
|
78
87
|
}
|
|
79
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The raw-Markdown mirror pattern. `Accept: text/markdown` negotiation is
|
|
91
|
+
* advertised only where the deployed site actually honors it — a Vercel
|
|
92
|
+
* server build, whose routing config gets the rewrite rules (see
|
|
93
|
+
* `deploy/vercel-negotiation.ts`), and a Cloudflare server build, whose
|
|
94
|
+
* deploy bundle gets a wrapper Worker (see `deploy/cloudflare-negotiation.ts`).
|
|
95
|
+
* Static builds and other adapters serve prerendered pages from a static
|
|
96
|
+
* layer with no request-time hook, so agents there should fetch the `.md`
|
|
97
|
+
* pattern directly.
|
|
98
|
+
*/
|
|
99
|
+
const markdownArtifact = (
|
|
100
|
+
config: BlumeProject["config"],
|
|
101
|
+
abs: (path: string) => string
|
|
102
|
+
): AgentArtifacts["markdown"] => {
|
|
103
|
+
const negotiates =
|
|
104
|
+
config.deployment.output === "server" &&
|
|
105
|
+
(config.deployment.adapter === "vercel" ||
|
|
106
|
+
config.deployment.adapter === "cloudflare");
|
|
107
|
+
return negotiates
|
|
108
|
+
? { contentNegotiation: "text/markdown", pattern: abs("/{route}.md") }
|
|
109
|
+
: { pattern: abs("/{route}.md") };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The JSON docs API's entry points, or null when the API is off. */
|
|
113
|
+
const apiArtifact = (
|
|
114
|
+
config: BlumeProject["config"],
|
|
115
|
+
abs: (path: string) => string
|
|
116
|
+
): ApiArtifact | null => {
|
|
117
|
+
if (!config.ai.api) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const api: ApiArtifact = {
|
|
121
|
+
openapi: abs(OPENAPI_PATH),
|
|
122
|
+
pages: abs(API_PAGES_PATH),
|
|
123
|
+
};
|
|
124
|
+
if (config.deployment.output === "server") {
|
|
125
|
+
api.search = abs(API_SEARCH_PATH);
|
|
126
|
+
}
|
|
127
|
+
return api;
|
|
128
|
+
};
|
|
129
|
+
|
|
80
130
|
/** The `.well-known` discovery artifacts the site publishes, if any. */
|
|
81
131
|
const wellKnownArtifacts = (
|
|
82
132
|
config: BlumeProject["config"],
|
|
@@ -99,8 +149,9 @@ const wellKnownArtifacts = (
|
|
|
99
149
|
|
|
100
150
|
/**
|
|
101
151
|
* Build `agent-readability.json`: a root manifest that indexes the project's
|
|
102
|
-
* agent-facing surface — llms.txt, the raw-Markdown mirrors, the
|
|
103
|
-
*
|
|
152
|
+
* agent-facing surface — llms.txt, the raw-Markdown mirrors, the JSON docs
|
|
153
|
+
* API and its OpenAPI description, the MCP server, Ask AI, sitemap, and feeds
|
|
154
|
+
* — so agents can discover and cite the docs without
|
|
104
155
|
* scraping HTML. URLs are absolute when a `site` is configured and root-relative
|
|
105
156
|
* (still under `deployment.base`) otherwise. Returns null when the manifest is
|
|
106
157
|
* disabled.
|
|
@@ -122,21 +173,13 @@ export const buildAgentReadability = (
|
|
|
122
173
|
return site ? absoluteUrl(site, based) : based;
|
|
123
174
|
};
|
|
124
175
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const negotiates =
|
|
133
|
-
config.deployment.output === "server" &&
|
|
134
|
-
(config.deployment.adapter === "vercel" ||
|
|
135
|
-
config.deployment.adapter === "cloudflare");
|
|
136
|
-
const markdown: AgentArtifacts["markdown"] = negotiates
|
|
137
|
-
? { contentNegotiation: "text/markdown", pattern: abs("/{route}.md") }
|
|
138
|
-
: { pattern: abs("/{route}.md") };
|
|
139
|
-
const artifacts: AgentArtifacts = { markdown };
|
|
176
|
+
const artifacts: AgentArtifacts = {
|
|
177
|
+
markdown: markdownArtifact(config, abs),
|
|
178
|
+
};
|
|
179
|
+
const api = apiArtifact(config, abs);
|
|
180
|
+
if (api) {
|
|
181
|
+
artifacts.api = api;
|
|
182
|
+
}
|
|
140
183
|
if (config.ai.llmsTxt.enabled) {
|
|
141
184
|
artifacts.llmsFullTxt = abs("/llms-full.txt");
|
|
142
185
|
artifacts.llmsTxt = abs("/llms.txt");
|
|
@@ -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
|
+
};
|