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,77 @@
1
+ import type { CommandMeta } from "citty";
2
+
3
+ /**
4
+ * Every command's `meta`, held apart from the command modules themselves.
5
+ *
6
+ * The CLI entry loads each command lazily (see `lazy-command.ts`), but citty
7
+ * still reads every subcommand's `meta` to render `blume --help` and to match
8
+ * an unknown name against aliases. Keeping that table here lets those paths
9
+ * run without importing a single command module — `dev` alone drags in Astro,
10
+ * `mcp-stdio` the MCP SDK. The command modules read their `meta` from this
11
+ * table too, so the entry and the command can't drift.
12
+ */
13
+ export const commandMeta = {
14
+ add: {
15
+ description: "Install a source component or template from the registry.",
16
+ name: "add",
17
+ },
18
+ audit: {
19
+ description: "Audit the built site for SEO and site-health issues.",
20
+ name: "audit",
21
+ },
22
+ build: {
23
+ description: "Build the docs site for production.",
24
+ name: "build",
25
+ },
26
+ check: {
27
+ description: "Type-check the docs site with astro check.",
28
+ name: "check",
29
+ },
30
+ dev: {
31
+ description: "Start the Blume development server.",
32
+ name: "dev",
33
+ },
34
+ doctor: {
35
+ description: "Diagnose common configuration and content problems.",
36
+ name: "doctor",
37
+ },
38
+ eject: {
39
+ description: "Promote the generated runtime into an owned Astro project.",
40
+ name: "eject",
41
+ },
42
+ eval: {
43
+ description:
44
+ "Test the docs: an agent answers your questions using only the documentation.",
45
+ name: "eval",
46
+ },
47
+ init: {
48
+ description: "Scaffold a minimal Blume project.",
49
+ name: "init",
50
+ },
51
+ "mcp-stdio": {
52
+ description:
53
+ "Serve an MCP data snapshot over stdio (internal, used by `blume eval`).",
54
+ name: "mcp-stdio",
55
+ },
56
+ preview: {
57
+ description: "Preview the last production build.",
58
+ name: "preview",
59
+ },
60
+ sync: {
61
+ description: "Re-fetch remote content sources and regenerate the runtime.",
62
+ name: "sync",
63
+ },
64
+ translate: {
65
+ description:
66
+ "Translate docs into the configured locales with a local agent CLI.",
67
+ name: "translate",
68
+ },
69
+ validate: {
70
+ description: "Validate internal, anchor, asset, and external links.",
71
+ name: "validate",
72
+ },
73
+ version: {
74
+ description: "Freeze the current docs as an archived version.",
75
+ name: "version",
76
+ },
77
+ } satisfies Record<string, CommandMeta>;
@@ -6,6 +6,7 @@ import { dirname, join } from "pathe";
6
6
 
7
7
  import { findItem, packageSrc, registry } from "../../registry/registry.ts";
8
8
  import { rewriteImports } from "../../registry/rewrite-imports.ts";
9
+ import { commandMeta } from "../command-meta.ts";
9
10
  import { logger } from "../log.ts";
10
11
 
