blume 1.7.2 → 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 (101) hide show
  1. package/CHANGELOG.md +10 -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-9bkjd11x.js → chunk-0xjyb285.js} +1 -1
  5. package/dist/cli/{chunk-hdm2dkd2.js → chunk-1jefwnfs.js} +13 -7
  6. package/dist/cli/{chunk-hdm2dkd2.js.map → chunk-1jefwnfs.js.map} +3 -3
  7. package/dist/cli/{chunk-n9sra6sy.js → chunk-3r45185y.js} +5 -7
  8. package/dist/cli/{chunk-n9sra6sy.js.map → chunk-3r45185y.js.map} +2 -2
  9. package/dist/cli/{chunk-cvky9gb2.js → chunk-4x36ddpw.js} +3 -3
  10. package/dist/cli/{chunk-mqb2ka8m.js → chunk-5093q3n7.js} +12 -12
  11. package/dist/cli/{chunk-9he6crym.js → chunk-5g0w1e2c.js} +4 -4
  12. package/dist/cli/{chunk-mt76t7dj.js → chunk-5qk08vmp.js} +11 -11
  13. package/dist/cli/{chunk-61j18dwk.js → chunk-7s8hm3b6.js} +7 -2
  14. package/dist/cli/{chunk-61j18dwk.js.map → chunk-7s8hm3b6.js.map} +3 -3
  15. package/dist/cli/{chunk-196vjxp9.js → chunk-8cjtbafj.js} +11 -9
  16. package/dist/cli/{chunk-196vjxp9.js.map → chunk-8cjtbafj.js.map} +2 -2
  17. package/dist/cli/{chunk-aztttvb3.js → chunk-97r59kpr.js} +4 -4
  18. package/dist/cli/{chunk-t3tj0dgr.js → chunk-ahnw3kxw.js} +8 -8
  19. package/dist/cli/{chunk-hs3gbh8p.js → chunk-b27xqwn9.js} +3 -3
  20. package/dist/cli/{chunk-450a7rcr.js → chunk-bf6bt1xt.js} +2 -2
  21. package/dist/cli/{chunk-eevwt1sc.js → chunk-bvwwhd84.js} +13 -13
  22. package/dist/cli/{chunk-12dxjqk7.js → chunk-cjtn640a.js} +17 -17
  23. package/dist/cli/{chunk-12dxjqk7.js.map → chunk-cjtn640a.js.map} +1 -1
  24. package/dist/cli/{chunk-ra1v2nc2.js → chunk-ct47dqpx.js} +14 -3
  25. package/dist/cli/{chunk-ra1v2nc2.js.map → chunk-ct47dqpx.js.map} +4 -3
  26. package/dist/cli/{chunk-ppfvdcd4.js → chunk-dwgcp5sm.js} +1 -1
  27. package/dist/cli/{chunk-3w7b2vcx.js → chunk-e7f42gdj.js} +2 -2
  28. package/dist/cli/{chunk-vkrsvbr5.js → chunk-esphfr8p.js} +8 -8
  29. package/dist/cli/{chunk-vkrsvbr5.js.map → chunk-esphfr8p.js.map} +1 -1
  30. package/dist/cli/{chunk-fmceyezb.js → chunk-ex56aa81.js} +27 -18
  31. package/dist/cli/chunk-ex56aa81.js.map +13 -0
  32. package/dist/cli/{chunk-jbj4qhfw.js → chunk-garjf5z9.js} +2 -2
  33. package/dist/cli/{chunk-wjt80jps.js → chunk-js7saxwm.js} +14 -18
  34. package/dist/cli/{chunk-wjt80jps.js.map → chunk-js7saxwm.js.map} +4 -6
  35. package/dist/cli/{chunk-5n7t497w.js → chunk-k79xp7av.js} +53 -91
  36. package/dist/cli/chunk-k79xp7av.js.map +39 -0
  37. package/dist/cli/{chunk-688e0dde.js → chunk-nn13znc2.js} +1 -1
  38. package/dist/cli/{chunk-ejjx8znq.js → chunk-ps4m1xh4.js} +15 -9
  39. package/dist/cli/{chunk-ejjx8znq.js.map → chunk-ps4m1xh4.js.map} +3 -3
  40. package/dist/cli/{chunk-xhtpx3ff.js → chunk-rqy0s5wh.js} +13 -13
  41. package/dist/cli/{chunk-30e87n55.js → chunk-rz9jmfhz.js} +4 -4
  42. package/dist/cli/{chunk-exeeb35e.js → chunk-vacwm2hv.js} +2 -2
  43. package/dist/cli/{chunk-8cd8tj54.js → chunk-yg63d42r.js} +7 -7
  44. package/dist/cli/index.js +15 -15
  45. package/dist/types/core/config-input.d.ts +33 -0
  46. package/dist/types/core/data.d.ts +2 -0
  47. package/dist/types/core/schema.d.ts +20 -0
  48. package/dist/types/core/types.d.ts +5 -0
  49. package/docs/configuration/ask-ai.mdx +14 -0
  50. package/docs/configuration/index.mdx +3 -1
  51. package/docs/content/navigation.mdx +3 -0
  52. package/docs/content/syntax.mdx +10 -0
  53. package/docs/discoverability/agent-discovery.mdx +82 -1
  54. package/docs/discoverability/index.mdx +1 -1
  55. package/docs/discoverability/llms-txt.mdx +1 -1
  56. package/docs/reference/frontmatter.mdx +2 -0
  57. package/package.json +1 -1
  58. package/src/ai/agent-readability.ts +5 -0
  59. package/src/ai/ai-catalog.ts +241 -0
  60. package/src/ai/link-headers.ts +12 -0
  61. package/src/ai/llms.ts +6 -0
  62. package/src/ai/mcp/discovery.ts +1 -1
  63. package/src/astro/generate.ts +2 -0
  64. package/src/cli/commands/build.ts +3 -1
  65. package/src/components/islands/hooks.ts +50 -1
  66. package/src/components/layout/RootLayout.astro +24 -1
  67. package/src/components/layout/analytics-client.ts +36 -7
  68. package/src/core/config-input.ts +34 -0
  69. package/src/core/data.ts +2 -0
  70. package/src/core/navigation.ts +22 -3
  71. package/src/core/schema.ts +32 -0
  72. package/src/core/types.ts +5 -0
  73. package/src/deploy/artifacts.ts +12 -1
  74. package/src/deploy/headers.ts +6 -0
  75. package/src/deploy/vercel-negotiation.ts +25 -2
  76. package/src/search/build.ts +25 -3
  77. package/src/theme/entry.ts +23 -0
  78. package/dist/cli/chunk-5n7t497w.js.map +0 -40
  79. package/dist/cli/chunk-88by27n5.js +0 -17
  80. package/dist/cli/chunk-88by27n5.js.map +0 -10
  81. package/dist/cli/chunk-fmceyezb.js.map +0 -13
  82. package/dist/cli/chunk-tqa1s0k8.js +0 -69
  83. package/dist/cli/chunk-tqa1s0k8.js.map +0 -11
  84. /package/dist/cli/{chunk-9bkjd11x.js.map → chunk-0xjyb285.js.map} +0 -0
  85. /package/dist/cli/{chunk-cvky9gb2.js.map → chunk-4x36ddpw.js.map} +0 -0
  86. /package/dist/cli/{chunk-mqb2ka8m.js.map → chunk-5093q3n7.js.map} +0 -0
  87. /package/dist/cli/{chunk-9he6crym.js.map → chunk-5g0w1e2c.js.map} +0 -0
  88. /package/dist/cli/{chunk-mt76t7dj.js.map → chunk-5qk08vmp.js.map} +0 -0
  89. /package/dist/cli/{chunk-aztttvb3.js.map → chunk-97r59kpr.js.map} +0 -0
  90. /package/dist/cli/{chunk-t3tj0dgr.js.map → chunk-ahnw3kxw.js.map} +0 -0
  91. /package/dist/cli/{chunk-hs3gbh8p.js.map → chunk-b27xqwn9.js.map} +0 -0
  92. /package/dist/cli/{chunk-450a7rcr.js.map → chunk-bf6bt1xt.js.map} +0 -0
  93. /package/dist/cli/{chunk-eevwt1sc.js.map → chunk-bvwwhd84.js.map} +0 -0
  94. /package/dist/cli/{chunk-ppfvdcd4.js.map → chunk-dwgcp5sm.js.map} +0 -0
  95. /package/dist/cli/{chunk-3w7b2vcx.js.map → chunk-e7f42gdj.js.map} +0 -0
  96. /package/dist/cli/{chunk-jbj4qhfw.js.map → chunk-garjf5z9.js.map} +0 -0
  97. /package/dist/cli/{chunk-688e0dde.js.map → chunk-nn13znc2.js.map} +0 -0
  98. /package/dist/cli/{chunk-xhtpx3ff.js.map → chunk-rqy0s5wh.js.map} +0 -0
  99. /package/dist/cli/{chunk-30e87n55.js.map → chunk-rz9jmfhz.js.map} +0 -0
  100. /package/dist/cli/{chunk-exeeb35e.js.map → chunk-vacwm2hv.js.map} +0 -0
  101. /package/dist/cli/{chunk-8cd8tj54.js.map → chunk-yg63d42r.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
+ };
@@ -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.
@@ -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(
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import type { BlumeClientData } from "../../core/data.ts";
4
+ import { track } from "../layout/analytics-client.ts";
4
5
  import type { SearchFn, SearchResult } from "../layout/search/types.ts";
5
6
  import { joinBase, stripBase } from "./base-path.ts";
6
7
 
@@ -196,6 +197,42 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
196
197
  const controller = new AbortController();
197
198
  abortRef.current = controller;
198
199
  const live = () => current === generation.current;
200
+ const path = currentPath();
201
+ // Usage reaches the configured analytics providers the same way page
202
+ // feedback does: the question now, its outcome once the stream settles.
203
+ // A reset mid-answer revokes the outcome along with the UI update.
204
+ // Analytics keys on the raw pathname, like page feedback and the
205
+ // providers' own pageviews, so the events join under a `base`; the
206
+ // endpoint gets the base-stripped route for grounding. Providers receive
207
+ // the question's length only: its text is free-form reader input (pasted
208
+ // keys, error logs) that would breach their PII terms and their
209
+ // per-value size caps, so it rides the `blume:track` event alone for a
210
+ // site to bridge on its own terms.
211
+ const { pathname } = window.location;
212
+ const report = (
213
+ event: "ask" | "ask_answer" | "ask_error",
214
+ props: Record<string, number>
215
+ ) =>
216
+ track(
217
+ event,
218
+ { ...props, path: pathname, questionChars: trimmed.length },
219
+ { question: trimmed }
220
+ );
221
+ report("ask", {});
222
+ // A monotonic clock: the wall clock can jump mid-stream (NTP, sleep).
223
+ const startedAt = performance.now();
224
+ const outcome = (
225
+ event: "ask_answer" | "ask_error",
226
+ props: Record<string, number>
227
+ ) =>
228
+ report(event, {
229
+ ...props,
230
+ ms: Math.round(performance.now() - startedAt),
231
+ });
232
+ // The HTTP status once a response exists. `streamText` defers provider
233
+ // errors to stream consumption, so a 200 can still break mid-flight;
234
+ // that reports as a 200 error, not as "no response".
235
+ let status = 0;
199
236
  const history: AskMessage[] = [
200
237
  ...messages,
201
238
  { content: trimmed, role: "user" },
@@ -207,16 +244,18 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
207
244
  const response = await fetch(endpoint, {
208
245
  body: JSON.stringify({
209
246
  messages: history,
210
- page: { path: currentPath() },
247
+ page: { path },
211
248
  }),
212
249
  headers: { "content-type": "application/json" },
213
250
  method: "POST",
214
251
  signal: controller.signal,
215
252
  });
253
+ ({ status } = response);
216
254
  if (!response.ok) {
217
255
  // An error body (JSON, HTML error page) must not stream in as the
218
256
  // assistant's answer.
219
257
  if (live()) {
258
+ outcome("ask_error", { status });
220
259
  assistant.content = errorMessage;
221
260
  setMessages([...history, { ...assistant }]);
222
261
  }
@@ -243,11 +282,21 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
243
282
  }
244
283
  }
245
284
  }
