blume 1.3.0 → 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.
@@ -63,7 +63,7 @@ The `.md` variant _downlevels_ components to plain Markdown for consumers that c
63
63
 
64
64
  ### Content negotiation
65
65
 
66
- Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy's routing rules automatically — no configuration needed. Other deploy targets serve prerendered pages from a static layer with no request-time hook, so agents there fetch the `.md` URL directly; the [agent readability manifest](#agent-readability) advertises `contentNegotiation` only on deployments that honor the header.
66
+ Agents don't need to know the `.md` convention: requesting a page's own URL with an [`Accept: text/markdown`](https://acceptmarkdown.com) header serves the Markdown variant at the same address, with `Vary: Accept` so caches keep the two apart. The dev server honors the header out of the box, and a [Vercel server build](/docs/deployment#server-rendering) wires the same negotiation into the deploy's routing rules automatically — no configuration needed. The homepage always negotiates, even when it's a custom landing page rather than a content page: its Markdown mirror falls back to the [`llms.txt`](#llmstxt) index, so an agent asking the site root for Markdown gets the machine-readable map of the site. Markdown responses also carry an `x-markdown-tokens` header — an estimated token count (~4 characters per token), following the convention of [Cloudflare's Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) — on every surface where Blume controls response headers: the dev server, server-rendered responses, and the negotiated homepage on Vercel. Other deploy targets serve prerendered pages from a static layer with no request-time hook, so agents there fetch the `.md` URL directly; the [agent readability manifest](#agent-readability) advertises `contentNegotiation` only on deployments that honor the header.
67
67
 
68
68
  ### Custom component serializers
69
69
 
@@ -324,7 +324,7 @@ Link: </agent-readability.json>; rel="describedby"; type="application/json",
324
324
  </index.md>; rel="alternate"; type="text/markdown"
325
325
  ```
326
326
 
327
- Each entry appears only when its feature is on, and the `alternate` link only when your homepage is a content page with a [raw Markdown](#raw-markdown) mirror. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules. Hosts that ignore `_headers` on static output (GitHub Pages, S3) can't send custom response headers at all — there, agents still find everything through `llms.txt` and `agent-readability.json` at the site root.
327
+ Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules. Hosts that ignore `_headers` on static output (GitHub Pages, S3) can't send custom response headers at all — there, agents still find everything through `llms.txt` and `agent-readability.json` at the site root.
328
328
 
329
329
  ### API catalog
330
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -40,9 +40,10 @@ export const buildHomeLinkHeader = (
40
40
  `<${deployBase}/llms.txt>; rel="describedby"; type="text/plain"`
41
41
  );
42
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.
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.
46
47
  if (routePaths.includes("/")) {
47
48
  links.push(
48
49
  `<${deployBase}/index.md>; rel="alternate"; type="text/markdown"`
package/src/ai/llms.ts CHANGED
@@ -60,8 +60,10 @@ const indexedNavigations = (
60
60
  * Build the compact `llms.txt` index: title and summary, then the sidebar tree
61
61
  * rendered as sections — group labels become headings, pages become link lists —
62
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`).
63
65
  */
