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.
- package/CHANGELOG.md +9 -0
- package/dist/cli/index.js +71 -55
- package/dist/cli/index.js.map +12 -12
- package/docs/configuration/ai.mdx +2 -2
- package/package.json +1 -1
- package/src/ai/link-headers.ts +4 -3
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +34 -1
- package/src/astro/generate.ts +2 -2
- package/src/astro/integration.ts +3 -1
- package/src/astro/templates.ts +8 -3
- package/src/cli/commands/build.ts +17 -11
- package/src/deploy/vercel-negotiation.ts +30 -13
- package/src/theme/fonts.ts +3 -1
|
@@ -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
|
|
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
package/src/ai/link-headers.ts
CHANGED
|
@@ -40,9 +40,10 @@ export const buildHomeLinkHeader = (
|
|
|
40
40
|
`<${deployBase}/llms.txt>; rel="describedby"; type="text/plain"`
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
-
// Same
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
|
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:
|
|
227
|
+
index: buildLlmsIndex(project),
|
|
226
228
|
});
|
package/src/ai/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/astro/generate.ts
CHANGED
|
@@ -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
|
|
1611
|
+
contentRoutes: markdownRoutePaths(project),
|
|
1612
1612
|
contentWatchesRuntimeDir: contentWatchesRuntimeDir(
|
|
1613
1613
|
hasFilesystemSource,
|
|
1614
1614
|
docsCollection.base,
|
package/src/astro/integration.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
|
|
1184
|
+
const body = entry ? ${
|
|
1185
1185
|
kind === "md" ? '(entry.md ?? entry.mdx ?? "")' : '(entry.mdx ?? "")'
|
|
1186
|
-
} : ""
|
|
1187
|
-
|
|
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
|
-
|
|
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 *
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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:
|
|
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:
|
|
174
|
-
* `handle: "filesystem"` with `continue`, so the header
|
|
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(
|
|
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
|
-
|
|
230
|
-
|
|
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
|
};
|
package/src/theme/fonts.ts
CHANGED
|
@@ -210,7 +210,9 @@ export const slugifyFontName = (name: string): string =>
|
|
|
210
210
|
name
|
|
211
211
|
.toLowerCase()
|
|
212
212
|
.replaceAll(/[^a-z0-9]+/gu, "-")
|
|
213
|
-
|
|
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}`;
|