blume 1.6.0 → 1.6.2

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 (97) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +1318 -270
  3. package/dist/cli/index.js.map +68 -61
  4. package/dist/types/core/config-input.d.ts +9 -0
  5. package/dist/types/core/data.d.ts +12 -1
  6. package/dist/types/core/i18n-ui.d.ts +4 -0
  7. package/dist/types/core/schema.d.ts +7 -0
  8. package/dist/types/core/types.d.ts +6 -0
  9. package/dist/types/openapi/references.d.ts +5 -0
  10. package/docs/07-faq.mdx +9 -9
  11. package/docs/advanced/api-reference.mdx +10 -1
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/graphql.mdx +1 -1
  14. package/docs/configuration/ai.mdx +76 -7
  15. package/docs/configuration/seo.mdx +3 -3
  16. package/docs/configuration/theming.mdx +6 -0
  17. package/docs/content/components.mdx +8 -1
  18. package/docs/index.mdx +2 -2
  19. package/package.json +53 -53
  20. package/skills/blume/SKILL.md +2 -2
  21. package/src/ai/agent-readability.ts +60 -17
  22. package/src/ai/api/handlers.ts +273 -0
  23. package/src/ai/api/paths.ts +14 -0
  24. package/src/ai/api/problem.ts +63 -0
  25. package/src/ai/api/spec.ts +681 -0
  26. package/src/ai/api-catalog.ts +11 -1
  27. package/src/ai/link-headers.ts +12 -3
  28. package/src/ai/llms.ts +9 -2
  29. package/src/ai/mcp/query.ts +390 -0
  30. package/src/ai/mcp/server.ts +32 -352
  31. package/src/astro/examples.ts +29 -2
  32. package/src/astro/generate.ts +256 -64
  33. package/src/astro/index.ts +7 -0
  34. package/src/astro/markdown-negotiation.ts +1 -1
  35. package/src/astro/runtime-modules.ts +196 -0
  36. package/src/astro/templates.ts +398 -38
  37. package/src/cli/commands/build.ts +9 -1
  38. package/src/cli/commands/dev.ts +6 -3
  39. package/src/cli/host-args.ts +18 -0
  40. package/src/cli/index.ts +2 -1
  41. package/src/components/copy-feedback.ts +93 -9
  42. package/src/components/islands/ask-ai.tsx +4 -1
  43. package/src/components/islands/hooks.ts +3 -1
  44. package/src/components/layout/PageActions.astro +25 -14
  45. package/src/core/config-input.ts +9 -0
  46. package/src/core/data.ts +17 -2
  47. package/src/core/define-components.ts +2 -0
  48. package/src/core/i18n-ui.ts +3 -0
  49. package/src/core/includes.ts +2 -1
  50. package/src/core/manifest.ts +10 -0
  51. package/src/core/schema.ts +20 -5
  52. package/src/core/types.ts +6 -0
  53. package/src/core/ui-packs/ar.ts +1 -0
  54. package/src/core/ui-packs/bg.ts +1 -0
  55. package/src/core/ui-packs/bn.ts +1 -0
  56. package/src/core/ui-packs/ca.ts +1 -0
  57. package/src/core/ui-packs/cs.ts +1 -0
  58. package/src/core/ui-packs/da.ts +1 -0
  59. package/src/core/ui-packs/de.ts +1 -0
  60. package/src/core/ui-packs/el.ts +1 -0
  61. package/src/core/ui-packs/es.ts +1 -0
  62. package/src/core/ui-packs/fa.ts +1 -0
  63. package/src/core/ui-packs/fi.ts +1 -0
  64. package/src/core/ui-packs/fr.ts +1 -0
  65. package/src/core/ui-packs/he.ts +1 -0
  66. package/src/core/ui-packs/hi.ts +1 -0
  67. package/src/core/ui-packs/hr.ts +1 -0
  68. package/src/core/ui-packs/hu.ts +1 -0
  69. package/src/core/ui-packs/id.ts +1 -0
  70. package/src/core/ui-packs/it.ts +1 -0
  71. package/src/core/ui-packs/ja.ts +1 -0
  72. package/src/core/ui-packs/ko.ts +1 -0
  73. package/src/core/ui-packs/nl.ts +1 -0
  74. package/src/core/ui-packs/no.ts +1 -0
  75. package/src/core/ui-packs/pl.ts +1 -0
  76. package/src/core/ui-packs/pt-br.ts +1 -0
  77. package/src/core/ui-packs/pt.ts +1 -0
  78. package/src/core/ui-packs/ro.ts +1 -0
  79. package/src/core/ui-packs/ru.ts +1 -0
  80. package/src/core/ui-packs/sk.ts +1 -0
  81. package/src/core/ui-packs/sr.ts +1 -0
  82. package/src/core/ui-packs/sv.ts +1 -0
  83. package/src/core/ui-packs/th.ts +1 -0
  84. package/src/core/ui-packs/tr.ts +1 -0
  85. package/src/core/ui-packs/uk.ts +1 -0
  86. package/src/core/ui-packs/vi.ts +1 -0
  87. package/src/core/ui-packs/zh-tw.ts +1 -0
  88. package/src/core/ui-packs/zh.ts +1 -0
  89. package/src/core/version-cut.ts +5 -3
  90. package/src/deploy/vercel-negotiation.ts +97 -6
  91. package/src/og/card.ts +1 -1
  92. package/src/openapi/references.ts +8 -0
  93. package/src/openapi/render-mdx.ts +18 -4
  94. package/src/openapi/scalar.ts +0 -4
  95. package/src/registry/eject.ts +36 -17
  96. package/src/theme/entry.ts +2 -2
  97. package/src/theme/sources.ts +49 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -66,107 +66,107 @@