285
+ if (live()) {
286
+ // A 200 with nothing in it (no body, an empty stream) leaves the
287
+ // reader a blank bubble — that is not an answer.
288
+ if (assistant.content) {
289
+ outcome("ask_answer", { chars: assistant.content.length });
290
+ } else {
291
+ outcome("ask_error", { status });
292
+ }
293
+ }
246
294
  } catch {
247
295
  // A thrown fetch (offline, DNS failure, CORS) must not strand the
248
296
  // pre-appended empty assistant message as a stuck placeholder. A
249
297
  // reset's abort lands here too — the guard keeps it silent.
250
298
  if (live()) {
299
+ outcome("ask_error", { status });
251
300
  assistant.content = errorMessage;
252
301
  setMessages([...history, { ...assistant }]);
253
302
  }
@@ -146,7 +146,11 @@ interface Props {
146
146
  * the site root. The HTML counterpart of the homepage-only HTTP `Link`
147
147
  * header (see `ai/link-headers.ts`).
148
148
  */
149
- discovery?: { agentReadability: boolean; llmsTxt: boolean } | null;
149
+ discovery?: {
150
+ agentReadability: boolean;
151
+ aiCatalog: boolean;
152
+ llmsTxt: boolean;
153
+ } | null;
150
154
  siteUrl?: string | null;
151
155
  pageType?: string;
152
156
  published?: string | Date | null;
@@ -548,6 +552,25 @@ createIconSprite(Astro.locals);
548
552
  <link href={withBase("/llms.txt")} rel="describedby" type="text/plain" />
549
553
  )
550
554
  }
