blume 1.2.0 → 1.3.0
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 +45 -0
- package/dist/cli/index.js +1715 -539
- package/dist/cli/index.js.map +40 -31
- 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 +17 -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/ask-context.ts +7 -1
- package/src/ai/ask-data.ts +1 -0
- package/src/ai/link-headers.ts +52 -0
- package/src/ai/llms.ts +12 -1
- package/src/ai/markdown.ts +15 -2
- package/src/ai/mcp/data.ts +7 -0
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +14 -8
- package/src/ai/mcp/stdio.ts +4 -1
- 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 +116 -6
- package/src/astro/integration.ts +52 -14
- package/src/astro/templates.ts +191 -37
- 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 +178 -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/NavTree.astro +4 -4
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +63 -11
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/components/layout/search/orama.ts +5 -2
- 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 +233 -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 +151 -7
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +183 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
package/skills/blume/SKILL.md
CHANGED
|
@@ -64,10 +64,12 @@ The Blume CLI discovers your content, builds a content graph, and generates a hi
|
|
|
64
64
|
|
|
65
65
|
## Full documentation
|
|
66
66
|
|
|
67
|
-
This is a high-level overview. For complete, authoritative docs — configuration reference, every CLI command and flag, component APIs, content authoring, navigation, search, SEO, AI features, theming, and deployment — read the bundled
|
|
67
|
+
This is a high-level overview. For complete, authoritative docs — configuration reference, every CLI command and flag, component APIs, content authoring, navigation, search, SEO, AI features, theming, and deployment — read the `docs/` directory bundled inside the installed `blume` package.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
**Locate the package first — it is not always at the repository root.** In a workspace monorepo (pnpm especially), `blume` is installed in the depending workspace's `node_modules` (e.g. `apps/docs/node_modules/blume/docs`), not the root. From the package that depends on `blume`, this prints the exact location:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
node -e "console.log(require.resolve('blume/package.json'))"
|
|
71
73
|
```
|
|
72
74
|
|
|
73
|
-
Start with `
|
|
75
|
+
The docs sit in `docs/` next to that `package.json`. Start with `docs/index.mdx` (Introduction) and `docs/01-quickstart.mdx`, then browse the `configuration/`, `content/`, `reference/`, and `advanced/` sections for specifics.
|
|
@@ -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
|
+
};
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface AskPage {
|
|
|
19
19
|
* `generated/ask-data.json` and built by {@link buildAskData}.
|
|
20
20
|
*/
|
|
21
21
|
export interface AskData {
|
|
22
|
+
/**
|
|
23
|
+
* The site's `i18n.defaultLocale`, when i18n is configured. Selects a
|
|
24
|
+
* word-segmenting Orama tokenizer for languages written without spaces, so
|
|
25
|
+
* retrieval can match CJK/Thai content.
|
|
26
|
+
*/
|
|
27
|
+
defaultLocale?: string;
|
|
22
28
|
documents: OramaDoc[];
|
|
23
29
|
site: string | null;
|
|
24
30
|
}
|
|
@@ -203,7 +209,7 @@ export const createAskContext = (
|
|
|
203
209
|
let dbPromise: Promise<Awaited<ReturnType<typeof buildOramaIndex>>> | null =
|
|
204
210
|
null;
|
|
205
211
|
const index = () => {
|
|
206
|
-
dbPromise ??= buildOramaIndex(data.documents);
|
|
212
|
+
dbPromise ??= buildOramaIndex(data.documents, data.defaultLocale);
|
|
207
213
|
return dbPromise;
|
|
208
214
|
};
|
|
209
215
|
const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
|
package/src/ai/ask-data.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
|
|
|
19
19
|
includeWhenDisabled: true,
|
|
20
20
|
});
|
|
21
21
|
return {
|
|
22
|
+
defaultLocale: project.config.i18n?.defaultLocale,
|
|
22
23
|
documents: documents.map((doc) => ({
|
|
23
24
|
content: doc.content,
|
|
24
25
|
description: doc.description,
|
|
@@ -0,0 +1,52 @@
|
|
|
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 home-mirror condition as the Vercel negotiation routes: "/" is a
|
|
44
|
+
// content route only when the docs sit at the site root (no `basePath`) and
|
|
45
|
+
// the root page is real content, so `/index.md` exists exactly then.
|
|
46
|
+
if (routePaths.includes("/")) {
|
|
47
|
+
links.push(
|
|
48
|
+
`<${deployBase}/index.md>; rel="alternate"; type="text/markdown"`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return links.length > 0 ? links.join(", ") : null;
|
|
52
|
+
};
|
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";
|
|
@@ -180,7 +181,17 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
|
180
181
|
|
|
181
182
|
const sections = await Promise.all(
|
|
182
183
|
pages.map(async (page) => {
|
|
183
|
-
|
|
184
|
+
let raw = await readEntryText(project, page);
|
|
185
|
+
// Colocated `./image.png` references resolve to nothing for a reader of
|
|
186
|
+
// llms-full.txt; point them at the served originals instead.
|
|
187
|
+
if (page.sourcePath) {
|
|
188
|
+
raw = rewriteRelativeImages({
|
|
189
|
+
deployBase: config.deployment.base,
|
|
190
|
+
projectRoot: project.context.root,
|
|
191
|
+
source: raw,
|
|
192
|
+
sourcePath: page.sourcePath,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
184
195
|
// Resolve `<Visibility>` audiences (web-only content omitted from the
|
|
185
196
|
// agent-facing output, agents-only unwrapped), then downlevel supported
|
|
186
197
|
// components to plain Markdown.
|
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";
|
|
@@ -33,7 +34,10 @@ export const agentMarkdown = (entry: RawMarkdownEntry): string =>
|
|
|
33
34
|
* exactly what the author wrote, while `.md` downlevels supported components
|
|
34
35
|
* to plain Markdown for consumers that can't interpret JSX. `<Visibility>`
|
|
35
36
|
* audiences are resolved for agents in both variants: web-only content is
|
|
36
|
-
* removed, agents-only unwrapped.
|
|
37
|
+
* removed, agents-only unwrapped. Relative image references are rewritten to
|
|
38
|
+
* their served `/blume-assets/content/…` URLs in both variants too — an agent
|
|
39
|
+
* fetches these endpoints by URL, where a colocated `./diagram.png` resolves
|
|
40
|
+
* to nothing.
|
|
37
41
|
*/
|
|
38
42
|
export const buildRawMarkdown = async (
|
|
39
43
|
project: BlumeProject
|
|
@@ -57,7 +61,16 @@ export const buildRawMarkdown = async (
|
|
|
57
61
|
|
|
58
62
|
const entries = await Promise.all(
|
|
59
63
|
project.manifest.routes.map(async (route) => {
|
|
60
|
-
|
|
64
|
+
let text = await readRoute(route);
|
|
65
|
+
if (route.sourcePath) {
|
|
66
|
+
text = rewriteRelativeImages({
|
|
67
|
+
deployBase: project.config.deployment.base,
|
|
68
|
+
projectRoot: project.context.root,
|
|
69
|
+
source: text,
|
|
70
|
+
sourcePath: route.sourcePath,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const source = applyAgentVisibility(text);
|
|
61
74
|
// The `.md` variant keeps the front-matter block in the output, but its
|
|
62
75
|
// data must also be in scope for `prop={frontmatter.*}` expressions.
|
|
63
76
|
const md = downlevelComponents(source, components, matter(source).data);
|
package/src/ai/mcp/data.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface McpData {
|
|
|
28
28
|
* paths, matching the sitemap/llms.txt convention.
|
|
29
29
|
*/
|
|
30
30
|
base: string;
|
|
31
|
+
/**
|
|
32
|
+
* The site's `i18n.defaultLocale`, when i18n is configured. Selects a
|
|
33
|
+
* word-segmenting Orama tokenizer for languages written without spaces, so
|
|
34
|
+
* `search_docs` can match CJK/Thai content.
|
|
35
|
+
*/
|
|
36
|
+
defaultLocale?: string;
|
|
31
37
|
documents: OramaDoc[];
|
|
32
38
|
instructions?: string;
|
|
33
39
|
name: string;
|
|
@@ -82,6 +88,7 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
|
|
|
82
88
|
|
|
83
89
|
return {
|
|
84
90
|
base: normalizeBasePath(config.deployment.base),
|
|
91
|
+
defaultLocale: config.i18n?.defaultLocale,
|
|
85
92
|
documents: documents.map((doc) => ({
|
|
86
93
|
content: doc.content,
|
|
87
94
|
description: doc.description,
|
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`. */
|
|
@@ -139,13 +140,18 @@ export type OramaIndexProvider = () => Promise<
|
|
|
139
140
|
Awaited<ReturnType<typeof buildOramaIndex>>
|
|
140
141
|
>;
|
|
141
142
|
|
|
142
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Memoize the search index so every server built from a snapshot shares it.
|
|
145
|
+
* `locale` is the snapshot's `defaultLocale`, forwarded so unspaced scripts
|
|
146
|
+
* (Japanese, Chinese, Korean, Thai) get a word-segmenting tokenizer.
|
|
147
|
+
*/
|
|
143
148
|
export const createIndexProvider = (
|
|
144
|
-
documents: OramaDoc[]
|
|
149
|
+
documents: OramaDoc[],
|
|
150
|
+
locale?: string
|
|
145
151
|
): OramaIndexProvider => {
|
|
146
152
|
let dbPromise: ReturnType<OramaIndexProvider> | null = null;
|
|
147
|
-
return ()
|
|
148
|
-
dbPromise ??= buildOramaIndex(documents);
|
|
153
|
+
return function provideIndex() {
|
|
154
|
+
dbPromise ??= buildOramaIndex(documents, locale);
|
|
149
155
|
return dbPromise;
|
|
150
156
|
};
|
|
151
157
|
};
|
|
@@ -240,7 +246,7 @@ export const buildServer = (
|
|
|
240
246
|
export const createMcpFetchHandler = (
|
|
241
247
|
data: McpData
|
|
242
248
|
): ((request: Request) => Promise<Response>) => {
|
|
243
|
-
const index = createIndexProvider(data.documents);
|
|
249
|
+
const index = createIndexProvider(data.documents, data.defaultLocale);
|
|
244
250
|
|
|
245
251
|
return async (request: Request): Promise<Response> => {
|
|
246
252
|
if (request.method === "OPTIONS") {
|
package/src/ai/mcp/stdio.ts
CHANGED
|
@@ -28,7 +28,10 @@ export const serveMcpStdio = async (
|
|
|
28
28
|
const stdin = streams.stdin ?? process.stdin;
|
|
29
29
|
const stdout = streams.stdout ?? process.stdout;
|
|
30
30
|
const transport = new StdioServerTransport(stdin, stdout);
|
|
31
|
-
const server = buildServer(
|
|
31
|
+
const server = buildServer(
|
|
32
|
+
data,
|
|
33
|
+
createIndexProvider(data.documents, data.defaultLocale)
|
|
34
|
+
);
|
|
32
35
|
await server.connect(transport);
|
|
33
36
|
await once(stdin, "end");
|
|
34
37
|
await transport.close();
|