66
66
  "typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit"
67
67
  },
68
68
  "dependencies": {
69
- "@astrojs/check": "^0.9.0",
70
- "@astrojs/markdown-satteri": "^0.3.2",
71
- "@astrojs/mdx": "^7.0.0",
72
- "@astrojs/node": "^11.0.0",
73
- "@astrojs/react": "^6.0.0",
74
- "@astrojs/vercel": "^11.0.3",
69
+ "@astrojs/check": "^0.9.10",
70
+ "@astrojs/markdown-satteri": "^0.4.0",
71
+ "@astrojs/mdx": "^8.0.0",
72
+ "@astrojs/node": "^11.1.5",
73
+ "@astrojs/react": "^6.0.5",
74
+ "@astrojs/vercel": "^11.0.10",
75
75
  "@asyncapi/converter": "^2.0.2",
76
76
  "@clack/prompts": "^1.7.0",
77
- "@iconify-json/lucide": "^1.2.115",
77
+ "@iconify-json/lucide": "^1.2.129",
78
78
  "@iconify/types": "^2.0.0",
79
- "@iconify/utils": "^3.1.3",
80
- "@modelcontextprotocol/sdk": "^1.29.0",
79
+ "@iconify/utils": "^3.1.5",
80
+ "@modelcontextprotocol/sdk": "^1.30.0",
81
81
  "@orama/orama": "^3.1.18",
82
- "@pierre/diffs": "^1.2.11",
83
- "@scalar/astro": "^0.4.5",
82
+ "@pierre/diffs": "^1.4.1",
83
+ "@scalar/astro": "^0.4.17",
84
84
  "@scalar/openapi-parser": "^0.29.0",
85
- "@scalar/openapi-types": "^0.9.1",
86
- "@shikijs/transformers": "^4.2.0",
87
- "@shikijs/twoslash": "^4.2.0",
85
+ "@scalar/openapi-types": "^0.9.5",
86
+ "@shikijs/transformers": "^4.4.3",
87
+ "@shikijs/twoslash": "^4.4.3",
88
88
  "@tailwindcss/typography": "^0.5.20",
89
- "@tailwindcss/vite": "^4",
89
+ "@tailwindcss/vite": "^4.3.3",
90
90
  "@types/mdast": "^4.0.4",
91
91
  "@vercel/analytics": "^2.0.1",
92
- "ai": "^7.0.42",
93
- "astro": "^7.2.3",
92
+ "ai": "^7.0.93",
93
+ "astro": "^7.3.1",
94
94
  "babel-plugin-react-compiler": "^1.0.0",
95
95
  "chokidar": "^5.0.0",
96
- "citty": "^0.1.6",
97
- "consola": "^3.4.0",
96
+ "citty": "^0.2.2",
97
+ "consola": "^3.4.2",
98
98
  "cross-spawn": "^7.0.6",
99
- "dompurify": "^3.4.13",
99
+ "dompurify": "^3.4.14",
100
100
  "dotenv": "^17.4.2",
101
101
  "epub-gen-memory": "^1.1.2",
102
- "fast-xml-parser": "^5.10.1",
102
+ "fast-xml-parser": "^5.11.1",
103
103
  "github-slugger": "^2.0.0",
104
104
  "graphql": "^17.0.2",
105
105
  "gray-matter": "^4.0.3",
106
106
  "html-escaper": "^3.0.3",
107
107
  "image-size": "^2.0.2",
108
- "jiti": "^2.4.0",
108
+ "jiti": "^2.7.0",
109
109
  "js-yaml": "^5.4.1",
110
- "katex": "^0.18.1",
110
+ "katex": "^0.18.6",
111
111
  "markdown-table": "^3.0.4",
112
- "marked": "^18.0.5",
112
+ "marked": "^18.0.11",
113
113
  "mdast-util-from-markdown": "^2.0.3",
114
114
  "mdast-util-gfm": "^3.1.0",
115
115
  "mdast-util-to-string": "^4.0.0",
116
116
  "medium-zoom": "^1.1.0",
117
- "mermaid": "^11.16.1",
117
+ "mermaid": "^11.17.2",
118
118
  "micromark-extension-gfm": "^3.0.0",
119
119
  "nanotar": "^0.3.0",
120
- "node-html-parser": "^9.0.0",
121
- "openapi-sampler": "^1.7.4",
122
- "p-limit": "^7.3.1",
123
- "p-map": "^7.0.6",
124
- "p-retry": "^8.0.0",
120
+ "node-html-parser": "^9.0.3",
121
+ "openapi-sampler": "^1.7.5",
122
+ "p-limit": "^7.3.2",
123
+ "p-map": "^7.0.7",
124
+ "p-retry": "^8.0.1",
125
125
  "package-manager-detector": "^1.8.0",
126
- "pagefind": "^1.3.0",
127
- "pathe": "^2.0.0",
126
+ "pagefind": "^1.5.2",
127
+ "pathe": "^2.0.3",
128
128
  "perfect-debounce": "^2.1.0",
129
- "picomatch": "^4.0.5",
129
+ "picomatch": "^4.0.7",
130
130
  "react": "^19.2.8",
131
131
  "react-dom": "^19.2.8",
132
132
  "robots-parser": "^3.0.1",
133
133
  "satteri": "^0.10.5",
134
134
  "semver": "^7.8.5",
135
- "sharp": "^0.35.3",
136
- "shiki": "^4.2.0",
135
+ "sharp": "^0.35.4",
136
+ "shiki": "^4.4.3",
137
137
  "simple-icons": "^16.29.0",
138
- "string-width": "^8.1.0",
138
+ "string-width": "^8.2.2",
139
139
  "sucrase": "^3.35.1",
140
140
  "tailwindcss": "^4.3.3",
141
- "takumi-js": "^2.2.1",
142
- "tinyglobby": "^0.2.10",
141
+ "takumi-js": "^2.13.6",
142
+ "tinyglobby": "^0.2.17",
143
143
  "twoslash": "^0.3.9",
144
144
  "typescript": "^6.0.3",
145
145
  "ufo": "^1.6.4",
146
- "undici": "^8.9.0",
146
+ "undici": "^8.10.2",
147
147
  "write-file-atomic": "^8.0.0",
148
- "zod": "^4.3.6"
148
+ "zod": "^4.5.4"
149
149
  },
