blume 1.6.5 → 1.7.0

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 (179) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0qhq7b8q.js +111 -0
  4. package/dist/cli/chunk-0qhq7b8q.js.map +11 -0
  5. package/dist/cli/chunk-18tjv4f7.js +96 -0
  6. package/dist/cli/chunk-18tjv4f7.js.map +10 -0
  7. package/dist/cli/chunk-27gtm2ym.js +69 -0
  8. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  9. package/dist/cli/chunk-2aj8ddew.js +72 -0
  10. package/dist/cli/chunk-2aj8ddew.js.map +10 -0
  11. package/dist/cli/chunk-3r94j3tc.js +221 -0
  12. package/dist/cli/chunk-3r94j3tc.js.map +10 -0
  13. package/dist/cli/chunk-4trphnvy.js +102 -0
  14. package/dist/cli/chunk-4trphnvy.js.map +11 -0
  15. package/dist/cli/chunk-4xyggvgf.js +21 -0
  16. package/dist/cli/chunk-4xyggvgf.js.map +10 -0
  17. package/dist/cli/chunk-5d4q7121.js +4064 -0
  18. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  19. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  20. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  21. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  22. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  23. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  24. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  25. package/dist/cli/chunk-9qs6acpw.js +176 -0
  26. package/dist/cli/chunk-9qs6acpw.js.map +10 -0
  27. package/dist/cli/chunk-agy5rzxy.js +2453 -0
  28. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  29. package/dist/cli/chunk-bcy492zc.js +16 -0
  30. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  31. package/dist/cli/chunk-btfr9yvw.js +41 -0
  32. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  33. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  34. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  35. package/dist/cli/chunk-cfw6x4rm.js +1967 -0
  36. package/dist/cli/chunk-cfw6x4rm.js.map +34 -0
  37. package/dist/cli/chunk-ckh3a410.js +277 -0
  38. package/dist/cli/chunk-ckh3a410.js.map +11 -0
  39. package/dist/cli/chunk-drke6t0h.js +259 -0
  40. package/dist/cli/chunk-drke6t0h.js.map +11 -0
  41. package/dist/cli/chunk-ev67ycx0.js +15 -0
  42. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  43. package/dist/cli/chunk-ey89bjj1.js +209 -0
  44. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  45. package/dist/cli/chunk-j6pxe0dt.js +69 -0
  46. package/dist/cli/chunk-j6pxe0dt.js.map +11 -0
  47. package/dist/cli/chunk-jk1zwka1.js +387 -0
  48. package/dist/cli/chunk-jk1zwka1.js.map +12 -0
  49. package/dist/cli/chunk-jtb45atp.js +467 -0
  50. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  51. package/dist/cli/chunk-jxkxjsc1.js +76 -0
  52. package/dist/cli/chunk-jxkxjsc1.js.map +10 -0
  53. package/dist/cli/chunk-kwx90v78.js +81 -0
  54. package/dist/cli/chunk-kwx90v78.js.map +10 -0
  55. package/dist/cli/chunk-n0nyat6g.js +30 -0
  56. package/dist/cli/chunk-n0nyat6g.js.map +10 -0
  57. package/dist/cli/chunk-pxj10x8y.js +35 -0
  58. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  59. package/dist/cli/chunk-qq9nm3qd.js +1141 -0
  60. package/dist/cli/chunk-qq9nm3qd.js.map +19 -0
  61. package/dist/cli/chunk-s102bysw.js +5170 -0
  62. package/dist/cli/chunk-s102bysw.js.map +47 -0
  63. package/dist/cli/chunk-s5dsk8bj.js +769 -0
  64. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  65. package/dist/cli/chunk-s5e5jt53.js +227 -0
  66. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  67. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  68. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  69. package/dist/cli/chunk-tnskyrej.js +117 -0
  70. package/dist/cli/chunk-tnskyrej.js.map +10 -0
  71. package/dist/cli/chunk-v2ymm99c.js +1016 -0
  72. package/dist/cli/chunk-v2ymm99c.js.map +13 -0
  73. package/dist/cli/chunk-v5mm027v.js +185 -0
  74. package/dist/cli/chunk-v5mm027v.js.map +11 -0
  75. package/dist/cli/chunk-vt8fgygt.js +23 -0
  76. package/dist/cli/chunk-vt8fgygt.js.map +10 -0
  77. package/dist/cli/chunk-vxv4x1n8.js +17 -0
  78. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  79. package/dist/cli/chunk-wd27zjcz.js +60 -0
  80. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  81. package/dist/cli/chunk-x66c5yjn.js +23 -0
  82. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  83. package/dist/cli/chunk-xv91q4nm.js +5314 -0
  84. package/dist/cli/chunk-xv91q4nm.js.map +58 -0
  85. package/dist/cli/chunk-y3g15rvv.js +679 -0
  86. package/dist/cli/chunk-y3g15rvv.js.map +15 -0
  87. package/dist/cli/chunk-ye9zdkgv.js +136 -0
  88. package/dist/cli/chunk-ye9zdkgv.js.map +10 -0
  89. package/dist/cli/chunk-ynacq3ev.js +1062 -0
  90. package/dist/cli/chunk-ynacq3ev.js.map +25 -0
  91. package/dist/cli/chunk-zr3ygrq3.js +54 -0
  92. package/dist/cli/chunk-zr3ygrq3.js.map +10 -0
  93. package/dist/cli/index.js +55 -27597
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  97. package/dist/types/core/code-fences.d.ts +11 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +70 -0
  100. package/dist/types/theme/fonts.d.ts +22 -22
  101. package/docs/02-deployment.mdx +22 -1
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/navigation.mdx +2 -0
  105. package/docs/content/syntax.mdx +1 -1
  106. package/docs/discoverability/open-graph.mdx +4 -0
  107. package/docs/reference/cli.mdx +1 -1
  108. package/package.json +4 -2
  109. package/src/ai/api/handlers.ts +4 -7
  110. package/src/ai/api/paths.ts +8 -0
  111. package/src/ai/api/spec.ts +2 -1
  112. package/src/ai/ask-context.ts +378 -22
  113. package/src/astro/generate.ts +161 -28
  114. package/src/astro/include-hmr.ts +10 -13
  115. package/src/astro/include-refresh.ts +0 -0
  116. package/src/astro/index.ts +6 -1
  117. package/src/astro/integration.ts +280 -53
  118. package/src/astro/module-types.ts +83 -0
  119. package/src/astro/templates.ts +256 -108
  120. package/src/audit/image-size.ts +10 -8
  121. package/src/cli/command-meta.ts +77 -0
  122. package/src/cli/commands/add.ts +2 -4
  123. package/src/cli/commands/audit.ts +2 -4
  124. package/src/cli/commands/build.ts +70 -346
  125. package/src/cli/commands/check.ts +2 -4
  126. package/src/cli/commands/dev.ts +31 -42
  127. package/src/cli/commands/doctor.ts +2 -4
  128. package/src/cli/commands/eject.ts +3 -41
  129. package/src/cli/commands/eval.ts +2 -5
  130. package/src/cli/commands/init.ts +2 -4
  131. package/src/cli/commands/mcp-stdio.ts +2 -5
  132. package/src/cli/commands/preview.ts +3 -5
  133. package/src/cli/commands/sync.ts +2 -4
  134. package/src/cli/commands/translate.ts +2 -5
  135. package/src/cli/commands/validate.ts +2 -4
  136. package/src/cli/commands/version.ts +2 -4
  137. package/src/cli/eject-scripts.ts +0 -45
  138. package/src/cli/host-args.ts +16 -0
  139. package/src/cli/index.ts +84 -35
  140. package/src/cli/lazy-command.ts +47 -0
  141. package/src/components/Icon.astro +24 -0
  142. package/src/components/content/GithubInfo.astro +4 -1
  143. package/src/components/icon-sprite-middleware.ts +41 -0
  144. package/src/components/icon-sprite.ts +93 -0
  145. package/src/components/layout/IconSprite.astro +11 -0
  146. package/src/components/layout/NavTree.astro +156 -188
  147. package/src/components/layout/NavTreeCache.astro +45 -0
  148. package/src/components/layout/NavTreeScript.astro +256 -0
  149. package/src/components/layout/PageActions.astro +11 -5
  150. package/src/components/layout/PageLayout.astro +21 -3
  151. package/src/components/layout/ReferenceLayout.astro +21 -4
  152. package/src/components/layout/RootLayout.astro +44 -6
  153. package/src/components/layout/nav-cache.ts +49 -0
  154. package/src/components/layout/nav-utils.ts +69 -1
  155. package/src/components/layout/page-locale.ts +29 -0
  156. package/src/core/api-name.ts +18 -0
  157. package/src/core/code-fences.ts +48 -0
  158. package/src/core/content-assets.ts +3 -7
  159. package/src/core/includes.ts +3 -7
  160. package/src/core/package-root.ts +1 -1
  161. package/src/core/schema.ts +19 -0
  162. package/src/core/sources/normalize.ts +2 -37
  163. package/src/core/sources/obsidian.ts +3 -2
  164. package/src/core/svg-dimensions.ts +97 -0
  165. package/src/core/version-cut.ts +2 -2
  166. package/src/deploy/artifacts.ts +370 -0
  167. package/src/deploy/cloudflare-negotiation.ts +97 -32
  168. package/src/deploy/function-bundle.ts +66 -20
  169. package/src/deploy/sitemap.ts +6 -0
  170. package/src/deploy/vercel-negotiation.ts +8 -30
  171. package/src/markdown/language-icon.ts +64 -20
  172. package/src/markdown/mermaid.ts +11 -0
  173. package/src/og/cache.ts +236 -0
  174. package/src/og/card.ts +18 -16
  175. package/src/og/index.ts +8 -1
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +23 -10
  178. package/src/theme/entry.ts +41 -7
  179. package/src/theme/fonts.ts +30 -23
