blume 1.2.1 → 1.3.1
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 +46 -0
- package/dist/cli/index.js +1765 -603
- package/dist/cli/index.js.map +37 -28
- package/dist/types/core/config-input.d.ts +131 -11
- package/dist/types/core/config.d.ts +9 -1
- package/dist/types/core/data.d.ts +24 -5
- package/dist/types/core/i18n-ui.d.ts +58 -799
- package/dist/types/core/schema.d.ts +534 -3305
- package/dist/types/theme/fonts.d.ts +55 -11
- package/docs/02-deployment.mdx +2 -0
- package/docs/07-faq.mdx +14 -14
- package/docs/advanced/skills.mdx +2 -2
- package/docs/configuration/ai.mdx +126 -2
- package/docs/configuration/index.mdx +19 -1
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +26 -3
- package/docs/configuration/theming.mdx +44 -2
- package/docs/content/syntax.mdx +18 -2
- package/docs/reference/cli.mdx +3 -3
- package/package.json +9 -8
- package/skills/blume/SKILL.md +6 -4
- package/skills/blume-migrate/SKILL.md +5 -3
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/monorepo.md +2 -1
- package/src/ai/agent-readability.ts +31 -1
- package/src/ai/api-catalog.ts +81 -0
- package/src/ai/link-headers.ts +53 -0
- package/src/ai/llms.ts +16 -3
- package/src/ai/markdown.ts +49 -3
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +5 -4
- package/src/ai/skills.ts +193 -0
- package/src/ai/tar.ts +104 -0
- package/src/ai/web-bot-auth.ts +30 -0
- package/src/astro/generate.ts +118 -8
- package/src/astro/integration.ts +54 -14
- package/src/astro/templates.ts +184 -36
- package/src/audit/catalog.ts +20 -0
- package/src/audit/checks/dns-aid.ts +190 -0
- package/src/audit/report.ts +5 -0
- package/src/audit/run.ts +2 -0
- package/src/cli/commands/build.ts +184 -9
- package/src/cli/init/scaffold.ts +1 -1
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/webmcp.ts +203 -0
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +62 -10
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/core/config-input.ts +143 -11
- package/src/core/config.ts +17 -1
- package/src/core/content-assets.ts +199 -0
- package/src/core/data.ts +21 -5
- package/src/core/diagnostics.ts +6 -5
- package/src/core/i18n-ui.ts +19 -28
- package/src/core/project-graph.ts +6 -0
- package/src/core/schema.ts +224 -71
- package/src/core/sources/normalize.ts +5 -5
- package/src/deploy/headers.ts +45 -3
- package/src/deploy/vercel-negotiation.ts +250 -0
- package/src/markdown/mermaid.ts +7 -1
- package/src/markdown/table-wrap.ts +33 -1
- package/src/og/card.ts +91 -22
- package/src/og/derive.ts +200 -0
- package/src/og/index.ts +6 -1
- package/src/search/orama-index.ts +98 -4
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +185 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
|
@@ -9,6 +9,8 @@ Blume is a **markdown-first** documentation framework on Astro/Vite. You drop Ma
|
|
|
9
9
|
|
|
10
10
|
Your job is to convert a source docs repo into an **idiomatic** Blume project — not a 1:1 transliteration. Read this file, detect the source framework, open the matching `references/<framework>.md` for the exact mappings, and work the loop below. Report everything you drop or approximate.
|
|
11
11
|
|
|
12
|
+
Throughout this skill (including the `references/` files), **`<skill>` means the absolute path of the directory containing this SKILL.md** — resolve it from wherever you read this file (e.g. `node_modules/blume/skills/blume-migrate` or `.claude/skills/blume-migrate`). It is a placeholder to substitute, never a literal path.
|
|
13
|
+
|
|
12
14
|
## Migration philosophy
|
|
13
15
|
|
|
14
16
|
- **Target idiomatic Blume, not a mechanical port.** Prefer filesystem-derived navigation over an exhaustive explicit `navigation.sidebar`. Prefer `:::` directives over JSX callouts. Prefer Blume defaults over restating them in config.
|
|
@@ -62,7 +64,7 @@ The single biggest shift for most sources — especially Mintlify — is that **
|
|
|
62
64
|
`defineConfig({...})` — every field optional, all with defaults:
|
|
63
65
|
|
|
64
66
|
- **Site:** `title`, `description`, `logo` (string SVG, or `{ image: string | { light, dark, alt }, text, href }`), `banner` (`{ content, link, dismissible, id }` — no color/type). A logo renders beside `title` in the header, so a **wordmark logo doubles the brand** ("Acme Acme") — set `text: ""` to render the mark alone. **Prefer the string form over `{ light, dark }`:** if you have the logo SVG locally and it's monochrome (solid black or white), rewrite its `fill`/`stroke` to `currentColor` and use `logo: "/logo.svg"` — it then inherits the theme's text color and adapts to light/dark automatically, so you don't need separate light/dark files.
|
|
65
|
-
- **`theme`:** `accent` (a color string for both modes, or `{ light, dark }` per mode), `action` (color), `mode` (`light`/`dark`/`system`), `radius`, `fonts` (`{ body, display, mono }` — curated Google-font
|
|
67
|
+
- **`theme`:** `accent` (a color string for both modes, or `{ light, dark }` per mode), `action` (color), `mode` (`light`/`dark`/`system`), `radius`, `fonts` (`{ body, display, mono }` — each a curated Google-font slug, a `{ name, provider?, weights? }` object for any Google/Fontsource/Bunny/Fontshare family, or `{ name, variants: [{ src, weight?, style? }] }` for local font files), `background` and `backgroundImage` (each a string, or `{ light, dark }` per mode). The old `accentDark`/`backgroundDark`/`backgroundImageDark` fields were **merged into these per-mode objects** — a bare string still applies to both modes, so only reach for `{ light, dark }` when the two modes differ. There is **no** `theme.strict` and **no** `theme.css` config field — custom CSS goes in a project-root **`theme.css` file** (auto-picked-up), and a source's "strict appearance" flags drop.
|
|
66
68
|
- **`content`:** `root` (default `"docs"`, relative to the project dir where `blume` runs), `include`/`exclude` (arrays of globs **relative to `content.root`**; defaults `["**/*.{md,mdx}"]` / `["**/_*", "**/.*"]`), `sources` (staged sources: `filesystem`, `github-releases`, `notion`, `sanity`, `mdx-remote`, `custom` — OpenAPI is **not** one of these; it's the top-level `openapi` field), `pages` (custom `.astro` dir), `defaultType`. When docs sit directly under the project dir (no `docs/` subfolder), set `root` there and **scope `include` to the real content folders** instead of scanning everything — `references/monorepo.md` §1.
|
|
67
69
|
- **`basePath`** (top-level): a site-wide mount point (e.g. `"/docs"`) prepended to **every** route while staying invisible to the sidebar (no wrapper group). This is the right target for a source that served all docs under a prefix (Docusaurus `routeBasePath`, a Fumadocs `baseUrl` of `/docs`) — distinct from a per-source `prefix` (which adds a nav group) and from `deployment.base` (host subdirectory).
|
|
68
70
|
- **`navigation`:** `tabs`, `selectors`, `featured` (links pinned above the sidebar on every route), `sidebar` (`{ display, items }` — `display` is the global render mode above; `items` is an explicit tree), `repo`. **Avoid an explicit `navigation.sidebar` unless you have to** — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for `sidebar.items` only for a shape files genuinely can't express (see "Config-declared nesting" above).
|
|
@@ -70,7 +72,7 @@ The single biggest shift for most sources — especially Mintlify — is that **
|
|
|
70
72
|
- **Don't set `deployment.site`.** Blume auto-fills it: the dev server's `localhost` URL in dev, and the deployment URL (`VERCEL_PROJECT_PRODUCTION_URL`/`VERCEL_URL`) on Vercel. Hardcoding it in `blume.config.ts` overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a `url`/`site` field. (Sitemap still generates in production because the deploy URL is present there.)
|
|
71
73
|
- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` **collapses to one** — pick a single file and report the loss.
|
|
72
74
|
|
|
73
|
-
The schema is exported from `blume/schema`; the full field reference is in `
|
|
75
|
+
The schema is exported from `blume/schema`; the full field reference is in the `docs/configuration/` directory of the installed `blume` package (see "Full documentation" below for how to locate it).
|
|
74
76
|
|
|
75
77
|
### Icons are Lucide, period
|
|
76
78
|
|
|
@@ -160,7 +162,7 @@ A `redirects: [{ from, to, status? }]` array **in `blume.config.ts`** maps old U
|
|
|
160
162
|
|
|
161
163
|
## Full documentation
|
|
162
164
|
|
|
163
|
-
The mapping details live in `references/`: one file per source framework (`mintlify.md`, `docusaurus.md`, `fumadocs.md`, `nextra.md`, `starlight.md`), plus **`monorepo.md`** for host-repo integration (content-layout detection, pnpm `minimumReleaseAge`, frozen-lockfile regeneration, the Vercel monorepo recipe, and the Astro/Vite patch). The Mintlify icon + frontmatter pass is automated by **`scripts/mintlify-codemod.mjs`** (zero-dependency, deterministic, idempotent; `--write` to apply). The authoritative Blume docs are bundled in the installed package at
|
|
165
|
+
The mapping details live in `references/`: one file per source framework (`mintlify.md`, `docusaurus.md`, `fumadocs.md`, `nextra.md`, `starlight.md`), plus **`monorepo.md`** for host-repo integration (content-layout detection, pnpm `minimumReleaseAge`, frozen-lockfile regeneration, the Vercel monorepo recipe, and the Astro/Vite patch). The Mintlify icon + frontmatter pass is automated by **`scripts/mintlify-codemod.mjs`** (zero-dependency, deterministic, idempotent; `--write` to apply). The authoritative Blume docs are bundled in the installed package's **`docs/` directory** — not necessarily at the repository root: in a workspace monorepo (pnpm especially) the package lives in the depending workspace's `node_modules` (e.g. `apps/docs/node_modules/blume/docs`); `node -e "console.log(require.resolve('blume/package.json'))"` run from the depending package prints the exact location. (In a repo checkout of Blume itself, the docs source is `apps/docs/content/docs`.) The most relevant pages:
|
|
164
166
|
|
|
165
167
|
- `configuration/index.mdx` — every `blume.config.ts` field.
|
|
166
168
|
- `content/navigation.mdx` — the sidebar/tabs/selectors model.
|
|
@@ -27,7 +27,7 @@ Resolve `$ref` includes first (Mintlify splits config across files). Map only wh
|
|
|
27
27
|
| `background.color.{light,dark}` | `theme.background` (`{ light, dark }`) | one field now: `background: { light: …, dark: … }` (a bare string applies to both) |
|
|
28
28
|
| `background.image` | `theme.backgroundImage` (`{ light, dark }`) | same per-mode shape as `background` |
|
|
29
29
|
| `background.decoration` | **drop** | no Blume equivalent |
|
|
30
|
-
| `fonts.family` / `fonts.{heading,body}.family` | `theme.fonts.{display,body}` |
|
|
30
|
+
| `fonts.family` / `fonts.{heading,body}.family` | `theme.fonts.{display,body}` | a curated slug (kebab-case, e.g. `space-grotesk`) maps directly; any other Google family maps to the object form `{ name: "Family Name" }`; a self-hosted font (`fonts.*.src` URLs) maps to `{ name, variants: [{ src, weight }] }` after downloading the files into the project |
|
|
31
31
|
| `banner` | `banner` (`{ content, dismissible, id, link }`) | **only** those keys — drop `banner.color`/`banner.type` |
|
|
32
32
|
| `styling.latex: true` | **drop the field** — block math `$$…$$` renders in `.mdx` with no config | there is **no** `markdown.math` field; inline `$…$` is **not** supported — convert inline math to `$$…$$` or drop it (report) |
|
|
33
33
|
| `styling.codeblocks.theme` | `markdown.codeBlocks.theme` (`{ light, dark }`) | |
|
|
@@ -50,9 +50,9 @@ Mintlify defaults to **FontAwesome**; Blume is **Lucide-only**. Convert every ic
|
|
|
50
50
|
**Automate the frontmatter pass — don't hand-edit it.** `scripts/mintlify-codemod.mjs` (in this skill, zero-dependency) remaps every **frontmatter** `icon:` using the table below, drops brand/no-equivalent icons, and — in the same pass — drops/renames unsupported frontmatter keys (see [Frontmatter](#frontmatter)). It's deterministic and idempotent, and reports what it changed per file plus what it couldn't (unknown icons, OpenAPI-stub flags):
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
# dry run first (report only), then apply:
|
|
54
|
-
node <
|
|
55
|
-
node <
|
|
53
|
+
# <skill> = this skill's directory (the one containing SKILL.md); dry run first (report only), then apply:
|
|
54
|
+
node <skill>/scripts/mintlify-codemod.mjs <content-dir>
|
|
55
|
+
node <skill>/scripts/mintlify-codemod.mjs --write <content-dir>
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
The codemod touches **only frontmatter**. Icons in MDX **body** (`<Icon icon="…">`, `<Card icon="…">`, nav-group icons in `docs.json`) it does not see — convert those by hand using the same table. Common mappings:
|
|
@@ -153,4 +153,4 @@ Mintlify serves every top-level dir (e.g. `/images`) at the site root. Blume ser
|
|
|
153
153
|
- **`footer.socials`** → suggest the `github` config, or a Footer override.
|
|
154
154
|
- **Per-language banners** (`navigation.languages[].banner`) → no equivalent.
|
|
155
155
|
- **Dynamic redirects** (`:slug*`/`:id` params) → can't be static path-to-path; move to host rules (`_redirects`, `vercel.json`).
|
|
156
|
-
- **`<Update>`** changelog components, `iconType`, `background.decoration`, `search.prompt`, `seo.metatags
|
|
156
|
+
- **`<Update>`** changelog components, `iconType`, `background.decoration`, `search.prompt`, `seo.metatags`.
|
|
@@ -193,7 +193,8 @@ If the repo uses (or the user wants to adopt) **[Ultracite](https://www.ultracit
|
|
|
193
193
|
The fix is a committed **pnpm patch** (`patches/oxfmt@0.55.0.patch`), shipped with this skill at `assets/oxfmt@0.55.0.patch`. Unlike the Astro/Vite patch, this one is a **known, deterministic diff** — copy it in, don't regenerate it:
|
|
194
194
|
|
|
195
195
|
```bash
|
|
196
|
-
# 1. Copy the shipped patch into the target repo's patches/ dir (keep the exact filename
|
|
196
|
+
# 1. Copy the shipped patch into the target repo's patches/ dir (keep the exact filename;
|
|
197
|
+
# <skill> = this skill's directory, the one containing SKILL.md):
|
|
197
198
|
mkdir -p patches
|
|
198
199
|
cp "<skill>/assets/oxfmt@0.55.0.patch" patches/oxfmt@0.55.0.patch
|
|
199
200
|
```
|
|
@@ -2,6 +2,7 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
|
2
2
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
3
3
|
import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
|
|
4
4
|
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
5
|
+
import { hasApiCatalog } from "./api-catalog.ts";
|
|
5
6
|
|
|
6
7
|
/** Token map for the machine-readable content-usage echo. */
|
|
7
8
|
const USAGE_TOKENS: [keyof ContentSignalPolicy, string][] = [
|
|
@@ -45,6 +46,26 @@ const askApiUrl = (
|
|
|
45
46
|
: endpoint;
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
/** The `.well-known` discovery artifacts the site publishes, if any. */
|
|
50
|
+
const wellKnownArtifacts = (
|
|
51
|
+
config: BlumeProject["config"],
|
|
52
|
+
abs: (path: string) => string
|
|
53
|
+
): Record<string, string> => {
|
|
54
|
+
const artifacts: Record<string, string> = {};
|
|
55
|
+
if (config.ai.webBotAuth.keys.length > 0) {
|
|
56
|
+
artifacts.httpMessageSignaturesDirectory = abs(
|
|
57
|
+
"/.well-known/http-message-signatures-directory"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (hasApiCatalog(config)) {
|
|
61
|
+
artifacts.apiCatalog = abs("/.well-known/api-catalog");
|
|
62
|
+
}
|
|
63
|
+
if (config.ai.skills) {
|
|
64
|
+
artifacts.agentSkills = abs("/.well-known/agent-skills/index.json");
|
|
65
|
+
}
|
|
66
|
+
return artifacts;
|
|
67
|
+
};
|
|
68
|
+
|
|
48
69
|
/**
|
|
49
70
|
* Build `agent-readability.json`: a root manifest that indexes the project's
|
|
50
71
|
* agent-facing surface — llms.txt, the raw-Markdown mirrors, the MCP server,
|
|
@@ -70,9 +91,17 @@ export const buildAgentReadability = (
|
|
|
70
91
|
return site ? `${site.replace(/\/+$/u, "")}${based}` : based;
|
|
71
92
|
};
|
|
72
93
|
|
|
94
|
+
// Advertise `Accept: text/markdown` negotiation only where the deployed site
|
|
95
|
+
// actually honors it — a Vercel server build, whose routing config gets the
|
|
96
|
+
// rewrite rules (see `deploy/vercel-negotiation.ts`). Static builds and other
|
|
97
|
+
// adapters serve prerendered pages from a static layer with no request-time
|
|
98
|
+
// hook, so agents there should fetch the `.md` pattern directly.
|
|
99
|
+
const negotiates =
|
|
100
|
+
config.deployment.output === "server" &&
|
|
101
|
+
config.deployment.adapter === "vercel";
|
|
73
102
|
const artifacts: Record<string, unknown> = {
|
|
74
103
|
markdown: {
|
|
75
|
-
contentNegotiation: "text/markdown",
|
|
104
|
+
...(negotiates ? { contentNegotiation: "text/markdown" } : {}),
|
|
76
105
|
pattern: abs("/{route}.md"),
|
|
77
106
|
},
|
|
78
107
|
};
|
|
@@ -89,6 +118,7 @@ export const buildAgentReadability = (
|
|
|
89
118
|
if (config.ai.ask?.enabled) {
|
|
90
119
|
artifacts.askApi = askApiUrl(config.ai.ask.endpoint, site, abs);
|
|
91
120
|
}
|
|
121
|
+
Object.assign(artifacts, wellKnownArtifacts(config, abs));
|
|
92
122
|
if (site && config.seo.sitemap) {
|
|
93
123
|
artifacts.sitemap = abs("/sitemap.xml");
|
|
94
124
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
2
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
+
import { trimEnd } from "../core/trim.ts";
|
|
4
|
+
import { resolveReferences } from "../openapi/references.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RFC 9727 API catalog: a linkset (RFC 9264) at `/.well-known/api-catalog`
|
|
8
|
+
* enumerating the APIs a publisher offers, so agents can discover them from
|
|
9
|
+
* the domain alone. Blume already knows the site's APIs — the configured
|
|
10
|
+
* OpenAPI/AsyncAPI references and the hosted MCP server — so the catalog is
|
|
11
|
+
* generated, never hand-written: one entry per API, anchored at its docs
|
|
12
|
+
* route (the RFC's own examples anchor on developer-portal pages), with
|
|
13
|
+
* `service-doc` pointing at the rendered reference and `service-desc` at the
|
|
14
|
+
* spec when it lives at a fetchable URL.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const API_CATALOG_PATH = "/.well-known/api-catalog";
|
|
18
|
+
export const API_CATALOG_TYPE = "application/linkset+json";
|
|
19
|
+
|
|
20
|
+
/** An RFC 9264 linkset entry, restricted to the relations Blume emits. */
|
|
21
|
+
interface LinksetEntry {
|
|
22
|
+
anchor: string;
|
|
23
|
+
"service-desc"?: { href: string; type?: string }[];
|
|
24
|
+
"service-doc"?: { href: string; type: string }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HTTP_URL = /^https?:\/\//u;
|
|
28
|
+
|
|
29
|
+
/** The catalog's linkset entries; empty when the site publishes no APIs. */
|
|
30
|
+
const linksetEntries = (config: ResolvedConfig): LinksetEntry[] => {
|
|
31
|
+
const site = config.deployment.site ?? null;
|
|
32
|
+
const deployBase = normalizeBasePath(config.deployment.base);
|
|
33
|
+
const abs = (path: string): string => {
|
|
34
|
+
const based = withBasePath(deployBase, path);
|
|
35
|
+
return site ? `${trimEnd(site, "/")}${based}` : based;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const entries: LinksetEntry[] = [];
|
|
39
|
+
for (const reference of resolveReferences(config)) {
|
|
40
|
+
// Blume-rendered pages mount under `basePath`; Scalar pages stay at the
|
|
41
|
+
// raw route (see `referenceRoutes`).
|
|
42
|
+
const docRoute =
|
|
43
|
+
reference.renderer === "blume"
|
|
44
|
+
? withBasePath(reference.basePath, reference.route)
|
|
45
|
+
: reference.route;
|
|
46
|
+
const entry: LinksetEntry = {
|
|
47
|
+
anchor: abs(docRoute),
|
|
48
|
+
"service-doc": [{ href: abs(docRoute), type: "text/html" }],
|
|
49
|
+
};
|
|
50
|
+
// A local spec file is parsed into pages, not served, so only a remote
|
|
51
|
+
// spec has a fetchable `service-desc` URL.
|
|
52
|
+
if (HTTP_URL.test(reference.spec)) {
|
|
53
|
+
entry["service-desc"] = [{ href: reference.spec }];
|
|
54
|
+
}
|
|
55
|
+
entries.push(entry);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (config.ai.mcp.enabled) {
|
|
59
|
+
entries.push({
|
|
60
|
+
anchor: abs(config.ai.mcp.route),
|
|
61
|
+
"service-desc": [
|
|
62
|
+
{ href: abs("/.well-known/mcp.json"), type: "application/json" },
|
|
63
|
+
],
|
|
64
|
+
"service-doc": [{ href: abs("/"), type: "text/html" }],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return entries;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Whether the site has anything to catalog — gates every emission surface. */
|
|
71
|
+
export const hasApiCatalog = (config: ResolvedConfig): boolean =>
|
|
72
|
+
linksetEntries(config).length > 0;
|
|
73
|
+
|
|
74
|
+
/** The `application/linkset+json` document, or null when there are no APIs. */
|
|
75
|
+
export const buildApiCatalog = (config: ResolvedConfig): string | null => {
|
|
76
|
+
const linkset = linksetEntries(config);
|
|
77
|
+
if (linkset.length === 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return `${JSON.stringify({ linkset }, null, 2)}\n`;
|
|
81
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { normalizeBasePath } from "../core/base-path.ts";
|
|
2
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
+
import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The homepage `Link` response header (RFC 8288) — agent discovery for the
|
|
7
|
+
* machine-readable surface Blume already publishes. Agents probing a site read
|
|
8
|
+
* this header off `GET /` to find the resources without scraping HTML:
|
|
9
|
+
* `agent-readability.json` and `llms.txt` as `rel="describedby"`, and the
|
|
10
|
+
* homepage's raw-Markdown mirror as `rel="alternate"` (only when the home
|
|
11
|
+
* route is a content page — a user landing page has no mirror). Both rel
|
|
12
|
+
* values are IANA-registered, which agent-readiness checkers require.
|
|
13
|
+
*
|
|
14
|
+
* The header is homepage-only by design: the root response is what agents
|
|
15
|
+
* probe, and `agent-readability.json` indexes the rest of the surface (the
|
|
16
|
+
* per-route Markdown pattern, MCP, feeds) far better than per-page headers
|
|
17
|
+
* could. Targets are root-relative under `deployment.base` — RFC 8288 resolves
|
|
18
|
+
* them against the request URL. Returns null when nothing is advertisable.
|
|
19
|
+
*/
|
|
20
|
+
export const buildHomeLinkHeader = (
|
|
21
|
+
config: ResolvedConfig,
|
|
22
|
+
routePaths: readonly string[]
|
|
23
|
+
): string | null => {
|
|
24
|
+
const deployBase = normalizeBasePath(config.deployment.base);
|
|
25
|
+
const links: string[] = [];
|
|
26
|
+
// RFC 9727 §3: the api-catalog relation is how a homepage advertises the
|
|
27
|
+
// well-known catalog.
|
|
28
|
+
if (hasApiCatalog(config)) {
|
|
29
|
+
links.push(
|
|
30
|
+
`<${deployBase}${API_CATALOG_PATH}>; rel="api-catalog"; type="application/linkset+json"`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (config.seo.agentReadability) {
|
|
34
|
+
links.push(
|
|
35
|
+
`<${deployBase}/agent-readability.json>; rel="describedby"; type="application/json"`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (config.ai.llmsTxt.enabled) {
|
|
39
|
+
links.push(
|
|
40
|
+
`<${deployBase}/llms.txt>; rel="describedby"; type="text/plain"`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
// Same route list as the negotiation surfaces (`markdownRoutePaths`): "/"
|
|
44
|
+
// always has a mirror — the page's own source when the home route is a
|
|
45
|
+
// content page, the synthesized llms.txt fallback otherwise — so callers
|
|
46
|
+
// passing that list always advertise `/index.md` here.
|
|
47
|
+
if (routePaths.includes("/")) {
|
|
48
|
+
links.push(
|
|
49
|
+
`<${deployBase}/index.md>; rel="alternate"; type="text/markdown"`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return links.length > 0 ? links.join(", ") : null;
|
|
53
|
+
};
|
package/src/ai/llms.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
2
|
+
import { rewriteRelativeImages } from "../core/content-assets.ts";
|
|
2
3
|
import matter from "../core/frontmatter.ts";
|
|
3
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
5
|
import { readEntryText } from "../core/sources/read.ts";
|
|
@@ -59,8 +60,10 @@ const indexedNavigations = (
|
|
|
59
60
|
* Build the compact `llms.txt` index: title and summary, then the sidebar tree
|
|
60
61
|
* rendered as sections — group labels become headings, pages become link lists —
|
|
61
62
|
* so the file mirrors how the docs are organized rather than one flat blob.
|
|
63
|
+
* Also serves as the homepage's synthesized Markdown mirror when the home
|
|
64
|
+
* route is a landing page (see `buildRawMarkdown`).
|
|
62
65
|
*/
|
|
63
|
-
const
|
|
66
|
+
export const buildLlmsIndex = (project: BlumeProject): string => {
|
|
64
67
|
const { config } = project;
|
|
65
68
|
const { site } = config.deployment;
|
|
66
69
|
const base = normalizeBasePath(config.deployment.base);
|
|
@@ -180,7 +183,17 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
|
180
183
|
|
|
181
184
|
const sections = await Promise.all(
|
|
182
185
|
pages.map(async (page) => {
|
|
183
|
-
|
|
186
|
+
let raw = await readEntryText(project, page);
|
|
187
|
+
// Colocated `./image.png` references resolve to nothing for a reader of
|
|
188
|
+
// llms-full.txt; point them at the served originals instead.
|
|
189
|
+
if (page.sourcePath) {
|
|
190
|
+
raw = rewriteRelativeImages({
|
|
191
|
+
deployBase: config.deployment.base,
|
|
192
|
+
projectRoot: project.context.root,
|
|
193
|
+
source: raw,
|
|
194
|
+
sourcePath: page.sourcePath,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
184
197
|
// Resolve `<Visibility>` audiences (web-only content omitted from the
|
|
185
198
|
// agent-facing output, agents-only unwrapped), then downlevel supported
|
|
186
199
|
// components to plain Markdown.
|
|
@@ -211,5 +224,5 @@ export const buildLlmsFiles = async (
|
|
|
211
224
|
project: BlumeProject
|
|
212
225
|
): Promise<{ index: string; full: string }> => ({
|
|
213
226
|
full: await buildFull(project),
|
|
214
|
-
index:
|
|
227
|
+
index: buildLlmsIndex(project),
|
|
215
228
|
});
|
package/src/ai/markdown.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
import { rewriteRelativeImages } from "../core/content-assets.ts";
|
|
3
4
|
import matter from "../core/frontmatter.ts";
|
|
4
5
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
6
|
import { readEntryText } from "../core/sources/read.ts";
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
downlevelComponents,
|
|
9
10
|
exampleComponentSerializers,
|
|
10
11
|
} from "./component-markdown.ts";
|
|
12
|
+
import { buildLlmsIndex } from "./llms.ts";
|
|
11
13
|
import { applyAgentVisibility } from "./visibility.ts";
|
|
12
14
|
|
|
13
15
|
/** One route's raw-Markdown variants. */
|
|
@@ -27,13 +29,26 @@ export interface RawMarkdownEntry {
|
|
|
27
29
|
export const agentMarkdown = (entry: RawMarkdownEntry): string =>
|
|
28
30
|
entry.md ?? entry.mdx;
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Estimated token count of a Markdown document, for the `x-markdown-tokens`
|
|
34
|
+
* response header (the convention Cloudflare's Markdown for Agents ships; the
|
|
35
|
+
* tokenizer is unspecified there too, so this is the common ~4-characters-
|
|
36
|
+
* per-token estimate). Kept in sync with the inline computation in
|
|
37
|
+
* `rawMarkdownEndpointTemplate`, which can't import build-time code.
|
|
38
|
+
*/
|
|
39
|
+
export const markdownTokenCount = (text: string): number =>
|
|
40
|
+
Math.ceil(text.length / 4);
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Map every route to its raw source Markdown. Powers the `<route>.md` and
|
|
32
44
|
* `<route>.mdx` endpoints: `.mdx` serves the original source so tools can see
|
|
33
45
|
* exactly what the author wrote, while `.md` downlevels supported components
|
|
34
46
|
* to plain Markdown for consumers that can't interpret JSX. `<Visibility>`
|
|
35
47
|
* audiences are resolved for agents in both variants: web-only content is
|
|
36
|
-
* removed, agents-only unwrapped.
|
|
48
|
+
* removed, agents-only unwrapped. Relative image references are rewritten to
|
|
49
|
+
* their served `/blume-assets/content/…` URLs in both variants too — an agent
|
|
50
|
+
* fetches these endpoints by URL, where a colocated `./diagram.png` resolves
|
|
51
|
+
* to nothing.
|
|
37
52
|
*/
|
|
38
53
|
export const buildRawMarkdown = async (
|
|
39
54
|
project: BlumeProject
|
|
@@ -57,7 +72,16 @@ export const buildRawMarkdown = async (
|
|
|
57
72
|
|
|
58
73
|
const entries = await Promise.all(
|
|
59
74
|
project.manifest.routes.map(async (route) => {
|
|
60
|
-
|
|
75
|
+
let text = await readRoute(route);
|
|
76
|
+
if (route.sourcePath) {
|
|
77
|
+
text = rewriteRelativeImages({
|
|
78
|
+
deployBase: project.config.deployment.base,
|
|
79
|
+
projectRoot: project.context.root,
|
|
80
|
+
source: text,
|
|
81
|
+
sourcePath: route.sourcePath,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const source = applyAgentVisibility(text);
|
|
61
85
|
// The `.md` variant keeps the front-matter block in the output, but its
|
|
62
86
|
// data must also be in scope for `prop={frontmatter.*}` expressions.
|
|
63
87
|
const md = downlevelComponents(source, components, matter(source).data);
|
|
@@ -66,5 +90,27 @@ export const buildRawMarkdown = async (
|
|
|
66
90
|
return [route.path, entry] as const;
|
|
67
91
|
})
|
|
68
92
|
);
|
|
69
|
-
|
|
93
|
+
const map = Object.fromEntries(entries);
|
|
94
|
+
// A landing-page homepage (user `.astro` page, or no home route at all) has
|
|
95
|
+
// no Markdown source, but agents negotiating `Accept: text/markdown` on `/`
|
|
96
|
+
// still expect a Markdown answer. The llms.txt index — the machine-readable
|
|
97
|
+
// representation of the site a landing page fronts — becomes its mirror, so
|
|
98
|
+
// `/index.md` always exists (see `markdownRoutePaths`).
|
|
99
|
+
if (!map["/"]) {
|
|
100
|
+
map["/"] = { mdx: buildLlmsIndex(project) };
|
|
101
|
+
}
|
|
102
|
+
return map;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every route path with a raw-Markdown mirror: the manifest routes, plus the
|
|
107
|
+
* homepage when its mirror is the synthesized llms.txt fallback (see
|
|
108
|
+
* `buildRawMarkdown`). This is the route list the negotiation surfaces (dev
|
|
109
|
+
* middleware, Vercel routing config) and the homepage `Link` header build
|
|
110
|
+
* from, so `Accept: text/markdown` on `/` resolves even when the homepage is
|
|
111
|
+
* a landing page.
|
|
112
|
+
*/
|
|
113
|
+
export const markdownRoutePaths = (project: BlumeProject): string[] => {
|
|
114
|
+
const paths = project.manifest.routes.map((route) => route.path);
|
|
115
|
+
return paths.includes("/") ? paths : [...paths, "/"];
|
|
70
116
|
};
|
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withBasePath } from "../../core/base-path.ts";
|
|
2
2
|
import { trimEnd } from "../../core/trim.ts";
|
|
3
|
+
import { slugify } from "../../openapi/references.ts";
|
|
3
4
|
import { MCP_TOOLS } from "./tools.ts";
|
|
4
5
|
|
|
5
6
|
/** Inputs needed to describe the MCP server in discovery documents. */
|
|
@@ -38,22 +39,76 @@ export const buildMcpDiscovery = (
|
|
|
38
39
|
],
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
/** The published `/v1/` Server Card schema (SEP-2127 extension). */
|
|
43
|
+
const SERVER_CARD_SCHEMA =
|
|
44
|
+
"https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
45
|
+
|
|
46
|
+
/** The schema caps `description` (and `title`) at 100 characters. */
|
|
47
|
+
const CARD_TEXT_MAX = 100;
|
|
48
|
+
|
|
49
|
+
const truncate = (text: string): string =>
|
|
50
|
+
text.length > CARD_TEXT_MAX ? `${text.slice(0, CARD_TEXT_MAX - 1)}…` : text;
|
|
51
|
+
|
|
41
52
|
/**
|
|
42
|
-
* The
|
|
43
|
-
* the
|
|
53
|
+
* The card's identity in the schema's reverse-DNS `namespace/server` form:
|
|
54
|
+
* the site hostname reversed (`useblume.dev` → `dev.useblume`), or
|
|
55
|
+
* `localhost` when no site is configured, plus the slugged display name.
|
|
56
|
+
*/
|
|
57
|
+
const reverseDnsName = (input: McpDiscoveryInput): string => {
|
|
58
|
+
let namespace = "localhost";
|
|
59
|
+
if (input.site) {
|
|
60
|
+
try {
|
|
61
|
+
namespace = new URL(input.site).hostname
|
|
62
|
+
.split(".")
|
|
63
|
+
.toReversed()
|
|
64
|
+
.join(".");
|
|
65
|
+
} catch {
|
|
66
|
+
// Not a parsable URL; the local namespace is honest enough.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return `${namespace}/${slugify(input.name) || "docs"}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const HTTP_URL = /^https?:\/\//u;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `/.well-known/mcp/server-card.json` document. The core follows the
|
|
76
|
+
* SEP-2127 Server Card extension schema (`$schema`, reverse-DNS `name`,
|
|
77
|
+
* `remotes` — which the schema requires to be absolute, so it appears only
|
|
78
|
+
* when a `site` is configured). Alongside it ride initialize-shaped compat
|
|
79
|
+
* fields (`serverInfo`, `capabilities`, `transports`) for scanners built
|
|
80
|
+
* against the SEP's earlier revision, plus Blume's advertised tool set —
|
|
81
|
+
* schema-legal extras (`additionalProperties` is open), and the tool list is
|
|
82
|
+
* genuinely static for a docs server, unlike the dynamic servers the spec
|
|
83
|
+
* excludes primitives for. Full input schemas are served live via
|
|
84
|
+
* `tools/list`.
|
|
44
85
|
*/
|
|
45
86
|
export const buildMcpServerCard = (
|
|
46
87
|
input: McpDiscoveryInput
|
|
47
|
-
): Record<string, unknown> =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
description:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
88
|
+
): Record<string, unknown> => {
|
|
89
|
+
const url = serverUrl(input);
|
|
90
|
+
return {
|
|
91
|
+
$schema: SERVER_CARD_SCHEMA,
|
|
92
|
+
capabilities: { tools: { listChanged: false } },
|
|
93
|
+
description: truncate(
|
|
94
|
+
`Model Context Protocol server for the ${input.name} documentation.`
|
|
95
|
+
),
|
|
96
|
+
name: reverseDnsName(input),
|
|
97
|
+
...(HTTP_URL.test(url)
|
|
98
|
+
? { remotes: [{ type: "streamable-http", url }] }
|
|
99
|
+
: {}),
|
|
100
|
+
serverInfo: { name: input.name, version: input.version },
|
|
101
|
+
title: truncate(input.name),
|
|
102
|
+
tools: MCP_TOOLS.map((tool) => ({
|
|
103
|
+
annotations: tool.annotations,
|
|
104
|
+
description: tool.description,
|
|
105
|
+
name: tool.name,
|
|
106
|
+
title: tool.title,
|
|
107
|
+
})),
|
|
108
|
+
transport: "streamable-http",
|
|
109
|
+
transports: [{ endpoint: url, type: "streamable-http" }],
|
|
110
|
+
url,
|
|
111
|
+
version: input.version,
|
|
112
|
+
...(input.site ? { websiteUrl: trimEnd(input.site, "/") } : {}),
|
|
113
|
+
};
|
|
114
|
+
};
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -13,9 +13,10 @@ import { MCP_TOOLS } from "./tools.ts";
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* The low-level SDK `Server` is used (rather than the high-level `McpServer`)
|
|
16
|
-
* because the latter's `registerTool` is generic over the caller's Zod
|
|
17
|
-
* Blume
|
|
18
|
-
* Hand-written JSON Schema and the SDK's own request schemas avoid that
|
|
16
|
+
* because the latter's `registerTool` is generic over the caller's Zod instance;
|
|
17
|
+
* Blume's zod and the SDK's may resolve to different copies, whose types don't
|
|
18
|
+
* unify. Hand-written JSON Schema and the SDK's own request schemas avoid that
|
|
19
|
+
* entirely.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
/** Default and maximum number of hits returned by `search_docs`. */
|
|
@@ -149,7 +150,7 @@ export const createIndexProvider = (
|
|
|
149
150
|
locale?: string
|
|
150
151
|
): OramaIndexProvider => {
|
|
151
152
|
let dbPromise: ReturnType<OramaIndexProvider> | null = null;
|
|
152
|
-
return ()
|
|
153
|
+
return function provideIndex() {
|
|
153
154
|
dbPromise ??= buildOramaIndex(documents, locale);
|
|
154
155
|
return dbPromise;
|
|
155
156
|
};
|