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.
- package/CHANGELOG.md +10 -0
- package/dist/cli/chunk-0qymqwzz.js +164 -0
- package/dist/cli/chunk-0qymqwzz.js.map +15 -0
- package/dist/cli/{chunk-9bkjd11x.js → chunk-0xjyb285.js} +1 -1
- package/dist/cli/{chunk-hdm2dkd2.js → chunk-1jefwnfs.js} +13 -7
- package/dist/cli/{chunk-hdm2dkd2.js.map → chunk-1jefwnfs.js.map} +3 -3
- package/dist/cli/{chunk-n9sra6sy.js → chunk-3r45185y.js} +5 -7
- package/dist/cli/{chunk-n9sra6sy.js.map → chunk-3r45185y.js.map} +2 -2
- package/dist/cli/{chunk-cvky9gb2.js → chunk-4x36ddpw.js} +3 -3
- package/dist/cli/{chunk-mqb2ka8m.js → chunk-5093q3n7.js} +12 -12
- package/dist/cli/{chunk-9he6crym.js → chunk-5g0w1e2c.js} +4 -4
- package/dist/cli/{chunk-mt76t7dj.js → chunk-5qk08vmp.js} +11 -11
- package/dist/cli/{chunk-61j18dwk.js → chunk-7s8hm3b6.js} +7 -2
- package/dist/cli/{chunk-61j18dwk.js.map → chunk-7s8hm3b6.js.map} +3 -3
- package/dist/cli/{chunk-196vjxp9.js → chunk-8cjtbafj.js} +11 -9
- package/dist/cli/{chunk-196vjxp9.js.map → chunk-8cjtbafj.js.map} +2 -2
- package/dist/cli/{chunk-aztttvb3.js → chunk-97r59kpr.js} +4 -4
- package/dist/cli/{chunk-t3tj0dgr.js → chunk-ahnw3kxw.js} +8 -8
- package/dist/cli/{chunk-hs3gbh8p.js → chunk-b27xqwn9.js} +3 -3
- package/dist/cli/{chunk-450a7rcr.js → chunk-bf6bt1xt.js} +2 -2
- package/dist/cli/{chunk-eevwt1sc.js → chunk-bvwwhd84.js} +13 -13
- package/dist/cli/{chunk-12dxjqk7.js → chunk-cjtn640a.js} +17 -17
- package/dist/cli/{chunk-12dxjqk7.js.map → chunk-cjtn640a.js.map} +1 -1
- package/dist/cli/{chunk-ra1v2nc2.js → chunk-ct47dqpx.js} +14 -3
- package/dist/cli/{chunk-ra1v2nc2.js.map → chunk-ct47dqpx.js.map} +4 -3
- package/dist/cli/{chunk-ppfvdcd4.js → chunk-dwgcp5sm.js} +1 -1
- package/dist/cli/{chunk-3w7b2vcx.js → chunk-e7f42gdj.js} +2 -2
- package/dist/cli/{chunk-vkrsvbr5.js → chunk-esphfr8p.js} +8 -8
- package/dist/cli/{chunk-vkrsvbr5.js.map → chunk-esphfr8p.js.map} +1 -1
- package/dist/cli/{chunk-fmceyezb.js → chunk-ex56aa81.js} +27 -18
- package/dist/cli/chunk-ex56aa81.js.map +13 -0
- package/dist/cli/{chunk-jbj4qhfw.js → chunk-garjf5z9.js} +2 -2
- package/dist/cli/{chunk-wjt80jps.js → chunk-js7saxwm.js} +14 -18
- package/dist/cli/{chunk-wjt80jps.js.map → chunk-js7saxwm.js.map} +4 -6
- package/dist/cli/{chunk-5n7t497w.js → chunk-k79xp7av.js} +53 -91
- package/dist/cli/chunk-k79xp7av.js.map +39 -0
- package/dist/cli/{chunk-688e0dde.js → chunk-nn13znc2.js} +1 -1
- package/dist/cli/{chunk-ejjx8znq.js → chunk-ps4m1xh4.js} +15 -9
- package/dist/cli/{chunk-ejjx8znq.js.map → chunk-ps4m1xh4.js.map} +3 -3
- package/dist/cli/{chunk-xhtpx3ff.js → chunk-rqy0s5wh.js} +13 -13
- package/dist/cli/{chunk-30e87n55.js → chunk-rz9jmfhz.js} +4 -4
- package/dist/cli/{chunk-exeeb35e.js → chunk-vacwm2hv.js} +2 -2
- package/dist/cli/{chunk-8cd8tj54.js → chunk-yg63d42r.js} +7 -7
- package/dist/cli/index.js +15 -15
- package/dist/types/core/config-input.d.ts +33 -0
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/schema.d.ts +20 -0
- package/dist/types/core/types.d.ts +5 -0
- package/docs/configuration/ask-ai.mdx +14 -0
- package/docs/configuration/index.mdx +3 -1
- package/docs/content/navigation.mdx +3 -0
- package/docs/content/syntax.mdx +10 -0
- package/docs/discoverability/agent-discovery.mdx +82 -1
- package/docs/discoverability/index.mdx +1 -1
- package/docs/discoverability/llms-txt.mdx +1 -1
- package/docs/reference/frontmatter.mdx +2 -0
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +5 -0
- package/src/ai/ai-catalog.ts +241 -0
- package/src/ai/link-headers.ts +12 -0
- package/src/ai/llms.ts +6 -0
- package/src/ai/mcp/discovery.ts +1 -1
- package/src/astro/generate.ts +2 -0
- package/src/cli/commands/build.ts +3 -1
- package/src/components/islands/hooks.ts +50 -1
- package/src/components/layout/RootLayout.astro +24 -1
- package/src/components/layout/analytics-client.ts +36 -7
- package/src/core/config-input.ts +34 -0
- package/src/core/data.ts +2 -0
- package/src/core/navigation.ts +22 -3
- package/src/core/schema.ts +32 -0
- package/src/core/types.ts +5 -0
- package/src/deploy/artifacts.ts +12 -1
- package/src/deploy/headers.ts +6 -0
- package/src/deploy/vercel-negotiation.ts +25 -2
- package/src/search/build.ts +25 -3
- package/src/theme/entry.ts +23 -0
- package/dist/cli/chunk-5n7t497w.js.map +0 -40
- package/dist/cli/chunk-88by27n5.js +0 -17
- package/dist/cli/chunk-88by27n5.js.map +0 -10
- package/dist/cli/chunk-fmceyezb.js.map +0 -13
- package/dist/cli/chunk-tqa1s0k8.js +0 -69
- package/dist/cli/chunk-tqa1s0k8.js.map +0 -11
- /package/dist/cli/{chunk-9bkjd11x.js.map → chunk-0xjyb285.js.map} +0 -0
- /package/dist/cli/{chunk-cvky9gb2.js.map → chunk-4x36ddpw.js.map} +0 -0
- /package/dist/cli/{chunk-mqb2ka8m.js.map → chunk-5093q3n7.js.map} +0 -0
- /package/dist/cli/{chunk-9he6crym.js.map → chunk-5g0w1e2c.js.map} +0 -0
- /package/dist/cli/{chunk-mt76t7dj.js.map → chunk-5qk08vmp.js.map} +0 -0
- /package/dist/cli/{chunk-aztttvb3.js.map → chunk-97r59kpr.js.map} +0 -0
- /package/dist/cli/{chunk-t3tj0dgr.js.map → chunk-ahnw3kxw.js.map} +0 -0
- /package/dist/cli/{chunk-hs3gbh8p.js.map → chunk-b27xqwn9.js.map} +0 -0
- /package/dist/cli/{chunk-450a7rcr.js.map → chunk-bf6bt1xt.js.map} +0 -0
- /package/dist/cli/{chunk-eevwt1sc.js.map → chunk-bvwwhd84.js.map} +0 -0
- /package/dist/cli/{chunk-ppfvdcd4.js.map → chunk-dwgcp5sm.js.map} +0 -0
- /package/dist/cli/{chunk-3w7b2vcx.js.map → chunk-e7f42gdj.js.map} +0 -0
- /package/dist/cli/{chunk-jbj4qhfw.js.map → chunk-garjf5z9.js.map} +0 -0
- /package/dist/cli/{chunk-688e0dde.js.map → chunk-nn13znc2.js.map} +0 -0
- /package/dist/cli/{chunk-xhtpx3ff.js.map → chunk-rqy0s5wh.js.map} +0 -0
- /package/dist/cli/{chunk-30e87n55.js.map → chunk-rz9jmfhz.js.map} +0 -0
- /package/dist/cli/{chunk-exeeb35e.js.map → chunk-vacwm2hv.js.map} +0 -0
- /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
|
+
};
|
package/src/ai/link-headers.ts
CHANGED
|
@@ -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.`
|
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -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")
|
package/src/astro/generate.ts
CHANGED
|
@@ -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
|
|
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?: {
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
attempt(() =>
|
|
65
|
+
w.dispatchEvent(
|
|
66
|
+
new CustomEvent("blume:track", {
|
|
67
|
+
detail: { event, props: { ...props, ...local } },
|
|
68
|
+
})
|
|
69
|
+
)
|
|
70
|
+
);
|
|
42
71
|
};
|
package/src/core/config-input.ts
CHANGED
|
@@ -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;
|
package/src/core/navigation.ts
CHANGED
|
@@ -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);
|