blume 1.7.1 → 1.7.3

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 (138) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli/chunk-0qymqwzz.js +164 -0
  3. package/dist/cli/chunk-0qymqwzz.js.map +15 -0
  4. package/dist/cli/{chunk-8gnpdsn1.js → chunk-0xjyb285.js} +2 -2
  5. package/dist/cli/{chunk-12dzsn9b.js → chunk-1jefwnfs.js} +82 -81
  6. package/dist/cli/{chunk-12dzsn9b.js.map → chunk-1jefwnfs.js.map} +3 -3
  7. package/dist/cli/{chunk-27gtm2ym.js → chunk-2mzebbbz.js} +1 -1
  8. package/dist/cli/{chunk-s5e5jt53.js → chunk-2z47ypj8.js} +1 -1
  9. package/dist/cli/{chunk-jtb45atp.js → chunk-3r45185y.js} +10 -12
  10. package/dist/cli/{chunk-jtb45atp.js.map → chunk-3r45185y.js.map} +2 -2
  11. package/dist/cli/{chunk-6mq7qkve.js → chunk-4x36ddpw.js} +18 -24
  12. package/dist/cli/{chunk-6mq7qkve.js.map → chunk-4x36ddpw.js.map} +2 -2
  13. package/dist/cli/{chunk-he2zfgah.js → chunk-5093q3n7.js} +22 -30
  14. package/dist/cli/{chunk-he2zfgah.js.map → chunk-5093q3n7.js.map} +2 -2
  15. package/dist/cli/{chunk-k0v1f8bb.js → chunk-5g0w1e2c.js} +21 -28
  16. package/dist/cli/{chunk-k0v1f8bb.js.map → chunk-5g0w1e2c.js.map} +2 -2
  17. package/dist/cli/{chunk-5gfw0q4j.js → chunk-5qk08vmp.js} +26 -34
  18. package/dist/cli/{chunk-5gfw0q4j.js.map → chunk-5qk08vmp.js.map} +2 -2
  19. package/dist/cli/{chunk-r99hynxh.js → chunk-7s8hm3b6.js} +41 -9
  20. package/dist/cli/{chunk-r99hynxh.js.map → chunk-7s8hm3b6.js.map} +3 -3
  21. package/dist/cli/{chunk-vyqj481z.js → chunk-8cjtbafj.js} +68 -66
  22. package/dist/cli/chunk-8cjtbafj.js.map +13 -0
  23. package/dist/cli/{chunk-aqjvpd03.js → chunk-97r59kpr.js} +27 -33
  24. package/dist/cli/{chunk-aqjvpd03.js.map → chunk-97r59kpr.js.map} +2 -2
  25. package/dist/cli/{chunk-np8dmfb0.js → chunk-ahnw3kxw.js} +26 -33
  26. package/dist/cli/{chunk-np8dmfb0.js.map → chunk-ahnw3kxw.js.map} +2 -2
  27. package/dist/cli/{chunk-j5f2wrj5.js → chunk-b27xqwn9.js} +10 -15
  28. package/dist/cli/{chunk-j5f2wrj5.js.map → chunk-b27xqwn9.js.map} +2 -2
  29. package/dist/cli/{chunk-kmx2mydj.js → chunk-bf6bt1xt.js} +8 -8
  30. package/dist/cli/{chunk-kmx2mydj.js.map → chunk-bf6bt1xt.js.map} +1 -1
  31. package/dist/cli/{chunk-90pdhkpm.js → chunk-bvwwhd84.js} +23 -32
  32. package/dist/cli/{chunk-90pdhkpm.js.map → chunk-bvwwhd84.js.map} +2 -2
  33. package/dist/cli/{chunk-mfm4sjwx.js → chunk-cjtn640a.js} +32 -43
  34. package/dist/cli/{chunk-mfm4sjwx.js.map → chunk-cjtn640a.js.map} +2 -2
  35. package/dist/cli/{chunk-pxj10x8y.js → chunk-ct47dqpx.js} +14 -3
  36. package/dist/cli/{chunk-pxj10x8y.js.map → chunk-ct47dqpx.js.map} +4 -3
  37. package/dist/cli/{chunk-x66c5yjn.js → chunk-dwgcp5sm.js} +2 -2
  38. package/dist/cli/{chunk-4trphnvy.js → chunk-e7f42gdj.js} +10 -13
  39. package/dist/cli/{chunk-4trphnvy.js.map → chunk-e7f42gdj.js.map} +2 -2
  40. package/dist/cli/{chunk-82atea4k.js → chunk-esphfr8p.js} +14 -18
  41. package/dist/cli/{chunk-82atea4k.js.map → chunk-esphfr8p.js.map} +2 -2
  42. package/dist/cli/{chunk-q56730e0.js → chunk-ex56aa81.js} +53 -53
  43. package/dist/cli/chunk-ex56aa81.js.map +13 -0
  44. package/dist/cli/{chunk-ywn7t0pb.js → chunk-garjf5z9.js} +3 -3
  45. package/dist/cli/{chunk-ev67ycx0.js → chunk-jq5n4avg.js} +1 -1
  46. package/dist/cli/{chunk-ka5k7cz9.js → chunk-js7saxwm.js} +35 -39
  47. package/dist/cli/{chunk-ka5k7cz9.js.map → chunk-js7saxwm.js.map} +4 -6
  48. package/dist/cli/{chunk-x1wvw7a8.js → chunk-k79xp7av.js} +168 -208
  49. package/dist/cli/chunk-k79xp7av.js.map +39 -0
  50. package/dist/cli/{chunk-3r94j3tc.js → chunk-nn13znc2.js} +2 -2
  51. package/dist/cli/{chunk-4ae4f395.js → chunk-ps4m1xh4.js} +60 -35
  52. package/dist/cli/chunk-ps4m1xh4.js.map +15 -0
  53. package/dist/cli/{chunk-wd27zjcz.js → chunk-q4rae3bg.js} +1 -1
  54. package/dist/cli/{chunk-pdwg3q9g.js → chunk-rqy0s5wh.js} +21 -30
  55. package/dist/cli/{chunk-pdwg3q9g.js.map → chunk-rqy0s5wh.js.map} +2 -2
  56. package/dist/cli/{chunk-52cwcqvp.js → chunk-rz9jmfhz.js} +15 -24
  57. package/dist/cli/{chunk-52cwcqvp.js.map → chunk-rz9jmfhz.js.map} +2 -2
  58. package/dist/cli/{chunk-8p3xe5jv.js → chunk-vacwm2hv.js} +3 -3
  59. package/dist/cli/{chunk-cbjnx4s8.js → chunk-vh9w1sgp.js} +1 -1
  60. package/dist/cli/{chunk-sbdqrjbb.js → chunk-vrfp10qk.js} +1 -1
  61. package/dist/cli/{chunk-h9ekmtz7.js → chunk-yg63d42r.js} +28 -35
  62. package/dist/cli/{chunk-h9ekmtz7.js.map → chunk-yg63d42r.js.map} +2 -2
  63. package/dist/cli/{chunk-5hs6gb7n.js → chunk-yzhm0j9q.js} +1 -1
  64. package/dist/cli/index.js +397 -34
  65. package/dist/cli/index.js.map +12 -4
  66. package/dist/types/core/config-input.d.ts +59 -0
  67. package/dist/types/core/data.d.ts +2 -0
  68. package/dist/types/core/schema.d.ts +47 -3
  69. package/dist/types/core/types.d.ts +5 -0
  70. package/docs/configuration/ask-ai.mdx +61 -0
  71. package/docs/configuration/index.mdx +3 -1
  72. package/docs/content/navigation.mdx +3 -0
  73. package/docs/content/syntax.mdx +10 -0
  74. package/docs/discoverability/agent-discovery.mdx +82 -1
  75. package/docs/discoverability/index.mdx +1 -1
  76. package/docs/discoverability/llms-txt.mdx +1 -1
  77. package/docs/reference/frontmatter.mdx +2 -0
  78. package/package.json +1 -1
  79. package/src/ai/agent-readability.ts +5 -0
  80. package/src/ai/ai-catalog.ts +241 -0
  81. package/src/ai/cors.ts +87 -0
  82. package/src/ai/link-headers.ts +12 -0
  83. package/src/ai/llms.ts +6 -0
  84. package/src/ai/mcp/discovery.ts +1 -1
  85. package/src/astro/generate.ts +4 -0
  86. package/src/astro/templates.ts +88 -23
  87. package/src/cli/commands/build.ts +3 -1
  88. package/src/components/islands/hooks.ts +50 -1
  89. package/src/components/layout/NavTree.astro +75 -66
  90. package/src/components/layout/RootLayout.astro +28 -2
  91. package/src/components/layout/analytics-client.ts +36 -7
  92. package/src/components/layout/nav-utils.ts +17 -3
  93. package/src/core/adapter.ts +61 -0
  94. package/src/core/config-input.ts +60 -0
  95. package/src/core/data.ts +2 -0
  96. package/src/core/navigation.ts +22 -3
  97. package/src/core/schema.ts +88 -0
  98. package/src/core/types.ts +5 -0
  99. package/src/deploy/artifacts.ts +12 -1
  100. package/src/deploy/headers.ts +6 -0
  101. package/src/deploy/vercel-negotiation.ts +25 -2
  102. package/src/registry/eject.ts +2 -0
  103. package/src/search/build.ts +25 -3
  104. package/src/theme/entry.ts +23 -0
  105. package/dist/cli/chunk-2aj8ddew.js +0 -72
  106. package/dist/cli/chunk-2aj8ddew.js.map +0 -10
  107. package/dist/cli/chunk-4ae4f395.js.map +0 -15
  108. package/dist/cli/chunk-4xyggvgf.js +0 -21
  109. package/dist/cli/chunk-4xyggvgf.js.map +0 -10
  110. package/dist/cli/chunk-6kzzpsx8.js +0 -26
  111. package/dist/cli/chunk-6kzzpsx8.js.map +0 -10
  112. package/dist/cli/chunk-bcy492zc.js +0 -16
  113. package/dist/cli/chunk-bcy492zc.js.map +0 -10
  114. package/dist/cli/chunk-btfr9yvw.js +0 -41
  115. package/dist/cli/chunk-btfr9yvw.js.map +0 -10
  116. package/dist/cli/chunk-ey89bjj1.js +0 -209
  117. package/dist/cli/chunk-ey89bjj1.js.map +0 -11
  118. package/dist/cli/chunk-q56730e0.js.map +0 -13
  119. package/dist/cli/chunk-qvvpnwaz.js +0 -69
  120. package/dist/cli/chunk-qvvpnwaz.js.map +0 -11
  121. package/dist/cli/chunk-vt8fgygt.js +0 -23
  122. package/dist/cli/chunk-vt8fgygt.js.map +0 -10
  123. package/dist/cli/chunk-vxv4x1n8.js +0 -17
  124. package/dist/cli/chunk-vxv4x1n8.js.map +0 -10
  125. package/dist/cli/chunk-vyqj481z.js.map +0 -13
  126. package/dist/cli/chunk-x1wvw7a8.js.map +0 -40
  127. /package/dist/cli/{chunk-8gnpdsn1.js.map → chunk-0xjyb285.js.map} +0 -0
  128. /package/dist/cli/{chunk-27gtm2ym.js.map → chunk-2mzebbbz.js.map} +0 -0
  129. /package/dist/cli/{chunk-s5e5jt53.js.map → chunk-2z47ypj8.js.map} +0 -0
  130. /package/dist/cli/{chunk-x66c5yjn.js.map → chunk-dwgcp5sm.js.map} +0 -0
  131. /package/dist/cli/{chunk-ywn7t0pb.js.map → chunk-garjf5z9.js.map} +0 -0
  132. /package/dist/cli/{chunk-ev67ycx0.js.map → chunk-jq5n4avg.js.map} +0 -0
  133. /package/dist/cli/{chunk-3r94j3tc.js.map → chunk-nn13znc2.js.map} +0 -0
  134. /package/dist/cli/{chunk-wd27zjcz.js.map → chunk-q4rae3bg.js.map} +0 -0
  135. /package/dist/cli/{chunk-8p3xe5jv.js.map → chunk-vacwm2hv.js.map} +0 -0
  136. /package/dist/cli/{chunk-cbjnx4s8.js.map → chunk-vh9w1sgp.js.map} +0 -0
  137. /package/dist/cli/{chunk-sbdqrjbb.js.map → chunk-vrfp10qk.js.map} +0 -0
  138. /package/dist/cli/{chunk-5hs6gb7n.js.map → chunk-yzhm0j9q.js.map} +0 -0