555
+ {/* The AI Catalog / ARD manifest under both relations its two specs
556
+ define: `ai-catalog` (ai-catalog spec) and `ard` (ARD v0.91), each
557
+ pointing at that spec's own well-known path. */}
558
+ {
559
+ discovery?.aiCatalog && (
560
+ <>
561
+ <link
562
+ href={withBase("/.well-known/ai-catalog.json")}
563
+ rel="ai-catalog"
564
+ type="application/ai-catalog+json"
565
+ />
566
+ <link
567
+ href={withBase("/.well-known/ard.json")}
568
+ rel="ard"
569
+ type="application/json"
570
+ />
571
+ </>
572
+ )
573
+ }
551
574
  {
552
575
  markdownMirror && (
553
576
  <link href={markdownMirror} rel="alternate" type="text/markdown" />
@@ -6,7 +6,10 @@
6
6
  * `analytics.scripts` is reached via best-effort global detection or the
7
7
  * `blume:track` CustomEvent, which fires unconditionally so a project can bridge
8
8
  * the event to anything. Every call no-ops cleanly when a provider isn't present
9
- * — for example during `blume dev`, where `Analytics.astro` injects nothing.
9
+ * — for example during `blume dev`, where `Analytics.astro` injects nothing —
10
+ * and a provider that throws (a consent shim that stubs `gtag` with a raise, a
11
+ * broken snippet) is isolated so it neither starves the providers after it nor
12
+ * surfaces in the feature that reported the event.
10
13
  */
11
14
  import { track as vercelTrack } from "@vercel/analytics";
12
15
 
@@ -19,7 +22,27 @@ interface AnalyticsWindow {
19
22
  posthog?: { capture?: (event: string, props?: TrackProps) => void };
20
23
  }
21
24
 
22
- export const track = (event: string, props: TrackProps): void => {
25
+ /** Run one provider call; its failure must not reach the others or the caller. */
26
+ const attempt = (send: () => void): void => {
27
+ try {
28
+ send();
29
+ } catch {
30
+ // Analytics never breaks the feature that reported the event.
31
+ }
32
+ };
33
+
34
+ /**
35
+ * @param event The event name.
36
+ * @param props Properties every provider receives.
37
+ * @param local Properties only the `blume:track` CustomEvent carries — free
38
+ * text a site may bridge to a provider on its own terms, but that must not
39
+ * reach third parties unasked (a reader's Ask AI question, for instance).
40
+ */
41
+ export const track = (
42
+ event: string,
43
+ props: TrackProps,
44
+ local: TrackProps = {}
45
+ ): void => {
23
46
  // Read through `globalThis` so an SSR/import-time call sees `undefined`
24
47
  // instead of a bare-identifier ReferenceError.
25
48
  const browserWindow = globalThis.window;
@@ -31,12 +54,18 @@ export const track = (event: string, props: TrackProps): void => {
31
54
  const w = browserWindow as typeof browserWindow & AnalyticsWindow;
32
55
 
33
56
  // Vercel Web Analytics — self-gates to a no-op until `window.va` is set up.
34
- vercelTrack(event, props);
57
+ attempt(() => vercelTrack(event, props));
35
58
  // PostHog — the injected array.js stub queues calls until the lib loads.
36
- w.posthog?.capture?.(event, props);
59
+ attempt(() => w.posthog?.capture?.(event, props));
37
60
  // Popular providers wired through `analytics.scripts` (GA4/GTM, Plausible).
38
- w.gtag?.("event", event, props);
39
- w.plausible?.(event, { props });
61
+ attempt(() => w.gtag?.("event", event, props));
62
+ attempt(() => w.plausible?.(event, { props }));
40
63
  // Universal hook for any other integration.
41
- w.dispatchEvent(new CustomEvent("blume:track", { detail: { event, props } }));
64
+ attempt(() =>
65
+ w.dispatchEvent(
66
+ new CustomEvent("blume:track", {
67
+ detail: { event, props: { ...props, ...local } },
68
+ })
69
+ )
70
+ );
42
71
  };
@@ -776,6 +776,31 @@ export interface AskConfig {
776
776
  suggestions?: AskSuggestion[];
777
777
  }
778
778
 
779
+ /** What the AI Catalog (ARD) manifest carries. */
780
+ export interface AiCatalogConfig {
781
+ /** Emit `/.well-known/ai-catalog.json` and `/.well-known/ard.json`. Defaults to `true`. */
782
+ enabled?: boolean;
783
+ /**
784
+ * Representative queries per entry, keyed by the entry's `<namespace>:<name>`
785
+ * — its identifier minus the `urn:air:<host>:` prefix (`mcp:docs`,
786
+ * `skill:blume`, `api:docs`, `reference:<slug>`, `docs:llms-txt`). Each
787
+ * list replaces the generated defaults for that entry: 2–5 short
788
+ * natural-language questions the resource can answer, which agent
789
+ * registries embed for semantic search.
790
+ *
791
+ * ```ts
792
+ * ai: {
793
+ * catalog: {
794
+ * queries: {
795
+ * "mcp:acme": ["how do I install Acme", "search the Acme docs"],
796
+ * },
797
+ * },
798
+ * }
799
+ * ```
800
+ */
801
+ queries?: Record<string, string[]>;
802
+ }
803
+
779
804
  /** What the `llms.txt`/`llms-full.txt` files include. */
780
805
  export interface LlmsTxtConfig {
781
806
  /**
@@ -832,6 +857,15 @@ export interface AiConfig {
832
857
  api?: boolean;
833
858
  /** The Ask AI chat assistant. */
834
859
  ask?: AskConfig;
860
+ /**
861
+ * The AI Catalog / ARD manifest (`/.well-known/ai-catalog.json`, mirrored
862
+ * at `/.well-known/ard.json`): a domain-level index of the agent-facing
863
+ * resources the site publishes — MCP server, agent skills, the JSON docs
864
+ * API, API references, llms.txt — for agent registries. Needs a
865
+ * `deployment.site`. Defaults to `true`; the object form overrides the
866
+ * generated representative queries per entry.
867
+ */
868
+ catalog?: boolean | AiCatalogConfig;
835
869
  /**
836
870
  * Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`.
837
871
  * The object form adds knobs for what the files include.
package/src/core/data.ts CHANGED
@@ -140,6 +140,8 @@ export interface BlumeDataConfig {
140
140
  */
141
141
  discovery: {
142
142
  agentReadability: boolean;
143
+ /** Whether the AI Catalog / ARD manifest is published (`ai.catalog`). */
144
+ aiCatalog: boolean;
143
145
  /** Whether the JSON docs API and its `/openapi.json` are published. */
144
146
  api: boolean;
145
147
  llmsTxt: boolean;
@@ -112,6 +112,8 @@ interface MutableGroup {
112
112
  path: string;
113
113
  /** The group's URL path (folder route prefix); set as pages are inserted. */
114
114
  routePath?: string;
115
+ /** The folder's index page route, when it has one; the group row's link. */
116
+ route?: string;
115
117
  label: string;
116
118
  icon?: string;
117
119
  collapsed?: boolean;
@@ -214,8 +216,14 @@ const applyFolderMeta = (
214
216
  sharedMeta: Map<string, FolderMeta>,
215
217
  metaPrefix: string,
216
218
  sharedMetaPrefix: string,
217
- indexDisplay: Map<string, SidebarDisplay>
219
+ indexDisplay: Map<string, SidebarDisplay>,
220
+ indexRoute: Map<string, string>
218
221
  ): void => {
222
+ // A folder with an index page links its group row to it — the same shape
223
+ // as an explicit-config group's `root`, and the only sidebar link to the
224
+ // section's own page once the index row is hidden. Index-less folders keep
225
+ // no link: their row would 404.
226
+ group.route = indexRoute.get(group.path);
219
227
  // Locale-specific meta wins; a shared `meta.$.*` (keyed by the locale-stripped
220
228
  // group path — version-prefixed inside a snapshot) applies to every locale
221
229
  // otherwise.
@@ -254,7 +262,8 @@ const applyFolderMeta = (
254
262
  sharedMeta,
255
263
  metaPrefix,
256
264
  sharedMetaPrefix,
257
- indexDisplay
265
+ indexDisplay,
266
+ indexRoute
258
267
  );
259
268
  }
260
269
  }
@@ -510,6 +519,7 @@ const toNavNode = (node: MutableNode, display: SidebarDisplay): NavNode => {
510
519
  kind: "group",
511
520
  label: node.label,
512
521
  path: node.routePath,
522
+ route: node.route,
513
523
  };
514
524
  };
515
525
 
@@ -529,6 +539,11 @@ const buildFileSystemSidebar = (
529
539
  // Collected before the hidden filter (like the title check): hiding the index
530
540
  // row from the panel shouldn't stop it configuring its group.
531
541
  const indexDisplay = new Map<string, SidebarDisplay>();
542
+ // Folder path -> that folder's index page route, for the group row's link.
543
+ // Also collected before the hidden filter: hiding the index row is how a
544
+ // site drops the duplicate label under a linked header, so the link must
545
+ // survive it. The content root is not a group, so its index is skipped.
546
+ const indexRoute = new Map<string, string>();
532
547
 
533
548
  for (const page of pages) {
534
549
  // Group by the locale-stripped path so the locale dir is not a nav group.
@@ -558,6 +573,9 @@ const buildFileSystemSidebar = (
558
573
  if (page.meta.sidebar.display && !page.fallback) {
559
574
  indexDisplay.set(dirs.join("/"), page.meta.sidebar.display);
560
575
  }
576
+ if (dirs.length > 0) {
577
+ indexRoute.set(dirs.join("/"), page.route);
578
+ }
561
579
  }
562
580
 
563
581
  if (page.meta.sidebar.hidden) {
@@ -611,7 +629,8 @@ const buildFileSystemSidebar = (
611
629
  sharedMeta,
612
630
  metaPrefix,
613
631
  sharedMetaPrefix,
614
- indexDisplay
632
+ indexDisplay,
633
+ indexRoute
615
634
  );
616
635
  sortNodes(root.children, diagnostics);
617
636
  hoistPages(root.children, display, true);