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.
Files changed (71) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cli/index.js +1765 -603
  3. package/dist/cli/index.js.map +37 -28
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +2 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/link-headers.ts +53 -0
  28. package/src/ai/llms.ts +16 -3
  29. package/src/ai/markdown.ts +49 -3
  30. package/src/ai/mcp/discovery.ts +70 -15
  31. package/src/ai/mcp/server.ts +5 -4
  32. package/src/ai/skills.ts +193 -0
  33. package/src/ai/tar.ts +104 -0
  34. package/src/ai/web-bot-auth.ts +30 -0
  35. package/src/astro/generate.ts +118 -8
  36. package/src/astro/integration.ts +54 -14
  37. package/src/astro/templates.ts +184 -36
  38. package/src/audit/catalog.ts +20 -0
  39. package/src/audit/checks/dns-aid.ts +190 -0
  40. package/src/audit/report.ts +5 -0
  41. package/src/audit/run.ts +2 -0
  42. package/src/cli/commands/build.ts +184 -9
  43. package/src/cli/init/scaffold.ts +1 -1
  44. package/src/components/islands/ask-ai.tsx +4 -1
  45. package/src/components/islands/webmcp.ts +203 -0
  46. package/src/components/layout/PageLayout.astro +2 -0
  47. package/src/components/layout/ReferenceLayout.astro +2 -0
  48. package/src/components/layout/RootLayout.astro +62 -10
  49. package/src/components/layout/Search.astro +2 -2
  50. package/src/components/layout/WebMcp.astro +49 -0
  51. package/src/core/config-input.ts +143 -11
  52. package/src/core/config.ts +17 -1
  53. package/src/core/content-assets.ts +199 -0
  54. package/src/core/data.ts +21 -5
  55. package/src/core/diagnostics.ts +6 -5
  56. package/src/core/i18n-ui.ts +19 -28
  57. package/src/core/project-graph.ts +6 -0
  58. package/src/core/schema.ts +224 -71
  59. package/src/core/sources/normalize.ts +5 -5
  60. package/src/deploy/headers.ts +45 -3
  61. package/src/deploy/vercel-negotiation.ts +250 -0
  62. package/src/markdown/mermaid.ts +7 -1
  63. package/src/markdown/table-wrap.ts +33 -1
  64. package/src/og/card.ts +91 -22
  65. package/src/og/derive.ts +200 -0
  66. package/src/og/index.ts +6 -1
  67. package/src/search/orama-index.ts +98 -4
  68. package/src/theme/entry.ts +34 -13
  69. package/src/theme/fonts.ts +185 -30
  70. package/dist/types/og/card.d.ts +0 -63
  71. 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 slugs), `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.
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 `node_modules/blume/docs/configuration/`.
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 **`node_modules/blume/docs`** (or `apps/docs/content/docs` in a repo checkout). The most relevant pages:
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}` | only if the family is a curated Google-font slug (kebab-case, e.g. `space-grotesk`); otherwise drop and tell the user to add `@font-face` in `theme.css` |
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 <this-skill>/scripts/mintlify-codemod.mjs <content-dir>
55
- node <this-skill>/scripts/mintlify-codemod.mjs --write <content-dir>
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`, non-curated fonts.
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 buildIndex = (project: BlumeProject): string => {
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
- const raw = await readEntryText(project, page);
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: buildIndex(project),
227
+ index: buildLlmsIndex(project),
215
228
  });
@@ -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
- const source = applyAgentVisibility(await readRoute(route));
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
- return Object.fromEntries(entries);
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
  };
@@ -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 `/.well-known/mcp/server-card.json` document: richer metadata including
43
- * the advertised tool set (full input schemas are served live via `tools/list`).
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
- description: `Model Context Protocol server for the ${input.name} documentation.`,
49
- name: input.name,
50
- tools: MCP_TOOLS.map((tool) => ({
51
- annotations: tool.annotations,
52
- description: tool.description,
53
- name: tool.name,
54
- title: tool.title,
55
- })),
56
- transport: "streamable-http",
57
- url: serverUrl(input),
58
- version: input.version,
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
+ };
@@ -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 version;
17
- * Blume pins Zod 3 while the SDK resolves Zod 4, so their types don't unify.
18
- * Hand-written JSON Schema and the SDK's own request schemas avoid that entirely.
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
  };