@@ -0,0 +1,241 @@
1
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
+ import type { ResolvedConfig } from "../core/schema.ts";
3
+ import { absoluteUrl } from "../core/site-url.ts";
4
+ import { resolveReferences } from "../openapi/references.ts";
5
+ import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
6
+ import { OPENAPI_PATH } from "./api/paths.ts";
7
+ import { asciiSlugify } from "./mcp/discovery.ts";
8
+ import { AGENT_SKILLS_DIR } from "./skills.ts";
9
+ import type { SkillArtifact } from "./skills.ts";
10
+
11
+ /**
12
+ * The AI Catalog (Agent-Card/ai-catalog data model, `specVersion` 1.0) at
13
+ * `/.well-known/ai-catalog.json`, which is also the Agentic Resource
14
+ * Discovery (ARD) manifest: one entry per agent-facing resource the site
15
+ * publishes, each an `urn:air:<host>:<namespace>:<name>` identifier, a
16
+ * display name, the artifact's media type, a URL, and the
17
+ * `representativeQueries` registries embed for semantic search. Like the
18
+ * RFC 9727 catalog next to it, everything is derived from `blume.config.ts`
19
+ * and the build's published skills — nothing is hand-written.
20
+ *
21
+ * ARD v0.91 moved its manifest to `/.well-known/ard.json` and calls
22
+ * `ai-catalog.json` the predecessor path that consumers only *may* consult,
23
+ * while the ai-catalog spec and today's agent-readiness scanners key on
24
+ * `ai-catalog.json`. The ARD manifest is just an `entries` array (other
25
+ * members are ignored), so the one document is written to both paths.
26
+ *
27
+ * Served as plain `application/json`: the `.json` extension gets it from
28
+ * every static host with no header rule, and the scanners that gate on the
29
+ * file ask for exactly that type. The registered `application/ai-catalog+json`
30
+ * is what the `Link`/`<link>` advertisements declare the document to be.
31
+ */
32
+
33
+ export const AI_CATALOG_PATH = "/.well-known/ai-catalog.json";
34
+ export const ARD_MANIFEST_PATH = "/.well-known/ard.json";
35
+ export const AI_CATALOG_TYPE = "application/ai-catalog+json";
36
+ const SPEC_VERSION = "1.0";
37
+
38
+ /** One catalog entry, restricted to the terms Blume emits. */
39
+ interface CatalogEntry {
40
+ identifier: string;
41
+ displayName: string;
42
+ type: string;
43
+ url: string;
44
+ description?: string;
45
+ capabilities?: string[];
46
+ representativeQueries: string[];
47
+ }
48
+
49
+ /** An entry before its identifier and queries are resolved against the host. */
50
+ interface EntrySeed {
51
+ /** `<namespace>:<name>` — the identifier's tail and the `queries` key. */
52
+ key: string;
53
+ displayName: string;
54
+ type: string;
55
+ url: string;
56
+ description?: string;
57
+ capabilities?: string[];
58
+ /** Generated queries, replaced wholesale by a configured `queries[key]`. */
59
+ queries: string[];
60
+ }
61
+
62
+ const REFERENCE_KIND_LABEL = {
63
+ asyncapi: "AsyncAPI",
64
+ graphql: "GraphQL",
65
+ openapi: "OpenAPI",
66
+ } as const;
67
+
68
+ /** The `urn:air` publisher: the configured site's hostname. */
69
+ const publisherHost = (site: string): string => new URL(site).hostname;
70
+
71
+ /**
72
+ * Whether the site publishes a catalog: the feature is on, a `deployment.site`
73
+ * anchors the identifiers, and at least one entry exists. Every entry source
74
+ * is a config flag, so the answer needs no build output — the same gate the
75
+ * header rules, the `Link` header, and the head links read.
76
+ */
77
+ export const hasAiCatalog = (config: ResolvedConfig): boolean =>
78
+ config.ai.catalog.enabled &&
79
+ Boolean(config.deployment.site) &&
80
+ (config.ai.mcp.enabled ||
81
+ config.ai.api ||
82
+ config.ai.llmsTxt.enabled ||
83
+ Boolean(config.ai.skills) ||
84
+ resolveReferences(config).length > 0);
85
+
86
+ /**
87
+ * The `.well-known` documents agent registries fetch cross-origin (browser
88
+ * agents, hosted registries reading through a page). Each needs
89
+ * `Access-Control-Allow-Origin: *` on the static surface; the MCP endpoint
90
+ * itself already sets it at runtime.
91
+ */
92
+ export const crossOriginDiscoveryPaths = (config: ResolvedConfig): string[] => {
93
+ const paths: string[] = [];
94
+ if (hasAiCatalog(config)) {
95
+ paths.push(AI_CATALOG_PATH, ARD_MANIFEST_PATH);
96
+ }
97
+ if (hasApiCatalog(config)) {
98
+ paths.push(API_CATALOG_PATH);
99
+ }
100
+ if (config.ai.mcp.enabled) {
101
+ paths.push("/.well-known/mcp.json", "/.well-known/mcp/server-card.json");
102
+ }
103
+ return paths;
104
+ };
105
+
106
+ const entrySeeds = (
107
+ config: ResolvedConfig,
108
+ skills: readonly SkillArtifact[],
109
+ abs: (path: string) => string
110
+ ): EntrySeed[] => {
111
+ const { title } = config;
112
+ const seeds: EntrySeed[] = [];
113
+
114
+ if (config.ai.mcp.enabled) {
115
+ const name = config.ai.mcp.name ?? title;
116
+ seeds.push({
117
+ capabilities: ["search_docs", "get_page", "list_pages", "get_navigation"],
118
+ description:
119
+ config.ai.mcp.instructions ??
120
+ `Model Context Protocol server over the ${title} documentation: full-text search, page Markdown, the page index, and the navigation tree.`,
121
+ displayName: name,
122
+ key: `mcp:${asciiSlugify(name) || "docs"}`,
123
+ queries: [
124
+ `search the ${title} documentation`,
125
+ `get a ${title} docs page as Markdown`,
126
+ `list every page in the ${title} docs`,
127
+ ],
128
+ type: "application/mcp-server-card+json",
129
+ url: abs("/.well-known/mcp/server-card.json"),
130
+ });
131
+ }
132
+
133
+ for (const skill of skills) {
134
+ seeds.push({
135
+ description: skill.description,
136
+ displayName: skill.name,
137
+ key: `skill:${skill.name}`,
138
+ queries: [
139
+ `load the ${skill.name} agent skill`,
140
+ `how do I use ${skill.name}`,
141
+ ],
142
+ type:
143
+ skill.type === "archive"
144
+ ? "application/agent-skills+gzip"
145
+ : "application/agent-skills+md",
146
+ url: abs(`${AGENT_SKILLS_DIR}/${skill.path}`),
147
+ });
148
+ }
149
+
150
+ if (config.ai.api) {
151
+ seeds.push({
152
+ description: `REST API over the ${title} documentation: the page index, each page as JSON or Markdown, and the navigation tree, described by this OpenAPI document.`,
153
+ displayName: `${title} docs API`,
154
+ key: "api:docs",
155
+ queries: [
156
+ `fetch a ${title} docs page as JSON`,
157
+ `list the pages in the ${title} docs`,
158
+ `get the ${title} docs navigation tree`,
159
+ ],
160
+ type: "application/vnd.oai.openapi+json",
161
+ url: abs(OPENAPI_PATH),
162
+ });
163
+ }
164
+
165
+ for (const reference of resolveReferences(config)) {
166
+ // Blume-rendered pages mount under `basePath`; Scalar pages stay at the
167
+ // raw route (see `referenceRoutes`). The rendered reference is the
168
+ // resource this publisher owns — the spec itself is catalogued by URL in
169
+ // the RFC 9727 linkset.
170
+ const docRoute =
171
+ reference.renderer === "blume"
172
+ ? withBasePath(reference.basePath, reference.route)
173
+ : reference.route;
174
+ seeds.push({
175
+ description: `${reference.label}: rendered ${REFERENCE_KIND_LABEL[reference.kind]} reference in the ${title} documentation.`,
176
+ displayName: reference.label,
177
+ key: `reference:${reference.slug}`,
178
+ queries: [
179
+ `what operations does the ${reference.label} API expose`,
180
+ `how do I call the ${reference.label} API`,
181
+ ],
182
+ type: "text/html",
183
+ url: abs(docRoute),
184
+ });
185
+ }
186
+
187
+ if (config.ai.llmsTxt.enabled) {
188
+ seeds.push({
189
+ description: `llms.txt index of the ${title} documentation: every page with a one-line summary, plus the agent-facing resources on this site.`,
190
+ displayName: `${title} llms.txt`,
191
+ key: "docs:llms-txt",
192
+ queries: [`what is ${title}`, `overview of the ${title} documentation`],
193
+ type: "text/plain",
194
+ url: abs("/llms.txt"),
195
+ });
196
+ }
197
+
198
+ return seeds;
199
+ };
200
+
201
+ /** The catalog document, or null when the site publishes none. */
202
+ export const buildAiCatalog = (
203
+ config: ResolvedConfig,
204
+ skills: readonly SkillArtifact[]
205
+ ): string | null => {
206
+ const site = config.deployment.site ?? null;
207
+ if (!(site && hasAiCatalog(config))) {
208
+ return null;
209
+ }
210
+ const deployBase = normalizeBasePath(config.deployment.base);
211
+ const abs = (path: string): string =>
212
+ absoluteUrl(site, withBasePath(deployBase, path));
213
+ const host = publisherHost(site);
214
+ const entries = entrySeeds(config, skills, abs).map((seed): CatalogEntry => {
215
+ const entry: CatalogEntry = {
216
+ displayName: seed.displayName,
217
+ identifier: `urn:air:${host}:${seed.key}`,
218
+ representativeQueries:
219
+ config.ai.catalog.queries[seed.key] ?? seed.queries,
220
+ type: seed.type,
221
+ url: seed.url,
222
+ };
223
+ if (seed.description) {
224
+ entry.description = seed.description;
225
+ }
226
+ if (seed.capabilities) {
227
+ entry.capabilities = seed.capabilities;
228
+ }
229
+ return entry;
230
+ });
231
+ const catalog = {
232
+ entries,
233
+ host: {
234
+ displayName: config.title,
235
+ documentationUrl: abs("/"),
236
+ identifier: `did:web:${host}`,
237
+ },
238
+ specVersion: SPEC_VERSION,
239
+ };
240
+ return `${JSON.stringify(catalog, null, 2)}\n`;
241
+ };
package/src/ai/cors.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * CORS for the generated Ask AI route (`ai.ask.cors`).
3
+ *
4
+ * A browser only lets a page on another origin read a response that names
5
+ * that origin, and a JSON `POST` preflights first. `preflightResponse` answers
6
+ * the `OPTIONS`; `withCors` wraps the `POST` handler so every response it
7
+ * returns — the stream, a 400, a 500 — carries the headers. Wrapping once,
8
+ * rather than stamping each `return`, keeps the next return site added to the
9
+ * handler from shipping an opaque failure for that one status.
10
+ *
11
+ * `allowed` is the `ai.ask.cors` list: origins already reduced to their
12
+ * `scheme://host[:port]` form by the config schema, or the single entry `"*"`
13
+ * to admit every origin.
14
+ */
15
+
16
+ /** The `ai.ask.cors` entry that admits every origin. */
17
+ export const ANY_ORIGIN = "*";
18
+
19
+ /** The headers a response carries for a cross-origin caller. */
20
+ export interface CorsHeaders {
21
+ "access-control-allow-origin"?: string;
22
+ vary?: string;
23
+ }
24
+
25
+ /** The response headers that name the caller's origin when `allowed` lists it. */
26
+ export const corsHeaders = (
27
+ request: Request,
28
+ allowed: readonly string[]
29
+ ): CorsHeaders => {
30
+ if (allowed.includes(ANY_ORIGIN)) {
31
+ // A wildcard answer is the same for every caller, so nothing to vary on.
32
+ return { "access-control-allow-origin": ANY_ORIGIN };
33
+ }
34
+ // `Vary` rides on both branches: the answer depends on `Origin` whether or
35
+ // not it was listed, so a shared cache never hands one origin's response
36
+ // (or the header-less one) to another.
37
+ const origin = request.headers.get("origin");
38
+ return origin && allowed.includes(origin)
39
+ ? { "access-control-allow-origin": origin, vary: "origin" }
40
+ : { vary: "origin" };
41
+ };
42
+
43
+ /** Answer the browser's `OPTIONS` preflight for the route. */
44
+ export const preflightResponse = (
45
+ request: Request,
46
+ allowed: readonly string[]
47
+ ): Response =>
48
+ new Response(null, {
49
+ headers: {
50
+ ...corsHeaders(request, allowed),
51
+ // Reflect whatever the caller's fetch wrapper asks to send, falling back
52
+ // to the JSON POST's own `content-type`; a listed origin shouldn't need
53
+ // an eject to add a header of its own.
54
+ "access-control-allow-headers":
55
+ request.headers.get("access-control-request-headers") ?? "content-type",
56
+ "access-control-allow-methods": "POST",
57
+ "access-control-max-age": "86400",
58
+ },
59
+ status: 204,
60
+ });
61
+
62
+ /** The slice of Astro's `APIContext` the wrapped handler reads. */
63
+ interface RequestContext {
64
+ request: Request;
65
+ }
66
+
67
+ /** Stamp the CORS headers on every response `handler` returns. */
68
+ export const withCors =
69
+ (
70
+ allowed: readonly string[],
71
+ handler: (context: RequestContext) => Promise<Response> | Response
72
+ ): ((context: RequestContext) => Promise<Response>) =>
73
+ async (context) => {
74
+ const response = await handler(context);
75
+ for (const [key, value] of Object.entries(
76
+ corsHeaders(context.request, allowed)
77
+ )) {
78
+ // `Vary` accumulates (the handler may already vary on something), the
79
+ // rest replace.
80
+ if (key === "vary") {
81
+ response.headers.append(key, value);
82
+ } else {
83
+ response.headers.set(key, value);
84
+ }
85
+ }
86
+ return response;
87
+ };
@@ -1,5 +1,10 @@
1
1
  import { normalizeBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
+ import {
4
+ AI_CATALOG_PATH,
5
+ AI_CATALOG_TYPE,
6
+ hasAiCatalog,
7
+ } from "./ai-catalog.ts";
3
8
  import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
4
9
  import { OPENAPI_PATH } from "./api/paths.ts";
5
10
 
@@ -37,6 +42,13 @@ export const buildHomeLinkHeader = (
37
42
  `<${deployBase}${API_CATALOG_PATH}>; rel="api-catalog"; type="application/linkset+json"`
38
43
  );
39
44
  }