@@ -0,0 +1,370 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+
4
+ import { dirname, join, resolve } from "pathe";
5
+
6
+ import { buildAgentReadability } from "../ai/agent-readability.ts";
7
+ import { API_CATALOG_PATH, buildApiCatalog } from "../ai/api-catalog.ts";
8
+ import { buildHomeLinkHeader } from "../ai/link-headers.ts";
9
+ import { buildLlmsFiles } from "../ai/llms.ts";
10
+ import { markdownRoutePaths } from "../ai/markdown.ts";
11
+ import {
12
+ AGENT_SKILLS_DIR,
13
+ buildSkillsIndex,
14
+ collectSkills,
15
+ } from "../ai/skills.ts";
16
+ import type { SkillArtifact } from "../ai/skills.ts";
17
+ import {
18
+ buildSignaturesDirectory,
19
+ SIGNATURES_DIRECTORY_PATH,
20
+ } from "../ai/web-bot-auth.ts";
21
+ import type { BlumeProject } from "../core/project-graph.ts";
22
+ import type { ResolvedConfig } from "../core/schema.ts";
23
+ import { buildSearchIndex } from "../search/build.ts";
24
+ import { syncSearchProvider } from "../search/sync/index.ts";
25
+ import { readsHeaderFiles } from "./adapter-output.ts";
26
+ import { buildNetlifyHeaders } from "./headers.ts";
27
+ import {
28
+ buildNetlifyRedirects,
29
+ buildRedirectManifest,
30
+ buildVercelConfig,
31
+ platformRedirects,
32
+ } from "./redirects.ts";
33
+ import { buildRobots } from "./robots.ts";
34
+ import { buildSitemapFiles, describeSitemapFiles } from "./sitemap.ts";
35
+
36
+ /**
37
+ * The deploy artifacts Blume layers onto Astro's build output: the search
38
+ * index (and hosted-provider sync), llms.txt, sitemap, robots, the
39
+ * agent-readability manifest, the `.well-known` discovery files, Agent
40
+ * Skills, and the platform `_redirects`/`_headers` files.
41
+ *
42
+ * Written from the integration's `astro:build:done` hook — Astro's channel
43
+ * for exactly this kind of post-build work — into the directory Astro
44
+ * reports as the client output (`dist/`, or `dist/client` for a server
45
+ * build). The Vercel adapter copies that directory into its Build Output
46
+ * static tree in a later hook, so the artifacts ride along; every other
47
+ * adapter serves it directly. Running inside the hook rather than after
48
+ * `astro build` returns means an ejected project keeps producing them.
49
+ *
50
+ * Every file yields to one the user ships in `public/` (Astro copied it into
51
+ * the output before this runs).
52
+ */
53
+
54
+ /** The log surface the writers report through (Astro's integration logger). */
55
+ export interface ArtifactLogger {
56
+ info: (message: string) => void;
57
+ warn: (message: string) => void;
58
+ }
59
+
60
+ /**
61
+ * Emit platform redirect files for a static build (adapters wire redirects
62
+ * natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
63
+ * when the user hasn't shipped one via public/. Note that Vercel's
64
+ * git-integration builds read `vercel.json` from the repository root only —
65
+ * the copy emitted here takes effect when the dist folder itself is deployed
66
+ * directly via the Vercel CLI.
67
+ */
68
+ const emitRedirectFiles = async (
69
+ config: ResolvedConfig,
70
+ distDir: string,
71
+ logger: ArtifactLogger
72
+ ): Promise<void> => {
73
+ const redirects = platformRedirects(config);
74
+ if (redirects.length === 0 || config.deployment.output !== "static") {
75
+ return;
76
+ }
77
+ await writeFile(
78
+ join(distDir, "blume-redirects.json"),
79
+ buildRedirectManifest(redirects),
80
+ "utf-8"
81
+ );
82
+ const platformFiles = [
83
+ { content: buildNetlifyRedirects(redirects), name: "_redirects" },
84
+ { content: buildVercelConfig(redirects), name: "vercel.json" },
85
+ ];
86
+ await Promise.all(
87
+ platformFiles.map((file) =>
88
+ existsSync(join(distDir, file.name))
89
+ ? Promise.resolve()
90
+ : writeFile(join(distDir, file.name), file.content, "utf-8")
91
+ )
92
+ );
93
+ logger.info(`Emitted redirect files for ${redirects.length} redirect(s)`);
94
+ };
95
+
96
+ /**
97
+ * Emit a `_headers` file so Netlify / Cloudflare serve the raw AI-ready
98
+ * endpoints (`*.md`, `*.mdx`, `*.txt`) with an explicit `charset=utf-8`. Without
99
+ * it those hosts send `text/markdown` / `text/plain` with no charset and
100
+ * browsers fall back to Windows-1252, garbling any non-ASCII docs (#82).
101
+ *
102
+ * The same file carries the rest of the agent-discovery surface that only a
103
+ * response header can express: the homepage `Link` header (RFC 8288, see
104
+ * `ai/link-headers.ts`), and the registered media types for the extensionless
105
+ * well-known files — `application/linkset+json` for the API catalog, the
106
+ * signatures directory, and the Agent Skills archives. A static host serves
107
+ * those as `octet-stream` or nothing at all without a rule.
108
+ *
109
+ * A `_headers` shipped in `public/` wins, exactly like `_redirects` — the opt-out
110
+ * is checked at its source rather than in `dist`, because on Cloudflare the file
111
+ * in `dist` is not necessarily the user's: `@astrojs/cloudflare` writes its own
112
+ * `_headers` (an immutable `Cache-Control` rule for `/_astro/*`) during the
113
+ * build, before this runs. Testing `dist` therefore read an adapter-generated
114
+ * file as a user opt-out and skipped silently. When both exist, the adapter's
115
+ * rules are preserved and ours are appended.
116
+ *
117
+ * Gated on {@link readsHeaderFiles}, not on `output === "static"`. A **Cloudflare
118
+ * server** build serves `dist/client` through the Worker's ASSETS binding, and
119
+ * Workers static assets honor `_headers` from that directory — so the file
120
+ * applies there too, and skipping it left every Cloudflare server build with no
121
+ * `Link` header and no media type on its own discovery files. The charset half
122
+ * of this file *is* redundant on a server build, because the runtime endpoint
123
+ * sets Content-Type on the Response itself; the `Link` and well-known halves are
124
+ * not, and one conclusion about the first was applied to all three.
125
+ *
126
+ * Exported for the test suite, which exercises it in a subprocess like the
127
+ * command helpers.
128
+ */
129
+ export const emitHeaderFiles = async (
130
+ project: BlumeProject,
131
+ distDir: string,
132
+ logger: ArtifactLogger
133
+ ): Promise<void> => {
134
+ const { config } = project;
135
+ if (
136
+ !readsHeaderFiles(config.deployment) ||
137
+ existsSync(join(project.context.root, "public", "_headers"))
138
+ ) {
139
+ return;
140
+ }
141
+ const ours = buildNetlifyHeaders(
142
+ config,
143
+ buildHomeLinkHeader(config, markdownRoutePaths(project))
144
+ );
145
+ // An adapter may have written its own rules here already (Cloudflare adds an
146
+ // immutable Cache-Control for /_astro/*). Keep them and append ours: both
147
+ // sets are wanted, and `_headers` has no merge semantics beyond order.
148
+ const target = join(distDir, "_headers");
149
+ const existing = existsSync(target) ? await readFile(target, "utf-8") : "";
150
+ await writeFile(
151
+ target,
152
+ existing ? `${existing.trimEnd()}\n${ours}` : ours,
153
+ "utf-8"
154
+ );
155
+ logger.info("Emitted _headers (UTF-8 Content-Type + homepage Link header)");
156
+ };
157
+
158
+ /**
159
+ * Collect the Agent Skills `ai.skills` publishes, once per build, so both the
160
+ * skills surface and llms.txt (which lists them) read the same set. Empty
161
+ * when the feature is off, the directory is missing, nothing in it is
162
+ * publishable (each with a warning), or a user-shipped
163
+ * `public/.well-known/agent-skills/index.json` already owns the surface.
164
+ */
165
+ const collectConfiguredSkills = async (
166
+ project: BlumeProject,
167
+ distDir: string,
168
+ logger: ArtifactLogger
169
+ ): Promise<SkillArtifact[]> => {
170
+ const configured = project.config.ai.skills;
171
+ if (!configured) {
172
+ return [];
173
+ }
174
+ const dir = resolve(project.context.root, configured);
175
+ if (!existsSync(dir)) {
176
+ logger.warn(
177
+ `ai.skills points at "${configured}" (${dir}), which does not exist; no skills published.`
178
+ );
179
+ return [];
180
+ }
181
+ if (existsSync(join(distDir, AGENT_SKILLS_DIR.slice(1), "index.json"))) {
182
+ return [];
183
+ }
184
+ const { skills, warnings } = await collectSkills(dir);
185
+ for (const warning of warnings) {
186
+ logger.warn(warning);
187
+ }
188
+ if (skills.length === 0) {
189
+ logger.warn(`ai.skills: no publishable skills found in "${configured}".`);
190
+ }
191
+ return skills;
192
+ };
193
+
194
+ /**
195
+ * Publish the collected Agent Skills: copy each skill artifact under
196
+ * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
197
+ * `public/.well-known/agent-skills/index.json` takes over the whole surface
198
+ * (the collector returns nothing then), matching every other generated
199
+ * artifact.
200
+ */
201
+ const emitAgentSkills = async (
202
+ project: BlumeProject,
203
+ distDir: string,
204
+ skills: readonly SkillArtifact[],
205
+ logger: ArtifactLogger
206
+ ): Promise<void> => {
207
+ if (skills.length === 0) {
208
+ return;
209
+ }
210
+ const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
211
+ await Promise.all(
212
+ skills.map(async (skill) => {
213
+ const target = join(outDir, skill.path);
214
+ await mkdir(dirname(target), { recursive: true });
215
+ await writeFile(target, skill.content);
216
+ })
217
+ );
218
+ await writeFile(
219
+ join(outDir, "index.json"),
220
+ buildSkillsIndex(skills, project.config),
221
+ "utf-8"
222
+ );
223
+ logger.info(
224
+ `Published ${skills.length} agent skill(s) (.well-known/agent-skills/index.json)`
225
+ );
226
+ };
227
+
228
+ /**
229
+ * Emit the generated `.well-known` discovery files — the RFC 9727 API catalog
230
+ * and the Web Bot Auth signature directory — each skipped when the feature is
231
+ * off or when the user ships their own copy via public/ (already in dist by
232
+ * the time this runs).
233
+ */
234
+ const emitWellKnownFiles = async (
235
+ config: ResolvedConfig,
236
+ distDir: string,
237
+ logger: ArtifactLogger
238
+ ): Promise<void> => {
239
+ const files = [
240
+ {
241
+ content: buildSignaturesDirectory(config),
242
+ label: "Web Bot Auth",
243
+ path: SIGNATURES_DIRECTORY_PATH,
244
+ },
245
+ {
246
+ content: buildApiCatalog(config),
247
+ label: "RFC 9727",
248
+ path: API_CATALOG_PATH,
249
+ },
250
+ ];
251
+ for (const file of files) {
252
+ const target = join(distDir, file.path.slice(1));
253
+ if (!file.content || existsSync(target)) {
254
+ continue;
255
+ }
256
+ // Sequential by nature: both files share the .well-known dir creation.
257
+ // oxlint-disable-next-line no-await-in-loop
258
+ await mkdir(join(distDir, ".well-known"), { recursive: true });
259
+ // oxlint-disable-next-line no-await-in-loop
260
+ await writeFile(target, file.content, "utf-8");
261
+ logger.info(`Generated ${file.path.slice(1)} (${file.label})`);
262
+ }
263
+ };
264
+
265
+ /**
266
+ * Generate `llms.txt`/`llms-full.txt` into the dist dir. A user's own file in
267
+ * `public/` (copied into dist by Astro before this runs, like the sitemap and
268
+ * robots.txt) wins over the generated one — each file is checked and replaced
269
+ * independently, so a custom `llms.txt` still gets a generated `llms-full.txt`.
270
+ */
271
+ const publishLlmsFiles = async (
272
+ project: BlumeProject,
273
+ distDir: string,
274
+ skills: readonly SkillArtifact[],
275
+ logger: ArtifactLogger
276
+ ): Promise<void> => {
277
+ const indexPath = join(distDir, "llms.txt");
278
+ const fullPath = join(distDir, "llms-full.txt");
279
+ const writeIndex = !existsSync(indexPath);
280
+ const writeFull = !existsSync(fullPath);
281
+ if (!(writeIndex || writeFull)) {
282
+ return;
283
+ }
284
+ const { index, full } = await buildLlmsFiles(project, { skills });
285
+ const writes: Promise<void>[] = [];
286
+ if (writeIndex) {
287
+ writes.push(writeFile(indexPath, index, "utf-8"));
288
+ }
289
+ if (writeFull) {
290
+ writes.push(writeFile(fullPath, full, "utf-8"));
291
+ }
292
+ await Promise.all(writes);
293
+ logger.info(
294
+ `Generated ${[
295
+ writeIndex ? "llms.txt" : null,
296
+ writeFull ? "llms-full.txt" : null,
297
+ ]
298
+ .filter(Boolean)
299
+ .join(" and ")}`
300
+ );
301
+ };
302
+
303
+ /**
304
+ * Write every deploy artifact into `distDir`, the directory the platform
305
+ * serves as static files (see the module comment). A user's own `public/`
306
+ * file always wins. `indexSearch` is the Pagefind indexer, replaceable by
307
+ * tests: Pagefind's in-process service cannot be reopened once a build has
308
+ * closed it, so only one suite may run the real one.
309
+ */
310
+ export const publishBuildArtifacts = async (
311
+ project: BlumeProject,
312
+ distDir: string,
313
+ logger: ArtifactLogger,
314
+ indexSearch: (outDir: string) => Promise<number> = buildSearchIndex
315
+ ): Promise<void> => {
316
+ if (project.config.search.provider === "pagefind") {
317
+ logger.info("Building search index");
318
+ const indexed = await indexSearch(distDir);
319
+ logger.info(`Indexed ${indexed} page(s) for search`);
320
+ }
321
+
322
+ // Upload the index to a hosted provider (Algolia, Orama Cloud, Typesense).
323
+ // Skipped with a warning when its admin key isn't configured.
324
+ await syncSearchProvider(project, {
325
+ start: logger.info.bind(logger),
326
+ success: logger.info.bind(logger),
327
+ warn: logger.warn.bind(logger),
328
+ });
329
+
330
+ // Collected once: llms.txt lists the skills the build publishes below.
331
+ const skills = await collectConfiguredSkills(project, distDir, logger);
332
+ if (project.config.ai.llmsTxt.enabled) {
333
+ await publishLlmsFiles(project, distDir, skills, logger);
334
+ }
335
+
336
+ const sitemapFiles = buildSitemapFiles(project);
337
+ if (sitemapFiles && !existsSync(join(distDir, "sitemap.xml"))) {
338
+ await Promise.all(
339
+ sitemapFiles.map((file) =>
340
+ writeFile(join(distDir, file.name), file.xml, "utf-8")
341
+ )
342
+ );
343
+ logger.info(describeSitemapFiles(sitemapFiles));
344
+ }
345
+
346
+ const robots = buildRobots(project);
347
+ if (robots && !existsSync(join(distDir, "robots.txt"))) {
348
+ await writeFile(join(distDir, "robots.txt"), robots, "utf-8");
349
+ logger.info("Generated robots.txt");
350
+ }
351
+
352
+ const agentReadability = buildAgentReadability(project);
353
+ if (
354
+ agentReadability &&
355
+ !existsSync(join(distDir, "agent-readability.json"))
356
+ ) {
357
+ await writeFile(
358
+ join(distDir, "agent-readability.json"),
359
+ `${JSON.stringify(agentReadability, null, 2)}\n`,
360
+ "utf-8"
361
+ );
362
+ logger.info("Generated agent-readability.json");
363
+ }
364
+
365
+ await emitWellKnownFiles(project.config, distDir, logger);
366
+ await emitAgentSkills(project, distDir, skills, logger);
367
+
368
+ await emitRedirectFiles(project.config, distDir, logger);
369
+ await emitHeaderFiles(project, distDir, logger);
370
+ };
@@ -4,9 +4,11 @@
4
4
  * Blume prerenders every content page — even under `deployment.output:
5
5
  * "server"` — and on Cloudflare the ASSETS binding serves those files before
