blume 1.2.1 → 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 (71) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/index.js +1680 -534
  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 +52 -0
  28. package/src/ai/llms.ts +12 -1
  29. package/src/ai/markdown.ts +15 -2
  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 +116 -6
  36. package/src/astro/integration.ts +52 -14
  37. package/src/astro/templates.ts +176 -33
  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 +178 -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 +233 -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 +183 -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,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);
@@ -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
  };
@@ -0,0 +1,193 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+
4
+ import { join } from "pathe";
5
+
6
+ import { normalizeBasePath } from "../core/base-path.ts";
7
+ import matter from "../core/frontmatter.ts";
8
+ import type { ResolvedConfig } from "../core/schema.ts";
9
+ import { buildTarGz } from "./tar.ts";
10
+ import type { TarEntry } from "./tar.ts";
11
+
12
+ /**
13
+ * Agent Skills discovery (Cloudflare's Agent Skills Discovery RFC v0.2.0):
14
+ * the skills a site publishes are enumerated in an index at
15
+ * `/.well-known/agent-skills/index.json`, each entry pointing at its artifact
16
+ * with a SHA-256 digest. A skill that is only a `SKILL.md` publishes the file
17
+ * verbatim (`type: "skill-md"`); a skill with supporting resources (scripts,
18
+ * references, assets) is bundled into a deterministic `.tar.gz`
19
+ * (`type: "archive"`) so its relative references resolve after unpacking.
20
+ */
21
+
22
+ export const AGENT_SKILLS_DIR = "/.well-known/agent-skills";
23
+ export const AGENT_SKILLS_INDEX_PATH = "/.well-known/agent-skills/index.json";
24
+
25
+ const AGENT_SKILLS_SCHEMA =
26
+ "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
27
+
28
+ /** Skill naming rule from the Agent Skills spec (1-64 chars enforced apart). */
29
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
30
+ const SKILL_NAME_MAX = 64;
31
+ /** The Agent Skills spec caps `description` at 1024 characters. */
32
+ const DESCRIPTION_MAX = 1024;
33
+
34
+ /** One publishable skill artifact plus its index entry. */
35
+ export interface SkillArtifact {
36
+ /** Raw bytes to publish (the digest is computed over exactly these). */
37
+ content: Uint8Array;
38
+ description: string;
39
+ /** `sha256:{hex}` digest of `content`. */
40
+ digest: string;
41
+ name: string;
42
+ /** Path under the agent-skills dir: `{name}/SKILL.md` or `{name}.tar.gz`. */
43
+ path: string;
44
+ type: "archive" | "skill-md";
45
+ }
46
+
47
+ export interface CollectedSkills {
48
+ skills: SkillArtifact[];
49
+ /** Human-readable reasons for anything skipped. */
50
+ warnings: string[];
51
+ }
52
+
53
+ const sha256 = (content: Uint8Array): string =>
54
+ `sha256:${createHash("sha256").update(content).digest("hex")}`;
55
+
56
+ /**
57
+ * Every regular file under a skill directory, as archive entries with
58
+ * `/`-separated relative paths, dotfiles (`.DS_Store`, `.git`) excluded,
59
+ * sorted for deterministic archives. The owner-execute bit is preserved so a
60
+ * skill's scripts stay runnable after unpacking.
61
+ */
62
+ const collectEntries = async (
63
+ dir: string,
64
+ prefix = ""
65
+ ): Promise<TarEntry[]> => {
66
+ const entries: TarEntry[] = [];
67
+ const items = await readdir(dir, { withFileTypes: true });
68
+ for (const item of items.toSorted((a, b) => (a.name < b.name ? -1 : 1))) {
69
+ if (item.name.startsWith(".")) {
70
+ continue;
71
+ }
72
+ const path = join(dir, item.name);
73
+ const relative = prefix ? `${prefix}/${item.name}` : item.name;
74
+ if (item.isDirectory()) {
75
+ // Sequential recursion keeps ordering deterministic.
76
+ // oxlint-disable-next-line no-await-in-loop
77
+ entries.push(...(await collectEntries(path, relative)));
78
+ } else if (item.isFile()) {
79
+ // oxlint-disable-next-line no-await-in-loop
80
+ const [content, info] = await Promise.all([readFile(path), stat(path)]);
81
+ entries.push({
82
+ content: new Uint8Array(content),
83
+ // oxlint-disable-next-line no-bitwise -- testing the owner-execute mode bit
84
+ executable: (info.mode & 0o100) !== 0,
85
+ path: relative,
86
+ });
87
+ }
88
+ }
89
+ return entries;
90
+ };
91
+
92
+ /** Frontmatter of a SKILL.md, or null with a warning when unusable. */
93
+ const skillMeta = (
94
+ raw: string,
95
+ dirName: string
96
+ ): { meta: { description: string; name: string } | null; warning?: string } => {
97
+ let data: Record<string, unknown>;
98
+ try {
99
+ ({ data } = matter(raw) as unknown as { data: Record<string, unknown> });
100
+ } catch {
101
+ return {
102
+ meta: null,
103
+ warning: `Skill "${dirName}" has unparsable SKILL.md frontmatter; skipped.`,
104
+ };
105
+ }
106
+ const name = typeof data.name === "string" ? data.name : "";
107
+ const description =
108
+ typeof data.description === "string" ? data.description : "";
109
+ if (!(name && description)) {
110
+ return {
111
+ meta: null,
112
+ warning: `Skill "${dirName}" is missing the required "name"/"description" frontmatter; skipped.`,
113
+ };
114
+ }
115
+ if (!SKILL_NAME.test(name) || name.length > SKILL_NAME_MAX) {
116
+ return {
117
+ meta: null,
118
+ warning: `Skill "${dirName}" has an invalid name "${name}" (lowercase alphanumerics and single hyphens, max 64 chars); skipped.`,
119
+ };
120
+ }
121
+ return {
122
+ meta: { description: description.slice(0, DESCRIPTION_MAX), name },
123
+ };
124
+ };
125
+
126
+ /**
127
+ * Collect the publishable skills from a directory whose subdirectories each
128
+ * hold a `SKILL.md` (the layout `npx skills add` consumes). Subdirectories
129
+ * without one are ignored silently — the directory may hold other assets —
130
+ * while a present-but-invalid skill earns a warning so it isn't dropped
131
+ * behind the publisher's back.
132
+ */
133
+ export const collectSkills = async (dir: string): Promise<CollectedSkills> => {
134
+ const skills: SkillArtifact[] = [];
135
+ const warnings: string[] = [];
136
+ const items = await readdir(dir, { withFileTypes: true });
137
+ for (const item of items.toSorted((a, b) => (a.name < b.name ? -1 : 1))) {
138
+ if (!item.isDirectory() || item.name.startsWith(".")) {
139
+ continue;
140
+ }
141
+ // oxlint-disable-next-line no-await-in-loop
142
+ const entries = await collectEntries(join(dir, item.name));
143
+ const skillMd = entries.find((entry) => entry.path === "SKILL.md");
144
+ if (!skillMd) {
145
+ continue;
146
+ }
147
+ const { meta, warning } = skillMeta(
148
+ new TextDecoder().decode(skillMd.content),
149
+ item.name
150
+ );
151
+ if (!meta) {
152
+ if (warning) {
153
+ warnings.push(warning);
154
+ }
155
+ continue;
156
+ }
157
+ // A lone SKILL.md ships verbatim; supporting resources ship as an
158
+ // archive so the skill's relative references resolve after unpacking.
159
+ const single = entries.length === 1;
160
+ const content = single ? skillMd.content : buildTarGz(entries);
161
+ skills.push({
162
+ content,
163
+ description: meta.description,
164
+ digest: sha256(content),
165
+ name: meta.name,
166
+ path: single ? `${meta.name}/SKILL.md` : `${meta.name}.tar.gz`,
167
+ type: single ? "skill-md" : "archive",
168
+ });
169
+ }
170
+ return { skills, warnings };
171
+ };
172
+
173
+ /**
174
+ * The discovery index (v0.2.0 schema). Artifact URLs are path-absolute under
175
+ * `deployment.base` — the RFC resolves them against the index origin.
176
+ */
177
+ export const buildSkillsIndex = (
178
+ skills: readonly SkillArtifact[],
179
+ config: ResolvedConfig
180
+ ): string => {
181
+ const deployBase = normalizeBasePath(config.deployment.base);
182
+ const index = {
183
+ $schema: AGENT_SKILLS_SCHEMA,
184
+ skills: skills.map((skill) => ({
185
+ description: skill.description,
186
+ digest: skill.digest,
187
+ name: skill.name,
188
+ type: skill.type,
189
+ url: `${deployBase}${AGENT_SKILLS_DIR}/${skill.path}`,
190
+ })),
191
+ };
192
+ return `${JSON.stringify(index, null, 2)}\n`;
193
+ };