11
12
  export const addCommand = defineCommand({
@@ -17,10 +18,7 @@ export const addCommand = defineCommand({
17
18
  type: "positional",
18
19
  },
19
20
  },
20
- meta: {
21
- description: "Install a source component or template from the registry.",
22
- name: "add",
23
- },
21
+ meta: commandMeta.add,
24
22
  async run({ args }) {
25
23
  const root = process.cwd();
26
24
 
@@ -13,6 +13,7 @@ import type { AuditResult } from "../../audit/run.ts";
13
13
  import { BlumeError } from "../../core/diagnostics.ts";
14
14
  import { scanProject } from "../../core/project-graph.ts";
15
15
  import type { DiagnosticSeverity } from "../../core/types.ts";
16
+ import { commandMeta } from "../command-meta.ts";
16
17
  import { reportInternalError } from "../internal-error.ts";
17
18
  import { flushStdout, logger } from "../log.ts";
18
19
 
@@ -115,10 +116,7 @@ export const auditCommand = defineCommand({
115
116
  type: "boolean",
116
117
  },
117
118
  },
118
- meta: {
119
- description: "Audit the built site for SEO and site-health issues.",
120
- name: "audit",
121
- },
119
+ meta: commandMeta.audit,
122
120
  async run({ args }) {
123
121
  if (args["list-checks"]) {
124
122
  process.stdout.write(formatCatalog());
@@ -1,19 +1,17 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { build } from "astro";
5
5
  import { defineCommand } from "citty";
6
- import { dirname, join, resolve } from "pathe";
6
+ import { join } from "pathe";
7
7
 
8
- import { buildAgentReadability } from "../../ai/agent-readability.ts";
9
8
  import {
10
9
  API_CATALOG_PATH,
11
10
  API_CATALOG_TYPE,
12
- buildApiCatalog,
13
11
  hasApiCatalog,
14
12
  } from "../../ai/api-catalog.ts";
13
+ import { pageJsonPath } from "../../ai/api/paths.ts";
15
14
  import { buildHomeLinkHeader } from "../../ai/link-headers.ts";
16
- import { buildLlmsFiles } from "../../ai/llms.ts";
17
15
  import {
18
16
  agentMarkdown,
19
17
  buildRawMarkdown,
@@ -21,16 +19,10 @@ import {
21
19
  markdownTokenCount,
22
20
  } from "../../ai/markdown.ts";
23
21
  import {
24
- AGENT_SKILLS_DIR,
25
- buildSkillsIndex,
26
- collectSkills,
27
- } from "../../ai/skills.ts";
28
- import type { SkillArtifact } from "../../ai/skills.ts";
29
- import {
30
- buildSignaturesDirectory,
31
22
  SIGNATURES_DIRECTORY_PATH,
32
23
  SIGNATURES_DIRECTORY_TYPE,
33
24
  } from "../../ai/web-bot-auth.ts";
25
+ import { publishBuildProject } from "../../astro/integration.ts";
34
26
  import { ensureGitignore } from "../../core/gitignore.ts";
35
27
  import type { BlumeProject } from "../../core/project-graph.ts";
36
28
  import type { ResolvedConfig } from "../../core/schema.ts";
@@ -39,7 +31,6 @@ import type { ProjectContext } from "../../core/types.ts";
39
31
  import {
40
32
  ADAPTER_IGNORE_DIRS,
41
33
  deployStaticDir,
42
- readsHeaderFiles,
43
34
  servesClientSubdir,
44
35
  surfaceAdapterOutput,
45
36
  } from "../../deploy/adapter-output.ts";
@@ -52,18 +43,10 @@ import {
52
43
  blumeDependencyNames,
53
44
  functionBundleVerdict,
54
45
  } from "../../deploy/function-bundle.ts";
55
- import { buildNetlifyHeaders } from "../../deploy/headers.ts";
56
- import {
57
- buildNetlifyRedirects,
58
- buildRedirectManifest,
59
- buildVercelConfig,
60
- platformRedirects,
61
- } from "../../deploy/redirects.ts";
62
- import { buildRobots } from "../../deploy/robots.ts";
63
- import { buildSitemapFiles } from "../../deploy/sitemap.ts";
46
+ import { platformRedirects } from "../../deploy/redirects.ts";
64
47
  import { injectNegotiationRoutes } from "../../deploy/vercel-negotiation.ts";
65
- import { buildSearchIndex } from "../../search/build.ts";
66
- import { syncSearchProvider } from "../../search/sync/index.ts";
48
+ import { cardCacheTally, ogCacheDir, pruneCardCache } from "../../og/cache.ts";
49
+ import { commandMeta } from "../command-meta.ts";
67
50
  import { refuseIfDevRunning } from "../dev-lock.ts";
68
51
  import { logger } from "../log.ts";
69
52
  import { prepareProject } from "../prepare.ts";
@@ -102,208 +85,6 @@ const validateBudgetFlags = (args: BudgetArgs): void => {
102
85
  }
103
86
  };
104
87
 
105
- /**
106
- * Emit platform redirect files for a static build (adapters wire redirects
107
- * natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
108
- * when the user hasn't shipped one via public/. Note that Vercel's
109
- * git-integration builds read `vercel.json` from the repository root only —
110
- * the copy emitted here takes effect when the dist folder itself is deployed
111
- * directly via the Vercel CLI.
112
- */
113
- const emitRedirectFiles = async (
114
- config: ResolvedConfig,
115
- distDir: string
116
- ): Promise<void> => {
117
- const redirects = platformRedirects(config);
118
- if (redirects.length === 0 || config.deployment.output !== "static") {
119
- return;
120
- }
121
- await writeFile(
122
- join(distDir, "blume-redirects.json"),
123
- buildRedirectManifest(redirects),
124
- "utf-8"
125
- );
126
- const platformFiles = [
127
- { content: buildNetlifyRedirects(redirects), name: "_redirects" },
128
- { content: buildVercelConfig(redirects), name: "vercel.json" },
129
- ];
130
- await Promise.all(
131
- platformFiles.map((file) =>
132
- existsSync(join(distDir, file.name))
133
- ? Promise.resolve()
134
- : writeFile(join(distDir, file.name), file.content, "utf-8")
135
- )
136
- );
137
- logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
138
- };
139
-
140
- /**
141
- * Emit a `_headers` file so Netlify / Cloudflare serve the raw AI-ready
142
- * endpoints (`*.md`, `*.mdx`, `*.txt`) with an explicit `charset=utf-8`. Without
143
- * it those hosts send `text/markdown` / `text/plain` with no charset and
144
- * browsers fall back to Windows-1252, garbling any non-ASCII docs (#82).
145
- *
146
- * The same file carries the rest of the agent-discovery surface that only a
147
- * response header can express: the homepage `Link` header (RFC 8288, see
148
- * `ai/link-headers.ts`), and the registered media types for the extensionless
149
- * well-known files — `application/linkset+json` for the API catalog, the
150
- * signatures directory, and the Agent Skills archives. A static host serves
151
- * those as `octet-stream` or nothing at all without a rule.
152
- *
153
- * A `_headers` shipped in `public/` wins, exactly like `_redirects` — the opt-out
154
- * is checked at its source rather than in `dist`, because on Cloudflare the file
155
- * in `dist` is not necessarily the user's: `@astrojs/cloudflare` writes its own
156
- * `_headers` (an immutable `Cache-Control` rule for `/_astro/*`) during the
157
- * build, before this runs. Testing `dist` therefore read an adapter-generated
158
- * file as a user opt-out and skipped silently. When both exist, the adapter's
159
- * rules are preserved and ours are appended.
160
- *
161
- * Gated on {@link readsHeaderFiles}, not on `output === "static"`. A **Cloudflare
162
- * server** build serves `dist/client` through the Worker's ASSETS binding, and
163
- * Workers static assets honor `_headers` from that directory — so the file
164
- * applies there too, and skipping it left every Cloudflare server build with no
165
- * `Link` header and no media type on its own discovery files. The charset half
166
- * of this file *is* redundant on a server build, because the runtime endpoint
167
- * sets Content-Type on the Response itself; the `Link` and well-known halves are
168
- * not, and one conclusion about the first was applied to all three.
169
- *
170
- * Exported for the test suite, which exercises it in a subprocess like the
171
- * other command helpers.
172
- */
173
- export const emitHeaderFiles = async (
174
- project: BlumeProject,
175
- distDir: string
176
- ): Promise<void> => {
177
- const { config } = project;
178
- if (
179
- !readsHeaderFiles(config.deployment) ||
180
- existsSync(join(project.context.root, "public", "_headers"))
181
- ) {
182
- return;
183
- }
184
- const ours = buildNetlifyHeaders(
185
- config,
186
- buildHomeLinkHeader(config, markdownRoutePaths(project))
187
- );
188
- // An adapter may have written its own rules here already (Cloudflare adds an
189
- // immutable Cache-Control for /_astro/*). Keep them and append ours: both
190
- // sets are wanted, and `_headers` has no merge semantics beyond order.
191
- const target = join(distDir, "_headers");
192
- const existing = existsSync(target) ? await readFile(target, "utf-8") : "";
193
- await writeFile(
194
- target,
195
- existing ? `${existing.trimEnd()}\n${ours}` : ours,
196
- "utf-8"
197
- );
198
- logger.success(
199
- "Emitted _headers (UTF-8 Content-Type + homepage Link header)"
200
- );
201
- };
202
-
203
- /**
204
- * Collect the Agent Skills `ai.skills` publishes, once per build, so both the
205
- * skills surface and llms.txt (which lists them) read the same set. Empty
206
- * when the feature is off, the directory is missing, nothing in it is
207
- * publishable (each with a warning), or a user-shipped
208
- * `public/.well-known/agent-skills/index.json` already owns the surface.
209
- */
210
- const collectConfiguredSkills = async (
211
- project: BlumeProject,
212
- distDir: string
213
- ): Promise<SkillArtifact[]> => {
214
- const configured = project.config.ai.skills;
215
- if (!configured) {
216
- return [];
217
- }
218
- const dir = resolve(project.context.root, configured);
219
- if (!existsSync(dir)) {
220
- logger.warn(
221
- `ai.skills points at "${configured}" (${dir}), which does not exist; no skills published.`
222
- );
223
- return [];
224
- }
225
- if (existsSync(join(distDir, AGENT_SKILLS_DIR.slice(1), "index.json"))) {
226
- return [];
227
- }
228
- const { skills, warnings } = await collectSkills(dir);
229
- for (const warning of warnings) {
230
- logger.warn(warning);
231
- }
232
- if (skills.length === 0) {
233
- logger.warn(`ai.skills: no publishable skills found in "${configured}".`);
234
- }
235
- return skills;
236
- };
237
-
238
- /**
239
- * Publish the collected Agent Skills: copy each skill artifact under
240
- * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
241
- * `public/.well-known/agent-skills/index.json` takes over the whole surface
242
- * (the collector returns nothing then), matching every other generated
243
- * artifact.
244
- */
245
- const emitAgentSkills = async (
246
- project: BlumeProject,
247
- distDir: string,
248
- skills: readonly SkillArtifact[]
249
- ): Promise<void> => {
250
- if (skills.length === 0) {
251
- return;
252
- }
253
- const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
254
- await Promise.all(
255
- skills.map(async (skill) => {
256
- const target = join(outDir, skill.path);
257
- await mkdir(dirname(target), { recursive: true });
258
- await writeFile(target, skill.content);
259
- })
260
- );
261
- await writeFile(
262
- join(outDir, "index.json"),
263
- buildSkillsIndex(skills, project.config),
264
- "utf-8"
265
- );
266
- logger.success(
267
- `Published ${skills.length} agent skill(s) (.well-known/agent-skills/index.json)`
268
- );
269
- };
270
-
271
- /**
272
- * Emit the generated `.well-known` discovery files — the RFC 9727 API catalog
273
- * and the Web Bot Auth signature directory — each skipped when the feature is
274
- * off or when the user ships their own copy via public/ (already in dist by
275
- * the time this runs).
276
- */
277
- const emitWellKnownFiles = async (
278
- config: ResolvedConfig,
279
- distDir: string
280
- ): Promise<void> => {
281
- const files = [
282
- {
283
- content: buildSignaturesDirectory(config),
284
- label: "Web Bot Auth",
285
- path: SIGNATURES_DIRECTORY_PATH,
286
- },
287
- {
288
- content: buildApiCatalog(config),
289
- label: "RFC 9727",
290
- path: API_CATALOG_PATH,
291
- },
292
- ];
293
- for (const file of files) {
294
- const target = join(distDir, file.path.slice(1));
295
- if (!file.content || existsSync(target)) {
296
- continue;
297
- }
298
- // Sequential by nature: both files share the .well-known dir creation.
299
- // oxlint-disable-next-line no-await-in-loop
300
- await mkdir(join(distDir, ".well-known"), { recursive: true });
301
- // oxlint-disable-next-line no-await-in-loop
302
- await writeFile(target, file.content, "utf-8");
303
- logger.success(`Generated ${file.path.slice(1)} (${file.label})`);
304
- }
305
- };
306
-
307
88
  /**
308
89
  * Splice `Accept: text/markdown` negotiation routes into the Vercel adapter's
309
90
  * Build Output config, so a content-page request that prefers Markdown gets the
@@ -436,6 +217,15 @@ const emitCloudflareNegotiation = async (
436
217
  contentRoutePaths: project.manifest.routes.map((route) => route.path),
437
218
  homeLinkHeader: buildHomeLinkHeader(config, routePaths),
438
219
  homeTokens: home ? markdownTokenCount(agentMarkdown(home)) : undefined,
220
+ // Exactly the per-page JSON documents the API emits (see `pageParams`):
221
+ // the non-hidden routes with agent Markdown, when the API is on.
222
+ pageJsonPaths: config.ai.api
223
+ ? project.manifest.routes
224
+ .filter(
225
+ (route) => !route.hidden && rawMarkdown[route.path] !== undefined
226
+ )
227
+ .map((route) => pageJsonPath(route.path))
228
+ : [],
439
229
  // The wrapper Worker matches full served URLs, so the redirects are
440
230
  // based the same way the platform files are — it answers any the
441
231
  // worker-first rules claim, where `_redirects` is never consulted and
@@ -459,6 +249,29 @@ const emitCloudflareNegotiation = async (
459
249
  );
460
250
  };
461
251
 
252
+ /**
253
+ * Report how many OG cards the build read back from the on-disk cache against
254
+ * how many it rendered (the endpoint tallies both), so a warm rebuild shows
255
+ * where the time went. With `prune`, also drop the cached cards this build
256
+ * never asked for — renamed pages, edited descriptions, cards from an earlier
257
+ * Blume version — so a persisted cache holds exactly the current site's cards.
258
+ */
259
+ const reportCardCache = async (
260
+ project: BlumeProject,
261
+ prune: boolean
262
+ ): Promise<void> => {
263
+ const cards = cardCacheTally();
264
+ if (!cards) {
265
+ return;
266
+ }
267
+ logger.info(
268
+ `OG cards: ${cards.hits} reused from the cache, ${cards.misses} rendered`
269
+ );
270
+ if (prune) {
271
+ await pruneCardCache(ogCacheDir(project.context));
272
+ }
273
+ };
274
+
462
275
  const formatBytes = (bytes: number): string => {
463
276
  if (bytes < 1024) {
464
277
  return `${bytes} B`;
@@ -616,118 +429,22 @@ export const isolatedStaticDir = (
616
429
  };
617
430
 
618
431
  /**
619
- * Generate `llms.txt`/`llms-full.txt` into the dist dir. A user's own file in
620
- * `public/` (copied into dist by Astro before this runs, like the sitemap and
621
- * robots.txt) wins over the generated one — each file is checked and replaced
622
- * independently, so a custom `llms.txt` still gets a generated `llms-full.txt`.
623
- */
624
- const publishLlmsFiles = async (
625
- project: BlumeProject,
626
- distDir: string,
627
- skills: readonly SkillArtifact[]
628
- ): Promise<void> => {
629
- const indexPath = join(distDir, "llms.txt");
630
- const fullPath = join(distDir, "llms-full.txt");
631
- const writeIndex = !existsSync(indexPath);
632
- const writeFull = !existsSync(fullPath);
633
- if (!(writeIndex || writeFull)) {
634
- return;
635
- }
636
- const { index, full } = await buildLlmsFiles(project, { skills });
637
- const writes: Promise<void>[] = [];
638
- if (writeIndex) {
639
- writes.push(writeFile(indexPath, index, "utf-8"));
640
- }
641
- if (writeFull) {
642
- writes.push(writeFile(fullPath, full, "utf-8"));
643
- }
644
- await Promise.all(writes);
645
- logger.success(
646
- `Generated ${[
647
- writeIndex ? "llms.txt" : null,
648
- writeFull ? "llms-full.txt" : null,
649
- ]
650
- .filter(Boolean)
651
- .join(" and ")}`
652
- );
653
- };
654
-
655
- /**
656
- * Run every deploy post-step of a real (non-isolated) build: the search index +
657
- * hosted-provider sync, llms.txt, sitemap/robots, redirect files, the summary
658
- * box, and the optional bundle report / budget gate. Exits non-zero if a budget
659
- * is exceeded. Isolated verify builds skip all of this except the bundle
660
- * report / budget gate, which they run against their own output.
432
+ * Print the build summary box and run the optional bundle report / budget
433
+ * gate against the served static dir. The deploy artifacts themselves
434
+ * (search index, llms.txt, sitemap, robots, redirect and header files, …)
435
+ * were written by the integration's `astro:build:done` hook during
436
+ * `build()` — see `deploy/artifacts.ts`. Exits non-zero if a budget is
437
+ * exceeded.
661
438
  */
662
- const publishBuildArtifacts = async (
439
+ const reportBuild = async (
663
440
  project: BlumeProject,
664
441
  distDir: string,
665
442
  args: { analyze?: boolean } & BudgetArgs
666
443
  ): Promise<void> => {
667
- if (project.config.search.provider === "pagefind") {
668
- logger.start("Building search index");
669
- const indexed = await buildSearchIndex(distDir);
670
- logger.success(`Indexed ${indexed} page(s) for search`);
671
- }
672
-
673
- // Upload the index to a hosted provider (Algolia, Orama Cloud, Typesense).
674
- // Skipped with a warning when its admin key isn't configured.
675
- await syncSearchProvider(project, {
676
- start: (message) => logger.start(message),
677
- success: (message) => logger.success(message),
678
- warn: (message) => logger.warn(message),
679
- });
680
-
681
- // Collected once: llms.txt lists the skills the build publishes below.
682
- const skills = await collectConfiguredSkills(project, distDir);
683
- if (project.config.ai.llmsTxt.enabled) {
684
- await publishLlmsFiles(project, distDir, skills);
685
- }
686
-
687
- // A user's own public/ file (copied into dist by Astro) always wins.
688
- const sitemapFiles = buildSitemapFiles(project);
689
- if (sitemapFiles && !existsSync(join(distDir, "sitemap.xml"))) {
690
- await Promise.all(
691
- sitemapFiles.map((file) =>
692
- writeFile(join(distDir, file.name), file.xml, "utf-8")
693
- )
694
- );
695
- logger.success(
696
- sitemapFiles.length === 1
697
- ? "Generated sitemap.xml"
698
- : `Generated sitemap.xml (index of ${sitemapFiles.length - 1} sitemap files)`
699
- );
700
- }
701
-
702
- const robots = buildRobots(project);
703
- if (robots && !existsSync(join(distDir, "robots.txt"))) {
704
- await writeFile(join(distDir, "robots.txt"), robots, "utf-8");
705
- logger.success("Generated robots.txt");
706
- }
707
-
708
- const agentReadability = buildAgentReadability(project);
709
- if (
710
- agentReadability &&
711
- !existsSync(join(distDir, "agent-readability.json"))
712
- ) {
713
- await writeFile(
714
- join(distDir, "agent-readability.json"),
715
- `${JSON.stringify(agentReadability, null, 2)}\n`,
716
- "utf-8"
717
- );
718
- logger.success("Generated agent-readability.json");
719
- }
720
-
721
- await emitWellKnownFiles(project.config, distDir);
722
- await emitAgentSkills(project, distDir, skills);
723
-
724
- await emitRedirectFiles(project.config, distDir);
725
- await emitHeaderFiles(project, distDir);
726
-
727
444
  const { config } = project;
728
445
  const features = serverFeatures(config);
729
- // `buildSitemapFiles` returns null both when the sitemap is disabled and when no
730
- // `site` is configured — only the latter deserves the remediation hint.
446
+ // The sitemap needs both the flag and a `site` (absolute URLs) — only the
447
+ // latter deserves the remediation hint.
731
448
  const sitemapNote = config.seo.sitemap
732
449
  ? "no (set deployment.site)"
733
450
  : "no (seo.sitemap is false)";
@@ -738,9 +455,9 @@ const publishBuildArtifacts = async (
738
455
  `Site ${config.deployment.site ?? "not set"}`,
739
456
  `Search ${config.search.provider}`,
740
457
  `Redirects ${config.redirects.length}`,
741
- `Sitemap ${sitemapFiles ? "yes" : sitemapNote}`,
742
- `Robots ${robots ? "yes" : "no"}`,
743
- `Agent JSON ${agentReadability ? "yes" : "no"}`,
458
+ `Sitemap ${config.deployment.site && config.seo.sitemap ? "yes" : sitemapNote}`,
459
+ `Robots ${config.seo.robots ? "yes" : "no"}`,
460
+ `Agent JSON ${config.seo.agentReadability ? "yes" : "no"}`,
744
461
  `LLM files ${config.ai.llmsTxt.enabled ? "yes" : "no"}`,
745
462
  `Server features ${features.length > 0 ? features.join(", ") : "none"}`,
746
463
  ].join("\n")
@@ -800,10 +517,7 @@ export const buildCommand = defineCommand({
800
517
  type: "boolean",
801
518
  },
802
519
  },
803
- meta: {
804
- description: "Build the docs site for production.",
805
- name: "build",
806
- },
520
+ meta: commandMeta.build,
807
521
  async run({ args }) {
808
522
  const root = process.cwd();
809
523
 
@@ -852,18 +566,28 @@ export const buildCommand = defineCommand({
852
566
  `Building ${project.graph.pages.length} page(s) (${project.config.deployment.output} output)`
853
567
  );
854
568
 
569
+ // Hand the scanned project to the integration: its `astro:build:done`
570
+ // hook writes the deploy artifacts (search index, llms.txt, sitemap, …)
571
+ // into Astro's client output during the build. An isolated build is a
572
+ // throwaway verify that only needs to confirm the site compiles and
573
+ // renders, so it publishes nothing — no network post-steps (a hosted
574
+ // search sync would push), no deploy artifacts.
575
+ if (!runtimeDir) {
576
+ publishBuildProject(project);
577
+ }
578
+
855
579
  await build({
856
580
  logLevel: "info",
857
581
  root: project.context.outDir,
858
582
  });
859
583
 
860
- // An isolated build is a throwaway verify: it only needs to confirm the site
861
- // compiles and renders. Skip the network post-steps (search sync) and
862
- // deploy artifacts (index/llms/sitemap/robots/redirects) that only matter
863
- // for a real publish and would push to hosted providers. The bundle report
864
- // and budget gate still run, though — `blume build --isolated --budget-js
865
- // 100` exiting 0 without measuring anything would be a silent false pass
866
- // in CI.
584
+ // A real build also prunes the cache; an isolated verify must not evict
585
+ // cards a live dev server is still serving.
586
+ await reportCardCache(project, !runtimeDir);
587
+
588
+ // The bundle report and budget gate still run for an isolated build —
589
+ // `blume build --isolated --budget-js 100` exiting 0 without measuring
590
+ // anything would be a silent false pass in CI.
867
591
  if (runtimeDir) {
868
592
  if (
869
593
  project.config.deployment.output === "server" &&
@@ -918,7 +642,7 @@ export const buildCommand = defineCommand({
918
642
  await emitCloudflareNegotiation(project, markdownRoutePaths(project));
919
643
  }
920
644
 
921
- await publishBuildArtifacts(
645
+ await reportBuild(
922
646
  project,
923
647
  deployStaticDir(project.config, project.context),
924
648
  args
@@ -6,6 +6,7 @@ import { defineCommand } from "citty";
6
6
  import { join } from "pathe";
7
7
 
8
8
  import { ensureGitignore } from "../../core/gitignore.ts";
9
+ import { commandMeta } from "../command-meta.ts";
9
10
  import { refuseIfDevRunning } from "../dev-lock.ts";
10
11
  import { logger } from "../log.ts";
11
12
  import { prepareProject } from "../prepare.ts";
@@ -26,10 +27,7 @@ export const checkCommand = defineCommand({
26
27
  type: "boolean",
27
28
  },
28
29
  },
29
- meta: {
30
- description: "Type-check the docs site with astro check.",
31
- name: "check",
32
- },
30
+ meta: commandMeta.check,
33
31
  async run({ args }) {
34
32
  const root = process.cwd();
35
33