45
+ // The ai-catalog spec's own relation for its well-known document, the
46
+ // header form of the `<link rel="ai-catalog">` every page carries.
47
+ if (hasAiCatalog(config)) {
48
+ links.push(
49
+ `<${deployBase}${AI_CATALOG_PATH}>; rel="ai-catalog"; type="${AI_CATALOG_TYPE}"`
50
+ );
51
+ }
40
52
  // RFC 8631: `service-desc` is the relation for a machine-readable
41
53
  // description of the service — the JSON docs API's OpenAPI document.
42
54
  if (config.ai.api) {
package/src/ai/llms.ts CHANGED
@@ -6,6 +6,7 @@ import { absoluteUrl } from "../core/site-url.ts";
6
6
  import { readExpandedEntryText } from "../core/sources/read.ts";
7
7
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
8
8
  import { buildRssFeeds } from "../deploy/rss.ts";
9
+ import { AI_CATALOG_PATH, hasAiCatalog } from "./ai-catalog.ts";
9
10
  import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
10
11
  import { API_PAGES_PATH, OPENAPI_PATH } from "./api/paths.ts";
11
12
  import { downlevelComponents } from "./component-markdown.ts";
@@ -75,6 +76,11 @@ const agentResourceLines = (project: BlumeProject): string[] => {
75
76
  `- [API catalog](${url(API_CATALOG_PATH)}): RFC 9727 linkset of the APIs documented here.`
76
77
  );
77
78
  }
