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.
Files changed (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  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 +17 -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/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. package/dist/types/og/dimensions.d.ts +0 -12
@@ -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 docs in the installed package:
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
- node_modules/blume/docs
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 `node_modules/blume/docs/index.mdx` (Introduction) and `node_modules/blume/docs/01-quickstart.mdx`, then browse the `configuration/`, `content/`, `reference/`, and `advanced/` sections for specifics.
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 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
+ };
@@ -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]));
@@ -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
- const raw = await readEntryText(project, page);
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.
@@ -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
- const source = applyAgentVisibility(await readRoute(route));
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);
@@ -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,
@@ -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`. */
@@ -139,13 +140,18 @@ export type OramaIndexProvider = () => Promise<
139
140
  Awaited<ReturnType<typeof buildOramaIndex>>
140
141
  >;
141
142
 
142
- /** Memoize the search index so every server built from a snapshot shares it. */
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") {
@@ -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(data, createIndexProvider(data.documents));
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();