150
150
  "devDependencies": {
151
- "@ai-sdk/openai-compatible": "^3.0.0",
152
- "@mixedbread/sdk": "^0.76.0",
151
+ "@ai-sdk/openai-compatible": "^3.0.44",
152
+ "@mixedbread/sdk": "^0.77.0",
153
153
  "@notionhq/client": "^5.26.0",
154
154
  "@openrouter/ai-sdk-provider": "^3.0.0",
155
- "@oramacloud/client": "^2.1.0",
156
- "@sanity/client": "^8.4.0",
155
+ "@oramacloud/client": "^2.1.4",
156
+ "@sanity/client": "^8.5.0",
157
157
  "@types/cross-spawn": "^6.0.6",
158
158
  "@types/html-escaper": "^3.0.4",
159
- "@types/node": "^22.10.0",
159
+ "@types/node": "^22.20.1",
160
160
  "@types/picomatch": "^4.0.3",
161
161
  "@types/react": "^19.2.18",
162
- "@types/react-dom": "^19.0.0",
162
+ "@types/react-dom": "^19.2.7",
163
163
  "@types/semver": "^7.8.0",
164
164
  "@types/write-file-atomic": "^4.0.3",
165
- "@typescript/native-preview": "^7.0.0-dev.20260626.1",
166
- "algoliasearch": "^5.55.0",
167
- "bun-types": "^1.3.14",
168
- "flexsearch": "^0.8.0",
169
- "typesense": "^3.0.0"
165
+ "@typescript/native-preview": "^7.0.0-dev.20260707.2",
166
+ "algoliasearch": "^5.57.0",
167
+ "bun-types": "^1.4.2",
168
+ "flexsearch": "^0.8.212",
169
+ "typesense": "^3.0.6"
170
170
  },