64
- const buildIndex = (project: BlumeProject): string => {
66
+ export const buildLlmsIndex = (project: BlumeProject): string => {
65
67
  const { config } = project;
66
68
  const { site } = config.deployment;
67
69
  const base = normalizeBasePath(config.deployment.base);
@@ -222,5 +224,5 @@ export const buildLlmsFiles = async (
222
224
  project: BlumeProject
223
225
  ): Promise<{ index: string; full: string }> => ({
224
226
  full: await buildFull(project),
225
- index: buildIndex(project),
227
+ index: buildLlmsIndex(project),
226
228
  });
@@ -9,6 +9,7 @@ import {
9
9
  downlevelComponents,
10
10
  exampleComponentSerializers,
11
11
  } from "./component-markdown.ts";
12
+ import { buildLlmsIndex } from "./llms.ts";
12
13
  import { applyAgentVisibility } from "./visibility.ts";
13
14
 
14
15
  /** One route's raw-Markdown variants. */
@@ -28,6 +29,16 @@ export interface RawMarkdownEntry {
28
29
  export const agentMarkdown = (entry: RawMarkdownEntry): string =>
29
30
  entry.md ?? entry.mdx;
30
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
+
31
42
  /**
32
43
  * Map every route to its raw source Markdown. Powers the `<route>.md` and
33
44
  * `<route>.mdx` endpoints: `.mdx` serves the original source so tools can see
@@ -79,5 +90,27 @@ export const buildRawMarkdown = async (
79
90
  return [route.path, entry] as const;
80
91
  })
81
92
  );
82
- 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, "/"];
83
116
  };
@@ -19,7 +19,7 @@ import { glob } from "tinyglobby";
19
19
 
20
20
  import { buildAskData } from "../ai/ask-data.ts";
21
21
  import { resolveAskBackend } from "../ai/ask.ts";
22
- import { buildRawMarkdown } from "../ai/markdown.ts";
22
+ import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
23
23
  import { buildMcpData } from "../ai/mcp/data.ts";
24
24
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
25
25
  import { normalizeBasePath } from "../core/base-path.ts";
@@ -1608,7 +1608,7 @@ export const generateRuntime = async (
1608
1608
  aliases: resolveTsconfigAliases(context.root),
1609
1609
  askPath,
1610
1610
  config,
1611
- contentRoutes: project.manifest.routes.map((route) => route.path),
1611
+ contentRoutes: markdownRoutePaths(project),
1612
1612
  contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1613
1613
  hasFilesystemSource,
1614
1614
  docsCollection.base,
@@ -116,7 +116,9 @@ const isHomeUrl = (rawUrl: string | undefined, base?: string): boolean => {
116
116
  * the same negotiation from routing rules spliced into the Build Output config
117
117
  * (see `deploy/vercel-negotiation.ts`); every other build exposes the same
118
118
  * content at the `.md` URL. Only routes with a Markdown variant are rewritten,
119
- * so landing pages and user `.astro` pages keep serving HTML. The same
119
+ * so user `.astro` pages keep serving HTML except the homepage, whose
120
+ * variant falls back to the synthesized llms.txt mirror when it's a landing
121
+ * page (see `markdownRoutePaths`). The same
120
122
  * middleware also stamps the homepage agent-discovery `Link` header, mirroring
121
123
  * what the deployed site sends via `_headers` / the Vercel routing config.
122
124
  */
@@ -1181,10 +1181,15 @@ export function getStaticPaths() {
1181
1181
  export function GET({ props }: { props: { route: string } }) {
1182
1182
  const entries = raw as Record<string, { md?: string; mdx?: string }>;
1183
1183
  const entry = entries[props.route];
1184
- return new Response(entry ? ${
1184
+ const body = entry ? ${
1185
1185
  kind === "md" ? '(entry.md ?? entry.mdx ?? "")' : '(entry.mdx ?? "")'
1186
- } : "", {
1187
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
1186
+ } : "";
1187
+ return new Response(body, {
1188
+ headers: {
1189
+ "Content-Type": "text/markdown; charset=utf-8",
1190
+ // ~4 characters per token; keep in sync with markdownTokenCount.
1191
+ "x-markdown-tokens": String(Math.ceil(body.length / 4)),
1192
+ },
1188
1193
  });
1189
1194
  }
1190
1195
  `;
@@ -14,6 +14,12 @@ import {
14
14
  } from "../../ai/api-catalog.ts";
15
15
  import { buildHomeLinkHeader } from "../../ai/link-headers.ts";
16
16
  import { buildLlmsFiles } from "../../ai/llms.ts";
17
+ import {
18
+ agentMarkdown,
19
+ buildRawMarkdown,
20
+ markdownRoutePaths,
21
+ markdownTokenCount,
22
+ } from "../../ai/markdown.ts";
17
23
  import {
18
24
  AGENT_SKILLS_DIR,
19
25
  buildSkillsIndex,
@@ -146,10 +152,7 @@ const emitHeaderFiles = async (
146
152
  join(distDir, "_headers"),
147
153
  buildNetlifyHeaders(
148
154
  config,
149
- buildHomeLinkHeader(
150
- config,
151
- project.manifest.routes.map((route) => route.path)
152
- )
155
+ buildHomeLinkHeader(config, markdownRoutePaths(project))
153
156
  ),
154
157
  "utf-8"
155
158
  );
@@ -253,10 +256,11 @@ const emitWellKnownFiles = async (
253
256
  * straight to the project root (see `withAdapterRoot`).
254
257
  */
255
258
  const emitVercelNegotiation = async (
256
- config: ResolvedConfig,
259
+ project: BlumeProject,
257
260
  routePaths: string[],
258
261
  root: string
259
262
  ): Promise<void> => {
263
+ const { config } = project;
260
264
  const configPath = join(root, ".vercel", "output", "config.json");
261
265
  if (!existsSync(configPath)) {
262
266
  return;
@@ -269,11 +273,17 @@ const emitVercelNegotiation = async (
269
273
  ? { [SIGNATURES_DIRECTORY_PATH.slice(1)]: SIGNATURES_DIRECTORY_TYPE }
270
274
  : {}),
271
275
  };
276
+ // The homepage rewrite serves `/index.md` from the static layer, so its
277
+ // `x-markdown-tokens` estimate has to ride the routing config; the runtime
278
+ // endpoint stamps it on dev/server-rendered responses itself.
279
+ const rawMarkdown = await buildRawMarkdown(project);
280
+ const home = rawMarkdown["/"];
272
281
  const injected = injectNegotiationRoutes(
273
282
  await readFile(configPath, "utf-8"),
274
283
  routePaths,
275
284
  buildHomeLinkHeader(config, routePaths),
276
- overrides
285
+ overrides,
286
+ home ? markdownTokenCount(agentMarkdown(home)) : undefined
277
287
  );
278
288
  if (injected === null) {
279
289
  logger.warn(
@@ -709,11 +719,7 @@ export const buildCommand = defineCommand({
709
719
  }
710
720
 
711
721
  if (project.config.deployment.output === "server" && adapter === "vercel") {
712
- await emitVercelNegotiation(
713
- project.config,
714
- project.manifest.routes.map((route) => route.path),
715
- root
716
- );
722
+ await emitVercelNegotiation(project, markdownRoutePaths(project), root);
717
723
  }
718
724
 
719
725
  await publishBuildArtifacts(
@@ -85,9 +85,12 @@ const chunkPatterns = (patterns: readonly string[]): string[][] => {
85
85
  export interface NegotiationRoutes {
86
86
  /**
87
87
  * `Vary: Accept` for the plain-HTML side of every negotiated URL, so shared
88
- * caches keep the two variants apart. Spliced *after* `handle: "filesystem"`
89
- * routes there run against filesystem matches (the same slot the Vercel
90
- * adapter uses for its `_astro` cache headers).
88
+ * caches keep the two variants apart. Spliced *before* `handle:
89
+ * "filesystem"` with `continue`: main-phase headers accumulate and ride on
90
+ * whatever ultimately serves the request. Routes placed after the filesystem
91
+ * marker are the miss phase — they run only when no static file matches, and
92
+ * every Blume content page is a prerendered static file, so a header route
93
+ * there never fires.
91
94
  */
92
95
  headerRoutes: VercelRoute[];
93
96
  /**
@@ -102,10 +105,15 @@ export interface NegotiationRoutes {
102
105
  * Build the routes for the given content-route paths (the routes that have a
103
106
  * raw-Markdown mirror, straight from the manifest). Paths are matched with an
104
107
  * optional trailing slash and rewritten `/{route}` → `/{route}.md`; the home
105
- * page's mirror lives at `/index.md`.
108
+ * page's mirror lives at `/index.md`. When `homeTokens` is given, the home
109
+ * rewrite also stamps `x-markdown-tokens` — the estimated token count of the
110
+ * homepage mirror (Cloudflare's Markdown for Agents convention). Only the home
111
+ * route can carry it: the other rewrites are chunked alternations spanning
112
+ * many pages, and a count is per-page.
106
113
  */
107
114
  export const buildNegotiationRoutes = (
108
- routePaths: readonly string[]
115
+ routePaths: readonly string[],
116
+ homeTokens?: number
109
117
  ): NegotiationRoutes => {
110
118
  const home = routePaths.includes("/");
111
119
  const rest = routePaths
@@ -118,7 +126,10 @@ export const buildNegotiationRoutes = (
118
126
  {
119
127
  dest: "/index.md",
120
128
  has: ACCEPT_MARKDOWN_CONDITION,
121
- headers: VARY_ACCEPT,
129
+ headers:
130
+ homeTokens === undefined
131
+ ? VARY_ACCEPT
132
+ : { ...VARY_ACCEPT, "x-markdown-tokens": String(homeTokens) },
122
133
  src: "^/$",
123
134
  },
124
135
  ]
@@ -170,9 +181,9 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
170
181
  /**
171
182
  * Splice the negotiation routes into a Build Output `config.json`, plus — when
172
183
  * given — a homepage `Link` header route for agent discovery (see
173
- * `ai/link-headers.ts`), applied the same way the `Vary` routes are: after
174
- * `handle: "filesystem"` with `continue`, so the header rides on the
175
- * prerendered homepage response. `contentTypeOverrides` maps static-dir
184
+ * `ai/link-headers.ts`), applied the same way the `Vary` routes are: in the
185
+ * main phase before `handle: "filesystem"` with `continue`, so the header
186
+ * rides on the prerendered homepage response. `contentTypeOverrides` maps static-dir
176
187
  * relative paths to media types via the Build Output `overrides` field — the
177
188
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
178
189
  * signature directory). Returns the updated JSON text (tab-indented, like the
@@ -184,7 +195,8 @@ export const injectNegotiationRoutes = (
184
195
  configText: string,
185
196
  routePaths: readonly string[],
186
197
  homeLinkHeader?: string | null,
187
- contentTypeOverrides?: Record<string, string>
198
+ contentTypeOverrides?: Record<string, string>,
199
+ homeTokens?: number
188
200
  ): string | null => {
189
201
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
190
202
  if (
@@ -218,7 +230,10 @@ export const injectNegotiationRoutes = (
218
230
  if (filesystemIndex === -1) {
219
231
  return null;
220
232
  }
221
- const { headerRoutes, rewriteRoutes } = buildNegotiationRoutes(routePaths);
233
+ const { headerRoutes, rewriteRoutes } = buildNegotiationRoutes(
234
+ routePaths,
235
+ homeTokens
236
+ );
222
237
  if (homeLinkHeader) {
223
238
  headerRoutes.push({
224
239
  continue: true,
@@ -226,8 +241,10 @@ export const injectNegotiationRoutes = (
226
241
  src: HOME_SRC,
227
242
  });
228
243
  }
229
- routes.splice(filesystemIndex + 1, 0, ...headerRoutes);
230
- routes.splice(filesystemIndex, 0, ...rewriteRoutes);
244
+ // Headers first: `continue` routes accumulate, so a request the rewrite
245
+ // route then terminates (Markdown negotiation on the homepage) still carries
246
+ // the Link header.
247
+ routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes);
231
248
  config.routes = routes;
232
249
  return `${JSON.stringify(config, null, "\t")}\n`;
233
250
  };
@@ -210,7 +210,9 @@ export const slugifyFontName = (name: string): string =>
210
210
  name
211
211
  .toLowerCase()
212
212
  .replaceAll(/[^a-z0-9]+/gu, "-")
213
- .replaceAll(/^-+|-+$/gu, "");
213
+ // The collapse above leaves only single dashes, so no quantifiers needed
214
+ // (an unanchored `-+$` backtracks quadratically on long dash runs).
215
+ .replaceAll(/^-|-$/gu, "");
214
216
 
215
217
  /** The CSS variable Astro populates for a given font (shared across roles). */
216
218
  const fontVar = (slug: string): string => `--blume-ff-${slug}`;