79
+ if (hasAiCatalog(config)) {
80
+ lines.push(
81
+ `- [AI catalog](${url(AI_CATALOG_PATH)}): ARD manifest of the agent-facing resources on this site (MCP server, skills, APIs).`
82
+ );
83
+ }
78
84
  if (config.seo.agentReadability) {
79
85
  lines.push(
80
86
  `- [agent-readability.json](${url("/agent-readability.json")}): Manifest of every agent-facing artifact on this site.`
@@ -56,7 +56,7 @@ const truncate = (text: string): string =>
56
56
  const NON_ASCII_SLUG = /[^a-z0-9]+/gu;
57
57
  const COMBINING_MARKS = /\p{M}+/gu;
58
58
 
59
- const asciiSlugify = (text: string): string =>
59
+ export const asciiSlugify = (text: string): string =>
60
60
  trimChar(
61
61
  text
62
62
  .normalize("NFKD")
@@ -24,6 +24,7 @@ import {
24
24
  } from "pathe";
25
25
  import { glob } from "tinyglobby";
26
26
 
27
+ import { hasAiCatalog } from "../ai/ai-catalog.ts";
27
28
  import { OPENAPI_PATH } from "../ai/api/paths.ts";
28
29
  import { buildApiSpec } from "../ai/api/spec.ts";
29
30
  import { buildAskData } from "../ai/ask-data.ts";
@@ -1357,6 +1358,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1357
1358
  description: config.description,
1358
1359
  discovery: {
1359
1360
  agentReadability: config.seo.agentReadability,
1361
+ aiCatalog: hasAiCatalog(config),
1360
1362
  api: config.ai.api,
1361
1363
  llmsTxt: config.ai.llmsTxt.enabled,
1362
1364
  // Mirrors `buildSitemapFiles`: no site, no sitemap.
@@ -1826,7 +1828,9 @@ const writeAskFiles = async (
1826
1828
  await write(
1827
1829
  join(srcDir, "pages", "api", "ask.ts"),
1828
1830
  askEndpointTemplate(resolveAskBackend(ask), grounded, {
1831
+ cors: ask.cors,
1829
1832
  instructions: ask.instructions,
1833
+ reasoning: ask.reasoning,
1830
1834
  retrieval: ask.retrieval,
1831
1835
  })
1832
1836
  );
@@ -9,7 +9,7 @@ import type { AskBackend } from "../ai/ask.ts";
9
9
  import { buildHomeLinkHeader } from "../ai/link-headers.ts";
10
10
  import { normalizeBasePath } from "../core/base-path.ts";
11
11
  import { TOC_HIDDEN_KEY } from "../core/heading-markers.ts";
12
- import type { ResolvedConfig } from "../core/schema.ts";
12
+ import type { AskReasoning, ResolvedConfig } from "../core/schema.ts";
13
13
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
14
14
  import { trimChar } from "../core/trim.ts";
15
15
  import type { ProjectContext } from "../core/types.ts";
@@ -1040,12 +1040,58 @@ const ASK_FALLBACK_PROMPT =
1040
1040
 
1041
1041
  /** The `ai.ask` values the generated endpoint has to carry with it. */
1042
1042
  export interface AskEndpointOptions {
1043
+ /** `ai.ask.cors` — origins allowed to call the route from another site. */
1044
+ cors?: string[];
1043
1045
  /** `ai.ask.instructions` — extra system-prompt text. */
1044
1046
  instructions?: string;
1047
+ /**
1048
+ * `ai.ask.reasoning` — how much the model reasons before answering, sent
1049
+ * as the backend's own reasoning-effort control.
1050
+ */
1051
+ reasoning?: AskReasoning;
1045
1052
  /** `ai.ask.retrieval` — how much documentation each question carries. */
1046
1053
  retrieval?: AskRetrievalOptions;
1047
1054
  }
1048
1055
 
1056
+ /** The pieces `askEndpointTemplate` splices in for `ai.ask.cors`. */
1057
+ interface AskCorsTemplate {
1058
+ /** The route's closing token: `});` when the POST is wrapped, `};` otherwise. */
1059
+ close: string;
1060
+ /** The runtime import, when anything is listed. */
1061
+ imports: string[];
1062
+ /** The opening of `export const POST: APIRoute = `. */
1063
+ open: string;
1064
+ /** The allow list and the `OPTIONS` handler, spliced after the provider setup. */
1065
+ setup: string;
1066
+ }
1067
+
1068
+ /**
1069
+ * `ai.ask.cors`: a browser only lets another origin read the stream when the
1070
+ * response names that origin, and a JSON POST preflights first, so the route
1071
+ * answers `OPTIONS` and wraps the `POST` in `withCors`, which stamps a listed
1072
+ * origin on every response — errors included, so a cross-origin caller can
1073
+ * tell a 400 from a 500 — without each `return` having to remember to.
1074
+ * Unlisted origins get no allow header and stay subject to the same-origin
1075
+ * rule. Left out entirely when nothing is listed, so the default route is
1076
+ * unchanged.
1077
+ */
1078
+ const askCorsTemplate = (cors: readonly string[] = []): AskCorsTemplate =>
1079
+ cors.length > 0
1080
+ ? {
1081
+ close: "});",
1082
+ imports: [
1083
+ 'import { preflightResponse, withCors } from "blume/ai/cors.ts";',
1084
+ ],
1085
+ open: "withCors(ALLOWED_ORIGINS, async ({ request }) => {",
1086
+ setup: `
1087
+ const ALLOWED_ORIGINS = ${JSON.stringify(cors)};
1088
+
1089
+ export const OPTIONS: APIRoute = ({ request }) =>
1090
+ preflightResponse(request, ALLOWED_ORIGINS);
1091
+ `,
1092
+ }
1093
+ : { close: "};", imports: [], open: "async ({ request }) => {", setup: "" };
1094
+
1049
1095
  /**
1050
1096
  * Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
1051
1097
  *
@@ -1053,15 +1099,24 @@ export interface AskEndpointOptions {
1053
1099
  * built-in prompt on every path: the grounded prompt via `createAskContext`,
1054
1100
  * and the plain fallback here. `options.retrieval` (the `ai.ask.retrieval`
1055
1101
  * config) is forwarded to `createAskContext` on the grounded path, where it
1056
- * sizes retrieval. Both travel in one options object so a new call site can't
1057
- * silently drop one of them.
1102
+ * sizes retrieval. `options.reasoning` (the `ai.ask.reasoning` config)
1103
+ * reaches the model call on both paths. `options.cors` (the `ai.ask.cors`
1104
+ * config) adds a preflight handler and wraps the `POST` so every response
1105
+ * names a listed origin. All four travel in one options object so a new call
1106
+ * site can't silently drop one of them.
1058
1107
  */
1059
1108
  export const askEndpointTemplate = (
1060
1109
  backend: AskBackend,
1061
1110
  grounded: boolean,
1062
1111
  options?: AskEndpointOptions
1063
1112
  ): string => {
1064
- const instructions = options?.instructions;
1113
+ const { instructions, reasoning, retrieval } = options ?? {};
1114
+ // `ai.ask.reasoning`. The gateway and OpenAI-compatible providers take it
1115
+ // from `streamText`'s top-level `reasoning` (the gateway maps it to the
1116
+ // model's own control, the OpenAI-compatible provider sends it as
1117
+ // `reasoning_effort`). OpenRouter's provider ignores that call option and
1118
+ // only reads its own model setting, so there the level rides on the model
1119
+ // as `reasoning.effort`. Omitted keeps the provider default on every path.
1065
1120
  const fallbackPrompt = instructions
1066
1121
  ? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
1067
1122
  : ASK_FALLBACK_PROMPT;
@@ -1097,7 +1152,10 @@ export const askEndpointTemplate = (
1097
1152
  setup = `\nconst openrouter = createOpenRouter({
1098
1153
  apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),${headersField}
1099
1154
  });\n`;
1100
- modelExpr = `openrouter(${JSON.stringify(backend.model)})`;
1155
+ const settings = reasoning
1156
+ ? `, { reasoning: { effort: ${JSON.stringify(reasoning)} } }`
1157
+ : "";
1158
+ modelExpr = `openrouter(${JSON.stringify(backend.model)}${settings})`;
1101
1159
  } else if (backend.kind === "openai-compatible") {
1102
1160
  imports.push(
1103
1161
  'import { createOpenAICompatible } from "@ai-sdk/openai-compatible";'
@@ -1120,13 +1178,15 @@ export const askEndpointTemplate = (
1120
1178
  if (instructions) {
1121
1179
  groundFields.push(`instructions: ${JSON.stringify(instructions)}`);
1122
1180
  }
1123
- if (options?.retrieval) {
1124
- groundFields.push(`retrieval: ${JSON.stringify(options.retrieval)}`);
1181
+ if (retrieval) {
1182
+ groundFields.push(`retrieval: ${JSON.stringify(retrieval)}`);
1125
1183
  }
1126
1184
  const groundOptions =
1127
1185
  groundFields.length > 0 ? `, { ${groundFields.join(", ")} }` : "";
1128
1186
  setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
1129
1187
  }
1188
+ const cors = askCorsTemplate(options?.cors);
1189
+ imports.push(...cors.imports);
1130
1190
  // Validate the client-supplied body and cap its size. The endpoint is
1131
1191
  // unauthenticated, so bounding message count/length limits how much a caller
1132
1192
  // can spend against the model per request, and restricting roles to
@@ -1183,24 +1243,29 @@ export const askEndpointTemplate = (
1183
1243
  const onError = ` onError({ error }) {
1184
1244
  console.error("Ask AI provider error:", error);
1185
1245
  },`;
1246
+ // The `streamText` argument list, built once so the grounded and plain
1247
+ // paths can't drift: they differ only in where the instructions come from.
1248
+ const streamFields = [
1249
+ `model: ${modelExpr}`,
1250
+ grounded
1251
+ ? "instructions"
1252
+ : `instructions:\n ${JSON.stringify(fallbackPrompt)}`,
1253
+ "messages",
1254
+ ];
1255
+ if (reasoning && backend.kind !== "openrouter") {
1256
+ streamFields.push(`reasoning: ${JSON.stringify(reasoning)}`);
1257
+ }
1258
+ const call = ` const result = streamText({
1259
+ ${streamFields.join(",\n ")},
1260
+ ${onError}
1261
+ });`;
1186
1262
  const stream = grounded
1187
1263
  ? ` const instructions =
1188
1264
  (await ground(messages, body.page)) ??
1189
1265
  ${JSON.stringify(fallbackPrompt)};
1190
- const result = streamText({
1191
- model: ${modelExpr},
1192
- instructions,
1193
- messages,
1194
- ${onError}
1195
- });`
1196
- : ` const result = streamText({
1197
- model: ${modelExpr},
1198
- instructions:
1199
- ${JSON.stringify(fallbackPrompt)},
1200
- messages,
1201
- ${onError}
1202
- });`;
1203
- const handler = `export const POST: APIRoute = async ({ request }) => {
1266
+ ${call}`
1267
+ : call;
1268
+ const handler = `export const POST: APIRoute = ${cors.open}
1204
1269
  ${validate}
1205
1270
  ${keyCheck}
1206
1271
  try {
@@ -1209,12 +1274,12 @@ ${stream}
1209
1274
  } catch {
1210
1275
  return new Response("Failed to generate a response.", { status: 500 });
1211
1276
  }
1212
- };`;
1277
+ ${cors.close}`;
1213
1278
  return `// Generated by Blume. Do not edit.
1214
1279
  ${imports.join("\n")}
1215
1280
 
1216
1281
  export const prerender = false;
1217
- ${setup}
1282
+ ${setup}${cors.setup}
1218
1283
  ${handler}
1219
1284
  `;
1220
1285
  };
@@ -5,6 +5,7 @@ import { build } from "astro";
5
5
  import { defineCommand } from "citty";
6
6
  import { join } from "pathe";
7
7
 
8
+ import { crossOriginDiscoveryPaths } from "../../ai/ai-catalog.ts";
8
9
  import {
9
10
  API_CATALOG_PATH,
10
11
  API_CATALOG_TYPE,
@@ -128,7 +129,8 @@ const emitVercelNegotiation = async (
128
129
  {
129
130
  json: existsSync(join(staticDir, "404.json")),
130
131
  markdown: existsSync(join(staticDir, "404.md")),
131
- }
132
+ },
133
+ crossOriginDiscoveryPaths(config)
132
134
  );
133
135
  if (injected === null) {
134
136
  logger.warn(