6
6
  * the Worker script runs, so no server-side code (Astro middleware included)
7
- * ever sees a content-page request. Worse, even a request that does reach the
8
- * Worker is answered by `@astrojs/cloudflare`'s handler straight from the
9
- * ASSETS binding, ahead of `app.render` — the only place middleware runs.
7
+ * ever sees a content-page request. Worse, even a content-page request that
8
+ * does reach the Worker is answered by `@astrojs/cloudflare`'s handler
9
+ * straight from the ASSETS binding, ahead of `app.render` — the only place
10
+ * middleware runs — because `app.match` resolves a prerendered route to
11
+ * nothing and the handler then falls back to the binding.
10
12
  *
11
13
  * Negotiation therefore needs two coordinated pieces, both applied to the
12
14
  * adapter's emitted deploy bundle after `astro build`:
@@ -20,6 +22,22 @@
20
22
  * from the ASSETS binding, and it delegates everything else to the Astro
21
23
  * Worker untouched.
22
24
  *
25
+ * The prerendered per-page JSON documents (`/api/docs/pages/{route}.json`)
26
+ * are the one prerendered surface the adapter's fallback does *not* cover.
27
+ * They come from a prerendered *dynamic* route, and for those Astro's
28
+ * `matchRequest` returns the first non-prerendered route matching the same
29
+ * path — on a server build that is the `/api/[...path]` catch-all, which
30
+ * answers with a 404 problem document; `fallbackToAssets` never runs. The
31
+ * static-pathname endpoints (`pages.json`, `navigation.json`) are unaffected:
32
+ * they sit in the manifest's asset set, which the handler serves from the
33
+ * binding first. So whenever a worker-first rule claims a page JSON URL, the
34
+ * wrapper answers it from the binding itself, keyed by the exact set of
35
+ * documents the build emitted. The generated rule sets that would claim every
36
+ * `.json` file — a subpath base and the coarse fallback — exempt `*.json`
37
+ * outright, keeping those files on the zero-Worker path; the wrapper branch
38
+ * covers the remaining claims (a content section under `/api`, or
39
+ * user-configured rules).
40
+ *
23
41
  * Cloudflare does not apply `_headers` to worker-first routes, so the wrapper
