blume 0.6.7 → 0.8.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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -11,11 +11,13 @@ import { ensureGitignore } from "../../core/gitignore.ts";
11
11
  import type { BlumeProject } from "../../core/project-graph.ts";
12
12
  import type { ResolvedConfig } from "../../core/schema.ts";
13
13
  import { serverFeatures } from "../../core/server-features.ts";
14
+ import type { ProjectContext } from "../../core/types.ts";
14
15
  import {
15
16
  deployStaticDir,
16
17
  surfaceAdapterOutput,
17
18
  } from "../../deploy/adapter-output.ts";
18
19
  import {
20
+ applyBaseToRedirects,
19
21
  buildNetlifyRedirects,
20
22
  buildRedirectManifest,
21
23
  buildVercelConfig,
@@ -30,18 +32,27 @@ import { prepareProject } from "../prepare.ts";
30
32
 
31
33
  const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
32
34
 
35
+ const BUDGET_JS = "budget-js";
36
+ const BUDGET_CSS = "budget-css";
37
+
38
+ interface BudgetArgs {
39
+ "budget-css"?: string;
40
+ "budget-js"?: string;
41
+ }
42
+
33
43
  /**
34
44
  * Reject a non-numeric performance budget. `Number("250kb")` is `NaN` and
35
45
  * `total > NaN` is always false, so a typo'd flag would silently pass the gate;
36
46
  * fail up front instead.
37
47
  */
38
- const validateBudgetFlags = (args: {
39
- "budget-css"?: string;
40
- "budget-js"?: string;
41
- }): void => {
42
- for (const flag of ["budget-js", "budget-css"] as const) {
48
+ const validateBudgetFlags = (args: BudgetArgs): void => {
49
+ for (const flag of [BUDGET_JS, BUDGET_CSS] as const) {
43
50
  const value = args[flag];
44
- if (value !== undefined && !(Number(value) > 0)) {
51
+ const parsed = Number(value);
52
+ // Equivalent to `!(parsed > 0)` but without the inverted check: this must
53
+ // also reject `NaN` (a typo'd flag like "250kb"), which `parsed <= 0` alone
54
+ // would let through since `NaN <= 0` is false.
55
+ if (value !== undefined && (Number.isNaN(parsed) || parsed <= 0)) {
45
56
  logger.error(
46
57
  `Invalid --${flag} "${value}" (expected a positive number of kB).`
47
58
  );
@@ -59,7 +70,7 @@ const emitRedirectFiles = async (
59
70
  config: ResolvedConfig,
60
71
  distDir: string
61
72
  ): Promise<void> => {
62
- const { redirects } = config;
73
+ const redirects = applyBaseToRedirects(config.redirects, config.basePath);
63
74
  if (redirects.length === 0 || config.deployment.output !== "static") {
64
75
  return;
65
76
  }
@@ -82,10 +93,13 @@ const emitRedirectFiles = async (
82
93
  logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
83
94
  };
84
95
 
85
- const formatBytes = (bytes: number): string =>
86
- bytes < 1024
87
- ? `${bytes} B`
88
- : `${(bytes / 1024).toFixed(bytes < 1024 * 100 ? 1 : 0)} kB`;
96
+ const formatBytes = (bytes: number): string => {
97
+ if (bytes < 1024) {
98
+ return `${bytes} B`;
99
+ }
100
+ const digits = bytes < 1024 * 100 ? 1 : 0;
101
+ return `${(bytes / 1024).toFixed(digits)} kB`;
102
+ };
89
103
 
90
104
  /** Sizes of `dist/_astro/*.<ext>`, largest first (empty when none exist). */
91
105
  const astroAssets = async (
@@ -144,14 +158,14 @@ const reportBundleSizes = async (distDir: string): Promise<void> => {
144
158
  */
145
159
  const enforceBudget = async (
146
160
  distDir: string,
147
- args: { "budget-css"?: string; "budget-js"?: string }
161
+ args: BudgetArgs
148
162
  ): Promise<"fail" | "pass" | "skip"> => {
149
163
  const checks: { ext: string; limitKb: number; name: string }[] = [
150
- ...(args["budget-js"]
151
- ? [{ ext: "js", limitKb: Number(args["budget-js"]), name: "JavaScript" }]
164
+ ...(args[BUDGET_JS]
165
+ ? [{ ext: "js", limitKb: Number(args[BUDGET_JS]), name: "JavaScript" }]
152
166
  : []),
153
- ...(args["budget-css"]
154
- ? [{ ext: "css", limitKb: Number(args["budget-css"]), name: "CSS" }]
167
+ ...(args[BUDGET_CSS]
168
+ ? [{ ext: "css", limitKb: Number(args[BUDGET_CSS]), name: "CSS" }]
155
169
  : []),
156
170
  ];
157
171
  if (checks.length === 0) {
@@ -176,16 +190,94 @@ const enforceBudget = async (
176
190
  return passed ? "pass" : "fail";
177
191
  };
178
192
 
193
+ /**
194
+ * Run the optional bundle report (`--analyze`) and performance-budget gate
195
+ * against the directory whose `_astro/` client assets the deploy serves.
196
+ * Shared by real and isolated builds — an isolated CI run passing
197
+ * `--budget-js` must still fail on an exceeded budget rather than silently
198
+ * skipping the check. Exits non-zero when a budget is exceeded.
199
+ */
200
+ export const runClientAssetChecks = async (
201
+ staticDir: string,
202
+ args: { analyze?: boolean } & BudgetArgs
203
+ ): Promise<void> => {
204
+ if (args.analyze) {
205
+ await reportBundleSizes(staticDir);
206
+ }
207
+ if ((await enforceBudget(staticDir, args)) === "fail") {
208
+ process.exit(1);
209
+ }
210
+ };
211
+
212
+ /**
213
+ * Directory holding an isolated build's client `_astro/` assets. Mirrors
214
+ * `deployStaticDir`, except that an isolated build never surfaces the adapter
215
+ * bundle to the project root — a Vercel server build's static output stays at
216
+ * `<runtime>/.vercel/output/static`, where `deployStaticDir` would instead
217
+ * point at the project-root copy (a previous real build's assets, or nothing).
218
+ */
219
+ export const isolatedStaticDir = (
220
+ config: ResolvedConfig,
221
+ context: ProjectContext
222
+ ): string => {
223
+ const { adapter, output } = config.deployment;
224
+ if (output === "server" && adapter === "vercel") {
225
+ return join(context.outDir, ".vercel", "output", "static");
226
+ }
227
+ const dist = context.distDir ?? join(context.outDir, "dist");
228
+ if (output === "server" && adapter === "node") {
229
+ return join(dist, "client");
230
+ }
231
+ return dist;
232
+ };
233
+
234
+ /**
235
+ * Generate `llms.txt`/`llms-full.txt` into the dist dir. A user's own file in
236
+ * `public/` (copied into dist by Astro before this runs, like the sitemap and
237
+ * robots.txt) wins over the generated one — each file is checked and replaced
238
+ * independently, so a custom `llms.txt` still gets a generated `llms-full.txt`.
239
+ */
240
+ const publishLlmsFiles = async (
241
+ project: BlumeProject,
242
+ distDir: string
243
+ ): Promise<void> => {
244
+ const indexPath = join(distDir, "llms.txt");
245
+ const fullPath = join(distDir, "llms-full.txt");
246
+ const writeIndex = !existsSync(indexPath);
247
+ const writeFull = !existsSync(fullPath);
248
+ if (!(writeIndex || writeFull)) {
249
+ return;
250
+ }
251
+ const { index, full } = await buildLlmsFiles(project);
252
+ const writes: Promise<void>[] = [];
253
+ if (writeIndex) {
254
+ writes.push(writeFile(indexPath, index, "utf-8"));
255
+ }
256
+ if (writeFull) {
257
+ writes.push(writeFile(fullPath, full, "utf-8"));
258
+ }
259
+ await Promise.all(writes);
260
+ logger.success(
261
+ `Generated ${[
262
+ writeIndex ? "llms.txt" : null,
263
+ writeFull ? "llms-full.txt" : null,
264
+ ]
265
+ .filter(Boolean)
266
+ .join(" and ")}`
267
+ );
268
+ };
269
+
179
270
  /**
180
271
  * Run every deploy post-step of a real (non-isolated) build: the search index +
181
272
  * hosted-provider sync, llms.txt, sitemap/robots, redirect files, the summary
182
273
  * box, and the optional bundle report / budget gate. Exits non-zero if a budget
183
- * is exceeded. Isolated verify builds skip all of this.
274
+ * is exceeded. Isolated verify builds skip all of this except the bundle
275
+ * report / budget gate, which they run against their own output.
184
276
  */
185
277
  const publishBuildArtifacts = async (
186
278
  project: BlumeProject,
187
279
  distDir: string,
188
- args: { analyze?: boolean; "budget-css"?: string; "budget-js"?: string }
280
+ args: { analyze?: boolean } & BudgetArgs
189
281
  ): Promise<void> => {
190
282
  if (project.config.search.provider === "pagefind") {
191
283
  logger.start("Building search index");
@@ -201,13 +293,8 @@ const publishBuildArtifacts = async (
201
293
  warn: (message) => logger.warn(message),
202
294
  });
203
295
 
204
- if (project.config.ai.llmsTxt) {
205
- const { index, full } = await buildLlmsFiles(project);
206
- await Promise.all([
207
- writeFile(join(distDir, "llms.txt"), index, "utf-8"),
208
- writeFile(join(distDir, "llms-full.txt"), full, "utf-8"),
209
- ]);
210
- logger.success("Generated llms.txt and llms-full.txt");
296
+ if (project.config.ai.llmsTxt.enabled) {
297
+ await publishLlmsFiles(project, distDir);
211
298
  }
212
299
 
213
300
  // A user's own public/ file (copied into dist by Astro) always wins.
@@ -240,6 +327,11 @@ const publishBuildArtifacts = async (
240
327
 
241
328
  const { config } = project;
242
329
  const features = serverFeatures(config);
330
+ // `buildSitemap` returns null both when the sitemap is disabled and when no
331
+ // `site` is configured — only the latter deserves the remediation hint.
332
+ const sitemapNote = config.seo.sitemap
333
+ ? "no (set deployment.site)"
334
+ : "no (seo.sitemap is false)";
243
335
  logger.box(
244
336
  [
245
337
  `Output ${config.deployment.output}`,
@@ -247,21 +339,15 @@ const publishBuildArtifacts = async (
247
339
  `Site ${config.deployment.site ?? "not set"}`,
248
340
  `Search ${config.search.provider}`,
249
341
  `Redirects ${config.redirects.length}`,
250
- `Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
342
+ `Sitemap ${sitemap ? "yes" : sitemapNote}`,
251
343
  `Robots ${robots ? "yes" : "no"}`,
252
344
  `Agent JSON ${agentReadability ? "yes" : "no"}`,
253
- `LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
345
+ `LLM files ${config.ai.llmsTxt.enabled ? "yes" : "no"}`,
254
346
  `Server features ${features.length > 0 ? features.join(", ") : "none"}`,
255
347
  ].join("\n")
256
348
  );
257
349
 
258
- if (args.analyze) {
259
- await reportBundleSizes(distDir);
260
- }
261
-
262
- if ((await enforceBudget(distDir, args)) === "fail") {
263
- process.exit(1);
264
- }
350
+ await runClientAssetChecks(distDir, args);
265
351
 
266
352
  logger.success(`Built to ${distDir}`);
267
353
  };
@@ -280,11 +366,11 @@ export const buildCommand = defineCommand({
280
366
  description: "Base path the site is served under (e.g. /docs).",
281
367
  type: "string",
282
368
  },
283
- "budget-css": {
369
+ [BUDGET_CSS]: {
284
370
  description: "Fail if total client CSS exceeds this many kB.",
285
371
  type: "string",
286
372
  },
287
- "budget-js": {
373
+ [BUDGET_JS]: {
288
374
  description: "Fail if total client JavaScript exceeds this many kB.",
289
375
  type: "string",
290
376
  },
@@ -317,7 +403,7 @@ export const buildCommand = defineCommand({
317
403
  const runtimeDir = args.isolated
318
404
  ? ".blume-verify"
319
405
  : process.env.BLUME_RUNTIME_DIR;
320
- refuseIfDevRunning(root, "building", runtimeDir);
406
+ refuseIfDevRunning(root, "building", { isolatedHint: true, runtimeDir });
321
407
  if (args.isolated) {
322
408
  await ensureGitignore(root, [".blume-verify/"]);
323
409
  }
@@ -361,8 +447,15 @@ export const buildCommand = defineCommand({
361
447
  // An isolated build is a throwaway verify: it only needs to confirm the site
362
448
  // compiles and renders. Skip the network post-steps (search sync) and
363
449
  // deploy artifacts (index/llms/sitemap/robots/redirects) that only matter
364
- // for a real publish and would push to hosted providers.
450
+ // for a real publish and would push to hosted providers. The bundle report
451
+ // and budget gate still run, though — `blume build --isolated --budget-js
452
+ // 100` exiting 0 without measuring anything would be a silent false pass
453
+ // in CI.
365
454
  if (runtimeDir) {
455
+ await runClientAssetChecks(
456
+ isolatedStaticDir(project.config, project.context),
457
+ args
458
+ );
366
459
  logger.success(
367
460
  `Isolated build OK — output at ${distDir} (not published).`
368
461
  );
@@ -39,7 +39,7 @@ export const checkCommand = defineCommand({
39
39
  const runtimeDir = args.isolated
40
40
  ? ".blume-verify"
41
41
  : process.env.BLUME_RUNTIME_DIR;
42
- refuseIfDevRunning(root, "checking", runtimeDir);
42
+ refuseIfDevRunning(root, "checking", { isolatedHint: true, runtimeDir });
43
43
  if (args.isolated) {
44
44
  await ensureGitignore(root, [".blume-verify/"]);
45
45
  }
@@ -15,9 +15,35 @@ import {
15
15
  DevLockHeldError,
16
16
  updateDevLockPort,
17
17
  } from "../dev-lock.ts";
18
- import { logger } from "../log.ts";
18
+ import { logger, reportDiagnostics } from "../log.ts";
19
19
  import { prepareProject } from "../prepare.ts";
20
20
 
21
+ /**
22
+ * Resolve a `--host` flag value into what Astro/Vite's `server.host` expects.
23
+ * citty (0.1) has no mixed string/boolean arg type, so `host` is declared as a
24
+ * string and a bare `--host` parses as `""` — Node would bind all interfaces
25
+ * for `""`, but Vite's `resolveHostname` treats it as a literal hostname and
26
+ * prints malformed URLs like `http://:4321/`. Match Astro's own `--host`
27
+ * semantics instead: bare flag → `true` (bind all interfaces), `--host
28
+ * 10.0.0.1` → that address, absent → `false` (localhost only).
29
+ */
30
+ export const normalizeHost = (host: string | undefined): boolean | string =>
31
+ host === "" ? true : (host ?? false);
32
+
33
+ /**
34
+ * A fingerprint of the route set: the sorted `path entryId` pairs. It changes
35
+ * when a page is added, removed, or renamed (a folder rename shifts many at
36
+ * once) but stays equal across pure body edits — so the dev loop can tell a
37
+ * "structural" change (needs a cold restart) from a hot-reloadable one.
38
+ */
39
+ const routeSignature = (
40
+ routes: readonly { entryId: string; path: string }[]
41
+ ): string =>
42
+ routes
43
+ .map((route) => `${route.path} ${route.entryId}`)
44
+ .toSorted()
45
+ .join("\n");
46
+
21
47
  export const devCommand = defineCommand({
22
48
  args: {
23
49
  "content-dir": {
@@ -84,15 +110,18 @@ export const devCommand = defineCommand({
84
110
  strict: args.strict,
85
111
  });
86
112
 
87
- const server = await dev({
88
- logLevel: args.debug ? "debug" : "info",
89
- root: project.context.outDir,
90
- server: {
91
- host: args.host ?? false,
92
- open: args.open ?? false,
93
- port: explicitPort,
94
- },
95
- });
113
+ // A factory so `runRegenerate` can recreate the server on a structural
114
+ // (route-set) change: only a cold container re-globs Astro's content store,
115
+ // which its in-place config restart doesn't. `open` is honored on first
116
+ // boot only — a restart must not reopen the browser.
117
+ const createServer = (listenPort: number | undefined, open: boolean) =>
118
+ dev({
119
+ logLevel: args.debug ? "debug" : "info",
120
+ root: project.context.outDir,
121
+ server: { host: normalizeHost(args.host), open, port: listenPort },
122
+ });
123
+
124
+ let server = await createServer(explicitPort, args.open ?? false);
96
125
 
97
126
  // Vite bumps to the next free port when the default is taken, so record
98
127
  // the port the server actually bound — the lock's URL is what a refused
@@ -109,11 +138,18 @@ export const devCommand = defineCommand({
109
138
  // (and its HMR channel) is up.
110
139
  showBlumeErrorOverlay(project.diagnostics);
111
140
 
112
- // Watch user inputs and regenerate the runtime data on change. Astro/Vite
113
- // hot-reloads the generated data module so nav and routes stay in sync.
114
- // `coalescedRunner` single-flights the scan so a burst of watch events can
115
- // never stack overlapping regenerations (a large project's scan can outlast
116
- // the debounce; piled-up scans exhaust the heap).
141
+ let lastSignature = routeSignature(project.manifest.routes);
142
+
143
+ // Watch user inputs and regenerate the runtime data on change. A body edit
144
+ // hot-reloads via Vite (fast path). A route-set change instead forces a cold
145
+ // server restart: Astro's in-place content sync never re-globs on a Blume
146
+ // route change (it strips `integrations` from its cache digest) and its glob
147
+ // watcher misses directory renames, so a renamed page 404s (`getEntry` reads
148
+ // a stale in-memory store) until the server is restarted. We restart it
149
+ // ourselves — stop, regenerate while down (no watcher races), then bring up
150
+ // a fresh container whose cold sync re-globs everything. `coalescedRunner`
151
+ // single-flights the scan so a burst of watch events can never stack
152
+ // overlapping regenerations (piled-up scans exhaust the heap).
117
153
  const runRegenerate = coalescedRunner(async () => {
118
154
  try {
119
155
  const next = await scanProject(root, {
@@ -122,8 +158,26 @@ export const devCommand = defineCommand({
122
158
  overrides,
123
159
  preview,
124
160
  });
125
- await generateRuntime(next);
126
- // Surface any content/config errors in the browser overlay too.
161
+ const nextSignature = routeSignature(next.manifest.routes);
162
+ const structural = nextSignature !== lastSignature;
163
+ if (structural) {
164
+ await server.stop();
165
+ await generateRuntime(next);
166
+ server = await createServer(boundPort, false);
167
+ } else {
168
+ await generateRuntime(next);
169
+ }
170
+ // Commit the signature only after the (re)generation succeeded. If the
171
+ // restart above throws mid-sequence, the signature stays stale so the
172
+ // next watch event retries the structural path — committing early would
173
+ // route it to the non-structural branch with the server still down.
174
+ lastSignature = nextSignature;
175
+ // Surface any content/config errors in the terminal AND the browser
176
+ // overlay. The terminal report must not be skipped: on a published
177
+ // install the CLI bundle holds its own copy of the integration module,
178
+ // separate from the Vite module graph that registers the overlay, so
179
+ // the overlay call below can be a no-op there.
180
+ reportDiagnostics(next.diagnostics, root);
127
181
  showBlumeErrorOverlay(next.diagnostics);
128
182
  } catch (error) {
129
183
  logger.error(`Regeneration failed: ${(error as Error).message}`);
@@ -34,8 +34,8 @@ const minSupportedNode = (): string => {
34
34
  };
35
35
 
36
36
  const versionBelow = (current: string, minimum: string): boolean => {
37
- const a = current.split(".").map((part) => Number.parseInt(part, 10));
38
- const b = minimum.split(".").map((part) => Number.parseInt(part, 10));
37
+ const a = current.split(".").map((part) => Math.trunc(Number(part)));
38
+ const b = minimum.split(".").map((part) => Math.trunc(Number(part)));
39
39
  for (let i = 0; i < 3; i += 1) {
40
40
  const delta = (a[i] ?? 0) - (b[i] ?? 0);
41
41
  if (delta !== 0) {
@@ -1,28 +1,32 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
-
3
1
  import { defineCommand } from "citty";
4
- import { join, relative } from "pathe";
2
+ import { relative } from "pathe";
5
3
 
4
+ import { loadConfig } from "../../core/config.ts";
6
5
  import { eject } from "../../registry/eject.ts";
7
6
  import { refuseIfDevRunning } from "../dev-lock.ts";
7
+ import {
8
+ droppedArtifactNotices,
9
+ updatePackageScripts,
10
+ } from "../eject-scripts.ts";
11
+ import { commandsFor, detectPackageManager } from "../init/scaffold.ts";
8
12
  import { logger } from "../log.ts";
9
13
 
10
- const updatePackageScripts = async (root: string): Promise<void> => {
11
- const pkgPath = join(root, "package.json");
12
- let pkg: Record<string, unknown>;
13
- try {
14
- pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
15
- } catch {
14
+ /**
15
+ * Warn which `blume build` post-build artifacts the ejected app stops
16
+ * producing. Printed both at the confirmation (so the decision is informed)
17
+ * and after `--yes` (so a direct eject still sees it). No-op when the config
18
+ * activates none of them.
19
+ */
20
+ const reportDroppedArtifacts = (notices: string[]): void => {
21
+ if (notices.length === 0) {
16
22
  return;
17
23
  }
18
- const scripts = (pkg.scripts ?? {}) as Record<string, string>;
19
- pkg.scripts = {
20
- ...scripts,
21
- build: "astro build",
22
- dev: "astro dev",
23
- preview: "astro preview",
24
- };
25
- await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
24
+ logger.warn(
25
+ [
26
+ "The ejected build script runs plain `astro build`, which stops producing these `blume build` artifacts:",
27
+ ...notices.map((notice) => ` - ${notice}`),
28
+ ].join("\n")
29
+ );
26
30
  };
27
31
 
28
32
  export const ejectCommand = defineCommand({
@@ -37,23 +41,47 @@ export const ejectCommand = defineCommand({
37
41
  const root = process.cwd();
38
42
  refuseIfDevRunning(root, "ejecting");
39
43
 
44
+ // Config-aware drop list: only the artifacts this project actually
45
+ // produces are mentioned (e.g. the Pagefind index only for
46
+ // `search.provider: "pagefind"`).
47
+ let notices: string[] = [];
48
+ try {
49
+ const { config } = await loadConfig(root);
50
+ notices = droppedArtifactNotices(config);
51
+ } catch {
52
+ // A config that fails to load can't gate the notice; the eject itself
53
+ // surfaces the load error.
54
+ }
55
+
40
56
  if (!args.yes) {
41
57
  logger.warn(
42
58
  "Eject is one-way: it writes astro.config.mjs, src/, and (if absent) tsconfig.json, rewrites your package.json scripts, and removes .blume. An existing tsconfig.json is left untouched."
43
59
  );
60
+ reportDroppedArtifacts(notices);
44
61
  logger.info("Re-run with --yes to proceed.");
45
62
  return;
46
63
  }
47
64
 
48
- const files = await eject(root);
65
+ const { files, warnings } = await eject(root);
49
66
  await updatePackageScripts(root);
50
67
 
68
+ // The same surface as the generated-runtime path (prepare.ts): one warn
69
+ // per generation warning, e.g. a Scalar reference spec that wasn't found.
70
+ for (const warning of warnings) {
71
+ logger.warn(warning);
72
+ }
73
+
51
74
  logger.success(`Ejected ${files.length} file(s):`);
52
75
  for (const file of files) {
53
76
  process.stdout.write(` ${relative(root, file)}\n`);
54
77
  }
78
+ reportDroppedArtifacts(notices);
79
+ // Print run commands matching the user's package manager, detected the
80
+ // same way as `blume init`'s next-steps hint.
81
+ const pm = detectPackageManager(process.env.npm_config_user_agent);
82
+ const { build, dev } = commandsFor(pm);
55
83
  logger.box(
56
- "Your project is now a standalone Astro app.\n\n bun run dev\n bun run build\n\nThe blume package remains importable."
84
+ `Your project is now a standalone Astro app.\n\n ${dev}\n ${build}\n\nThe blume package remains importable.`
57
85
  );
58
86
  },
59
87
  });