blume 0.2.0 → 0.4.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 (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { check } from "@astrojs/check";
4
+ import { sync } from "astro";
5
+ import { defineCommand } from "citty";
6
+ import { join } from "pathe";
7
+
8
+ import { logger } from "../log.ts";
9
+ import { prepareProject } from "../prepare.ts";
10
+
11
+ export const checkCommand = defineCommand({
12
+ args: {
13
+ preview: {
14
+ description: "Include drafts and unpublished CMS content.",
15
+ type: "boolean",
16
+ },
17
+ strict: {
18
+ description: "Fail on content diagnostics as well as type errors.",
19
+ type: "boolean",
20
+ },
21
+ },
22
+ meta: {
23
+ description: "Type-check the docs site with astro check.",
24
+ name: "check",
25
+ },
26
+ async run({ args }) {
27
+ const root = process.cwd();
28
+ const project = await prepareProject({
29
+ mode: "build",
30
+ preview: args.preview,
31
+ root,
32
+ strict: args.strict,
33
+ });
34
+
35
+ const { outDir } = project.context;
36
+
37
+ // Generate Astro's content/collection and font types into `.blume/.astro`
38
+ // so `astro:*` virtual modules resolve during the check.
39
+ await sync({ logLevel: "warn", root: outDir });
40
+
41
+ // The project-root tsconfig is what covers the authored `pages/` and config;
42
+ // without it astro check only sees the generated `.blume` project. Falls back
43
+ // to the generated project's own tsconfig when the project has none.
44
+ const tsconfig = join(root, "tsconfig.json");
45
+
46
+ logger.start(`Type-checking ${project.graph.pages.length} page(s)`);
47
+ const failed = await check({
48
+ minimumFailingSeverity: "error",
49
+ minimumSeverity: "hint",
50
+ root: outDir,
51
+ tsconfig: existsSync(tsconfig) ? tsconfig : undefined,
52
+ watch: false,
53
+ });
54
+
55
+ if (failed) {
56
+ logger.error("Type check failed.");
57
+ process.exit(1);
58
+ }
59
+
60
+ logger.success("No type errors.");
61
+ },
62
+ });
@@ -4,12 +4,23 @@ import { dev } from "astro";
4
4
  import { defineCommand } from "citty";
5
5
 
6
6
  import { generateRuntime } from "../../astro/generate.ts";
7
+ import { showBlumeErrorOverlay } from "../../astro/integration.ts";
7
8
  import { scanProject } from "../../core/project-graph.ts";
9
+ import { parsePort } from "../args.ts";
10
+ import { acquireDevLock } from "../dev-lock.ts";
8
11
  import { logger } from "../log.ts";
9
12
  import { prepareProject } from "../prepare.ts";
10
13
 
11
14
  export const devCommand = defineCommand({
12
15
  args: {
16
+ "content-dir": {
17
+ description: "Content folder to scan, overriding config (content.root).",
18
+ type: "string",
19
+ },
20
+ debug: {
21
+ description: "Verbose Astro/Vite logging for troubleshooting.",
22
+ type: "boolean",
23
+ },
13
24
  host: { description: "Network host to bind.", type: "string" },
14
25
  open: { description: "Open the browser on start.", type: "boolean" },
15
26
  port: { description: "Port to listen on.", type: "string" },
@@ -26,14 +37,19 @@ export const devCommand = defineCommand({
26
37
  async run({ args }) {
27
38
  const root = process.cwd();
28
39
  const preview = args.preview ?? false;
40
+ const overrides = args["content-dir"]
41
+ ? { contentRoot: args["content-dir"] }
42
+ : undefined;
29
43
  // Astro's dev server defaults to 4321 when no port is passed. Feeding the
30
44
  // resolved URL in as the `deployment.site` fallback lets site-gated features
31
45
  // (OG images, canonicals, sitemap) work locally without configuring a site.
32
- const port = args.port ? Number(args.port) : 4321;
46
+ const explicitPort = parsePort(args.port);
47
+ const port = explicitPort ?? 4321;
33
48
  const devServerUrl = `http://localhost:${port}`;
34
49
  const project = await prepareProject({
35
50
  devServerUrl,
36
51
  mode: "dev",
52
+ overrides,
37
53
  preview,
38
54
  root,
39
55
  strict: args.strict,
@@ -45,16 +61,25 @@ export const devCommand = defineCommand({
45
61
  );
46
62
  }
47
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
+
48
69
  const server = await dev({
49
- logLevel: "info",
70
+ logLevel: args.debug ? "debug" : "info",
50
71
  root: project.context.outDir,
51
72
  server: {
52
73
  host: args.host ?? false,
53
74
  open: args.open ?? false,
54
- port: args.port ? Number(args.port) : undefined,
75
+ port: explicitPort,
55
76
  },
56
77
  });
57
78
 
79
+ // Mirror any initial diagnostics into the browser overlay now the server
80
+ // (and its HMR channel) is up.
81
+ showBlumeErrorOverlay(project.diagnostics);
82
+
58
83
  // Watch user inputs and regenerate the runtime data on change. Astro/Vite
59
84
  // hot-reloads the generated data module so nav and routes stay in sync.
60
85
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -67,9 +92,12 @@ export const devCommand = defineCommand({
67
92
  const next = await scanProject(root, {
68
93
  devServerUrl,
69
94
  mode: "dev",
95
+ overrides,
70
96
  preview,
71
97
  });
72
98
  await generateRuntime(next);
99
+ // Surface any content/config errors in the browser overlay too.
100
+ showBlumeErrorOverlay(next.diagnostics);
73
101
  } catch (error) {
74
102
  logger.error(`Regeneration failed: ${(error as Error).message}`);
75
103
  }
@@ -98,6 +126,7 @@ export const devCommand = defineCommand({
98
126
  for (const dispose of disposers) {
99
127
  dispose();
100
128
  }
129
+ releaseLock();
101
130
  await server.stop();
102
131
  process.exit(0);
103
132
  };
@@ -4,16 +4,28 @@ import { BlumeError } from "../../core/diagnostics.ts";
4
4
  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
- import { logger, reportDiagnostics } from "../log.ts";
7
+ import { reportInternalError } from "../internal-error.ts";
8
+ import {
9
+ flushStdout,
10
+ logger,
11
+ reportDiagnostics,
12
+ reportDiagnosticsJson,
13
+ } from "../log.ts";
8
14
 
9
15
  const MIN_NODE_MAJOR = 20;
10
16
 
11
17
  export const doctorCommand = defineCommand({
18
+ args: {
19
+ json: {
20
+ description: "Emit diagnostics as JSON on stdout (for CI/editors).",
21
+ type: "boolean",
22
+ },
23
+ },
12
24
  meta: {
13
25
  description: "Diagnose common configuration and content problems.",
14
26
  name: "doctor",
15
27
  },
16
- async run() {
28
+ async run({ args }) {
17
29
  const root = process.cwd();
18
30
  const diagnostics: Diagnostic[] = [];
19
31
 
@@ -52,15 +64,29 @@ export const doctorCommand = defineCommand({
52
64
  });
53
65
  }
54
66
 
55
- logger.info(`Pages: ${project.graph.pages.length}`);
56
- logger.info(`Output: ${config.deployment.output}`);
57
- logger.info(`Search: ${config.search.provider}`);
67
+ if (!args.json) {
68
+ logger.info(`Pages: ${project.graph.pages.length}`);
69
+ logger.info(`Output: ${config.deployment.output}`);
70
+ logger.info(`Search: ${config.search.provider}`);
71
+ }
58
72
  } catch (error) {
59
73
  if (error instanceof BlumeError) {
60
74
  diagnostics.push(error.diagnostic);
61
75
  } else {
62
- throw error;
76
+ reportInternalError(error);
77
+ process.exit(1);
78
+ }
79
+ }
80
+
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.
85
+ if (reportDiagnosticsJson(diagnostics, root)) {
86
+ await flushStdout();
87
+ process.exit(1);
63
88
  }
89
+ return;
64
90
  }
65
91
 
66
92
  const hadErrors = reportDiagnostics(diagnostics, root);
@@ -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,9 +2,11 @@ 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";
9
+ import { eject } from "../../registry/eject.ts";
8
10
  import { logger } from "../log.ts";
9
11
 
10
12
  /**
@@ -32,25 +34,119 @@ const packageTemplate = (name: string, version: string): string => `{
32
34
  }
33
35
  `;
34
36
 
35
- const CONFIG_TEMPLATE = `import { defineConfig } from "blume";
37
+ const TEMPLATES = ["docs", "api", "sdk", "changelog"] as const;
38
+ type Template = (typeof TEMPLATES)[number];
39
+
40
+ const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
41
+ type PackageManager = (typeof PACKAGE_MANAGERS)[number];
42
+
43
+ /** A starter: the config plus seed content files (path is relative to root). */
44
+ interface Starter {
45
+ config: string;
46
+ files: (contentDir: string) => { content: string; path: string }[];
47
+ }
48
+
49
+ const configFor = (
50
+ extra: string
51
+ ): string => `import { defineConfig } from "blume";
36
52
 
37
53
  export default defineConfig({
38
54
  title: "My Docs",
39
- description: "Documentation powered by Blume.",
55
+ description: "Documentation powered by Blume.",${extra}
40
56
  });
41
57
  `;
42
58
 
43
- const INDEX_TEMPLATE = `---
44
- title: Introduction
45
- description: Welcome to your new Blume docs.
46
- ---
47
-
48
- # Introduction
59
+ const page = (title: string, description: string, body: string): string =>
60
+ `---\ntitle: ${title}\ndescription: ${description}\n---\n\n${body}\n`;
49
61
 
50
- Welcome to **Blume** markdown-first docs powered by Astro and Vite.
62
+ const STARTERS: Record<Template, Starter> = {
63
+ api: {
64
+ config: configFor(`
65
+ openapi: {
66
+ enabled: true,
67
+ route: "/api",
68
+ sources: [
69
+ {
70
+ label: "Petstore",
71
+ spec: "https://petstore3.swagger.io/api/v3/openapi.json",
72
+ },
73
+ ],
74
+ },`),
75
+ files: (dir) => [
76
+ {
77
+ content: page(
78
+ "API Reference",
79
+ "Explore the API.",
80
+ "# API Reference\n\nYour OpenAPI spec renders at [`/api`](/api). Point `openapi.sources` at your own spec in `blume.config.ts`."
81
+ ),
82
+ path: join(dir, "index.mdx"),
83
+ },
84
+ ],
85
+ },
86
+ changelog: {
87
+ config: configFor(`
88
+ navigation: {
89
+ tabs: [
90
+ { label: "Docs", path: "/" },
91
+ { label: "Changelog", path: "/changelog" },
92
+ ],
93
+ },`),
94
+ files: (dir) => [
95
+ {
96
+ content: page(
97
+ "Introduction",
98
+ "Welcome to your new Blume docs.",
99
+ "# Introduction\n\nWrite your docs here, and log releases under `changelog/`."
100
+ ),
101
+ path: join(dir, "index.mdx"),
102
+ },
103
+ {
104
+ content: `---\ntitle: v1.0.0\ntype: changelog\ndate: 2026-01-01\n---\n\nThe first release. Edit \`${dir}/changelog/v1-0-0.mdx\` or add new entries beside it.\n`,
105
+ path: join(dir, "changelog", "v1-0-0.mdx"),
106
+ },
107
+ ],
108
+ },
109
+ docs: {
110
+ config: configFor(""),
111
+ files: (dir) => [
112
+ {
113
+ content: page(
114
+ "Introduction",
115
+ "Welcome to your new Blume docs.",
116
+ `# Introduction\n\nWelcome to **Blume** — markdown-first docs powered by Astro and Vite.\n\nEdit \`${dir}/index.mdx\` to get started, then run \`blume dev\`.`
117
+ ),
118
+ path: join(dir, "index.mdx"),
119
+ },
120
+ ],
121
+ },
122
+ sdk: {
123
+ config: configFor(""),
124
+ files: (dir) => [
125
+ {
126
+ content: page(
127
+ "Introduction",
128
+ "Get started with the SDK.",
129
+ "# Introduction\n\nInstall the SDK and make your first call. See [Installation](/installation)."
130
+ ),
131
+ path: join(dir, "index.mdx"),
132
+ },
133
+ {
134
+ content: page(
135
+ "Installation",
136
+ "Install the SDK.",
137
+ "# Installation\n\n```package-install\nyour-sdk\n```"
138
+ ),
139
+ path: join(dir, "installation.mdx"),
140
+ },
141
+ ],
142
+ },
143
+ };
51
144
 
52
- Edit \`docs/index.mdx\` to get started, then run \`blume dev\`.
53
- `;
145
+ /** Install + dev commands to print for the chosen package manager. */
146
+ const commandsFor = (pm: PackageManager): { dev: string; install: string } => ({
147
+ dev: pm === "npm" ? "npm run dev" : `${pm} dev`,
148
+ install: `${pm} install`,
149
+ });
54
150
 
55
151
  const writeFileSafe = async (
56
152
  path: string,
@@ -73,6 +169,19 @@ export const initCommand = defineCommand({
73
169
  description: "Content directory.",
74
170
  type: "string",
75
171
  },
172
+ eject: {
173
+ description: "Eject to a standalone Astro project after scaffolding.",
174
+ type: "boolean",
175
+ },
176
+ "package-manager": {
177
+ description:
178
+ "Package manager for the next-steps hint (npm|pnpm|yarn|bun).",
179
+ type: "string",
180
+ },
181
+ template: {
182
+ description: "Starter template: docs | api | sdk | changelog.",
183
+ type: "string",
184
+ },
76
185
  yes: { description: "Skip prompts.", type: "boolean" },
77
186
  },
78
187
  meta: {
@@ -82,17 +191,76 @@ export const initCommand = defineCommand({
82
191
  async run({ args }) {
83
192
  const root = process.cwd();
84
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
+ }
85
205
 
206
+ const template = (args.template ?? "docs") as Template;
207
+ if (!TEMPLATES.includes(template)) {
208
+ logger.error(
209
+ `Unknown template "${args.template}" (use ${TEMPLATES.join(" | ")}).`
210
+ );
211
+ process.exit(1);
212
+ }
213
+ const pm = (args["package-manager"] ?? "npm") as PackageManager;
214
+ if (!PACKAGE_MANAGERS.includes(pm)) {
215
+ logger.error(
216
+ `Unknown package manager "${args["package-manager"]}" (use ${PACKAGE_MANAGERS.join(" | ")}).`
217
+ );
218
+ process.exit(1);
219
+ }
220
+
221
+ const starter = STARTERS[template];
86
222
  const createdPackage = await writeFileSafe(
87
223
  join(root, "package.json"),
88
224
  packageTemplate(toPackageName(basename(root)), getBlumeVersion())
89
225
  );
90
- await writeFileSafe(join(root, "blume.config.ts"), CONFIG_TEMPLATE);
91
- await writeFileSafe(join(root, contentDir, "index.mdx"), INDEX_TEMPLATE);
226
+ await writeFileSafe(join(root, "blume.config.ts"), starter.config);
227
+ await Promise.all(
228
+ starter
229
+ .files(contentDir)
230
+ .map((file) => writeFileSafe(join(root, file.path), file.content))
231
+ );
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
+
241
+ const commands = commandsFor(pm);
242
+
243
+ if (args.eject) {
244
+ // Eject only generates files (no Astro runtime), so it works right after
245
+ // scaffolding. The standalone app then runs with Astro directly.
246
+ try {
247
+ await eject(root);
248
+ logger.success("Ejected to a standalone Astro project.");
249
+ logger.box(`Next steps:\n\n ${commands.install}\n npx astro dev\n`);
250
+ } catch (error) {
251
+ logger.warn(
252
+ `Scaffolded, but eject failed: ${(error as Error).message}`
253
+ );
254
+ logger.box(
255
+ `Next steps:\n\n ${commands.install}\n blume eject --yes\n`
256
+ );
257
+ }
258
+ return;
259
+ }
92
260
 
93
261
  const nextSteps = createdPackage
94
- ? "Next steps:\n\n npm install\n blume dev\n"
95
- : "Next steps:\n\n blume dev\n";
262
+ ? `Next steps:\n\n ${commands.install}\n ${commands.dev}\n`
263
+ : `Next steps:\n\n ${commands.dev}\n`;
96
264
  logger.box(nextSteps);
97
265
  },
98
266
  });
@@ -6,6 +6,7 @@ import { join } from "pathe";
6
6
 
7
7
  import { loadConfig } from "../../core/config.ts";
8
8
  import { resolveProjectContext } from "../../core/project.ts";
9
+ import { parsePort } from "../args.ts";
9
10
  import { logger } from "../log.ts";
10
11
 
11
12
  export const previewCommand = defineCommand({
@@ -32,7 +33,7 @@ export const previewCommand = defineCommand({
32
33
  root: context.outDir,
33
34
  server: {
34
35
  host: args.host ?? false,
35
- port: args.port ? Number(args.port) : undefined,
36
+ port: parsePort(args.port),
36
37
  },
37
38
  });
38
39
  },
@@ -3,11 +3,18 @@ import { existsSync } from "node:fs";
3
3
  import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
5
 
6
+ import { resolveAssetMounts } from "../../core/assets.ts";
6
7
  import { BlumeError } from "../../core/diagnostics.ts";
7
8
  import { validateLinks } from "../../core/links.ts";
8
9
  import { scanProject } from "../../core/project-graph.ts";
9
10
  import type { Diagnostic } from "../../core/types.ts";
10
- import { logger, reportDiagnostics } from "../log.ts";
11
+ import { reportInternalError } from "../internal-error.ts";
12
+ import {
13
+ flushStdout,
14
+ logger,
15
+ reportDiagnostics,
16
+ reportDiagnosticsJson,
17
+ } from "../log.ts";
11
18
 
12
19
  export const validateCommand = defineCommand({
13
20
  args: {
@@ -15,6 +22,10 @@ export const validateCommand = defineCommand({
15
22
  description: "Check external (HTTP) links over the network.",
16
23
  type: "boolean",
17
24
  },
25
+ json: {
26
+ description: "Emit diagnostics as JSON on stdout (for CI/editors).",
27
+ type: "boolean",
28
+ },
18
29
  strict: {
19
30
  description: "Treat warnings as errors.",
20
31
  type: "boolean",
@@ -37,6 +48,7 @@ export const validateCommand = defineCommand({
37
48
  const publicDir = join(root, "public");
38
49
  diagnostics.push(
39
50
  ...(await validateLinks(project.graph, {
51
+ assetMounts: resolveAssetMounts(root, project.config.content.assets),
40
52
  checkExternal: Boolean(args.external),
41
53
  publicDir: existsSync(publicDir) ? publicDir : null,
42
54
  redirects: project.config.redirects,
@@ -46,8 +58,21 @@ export const validateCommand = defineCommand({
46
58
  if (error instanceof BlumeError) {
47
59
  diagnostics.push(error.diagnostic);
48
60
  } else {
49
- throw error;
61
+ reportInternalError(error);
62
+ process.exit(1);
63
+ }
64
+ }
65
+
66
+ if (args.json) {
67
+ // Drain stdout before exiting non-zero: `process.exit` would otherwise
68
+ // truncate the JSON payload mid-write when stdout is a pipe — exactly how
69
+ // `--json` is consumed in CI/editors.
70
+ const hadErrors = reportDiagnosticsJson(diagnostics, root);
71
+ if (hadErrors || (Boolean(args.strict) && diagnostics.length > 0)) {
72
+ await flushStdout();
73
+ process.exit(1);
50
74
  }
75
+ return;
51
76
  }
52
77
 
53
78
  const hadErrors = reportDiagnostics(diagnostics, root);
@@ -0,0 +1,84 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+
9
+ import { join } from "pathe";
10
+
11
+ import { logger } from "./log.ts";
12
+
13
+ /**
14
+ * A best-effort PID lock in the shared `.blume/` runtime dir. `blume dev`
15
+ * regenerates and serves `.blume` continuously, so a concurrent `build`,
16
+ * `eject`, or `sync --force` that regenerates or deletes it out from under the
17
+ * running Vite server corrupts the dev session. The lock lets those commands
18
+ * detect a live dev server and refuse.
19
+ */
20
+
21
+ const lockPath = (outDir: string): string => join(outDir, "dev.lock");
22
+
23
+ /**
24
+ * Whether another live `blume dev` holds the lock on `outDir`. A lock left by a
25
+ * process that has since exited (stale) is treated as absent.
26
+ */
27
+ export const isDevLocked = (outDir: string): boolean => {
28
+ const path = lockPath(outDir);
29
+ if (!existsSync(path)) {
30
+ return false;
31
+ }
32
+ const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
33
+ if (!(Number.isInteger(pid) && pid > 0)) {
34
+ return false;
35
+ }
36
+ try {
37
+ // Signal 0 probes liveness without actually signaling the process.
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ };
44
+
45
+ /**
46
+ * Write the current process's dev lock into `outDir` and return a release
47
+ * function. The release only removes the file if it's still ours, so a newer
48
+ * dev server's lock is never clobbered.
49
+ */
50
+ export const acquireDevLock = (outDir: string): (() => void) => {
51
+ const path = lockPath(outDir);
52
+ mkdirSync(outDir, { recursive: true });
53
+ writeFileSync(path, String(process.pid));
54
+ let released = false;
55
+ return () => {
56
+ if (released) {
57
+ return;
58
+ }
59
+ released = true;
60
+ try {
61
+ if (
62
+ existsSync(path) &&
63
+ readFileSync(path, "utf-8").trim() === String(process.pid)
64
+ ) {
65
+ rmSync(path, { force: true });
66
+ }
67
+ } catch {
68
+ // Best-effort cleanup; a stale lock is handled by the liveness check.
69
+ }
70
+ };
71
+ };
72
+
73
+ /**
74
+ * Exit with an error when a live `blume dev` owns the `.blume` dir under `root`.
75
+ * `action` names the operation being refused (e.g. "building").
76
+ */
77
+ export const refuseIfDevRunning = (root: string, action: string): void => {
78
+ if (isDevLocked(join(root, ".blume"))) {
79
+ logger.error(
80
+ `A \`blume dev\` server is running against .blume; ${action} would corrupt it. Stop the dev server first.`
81
+ );
82
+ process.exit(1);
83
+ }
84
+ };
package/src/cli/index.ts CHANGED
@@ -3,6 +3,7 @@ import { defineCommand, runMain } from "citty";
3
3
  import { getBlumeVersion } from "../core/version.ts";
4
4
  import { addCommand } from "./commands/add.ts";
5
5
  import { buildCommand } from "./commands/build.ts";
6
+ import { checkCommand } from "./commands/check.ts";
6
7
  import { devCommand } from "./commands/dev.ts";
7
8
  import { doctorCommand } from "./commands/doctor.ts";
8
9
  import { ejectCommand } from "./commands/eject.ts";
@@ -12,6 +13,7 @@ import { previewCommand } from "./commands/preview.ts";
12
13
  import { syncCommand } from "./commands/sync.ts";
13
14
  import { validateCommand } from "./commands/validate.ts";
14
15
  import { loadEnvFiles } from "./env.ts";
16
+ import { reportInternalError } from "./internal-error.ts";
15
17
 
16
18
  const main = defineCommand({
17
19
  meta: {
@@ -22,6 +24,7 @@ const main = defineCommand({
22
24
  subCommands: {
23
25
  add: addCommand,
24
26
  build: buildCommand,
27
+ check: checkCommand,
25
28
  dev: devCommand,
26
29
  doctor: doctorCommand,
27
30
  eject: ejectCommand,
@@ -37,4 +40,16 @@ const main = defineCommand({
37
40
  // can read their tokens (e.g. `GITHUB_TOKEN`) during the content scan.
38
41
  loadEnvFiles(process.cwd());
39
42
 
43
+ // Backstop for unexpected async failures that escape a command's own handling
44
+ // (e.g. a rejected timer/watcher in `blume dev`), so even those report through
45
+ // the stable internal-error contract rather than a bare stack trace.
46
+ process.on("uncaughtException", (error) => {
47
+ reportInternalError(error);
48
+ process.exit(1);
49
+ });
50
+ process.on("unhandledRejection", (error) => {
51
+ reportInternalError(error);
52
+ process.exit(1);
53
+ });
54
+
40
55
  runMain(main);