171
171
  "peerDependencies": {
172
172
  "@ai-sdk/openai-compatible": "^3.0.0",
@@ -174,7 +174,7 @@
174
174
  "@astrojs/netlify": "^8.0.0",
175
175
  "@astrojs/svelte": "^9.0.0",
176
176
  "@astrojs/vue": "^7.0.0",
177
- "@mixedbread/sdk": "^0.76.0",
177
+ "@mixedbread/sdk": "^0.77.0",
178
178
  "@notionhq/client": "^5.0.0",
179
179
  "@openrouter/ai-sdk-provider": "^3.0.0",
180
180
  "@oramacloud/client": "^2.1.0",
@@ -12,7 +12,7 @@ The core idea: **the framework _is_ the template.** There's no starter to clone
12
12
  ## What makes it different
13
13
 
14
14
  - **Fast by default** — Static HTML on Astro/Vite. The core theme ships no client framework JS so pages score well on Core Web Vitals out of the box. You opt into server features only when you need them.
15
- - **AI-ready out of the box** — Emits `llms.txt`/`llms-full.txt`, serves any page's raw Markdown by appending `.md` to its URL, offers **Copy as Markdown** and **Open in chat** on every page, and can host an optional **Ask AI** assistant or an **MCP server** so coding agents read your docs directly.
15
+ - **AI-ready out of the box** — Emits `llms.txt`/`llms-full.txt`, serves any page's raw Markdown by appending `.md` to its URL, publishes a JSON docs API (`/api/docs/…`) described by an OpenAPI document at `/openapi.json`, offers **Copy as Markdown** and **Open in chat** on every page, and can host an optional **Ask AI** assistant or an **MCP server** so coding agents read your docs directly.
16
16
  - **Zero configuration — even the template** — A folder of docs is a complete project. Navigation is inferred from files, search works in dev and production with no hosted service, and theming is a handful of tokens.
17
17
  - **Type-safe to the core** — `blume.config.ts` and every `meta.ts` are real TypeScript, validated by a schema and authored with `defineConfig` and `defineMeta`. Your editor autocompletes options and catches mistakes before a build.
18
18
 
@@ -52,7 +52,7 @@ Navigation, search, and page metadata are inferred from your files as you add th
52
52
 
53
53
  - **Components** — callouts, cards, steps, tabs, accordions, badges, file trees, and parameter tables, usable in MDX with no imports.
54
54
  - **Local search** — Orama in dev and production; Pagefind is one flag away for large sites. No hosted index.
55
- - **AI** — `llms.txt`, raw Markdown URLs, Copy as Markdown, Open in chat, an Ask AI assistant, and an MCP server endpoint served by the docs site itself.
55
+ - **AI** — `llms.txt`, raw Markdown URLs, a JSON docs API with an OpenAPI description, Copy as Markdown, Open in chat, an Ask AI assistant, and an MCP server endpoint served by the docs site itself.
56
56
  - **Navigation** — inferred from files, refined with `meta.ts` or config.
57
57
  - **SEO** — metadata, Open Graph images, RSS feeds, and JSON-LD.
58
58
  - **Customization** — component overrides, React islands, custom pages, theme tokens, and a source-component registry via `blume add`.
@@ -5,6 +5,7 @@ import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
5
5
  import { absoluteUrl } from "../core/site-url.ts";
6
6
  import { buildRssFeeds } from "../deploy/rss.ts";
7
7
  import { hasApiCatalog } from "./api-catalog.ts";
8
+ import { API_PAGES_PATH, API_SEARCH_PATH, OPENAPI_PATH } from "./api/paths.ts";
8
9
 
9
10
  /** Token map for the machine-readable content-usage echo. */
10
11
  const USAGE_TOKENS: [keyof ContentSignalPolicy, string][] = [
@@ -55,9 +56,17 @@ interface WellKnownArtifacts {
55
56
  agentSkills?: string;
56
57
  }
57
58
 
59
+ /** The JSON docs API's entry points; `search` exists on server output only. */
60
+ interface ApiArtifact {
61
+ openapi: string;
62
+ pages: string;
63
+ search?: string;
64
+ }
65
+
58
66
  /** The agent-facing artifact index the manifest publishes. */
59
67
  interface AgentArtifacts extends WellKnownArtifacts {
60
68
  markdown: { contentNegotiation?: string; pattern: string };
69
+ api?: ApiArtifact;
61
70
  llmsFullTxt?: string;
62
71
  llmsTxt?: string;
63
72
  mcp?: { discovery: string; url: string };
@@ -77,6 +86,47 @@ export interface AgentReadabilityManifest {
77
86
  repository?: string;
78
87
  }
79
88
 
89
+ /**
90
+ * The raw-Markdown mirror pattern. `Accept: text/markdown` negotiation is
91
+ * advertised only where the deployed site actually honors it — a Vercel
92
+ * server build, whose routing config gets the rewrite rules (see
93
+ * `deploy/vercel-negotiation.ts`), and a Cloudflare server build, whose
94
+ * deploy bundle gets a wrapper Worker (see `deploy/cloudflare-negotiation.ts`).
95
+ * Static builds and other adapters serve prerendered pages from a static
96
+ * layer with no request-time hook, so agents there should fetch the `.md`
97
+ * pattern directly.
98
+ */
99
+ const markdownArtifact = (
100
+ config: BlumeProject["config"],
101
+ abs: (path: string) => string
102
+ ): AgentArtifacts["markdown"] => {
103
+ const negotiates =
104
+ config.deployment.output === "server" &&
105
+ (config.deployment.adapter === "vercel" ||
106
+ config.deployment.adapter === "cloudflare");
107
+ return negotiates
108
+ ? { contentNegotiation: "text/markdown", pattern: abs("/{route}.md") }
109
+ : { pattern: abs("/{route}.md") };
110
+ };
111
+
112
+ /** The JSON docs API's entry points, or null when the API is off. */
113
+ const apiArtifact = (
114
+ config: BlumeProject["config"],
115
+ abs: (path: string) => string
116
+ ): ApiArtifact | null => {
117
+ if (!config.ai.api) {
118
+ return null;
119
+ }
120
+ const api: ApiArtifact = {
121
+ openapi: abs(OPENAPI_PATH),
122
+ pages: abs(API_PAGES_PATH),
123
+ };
124
+ if (config.deployment.output === "server") {
125
+ api.search = abs(API_SEARCH_PATH);
126
+ }
127
+ return api;
128
+ };
129
+
80
130
  /** The `.well-known` discovery artifacts the site publishes, if any. */
81
131
  const wellKnownArtifacts = (
82
132
  config: BlumeProject["config"],
@@ -99,8 +149,9 @@ const wellKnownArtifacts = (
99
149
 
100
150
  /**
101
151
  * Build `agent-readability.json`: a root manifest that indexes the project's
102
- * agent-facing surface — llms.txt, the raw-Markdown mirrors, the MCP server,
103
- * Ask AI, sitemap, and feeds so agents can discover and cite the docs without
152
+ * agent-facing surface — llms.txt, the raw-Markdown mirrors, the JSON docs
153
+ * API and its OpenAPI description, the MCP server, Ask AI, sitemap, and feeds
154
+ * — so agents can discover and cite the docs without
104
155
  * scraping HTML. URLs are absolute when a `site` is configured and root-relative
105
156
  * (still under `deployment.base`) otherwise. Returns null when the manifest is
106
157
  * disabled.
@@ -122,21 +173,13 @@ export const buildAgentReadability = (
122
173
  return site ? absoluteUrl(site, based) : based;
123
174
  };
124
175
 
125
- // Advertise `Accept: text/markdown` negotiation only where the deployed site
126
- // actually honors it — a Vercel server build, whose routing config gets the
127
- // rewrite rules (see `deploy/vercel-negotiation.ts`), and a Cloudflare server
128
- // build, whose deploy bundle gets a wrapper Worker (see
129
- // `deploy/cloudflare-negotiation.ts`). Static builds and other adapters serve
130
- // prerendered pages from a static layer with no request-time hook, so agents
131
- // there should fetch the `.md` pattern directly.
132
- const negotiates =
133
- config.deployment.output === "server" &&
134
- (config.deployment.adapter === "vercel" ||
135
- config.deployment.adapter === "cloudflare");
136
- const markdown: AgentArtifacts["markdown"] = negotiates
137
- ? { contentNegotiation: "text/markdown", pattern: abs("/{route}.md") }
138
- : { pattern: abs("/{route}.md") };
139
- const artifacts: AgentArtifacts = { markdown };
176
+ const artifacts: AgentArtifacts = {
177
+ markdown: markdownArtifact(config, abs),
178
+ };
179
+ const api = apiArtifact(config, abs);
180
+ if (api) {
181
+ artifacts.api = api;
182
+ }
140
183
  if (config.ai.llmsTxt.enabled) {
141
184
  artifacts.llmsFullTxt = abs("/llms-full.txt");
142
185
  artifacts.llmsTxt = abs("/llms.txt");
@@ -0,0 +1,273 @@
1
+ import { withBasePath } from "../../core/base-path.ts";
2
+ import { absoluteUrl } from "../../core/site-url.ts";
3
+ import type { Navigation } from "../../core/types.ts";
4
+ import type { McpData, McpRoute } from "../mcp/data.ts";
5
+ import {
6
+ createIndexProvider,
7
+ getPageMarkdown,
8
+ searchDocs,
9
+ TOOL_INPUTS,
10
+ urlFor,
11
+ } from "../mcp/query.ts";
12
+ import type { SearchHitPayload } from "../mcp/query.ts";
13
+ import {
14
+ API_BASE,
15
+ API_PAGES_PATH,
16
+ API_SEARCH_PATH,
17
+ OPENAPI_PATH,
18
+ } from "./paths.ts";
19
+ import { problemResponse } from "./problem.ts";
20
+
21
+ /**
22
+ * The JSON docs API: the REST twin of the MCP tools, over the same snapshot
23
+ * and the same operations (`mcp/query.ts`). The page index, per-page JSON,
24
+ * and navigation are prerendered, so a static site serves them from files;
25
+ * search is a live endpoint and exists on server output only. Errors are RFC
26
+ * 9457 problem details (`problem.ts`). The generated endpoints under
27
+ * `.blume/src/pages/api/docs/` are thin wrappers around these.
28
+ */
29
+
30
+ /** One page in the index; `version` only appears on versioned sites. */
31
+ export interface ApiPageSummary {
32
+ contentType: string;
33
+ description?: string;
34
+ facets?: Record<string, string>;
35
+ /** The page's JSON representation (this API's `getPage`). */
36
+ json: string;
37
+ lastModified: string | null;
38
+ locale: string;
39
+ /** The page's raw-Markdown mirror (`{route}.md`). */
40
+ markdownUrl: string;
41
+ route: string;
42
+ title: string;
43
+ /** Where the rendered page is served. */
44
+ url: string;
45
+ version?: string;
46
+ }
47
+
48
+ /** The `pages.json` document. */
49
+ export interface ApiPagesIndex {
50
+ count: number;
51
+ generator: string;
52
+ pages: ApiPageSummary[];
53
+ site: string | null;
54
+ }
55
+
56
+ /** A page's JSON representation: its index entry plus the agent Markdown. */
57
+ export interface ApiPage extends ApiPageSummary {
58
+ markdown: string;
59
+ }
60
+
61
+ /** The search endpoint's document. */
62
+ export interface ApiSearchResponse {
63
+ count: number;
64
+ query: string;
65
+ results: SearchHitPayload[];
66
+ }
67
+
68
+ /** The site + base an endpoint needs to build absolute URLs. */
69
+ export interface ApiSiteContext {
70
+ base: string;
71
+ site: string | null;
72
+ }
73
+
74
+ /** What the API serializes: one of its documents, or the navigation tree. */
75
+ export type ApiPayload =
76
+ | ApiPage
77
+ | ApiPagesIndex
78
+ | ApiSearchResponse
79
+ | Navigation;
80
+
81
+ /** A `Response` carrying JSON, pretty-printed for the humans who curl it. */
82
+ export const jsonResponse = (payload: ApiPayload, status = 200): Response =>
83
+ new Response(`${JSON.stringify(payload, null, 2)}\n`, {
84
+ headers: { "Content-Type": "application/json; charset=utf-8" },
85
+ status,
86
+ });
87
+
88
+ /** The `pages/{route}.json` path segment for a route (`index` for home). */
89
+ export const pageParam = (route: string): string =>
90
+ route === "/" ? "index" : route.slice(1);
91
+
92
+ /** The absolute (or root-relative) URL for a base-less path. */
93
+ const siteUrl = (path: string, context: ApiSiteContext): string => {
94
+ const based = withBasePath(context.base, path);
95
+ return context.site ? absoluteUrl(context.site, based) : based;
96
+ };
97
+
98
+ const summarize = (route: McpRoute, data: McpData): ApiPageSummary => {
99
+ const summary: ApiPageSummary = {
100
+ contentType: route.contentType,
101
+ json: siteUrl(`${API_BASE}/pages/${pageParam(route.route)}.json`, data),
102
+ lastModified: route.lastModified,
103
+ locale: route.locale,
104
+ markdownUrl: siteUrl(`/${pageParam(route.route)}.md`, data),
105
+ route: route.route,
106
+ title: route.title,
107
+ url: urlFor(route.route, data),
108
+ };
109
+ if (route.description !== undefined) {
110
+ summary.description = route.description;
111
+ }
112
+ if (route.facets) {
113
+ summary.facets = route.facets;
114
+ }
115
+ if (data.archivedVersions) {
116
+ summary.version = route.version;
117
+ }
118
+ return summary;
119
+ };
120
+
121
+ /** Every non-hidden page, in manifest order; the index is unfiltered. */
122
+ export const buildPagesIndex = (data: McpData): ApiPagesIndex => {
123
+ const pages = data.routes.map((route) => summarize(route, data));
124
+ return {
125
+ count: pages.length,
126
+ generator: `blume@${data.version}`,
127
+ pages,
128
+ site: data.site,
129
+ };
130
+ };
131
+
132
+ export const pagesIndexResponse = (data: McpData): Response =>
133
+ jsonResponse(buildPagesIndex(data));
134
+
135
+ /**
136
+ * `getStaticPaths` entries for the per-page endpoint: one per route that has
137
+ * agent Markdown to serve (a landing page without a mirror has no JSON twin
138
+ * either).
139
+ */
140
+ export const pageParams = (
141
+ data: McpData
142
+ ): { params: { route: string }; props: { route: string } }[] =>
143
+ data.routes
144
+ .filter((route) => getPageMarkdown(data, route.route) !== undefined)
145
+ .map((route) => ({
146
+ params: { route: pageParam(route.route) },
147
+ props: { route: route.route },
148
+ }));
149
+
150
+ /** A page's JSON document, or null when no page has the route. */
151
+ export const buildPage = (data: McpData, route: string): ApiPage | null => {
152
+ const entry = data.routes.find((candidate) => candidate.route === route);
153
+ const markdown = getPageMarkdown(data, route);
154
+ if (!entry || markdown === undefined) {
155
+ return null;
156
+ }
157
+ return { ...summarize(entry, data), markdown };
158
+ };
159
+
160
+ export const pageResponse = (data: McpData, route: string): Response => {
161
+ const page = buildPage(data, route);
162
+ if (!page) {
163
+ return problemResponse({
164
+ code: "PAGE_NOT_FOUND",
165
+ detail: `No documentation page has the route "${route}".`,
166
+ instance: siteUrl(`${API_BASE}/pages/${pageParam(route)}.json`, data),
167
+ resolution: `List every page at ${siteUrl(API_PAGES_PATH, data)}, or discover the API through ${siteUrl(OPENAPI_PATH, data)}.`,
168
+ status: 404,
169
+ title: "Page not found",
170
+ });
171
+ }
172
+ return jsonResponse(page);
173
+ };
174
+
175
+ /** The default navigation tree (default locale, current docs). */
176
+ export const buildNavigation = (data: McpData): Navigation => data.navigation;
177
+
178
+ export const navigationResponse = (data: McpData): Response =>
179
+ jsonResponse(buildNavigation(data));
180
+
181
+ /** Repeated and comma-separated values of a list query parameter. */
182
+ const listParam = (
183
+ params: URLSearchParams,
184
+ key: string
185
+ ): string[] | undefined => {
186
+ const values = params
187
+ .getAll(key)
188
+ .flatMap((value) => value.split(","))
189
+ .map((value) => value.trim())
190
+ .filter((value) => value.length > 0);
191
+ return values.length > 0 ? values : undefined;
192
+ };
193
+
194
+ const FILTER_PARAM = /^filters\[(?<key>.+)\]$/u;
195
+
196
+ /** The `filters[key]=value` (OpenAPI deepObject) facet filters. */
197
+ const filtersParam = (
198
+ params: URLSearchParams
199
+ ): Record<string, string> | undefined => {
200
+ const entries: [string, string][] = [];
201
+ for (const [key, value] of params) {
202
+ const facet = FILTER_PARAM.exec(key)?.groups?.key;
203
+ if (facet) {
204
+ entries.push([facet, value]);
205
+ }
206
+ }
207
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
208
+ };
209
+
210
+ /**
211
+ * The live search endpoint: `GET /api/docs/search?q=…`. Runs the same query
212
+ * `search_docs` runs, over an index built once per snapshot and shared across
213
+ * requests. A missing or blank `q` is a 400 problem.
214
+ */
215
+ export const createSearchHandler = (
216
+ data: McpData
217
+ ): ((request: Request) => Promise<Response>) => {
218
+ const index = createIndexProvider(data.documents, data.defaultLocale);
219
+ return async (request: Request): Promise<Response> => {
220
+ const url = new URL(request.url);
221
+ const params = url.searchParams;
222
+ const query = (params.get("q") ?? "").trim();
223
+ if (!query) {
224
+ return problemResponse({
225
+ code: "MISSING_QUERY",
226
+ detail: 'The "q" query parameter is required and must not be blank.',
227
+ instance: url.pathname,
228
+ resolution: `Repeat the request with ?q=<search terms>, e.g. ${siteUrl(API_SEARCH_PATH, data)}?q=install.`,
229
+ status: 400,
230
+ title: "Missing search query",
231
+ });
232
+ }
233
+ const input = TOOL_INPUTS.search_docs.parse({
234
+ contentTypes: listParam(params, "contentTypes"),
235
+ filters: filtersParam(params),
236
+ limit: params.get("limit") ?? undefined,
237
+ locale: params.get("locale") ?? undefined,
238
+ query,
239
+ version: params.get("version") ?? undefined,
240
+ });
241
+ const results = await searchDocs(data, index, input);
242
+ const payload: ApiSearchResponse = {
243
+ count: results.length,
244
+ query,
245
+ results,
246
+ };
247
+ return jsonResponse(payload);
248
+ };
249
+ };
250
+
251
+ /**
252
+ * The 404 for anything under `/api/` that no endpoint answers — the catch-all
253
+ * behind every live route on server output, so an agent probing the API
254
+ * namespace gets a problem document instead of the HTML not-found page.
255
+ */
256
+ export const apiNotFoundResponse = (
257
+ request: Request,
258
+ context: ApiSiteContext
259
+ ): Response => {
260
+ const { pathname } = new URL(request.url);
261
+ return problemResponse({
262
+ code: "API_ROUTE_NOT_FOUND",
263
+ detail: `No API route exists at ${pathname}.`,
264
+ instance: pathname,
265
+ links: [
266
+ { href: siteUrl(OPENAPI_PATH, context), label: "OpenAPI description" },
267
+ { href: siteUrl(API_PAGES_PATH, context), label: "Page index" },
268
+ ],
269
+ resolution: `Discover the available operations through the OpenAPI description at ${siteUrl(OPENAPI_PATH, context)}, or list every page at ${siteUrl(API_PAGES_PATH, context)}.`,
270
+ status: 404,
271
+ title: "API route not found",
272
+ });
273
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Where the JSON docs API and its OpenAPI description are served. Base-less
3
+ * (like every route Blume emits); callers layer `deployment.base` on top.
4
+ * Under `/api/` alongside the Ask AI endpoint (`/api/ask`) so the namespace a
5
+ * Blume site reserves for live endpoints stays one prefix, and under its own
6
+ * `docs` segment so a search provider's proxy at `/api/search` never collides.
7
+ */
8
+
9
+ export const OPENAPI_PATH = "/openapi.json";
10
+ export const API_BASE = "/api/docs";
11
+ export const API_PAGES_PATH = `${API_BASE}/pages.json`;
12
+ export const API_PAGE_PATH = `${API_BASE}/pages/{route}.json`;
13
+ export const API_NAVIGATION_PATH = `${API_BASE}/navigation.json`;
14
+ export const API_SEARCH_PATH = `${API_BASE}/search`;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * RFC 9457 problem details — the one error shape every Blume JSON endpoint
3
+ * returns, so an agent that hits a missing page, a bad query, or an unknown
4
+ * API route always gets a stable machine-readable `code`, a human-readable
5
+ * `detail`, and a `resolution` telling it where to go next.
6
+ */
7
+
8
+ export const PROBLEM_TYPE = "application/problem+json";
9
+
10
+ /** A recovery link carried on a problem (the 404's "where to look next"). */
11
+ export interface ProblemLink {
12
+ href: string;
13
+ label: string;
14
+ }
15
+
16
+ export interface Problem {
17
+ /** Stable, screaming-snake error code for programmatic handling. */
18
+ code: string;
19
+ /** Human-readable explanation specific to this occurrence. */
20
+ detail: string;
21
+ /** The request path the problem occurred on, when known. */
22
+ instance?: string;
23
+ /** Recovery links, when the problem has somewhere useful to send the caller. */
24
+ links?: ProblemLink[];
25
+ /** What to do next — the hint agents act on. */
26
+ resolution: string;
27
+ status: number;
28
+ title: string;
29
+ /** Problem type URI; `about:blank` when the status code says it all. */
30
+ type: string;
31
+ }
32
+
33
+ /** The problem's members, with `type` defaulting to `about:blank`. */
34
+ export const problem = (
35
+ input: Omit<Problem, "type"> & { type?: string }
36
+ ): Problem => {
37
+ const body: Problem = {
38
+ code: input.code,
39
+ detail: input.detail,
40
+ resolution: input.resolution,
41
+ status: input.status,
42
+ title: input.title,
43
+ type: input.type ?? "about:blank",
44
+ };
45
+ if (input.instance !== undefined) {
46
+ body.instance = input.instance;
47
+ }
48
+ if (input.links !== undefined) {
49
+ body.links = input.links;
50
+ }
51
+ return body;
52
+ };
53
+
54
+ /** A `Response` carrying the problem as `application/problem+json`. */
55
+ export const problemResponse = (
56
+ input: Omit<Problem, "type"> & { type?: string }
57
+ ): Response => {
58
+ const body = problem(input);
59
+ return new Response(`${JSON.stringify(body, null, 2)}\n`, {
60
+ headers: { "Content-Type": `${PROBLEM_TYPE}; charset=utf-8` },
61
+ status: body.status,
62
+ });
63
+ };