blume 0.3.0 → 0.5.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 (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -4,8 +4,10 @@ import { dirname, join } from "pathe";
4
4
 
5
5
  import { askBackendRuntimeDep } from "../ai/ask.ts";
6
6
  import type { AskBackend } from "../ai/ask.ts";
7
+ import { resolveAssetMounts } from "../core/assets.ts";
7
8
  import type { ResolvedConfig } from "../core/schema.ts";
8
9
  import type { ProjectContext } from "../core/types.ts";
10
+ import { hasScalarReferences } from "../openapi/references.ts";
9
11
  import { searchProviderMeta } from "../search/providers.ts";
10
12
  import { buildFontEntries } from "../theme/fonts.ts";
11
13
  import type { ExampleSpec } from "./examples.ts";
@@ -98,9 +100,10 @@ export const runtimeDependencies = (options: {
98
100
  if (needsSvelte) {
99
101
  deps.push("@astrojs/svelte");
100
102
  }
101
- // The Scalar integration is only declared when an API reference is configured,
102
- // so projects that don't use it never pull it into the runtime.
103
- if (config.openapi.enabled || config.asyncapi.enabled) {
103
+ // The Scalar integration is only declared for a Scalar-rendered reference
104
+ // (the `renderer: "scalar"` fallback, or AsyncAPI). Blume-rendered OpenAPI
105
+ // parses at generate time and needs no runtime Scalar dependency.
106
+ if (hasScalarReferences(config)) {
104
107
  deps.push("@scalar/astro");
105
108
  }
106
109
  // Only the configured search provider's SDK is declared, so a project pulls in
@@ -177,6 +180,10 @@ const renderUserAliases = (
177
180
  )
178
181
  .join("");
179
182
 
183
+ /** Astro's build output dir: the runtime's own `distDir`, else `<root>/dist`. */
184
+ const astroOutDir = (context: ProjectContext): string =>
185
+ context.distDir ?? `${context.root}/dist`;
186
+
180
187
  export const astroConfigTemplate = (options: {
181
188
  context: ProjectContext;
182
189
  config: ResolvedConfig;
@@ -189,6 +196,7 @@ export const astroConfigTemplate = (options: {
189
196
  examplesPath: string;
190
197
  themePath: string;
191
198
  searchClientPath: string;
199
+ openapiPath: string;
192
200
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
193
201
  aliases?: Record<string, string>;
194
202
  }): string => {
@@ -198,6 +206,7 @@ export const astroConfigTemplate = (options: {
198
206
  examplesPath,
199
207
  needsSvelte,
200
208
  needsVue,
209
+ openapiPath,
201
210
  searchClientPath,
202
211
  } = options;
203
212
  const { deployment } = config;
@@ -304,10 +313,12 @@ export const astroConfigTemplate = (options: {
304
313
  if (needsSvelte) {
305
314
  integrations.push("svelte()");
306
315
  }
307
- // Always mounted: injects user pages (a no-op when there are none) and wires
308
- // up dev-server `Accept: text/markdown` negotiation over the content routes.
316
+ // Always mounted: injects user pages (a no-op when there are none), serves
317
+ // `content.assets` mounts, and wires up dev-server `Accept: text/markdown`
318
+ // negotiation over the content routes.
319
+ const assets = resolveAssetMounts(context.root, config.content.assets);
309
320
  integrations.push(
310
- `blumeIntegration(${JSON.stringify({ contentRoutes, pages })})`
321
+ `blumeIntegration(${JSON.stringify({ assets, base: deployment.base, contentRoutes, pages })})`
311
322
  );
312
323
 
313
324
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
@@ -319,7 +330,7 @@ ${twoslashImport}${reactImport}${vueImport}${svelteImport}${blumeImport}${adapte
319
330
  export default defineConfig({
320
331
  root: ${JSON.stringify(context.outDir)},
321
332
  srcDir: ${JSON.stringify(`${context.outDir}/src`)},
322
- outDir: ${JSON.stringify(`${context.root}/dist`)},
333
+ outDir: ${JSON.stringify(astroOutDir(context))},
323
334
  publicDir: ${JSON.stringify(`${context.root}/public`)},
324
335
  output: ${JSON.stringify(deployment.output)},${adapterOption}${siteOption}${baseOption}${redirectsOption}${i18nOption}${fontsOption}
325
336
  integrations: [${integrations.join(", ")}],
@@ -354,6 +365,7 @@ export default defineConfig({
354
365
  alias: {
355
366
  "blume:data": ${JSON.stringify(dataPath)},
356
367
  "blume:examples": ${JSON.stringify(examplesPath)},
368
+ "blume:openapi": ${JSON.stringify(openapiPath)},
357
369
  "blume:search-client": ${JSON.stringify(searchClientPath)},
358
370
  "blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
359
371
  },
@@ -459,32 +471,49 @@ export const askEndpointTemplate = (
459
471
  if (grounded) {
460
472
  imports.push(
461
473
  'import { createAskContext } from "blume/ai/ask-context.ts";',
462
- 'import askData from "../generated/ask-data.json";'
474
+ 'import askData from "../../generated/ask-data.json";'
463
475
  );
464
476
  setup += "\nconst ground = createAskContext(askData);\n";
465
477
  }
466
- const handler = grounded
467
- ? `export const POST: APIRoute = async ({ request }) => {
468
- const { messages, page } = await request.json();
469
- const system =
470
- (await ground(messages, page)) ??
471
- "You are a helpful documentation assistant. Answer using the project's documentation.";
472
- const result = streamText({
473
- model: ${modelExpr},
474
- system,
475
- messages,
476
- });
477
- return result.toTextStreamResponse();
478
- };`
479
- : `export const POST: APIRoute = async ({ request }) => {
480
- const { messages } = await request.json();
481
- const result = streamText({
482
- model: ${modelExpr},
483
- system:
484
- "You are a helpful documentation assistant. Answer using the project's documentation.",
485
- messages,
486
- });
487
- return result.toTextStreamResponse();
478
+ // Validate the client-supplied body and cap its size. The endpoint is
479
+ // unauthenticated, so bounding message count/length limits how much a caller
480
+ // can spend against the model per request; front it with a rate limiter (or
481
+ // your provider's limits) for stronger protection.
482
+ const validate = ` const body = await request.json().catch(() => null);
483
+ const messages = body?.messages;
484
+ if (
485
+ !Array.isArray(messages) ||
486
+ messages.length === 0 ||
487
+ messages.length > 40 ||
488
+ JSON.stringify(messages).length > 24_000
489
+ ) {
490
+ return new Response("Invalid request: send 1-40 messages.", {
491
+ status: 400,
492
+ });
493
+ }`;
494
+ const stream = grounded
495
+ ? ` const system =
496
+ (await ground(messages, body.page)) ??
497
+ "You are a helpful documentation assistant. Answer using the project's documentation.";
498
+ const result = streamText({
499
+ model: ${modelExpr},
500
+ system,
501
+ messages,
502
+ });`
503
+ : ` const result = streamText({
504
+ model: ${modelExpr},
505
+ system:
506
+ "You are a helpful documentation assistant. Answer using the project's documentation.",
507
+ messages,
508
+ });`;
509
+ const handler = `export const POST: APIRoute = async ({ request }) => {
510
+ ${validate}
511
+ try {
512
+ ${stream}
513
+ return result.toTextStreamResponse();
514
+ } catch {
515
+ return new Response("Failed to generate a response.", { status: 500 });
516
+ }
488
517
  };`;
489
518
  return `// Generated by Blume. Do not edit.
490
519
  ${imports.join("\n")}
@@ -893,7 +922,10 @@ import FileTree from "blume/components/content/FileTree.astro";
893
922
  import Frame from "blume/components/content/Frame.astro";
894
923
  import GithubInfo from "blume/components/content/GithubInfo.astro";
895
924
  import Panel from "blume/components/content/Panel.astro";
925
+ import ParamField from "blume/components/content/ParamField.astro";
896
926
  import Prompt from "blume/components/content/Prompt.astro";
927
+ import RequestField from "blume/components/content/RequestField.astro";
928
+ import ResponseField from "blume/components/content/ResponseField.astro";
897
929
  import Step from "blume/components/content/Step.astro";
898
930
  import Steps from "blume/components/content/Steps.astro";
899
931
  import Tab from "blume/components/content/Tab.astro";
@@ -905,7 +937,10 @@ import TreeFile from "blume/components/content/TreeFile.astro";
905
937
  import TreeFolder from "blume/components/content/TreeFolder.astro";
906
938
  import TypeTable from "blume/components/content/TypeTable.astro";
907
939
  import Visibility from "blume/components/content/Visibility.astro";
940
+ import YouTube from "blume/components/content/YouTube.astro";
908
941
  import Icon from "blume/components/Icon.astro";
942
+ import ApiOverview from "blume/components/openapi/ApiOverview.astro";
943
+ import Operation from "blume/components/openapi/Operation.astro";
909
944
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
910
945
  import { islandComponents } from "../generated/islands.ts";
911
946
  import data from "../generated/data.json";
@@ -922,6 +957,7 @@ export const prerender = true;
922
957
  const components = {
923
958
  Accordion,
924
959
  AccordionItem,
960
+ ApiOverview,
925
961
  AutoTypeTable,
926
962
  Badge,
927
963
  Callout,
@@ -939,8 +975,12 @@ const components = {
939
975
  Frame,
940
976
  GithubInfo,
941
977
  Icon,
978
+ Operation,
942
979
  Panel,
980
+ ParamField,
943
981
  Prompt,
982
+ RequestField,
983
+ ResponseField,
944
984
  Step,
945
985
  Steps,
946
986
  Tab,
@@ -950,6 +990,7 @@ const components = {
950
990
  Tree,
951
991
  TypeTable,
952
992
  Visibility,
993
+ YouTube,
953
994
  ${mathEntry}...islandComponents,
954
995
  ...userMdx,
955
996
  };
@@ -1426,6 +1467,11 @@ declare module "blume:data" {
1426
1467
  export default data;
1427
1468
  }
1428
1469
 
1470
+ declare module "blume:openapi" {
1471
+ const specs: import("blume/openapi/model.ts").OpenApiData;
1472
+ export default specs;
1473
+ }
1474
+
1429
1475
  declare module "blume:search-client" {
1430
1476
  export const createSearch: () =>
1431
1477
  | import("blume/components/layout/search/types.ts").SearchFn
@@ -0,0 +1,23 @@
1
+ import { logger } from "./log.ts";
2
+
3
+ const MAX_PORT = 65_535;
4
+
5
+ /**
6
+ * Parse a `--port` value into a valid port number, or `undefined` when unset.
7
+ * A non-integer or out-of-range value (`--port abc` → `NaN`) exits with an
8
+ * error rather than propagating `localhost:NaN` into the dev server and the
9
+ * `deployment.site` fallback.
10
+ */
11
+ export const parsePort = (value?: string): number | undefined => {
12
+ if (value === undefined) {
13
+ return;
14
+ }
15
+ const port = Number(value);
16
+ if (!(Number.isInteger(port) && port >= 1 && port <= MAX_PORT)) {
17
+ logger.error(
18
+ `Invalid --port "${value}" (expected an integer 1-${MAX_PORT}).`
19
+ );
20
+ process.exit(1);
21
+ }
22
+ return port;
23
+ };
@@ -6,6 +6,8 @@ import { defineCommand } from "citty";
6
6
  import { join } from "pathe";
7
7
 
8
8
  import { buildLlmsFiles } from "../../ai/llms.ts";
9
+ import { ensureGitignore } from "../../core/gitignore.ts";
10
+ import type { BlumeProject } from "../../core/project-graph.ts";
9
11
  import type { ResolvedConfig } from "../../core/schema.ts";
10
12
  import { serverFeatures } from "../../core/server-features.ts";
11
13
  import {
@@ -17,11 +19,32 @@ import { buildRobots } from "../../deploy/robots.ts";
17
19
  import { buildSitemap } from "../../deploy/sitemap.ts";
18
20
  import { buildSearchIndex } from "../../search/build.ts";
19
21
  import { syncSearchProvider } from "../../search/sync/index.ts";
22
+ import { refuseIfDevRunning } from "../dev-lock.ts";
20
23
  import { logger } from "../log.ts";
21
24
  import { prepareProject } from "../prepare.ts";
22
25
 
23
26
  const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
24
27
 
28
+ /**
29
+ * Reject a non-numeric performance budget. `Number("250kb")` is `NaN` and
30
+ * `total > NaN` is always false, so a typo'd flag would silently pass the gate;
31
+ * fail up front instead.
32
+ */
33
+ const validateBudgetFlags = (args: {
34
+ "budget-css"?: string;
35
+ "budget-js"?: string;
36
+ }): void => {
37
+ for (const flag of ["budget-js", "budget-css"] as const) {
38
+ const value = args[flag];
39
+ if (value !== undefined && !(Number(value) > 0)) {
40
+ logger.error(
41
+ `Invalid --${flag} "${value}" (expected a positive number of kB).`
42
+ );
43
+ process.exit(1);
44
+ }
45
+ }
46
+ };
47
+
25
48
  /**
26
49
  * Emit platform redirect files for a static build (adapters wire redirects
27
50
  * natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
@@ -148,6 +171,82 @@ const enforceBudget = async (
148
171
  return passed ? "pass" : "fail";
149
172
  };
150
173
 
174
+ /**
175
+ * Run every deploy post-step of a real (non-isolated) build: the search index +
176
+ * hosted-provider sync, llms.txt, sitemap/robots, redirect files, the summary
177
+ * box, and the optional bundle report / budget gate. Exits non-zero if a budget
178
+ * is exceeded. Isolated verify builds skip all of this.
179
+ */
180
+ const publishBuildArtifacts = async (
181
+ project: BlumeProject,
182
+ distDir: string,
183
+ args: { analyze?: boolean; "budget-css"?: string; "budget-js"?: string }
184
+ ): Promise<void> => {
185
+ if (project.config.search.provider === "pagefind") {
186
+ logger.start("Building search index");
187
+ const indexed = await buildSearchIndex(distDir);
188
+ logger.success(`Indexed ${indexed} page(s) for search`);
189
+ }
190
+
191
+ // Upload the index to a hosted provider (Algolia, Orama Cloud, Typesense).
192
+ // Skipped with a warning when its admin key isn't configured.
193
+ await syncSearchProvider(project, {
194
+ start: (message) => logger.start(message),
195
+ success: (message) => logger.success(message),
196
+ warn: (message) => logger.warn(message),
197
+ });
198
+
199
+ if (project.config.ai.llmsTxt) {
200
+ const { index, full } = await buildLlmsFiles(project);
201
+ await Promise.all([
202
+ writeFile(join(distDir, "llms.txt"), index, "utf-8"),
203
+ writeFile(join(distDir, "llms-full.txt"), full, "utf-8"),
204
+ ]);
205
+ logger.success("Generated llms.txt and llms-full.txt");
206
+ }
207
+
208
+ // A user's own public/ file (copied into dist by Astro) always wins.
209
+ const sitemap = buildSitemap(project);
210
+ if (sitemap && !existsSync(join(distDir, "sitemap.xml"))) {
211
+ await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
212
+ logger.success("Generated sitemap.xml");
213
+ }
214
+
215
+ const robots = buildRobots(project);
216
+ if (robots && !existsSync(join(distDir, "robots.txt"))) {
217
+ await writeFile(join(distDir, "robots.txt"), robots, "utf-8");
218
+ logger.success("Generated robots.txt");
219
+ }
220
+
221
+ await emitRedirectFiles(project.config, distDir);
222
+
223
+ const { config } = project;
224
+ const features = serverFeatures(config);
225
+ logger.box(
226
+ [
227
+ `Output ${config.deployment.output}`,
228
+ `Adapter ${config.deployment.adapter ?? "none"}`,
229
+ `Site ${config.deployment.site ?? "not set"}`,
230
+ `Search ${config.search.provider}`,
231
+ `Redirects ${config.redirects.length}`,
232
+ `Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
233
+ `Robots ${robots ? "yes" : "no"}`,
234
+ `LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
235
+ `Server features ${features.length > 0 ? features.join(", ") : "none"}`,
236
+ ].join("\n")
237
+ );
238
+
239
+ if (args.analyze) {
240
+ await reportBundleSizes(distDir);
241
+ }
242
+
243
+ if ((await enforceBudget(distDir, args)) === "fail") {
244
+ process.exit(1);
245
+ }
246
+
247
+ logger.success(`Built to ${distDir}`);
248
+ };
249
+
151
250
  export const buildCommand = defineCommand({
152
251
  args: {
153
252
  adapter: {
@@ -170,6 +269,11 @@ export const buildCommand = defineCommand({
170
269
  description: "Fail if total client JavaScript exceeds this many kB.",
171
270
  type: "string",
172
271
  },
272
+ isolated: {
273
+ description:
274
+ "Build into an isolated .blume-verify runtime (and its own dist) so a running dev server and the real dist/ are untouched. For verifying changes while `blume dev` runs.",
275
+ type: "boolean",
276
+ },
173
277
  output: {
174
278
  description: "Output mode: static | server.",
175
279
  type: "string",
@@ -187,6 +291,18 @@ export const buildCommand = defineCommand({
187
291
  async run({ args }) {
188
292
  const root = process.cwd();
189
293
 
294
+ // `--isolated` (or BLUME_RUNTIME_DIR) relocates the whole runtime to a
295
+ // sibling dir so this build never touches a live dev server's `.blume/` or
296
+ // the user's real `dist/`. A non-default runtime dir has no dev lock, so the
297
+ // refusal below lets it proceed; a plain build still refuses.
298
+ const runtimeDir = args.isolated
299
+ ? ".blume-verify"
300
+ : process.env.BLUME_RUNTIME_DIR;
301
+ refuseIfDevRunning(root, "building", runtimeDir);
302
+ if (args.isolated) {
303
+ await ensureGitignore(root, [".blume-verify/"]);
304
+ }
305
+
190
306
  if (args.output && args.output !== "static" && args.output !== "server") {
191
307
  logger.error(`Invalid --output "${args.output}" (use static | server).`);
192
308
  process.exit(1);
@@ -197,6 +313,7 @@ export const buildCommand = defineCommand({
197
313
  );
198
314
  process.exit(1);
199
315
  }
316
+ validateBudgetFlags(args);
200
317
 
201
318
  const project = await prepareProject({
202
319
  mode: "build",
@@ -207,6 +324,7 @@ export const buildCommand = defineCommand({
207
324
  },
208
325
  preview: args.preview,
209
326
  root,
327
+ runtimeDir,
210
328
  strict: args.strict,
211
329
  });
212
330
 
@@ -219,70 +337,19 @@ export const buildCommand = defineCommand({
219
337
  root: project.context.outDir,
220
338
  });
221
339
 
222
- const distDir = join(root, "dist");
223
-
224
- if (project.config.search.provider === "pagefind") {
225
- logger.start("Building search index");
226
- const indexed = await buildSearchIndex(distDir);
227
- logger.success(`Indexed ${indexed} page(s) for search`);
228
- }
340
+ const distDir = project.context.distDir ?? join(root, "dist");
229
341
 
230
- // Upload the index to a hosted provider (Algolia, Orama Cloud, Typesense).
231
- // Skipped with a warning when its admin key isn't configured.
232
- await syncSearchProvider(project, {
233
- start: (message) => logger.start(message),
234
- success: (message) => logger.success(message),
235
- warn: (message) => logger.warn(message),
236
- });
237
-
238
- if (project.config.ai.llmsTxt) {
239
- const { index, full } = await buildLlmsFiles(project);
240
- await Promise.all([
241
- writeFile(join(distDir, "llms.txt"), index, "utf-8"),
242
- writeFile(join(distDir, "llms-full.txt"), full, "utf-8"),
243
- ]);
244
- logger.success("Generated llms.txt and llms-full.txt");
245
- }
246
-
247
- // A user's own public/ file (copied into dist by Astro) always wins.
248
- const sitemap = buildSitemap(project);
249
- if (sitemap && !existsSync(join(distDir, "sitemap.xml"))) {
250
- await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
251
- logger.success("Generated sitemap.xml");
252
- }
253
-
254
- const robots = buildRobots(project);
255
- if (robots && !existsSync(join(distDir, "robots.txt"))) {
256
- await writeFile(join(distDir, "robots.txt"), robots, "utf-8");
257
- logger.success("Generated robots.txt");
258
- }
259
-
260
- await emitRedirectFiles(project.config, distDir);
261
-
262
- const { config } = project;
263
- const features = serverFeatures(config);
264
- logger.box(
265
- [
266
- `Output ${config.deployment.output}`,
267
- `Adapter ${config.deployment.adapter ?? "none"}`,
268
- `Site ${config.deployment.site ?? "not set"}`,
269
- `Search ${config.search.provider}`,
270
- `Redirects ${config.redirects.length}`,
271
- `Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
272
- `Robots ${robots ? "yes" : "no"}`,
273
- `LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
274
- `Server features ${features.length > 0 ? features.join(", ") : "none"}`,
275
- ].join("\n")
276
- );
277
-
278
- if (args.analyze) {
279
- await reportBundleSizes(distDir);
280
- }
281
-
282
- if ((await enforceBudget(distDir, args)) === "fail") {
283
- process.exit(1);
342
+ // An isolated build is a throwaway verify: it only needs to confirm the site
343
+ // compiles and renders. Skip the network post-steps (search sync) and
344
+ // deploy artifacts (index/llms/sitemap/robots/redirects) that only matter
345
+ // for a real publish and would push to hosted providers.
346
+ if (runtimeDir) {
347
+ logger.success(
348
+ `Isolated build OK — output at ${distDir} (not published).`
349
+ );
350
+ return;
284
351
  }
285
352
 
286
- logger.success(`Built to ${distDir}`);
353
+ await publishBuildArtifacts(project, distDir, args);
287
354
  },
288
355
  });
@@ -5,11 +5,18 @@ import { sync } from "astro";
5
5
  import { defineCommand } from "citty";
6
6
  import { join } from "pathe";
7
7
 
8
+ import { ensureGitignore } from "../../core/gitignore.ts";
9
+ import { refuseIfDevRunning } from "../dev-lock.ts";
8
10
  import { logger } from "../log.ts";
9
11
  import { prepareProject } from "../prepare.ts";
10
12
 
11
13
  export const checkCommand = defineCommand({
12
14
  args: {
15
+ isolated: {
16
+ description:
17
+ "Type-check in an isolated .blume-verify runtime so a running dev server is untouched. For verifying changes while `blume dev` runs.",
18
+ type: "boolean",
19
+ },
13
20
  preview: {
14
21
  description: "Include drafts and unpublished CMS content.",
15
22
  type: "boolean",
@@ -25,10 +32,23 @@ export const checkCommand = defineCommand({
25
32
  },
26
33
  async run({ args }) {
27
34
  const root = process.cwd();
35
+
36
+ // `blume check` regenerates `.blume` just like `build`, so it must refuse a
37
+ // live dev server unless isolated. `--isolated` (or BLUME_RUNTIME_DIR)
38
+ // relocates the runtime to `.blume-verify`, which dev never locks.
39
+ const runtimeDir = args.isolated
40
+ ? ".blume-verify"
41
+ : process.env.BLUME_RUNTIME_DIR;
42
+ refuseIfDevRunning(root, "checking", runtimeDir);
43
+ if (args.isolated) {
44
+ await ensureGitignore(root, [".blume-verify/"]);
45
+ }
46
+
28
47
  const project = await prepareProject({
29
48
  mode: "build",
30
49
  preview: args.preview,
31
50
  root,
51
+ runtimeDir,
32
52
  strict: args.strict,
33
53
  });
34
54
 
@@ -6,6 +6,8 @@ import { defineCommand } from "citty";
6
6
  import { generateRuntime } from "../../astro/generate.ts";
7
7
  import { showBlumeErrorOverlay } from "../../astro/integration.ts";
8
8
  import { scanProject } from "../../core/project-graph.ts";
9
+ import { parsePort } from "../args.ts";
10
+ import { acquireDevLock } from "../dev-lock.ts";
9
11
  import { logger } from "../log.ts";
10
12
  import { prepareProject } from "../prepare.ts";
11
13
 
@@ -41,7 +43,8 @@ export const devCommand = defineCommand({
41
43
  // Astro's dev server defaults to 4321 when no port is passed. Feeding the
42
44
  // resolved URL in as the `deployment.site` fallback lets site-gated features
43
45
  // (OG images, canonicals, sitemap) work locally without configuring a site.
44
- const port = args.port ? Number(args.port) : 4321;
46
+ const explicitPort = parsePort(args.port);
47
+ const port = explicitPort ?? 4321;
45
48
  const devServerUrl = `http://localhost:${port}`;
46
49
  const project = await prepareProject({
47
50
  devServerUrl,
@@ -58,13 +61,18 @@ export const devCommand = defineCommand({
58
61
  );
59
62
  }
60
63
 
64
+ // Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
65
+ // rather than regenerating or deleting it out from under this server.
66
+ const releaseLock = acquireDevLock(project.context.outDir);
67
+ process.on("exit", releaseLock);
68
+
61
69
  const server = await dev({
62
70
  logLevel: args.debug ? "debug" : "info",
63
71
  root: project.context.outDir,
64
72
  server: {
65
73
  host: args.host ?? false,
66
74
  open: args.open ?? false,
67
- port: args.port ? Number(args.port) : undefined,
75
+ port: explicitPort,
68
76
  },
69
77
  });
70
78
 
@@ -118,6 +126,7 @@ export const devCommand = defineCommand({
118
126
  for (const dispose of disposers) {
119
127
  dispose();
120
128
  }
129
+ releaseLock();
121
130
  await server.stop();
122
131
  process.exit(0);
123
132
  };
@@ -5,7 +5,12 @@ import { scanProject } from "../../core/project-graph.ts";
5
5
  import { serverFeatures } from "../../core/server-features.ts";
6
6
  import type { Diagnostic } from "../../core/types.ts";
7
7
  import { reportInternalError } from "../internal-error.ts";
8
- import { logger, reportDiagnostics, reportDiagnosticsJson } from "../log.ts";
8
+ import {
9
+ flushStdout,
10
+ logger,
11
+ reportDiagnostics,
12
+ reportDiagnosticsJson,
13
+ } from "../log.ts";
9
14
 
10
15
  const MIN_NODE_MAJOR = 20;
11
16
 
@@ -74,7 +79,11 @@ export const doctorCommand = defineCommand({
74
79
  }
75
80
 
76
81
  if (args.json) {
82
+ // Drain stdout before exiting non-zero: `process.exit` would otherwise
83
+ // truncate the JSON payload mid-write when stdout is a pipe — exactly how
84
+ // `--json` is consumed in CI/editors.
77
85
  if (reportDiagnosticsJson(diagnostics, root)) {
86
+ await flushStdout();
78
87
  process.exit(1);
79
88
  }
80
89
  return;
@@ -4,6 +4,7 @@ import { defineCommand } from "citty";
4
4
  import { join, relative } from "pathe";
5
5
 
6
6
  import { eject } from "../../registry/eject.ts";
7
+ import { refuseIfDevRunning } from "../dev-lock.ts";
7
8
  import { logger } from "../log.ts";
8
9
 
9
10
  const updatePackageScripts = async (root: string): Promise<void> => {
@@ -34,10 +35,11 @@ export const ejectCommand = defineCommand({
34
35
  },
35
36
  async run({ args }) {
36
37
  const root = process.cwd();
38
+ refuseIfDevRunning(root, "ejecting");
37
39
 
38
40
  if (!args.yes) {
39
41
  logger.warn(
40
- "Eject is one-way: it writes astro.config.mjs and src/ into your project and removes .blume."
42
+ "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."
41
43
  );
42
44
  logger.info("Re-run with --yes to proceed.");
43
45
  return;
@@ -2,8 +2,9 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { defineCommand } from "citty";
5
- import { basename, dirname, join } from "pathe";
5
+ import { basename, dirname, isAbsolute, join, relative } from "pathe";
6
6
 
7
+ import { ensureGitignore } from "../../core/gitignore.ts";
7
8
  import { getBlumeVersion } from "../../core/version.ts";
8
9
  import { eject } from "../../registry/eject.ts";
9
10
  import { logger } from "../log.ts";
@@ -190,6 +191,17 @@ export const initCommand = defineCommand({
190
191
  async run({ args }) {
191
192
  const root = process.cwd();
192
193
  const contentDir = args["content-dir"] ?? "docs";
194
+ // The content dir is joined into every scaffolded file path, so an absolute
195
+ // or `../`-escaping value would write outside the project. Reject it.
196
+ if (
197
+ isAbsolute(contentDir) ||
198
+ relative(root, join(root, contentDir)).startsWith("..")
199
+ ) {
200
+ logger.error(
201
+ `Invalid --content-dir "${contentDir}" (must be a path inside the project).`
202
+ );
203
+ process.exit(1);
204
+ }
193
205
 
194
206
  const template = (args.template ?? "docs") as Template;
195
207
  if (!TEMPLATES.includes(template)) {
@@ -218,6 +230,14 @@ export const initCommand = defineCommand({
218
230
  .map((file) => writeFileSafe(join(root, file.path), file.content))
219
231
  );
220
232
 
233
+ // Keep Blume's generated runtime (`.blume/`) and build output (`dist/`) out
234
+ // of version control. Idempotent: creates `.gitignore` when absent and skips
235
+ // entries already present (trailing-slash agnostic).
236
+ const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
237
+ if (ignored.length > 0) {
238
+ logger.success(`Added ${ignored.join(", ")} to .gitignore`);
239
+ }
240
+
221
241
  const commands = commandsFor(pm);
222
242
 
223
243
  if (args.eject) {