24
42
  * also re-stamps what the static layer would otherwise add on the routes it
25
43
  * takes over: the homepage agent-discovery `Link` header and the Markdown
@@ -64,8 +82,19 @@ const MAX_RULE_LENGTH = 100;
64
82
  * route everything through the Worker except the fingerprinted build assets
65
83
  * and the raw AI-ready endpoints, whose `charset=utf-8` comes from `_headers`
66
84
  * (not applied on worker-first routes) and whose responses never negotiate.
85
+ * The prerendered `.json` documents are exempted for the same reason, and
86
+ * because the Astro Worker would hand the per-page ones to the `/api/`
87
+ * catch-all (see the module comment); a miss on an exempted path still
88
+ * invokes the Worker, so unknown `.json` URLs keep their problem document.
67
89
  */
68
- const FALLBACK_RULES = ["/*", "!/_astro/*", "!/*.md", "!/*.mdx", "!/*.txt"];
90
+ const FALLBACK_RULES = [
91
+ "/*",
92
+ "!/_astro/*",
93
+ "!/*.md",
94
+ "!/*.mdx",
95
+ "!/*.txt",
96
+ "!/*.json",
97
+ ];
69
98
 
70
99
  /**
71
100
  * The deployment base as a rule/URL prefix: trailing slash stripped, empty
@@ -107,7 +136,8 @@ const ruleBody = (rule: string): string =>
107
136
  * `.md`/`.mdx` mirrors; a bare route gets its exact path in both request
108
137
  * spellings (with and without the trailing slash) so no unrelated URL pays
109
138
  * the Worker hop. On a subpath deploy the whole base is routed as one group —
110
- * every route lives under it anyway.
139
+ * every route lives under it anyway — with the raw endpoints and the
140
+ * prerendered `.json` documents exempted like the coarse fallback does.
111
141
  *
112
142
  * Configured redirects need no exemption from these rules: the wrapper Worker
113
143
  * answers any it claims from its baked-in redirect table with the configured
@@ -125,6 +155,7 @@ export const buildRunWorkerFirstRules = (
125
155
  `!${prefix}/*.md`,
126
156
  `!${prefix}/*.mdx`,
127
157
  `!${prefix}/*.txt`,
158
+ `!${prefix}/*.json`,
128
159
  `!${prefix}/_astro/*`,
129
160
  ];
130
161
  }
@@ -230,6 +261,13 @@ export interface NegotiationWorkerOptions {
230
261
  homeTokens?: number;
231
262
  /** Configured redirects the wrapper answers with their exact status. */
232
263
  redirects?: readonly WorkerRedirect[];
264
+ /**
265
+ * Base-less served paths of the prerendered per-page JSON documents the
266
+ * build emitted (see `pageJsonPath`), decoded. The wrapper answers exactly
267
+ * these from the assets binding, since the Astro Worker would route them to
268
+ * the `/api/` catch-all (see the module comment).
269
+ */
270
+ pageJsonPaths?: readonly string[];
233
271
  }
234
272
 
235
273
  /**
@@ -242,6 +280,7 @@ export const buildNegotiationWorker = (
242
280
  options: NegotiationWorkerOptions
243
281
  ): string => {
244
282
  const routes = JSON.stringify(options.routePaths);
283
+ const pageJson = JSON.stringify(options.pageJsonPaths ?? []);
245
284
  const binding = JSON.stringify(options.assetsBinding);
246
285
  const prefix = JSON.stringify(encodeURI(basePrefix(options.base)));
247
286
  const homeLinkHeader = JSON.stringify(options.homeLinkHeader ?? null);
@@ -266,13 +305,17 @@ export const buildNegotiationWorker = (
266
305
  // build. \`assets.run_worker_first\` routes content-page requests here instead
267
306
  // of the platform's static layer; a client that prefers Markdown gets the
268
307
  // page's prerendered \`.md\` mirror from the assets binding, a configured
269
- // redirect is answered with its exact configured status, and every other
308
+ // redirect is answered with its exact configured status, and a prerendered
309
+ // per-page JSON document is served from the binding (the Astro Worker would
310
+ // route it to the \`/api/\` catch-all, because Astro resolves a prerendered
311
+ // dynamic route to the first live route on the same path). Every other
270
312
  // request is delegated to the Astro Worker untouched. \`_headers\` does not
271
313
  // apply to worker-first routes, so the homepage Link header and the Markdown
272
314
  // charset are re-stamped here.
273
315
  import server from ${JSON.stringify(options.mainSpecifier)};
274
316
 
275
317
  const ROUTES = new Set(${routes});
318
+ const PAGE_JSON = new Set(${pageJson});
276
319
  const BASE_PREFIX = ${prefix};
277
320
  const ASSETS_BINDING = ${binding};
278
321
  const HOME_LINK_HEADER = ${homeLinkHeader};
@@ -286,13 +329,32 @@ const REDIRECTS = ${redirects};
286
329
  const redirectFor = (pathname) => {
287
330
  const trimmed =
288
331
  pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
289
- let path = trimmed;
332
+ const path = safeDecode(trimmed);
333
+ return Object.hasOwn(REDIRECTS, path) ? REDIRECTS[path] : null;
334
+ };
335
+
336
+ // The request path with the deployment base removed: the whole path on a
337
+ // root deploy, \`""\` for the bare base, \`null\` for a path outside the base.
338
+ const stripBase = (pathname) => {
339
+ if (!BASE_PREFIX) {
340
+ return pathname;
341
+ }
342
+ if (pathname === BASE_PREFIX) {
343
+ return "";
344
+ }
345
+ return pathname.startsWith(BASE_PREFIX + "/")
346
+ ? pathname.slice(BASE_PREFIX.length)
347
+ : null;
348
+ };
349
+
350
+ // Decoded for the lookups, which are keyed by decoded paths; a malformed
351
+ // escape keeps the raw path, which simply won't match anything.
352
+ const safeDecode = (path) => {
290
353
  try {
291
- path = decodeURIComponent(trimmed);
354
+ return decodeURIComponent(path);
292
355
  } catch {
293
- // Keep the raw path; it simply won't match a configured redirect.
356
+ return path;
294
357
  }
295
- return Object.hasOwn(REDIRECTS, path) ? REDIRECTS[path] : null;
296
358
  };
297
359
 
298
360
  // \`_redirects\` semantics, which the static layer applies to these same
@@ -341,21 +403,13 @@ const markdownVariantUrl = (rawUrl) => {
341
403
  const queryIndex = rawUrl.indexOf("?");
342
404
  const query = queryIndex === -1 ? "" : rawUrl.slice(queryIndex);
343
405
  const rawPath = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
344
- let path = rawPath;
345
- if (BASE_PREFIX) {
346
- if (path === BASE_PREFIX || path.startsWith(BASE_PREFIX + "/")) {
347
- path = path.slice(BASE_PREFIX.length) || "/";
348
- } else {
349
- return null;
350
- }
406
+ const rest = stripBase(rawPath);
407
+ if (rest === null) {
408
+ return null;
351
409
  }
410
+ const path = rest || "/";
352
411
  const trimmed = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
353
- let pathname = trimmed;
354
- try {
355
- pathname = decodeURIComponent(trimmed);
356
- } catch {
357
- // Keep the raw path; it simply won't match a content route.
358
- }
412
+ const pathname = safeDecode(trimmed);
359
413
  if (!ROUTES.has(pathname)) {
360
414
  return null;
361
415
  }
@@ -363,15 +417,17 @@ const markdownVariantUrl = (rawUrl) => {
363
417
  return BASE_PREFIX + encodeURI(target) + ".md" + query;
364
418
  };
365
419
 
420
+ // Exactly the per-page JSON documents the build emitted, so a URL the site
421
+ // never generated (a hidden page, a path outside the API) takes the normal
422
+ // route to the Astro Worker and its problem document.
423
+ const isPageJson = (pathname) => {
424
+ const rest = stripBase(pathname);
425
+ return rest !== null && PAGE_JSON.has(safeDecode(rest));
426
+ };
427
+
366
428
  const isHomePath = (pathname) => {
367
- let path = pathname;
368
- if (BASE_PREFIX) {
369
- if (path !== BASE_PREFIX && !path.startsWith(BASE_PREFIX + "/")) {
370
- return false;
371
- }
372
- path = path.slice(BASE_PREFIX.length);
373
- }
374
- return path === "" || path === "/";
429
+ const rest = stripBase(pathname);
430
+ return rest === "" || rest === "/";
375
431
  };
376
432
 
377
433
  const withHeaders = (response, apply) => {
@@ -395,9 +451,18 @@ export default {
395
451
  if (request.method !== "GET" && request.method !== "HEAD") {
396
452
  return server.fetch(request, env, context);
397
453
  }
454
+ const assets = env[ASSETS_BINDING];
455
+ // The request goes through untouched, so a conditional revalidation
456
+ // reaches the binding and comes back as a 304 — anything but a miss is
457
+ // the document's own answer. A miss falls through to the Astro Worker.
458
+ if (assets !== undefined && isPageJson(url.pathname)) {
459
+ const asset = await assets.fetch(request);
460
+ if (asset.status !== 404) {
461
+ return asset;
462
+ }
463
+ }
398
464
  const variant = markdownVariantUrl(url.pathname + url.search);
399
465
  const home = isHomePath(url.pathname);
400
- const assets = env[ASSETS_BINDING];
401
466
  if (
402
467
  variant !== null &&
403
468
  assets !== undefined &&