blume 0.7.0 → 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 (168) 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 +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  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 +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -1,149 +1,73 @@
1
- import { existsSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
-
1
+ import * as clack from "@clack/prompts";
4
2
  import { defineCommand } from "citty";
5
- import { basename, dirname, isAbsolute, join, relative } from "pathe";
3
+ import { resolve } from "pathe";
6
4
 
7
5
  import { ensureGitignore } from "../../core/gitignore.ts";
8
- import { blumePackageJson, toPackageName } from "../../core/package-json.ts";
9
6
  import { eject } from "../../registry/eject.ts";
7
+ import { updatePackageScripts } from "../eject-scripts.ts";
8
+ import { collectAnswers } from "../init/questions.ts";
9
+ import {
10
+ applyPlan,
11
+ buildPlan,
12
+ commandsFor,
13
+ detectPackageManager,
14
+ nextSteps,
15
+ PACKAGE_MANAGERS,
16
+ TEMPLATES,
17
+ validateContentDir,
18
+ } from "../init/scaffold.ts";
19
+ import type {
20
+ InitAnswers,
21
+ PackageManager,
22
+ Template,
23
+ } from "../init/scaffold.ts";
10
24
  import { logger } from "../log.ts";
11
25
 
12
- const TEMPLATES = ["docs", "api", "sdk", "changelog"] as const;
13
- type Template = (typeof TEMPLATES)[number];
14
-
15
- const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
16
- type PackageManager = (typeof PACKAGE_MANAGERS)[number];
17
-
18
- /** A starter: the config plus seed content files (path is relative to root). */
19
- interface Starter {
20
- config: string;
21
- files: (contentDir: string) => { content: string; path: string }[];
22
- }
23
-
24
- const configFor = (
25
- extra: string
26
- ): string => `import { defineConfig } from "blume";
27
-
28
- export default defineConfig({
29
- title: "My Docs",
30
- description: "Documentation powered by Blume.",${extra}
31
- });
32
- `;
33
-
34
- const page = (title: string, description: string, body: string): string =>
35
- `---\ntitle: ${title}\ndescription: ${description}\n---\n\n${body}\n`;
36
-
37
- const STARTERS: Record<Template, Starter> = {
38
- api: {
39
- config: configFor(`
40
- openapi: {
41
- enabled: true,
42
- route: "/api",
43
- sources: [
44
- {
45
- label: "Petstore",
46
- spec: "https://petstore3.swagger.io/api/v3/openapi.json",
47
- },
48
- ],
49
- },`),
50
- files: (dir) => [
51
- {
52
- content: page(
53
- "API Reference",
54
- "Explore the API.",
55
- "# API Reference\n\nYour OpenAPI spec renders at [`/api`](/api). Point `openapi.sources` at your own spec in `blume.config.ts`."
56
- ),
57
- path: join(dir, "index.mdx"),
58
- },
59
- ],
60
- },
61
- changelog: {
62
- config: configFor(`
63
- navigation: {
64
- tabs: [
65
- { label: "Docs", path: "/" },
66
- { label: "Changelog", path: "/changelog" },
67
- ],
68
- },`),
69
- files: (dir) => [
70
- {
71
- content: page(
72
- "Introduction",
73
- "Welcome to your new Blume docs.",
74
- "# Introduction\n\nWrite your docs here, and log releases under `changelog/`."
75
- ),
76
- path: join(dir, "index.mdx"),
77
- },
78
- {
79
- 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`,
80
- path: join(dir, "changelog", "v1-0-0.mdx"),
81
- },
82
- ],
83
- },
84
- docs: {
85
- config: configFor(""),
86
- files: (dir) => [
87
- {
88
- content: page(
89
- "Introduction",
90
- "Welcome to your new Blume docs.",
91
- `# 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\`.`
92
- ),
93
- path: join(dir, "index.mdx"),
94
- },
95
- ],
96
- },
97
- sdk: {
98
- config: configFor(""),
99
- files: (dir) => [
100
- {
101
- content: page(
102
- "Introduction",
103
- "Get started with the SDK.",
104
- "# Introduction\n\nInstall the SDK and make your first call. See [Installation](/installation)."
105
- ),
106
- path: join(dir, "index.mdx"),
107
- },
108
- {
109
- content: page(
110
- "Installation",
111
- "Install the SDK.",
112
- "# Installation\n\n```package-install\nyour-sdk\n```"
113
- ),
114
- path: join(dir, "installation.mdx"),
115
- },
116
- ],
117
- },
118
- };
119
-
120
- /** Install + dev commands to print for the chosen package manager. */
121
- const commandsFor = (pm: PackageManager): { dev: string; install: string } => ({
122
- dev: pm === "npm" ? "npm run dev" : `${pm} dev`,
123
- install: `${pm} install`,
124
- });
125
-
126
- const writeFileSafe = async (
127
- path: string,
128
- content: string
129
- ): Promise<boolean> => {
130
- if (existsSync(path)) {
131
- logger.info(`Skipped existing ${path}`);
132
- return false;
26
+ /**
27
+ * Eject the freshly scaffolded project, or print the install-then-eject path.
28
+ * Eject jiti-loads the scaffolded blume.config.ts, whose `import { defineConfig }
29
+ * from "blume"` only resolves once dependencies are installed (or blume is
30
+ * hoisted from an ancestor node_modules, as in a monorepo) — so on a fresh
31
+ * scaffold the fallback below is the common path.
32
+ */
33
+ const ejectScaffold = async (
34
+ root: string,
35
+ answers: InitAnswers
36
+ ): Promise<void> => {
37
+ const commands = commandsFor(answers.packageManager);
38
+ const cd = answers.directory === "." ? [] : [`cd ${answers.directory}`];
39
+ try {
40
+ await eject(root);
41
+ // The scaffolded scripts point at the Blume CLI; the ejected app runs
42
+ // Astro directly (mirroring the standalone `blume eject` command).
43
+ await updatePackageScripts(root);
44
+ logger.success("Ejected to a standalone Astro project.");
45
+ const steps = [...cd, commands.install, commands.dev];
46
+ logger.box(`Next steps:\n\n ${steps.join("\n ")}\n`);
47
+ } catch (error) {
48
+ logger.warn(
49
+ `Scaffolded, but eject needs the project's dependencies installed to load blume.config.ts: ${(error as Error).message}`
50
+ );
51
+ const steps = [
52
+ ...cd,
53
+ commands.install,
54
+ `${commands.exec} blume eject --yes`,
55
+ ];
56
+ logger.box(`Next steps:\n\n ${steps.join("\n ")}\n`);
133
57
  }
134
- await mkdir(dirname(path), { recursive: true });
135
- await writeFile(path, content, "utf-8");
136
- logger.success(`Created ${path}`);
137
- return true;
138
58
  };
139
59
 
140
60
  export const initCommand = defineCommand({
141
61
  args: {
142
62
  "content-dir": {
143
- default: "docs",
144
63
  description: "Content directory.",
145
64
  type: "string",
146
65
  },
66
+ dir: {
67
+ description: "Directory to scaffold into (default: current directory).",
68
+ required: false,
69
+ type: "positional",
70
+ },
147
71
  eject: {
148
72
  description: "Eject to a standalone Astro project after scaffolding.",
149
73
  type: "boolean",
@@ -157,85 +81,101 @@ export const initCommand = defineCommand({
157
81
  description: "Starter template: docs | api | sdk | changelog.",
158
82
  type: "string",
159
83
  },
160
- yes: { description: "Skip prompts.", type: "boolean" },
84
+ yes: {
85
+ description: "Skip prompts and scaffold with defaults.",
86
+ type: "boolean",
87
+ },
161
88
  },
162
89
  meta: {
163
90
  description: "Scaffold a minimal Blume project.",
164
91
  name: "init",
165
92
  },
166
93
  async run({ args }) {
167
- const root = process.cwd();
168
- const contentDir = args["content-dir"] ?? "docs";
169
- // The content dir is joined into every scaffolded file path, so an absolute
170
- // or `../`-escaping value would write outside the project. Reject it.
171
- if (
172
- isAbsolute(contentDir) ||
173
- relative(root, join(root, contentDir)).startsWith("..")
174
- ) {
94
+ const cwd = process.cwd();
95
+
96
+ const template = args.template as Template | undefined;
97
+ if (template !== undefined && !TEMPLATES.includes(template)) {
175
98
  logger.error(
176
- `Invalid --content-dir "${contentDir}" (must be a path inside the project).`
99
+ `Unknown template "${args.template}" (use ${TEMPLATES.join(" | ")}).`
177
100
  );
178
101
  process.exit(1);
179
102
  }
180
-
181
- const template = (args.template ?? "docs") as Template;
182
- if (!TEMPLATES.includes(template)) {
103
+ const pm = args["package-manager"] as PackageManager | undefined;
104
+ if (pm !== undefined && !PACKAGE_MANAGERS.includes(pm)) {
183
105
  logger.error(
184
- `Unknown template "${args.template}" (use ${TEMPLATES.join(" | ")}).`
106
+ `Unknown package manager "${args["package-manager"]}" (use ${PACKAGE_MANAGERS.join(" | ")}).`
185
107
  );
186
108
  process.exit(1);
187
109
  }
188
- const pm = (args["package-manager"] ?? "npm") as PackageManager;
189
- if (!PACKAGE_MANAGERS.includes(pm)) {
110
+
111
+ const interactive =
112
+ !args.yes &&
113
+ process.stdin.isTTY === true &&
114
+ clack.isTTY(process.stdout) &&
115
+ !clack.isCI();
116
+
117
+ let answers: InitAnswers;
118
+ if (interactive) {
119
+ clack.intro("blume init");
120
+ const collected = await collectAnswers(
121
+ clack,
122
+ {
123
+ contentDir: args["content-dir"],
124
+ directory: args.dir,
125
+ packageManager: pm,
126
+ template,
127
+ },
128
+ { cwd, userAgent: process.env.npm_config_user_agent }
129
+ );
130
+ if (collected === null) {
131
+ clack.cancel("Cancelled — nothing was written.");
132
+ process.exit(0);
133
+ }
134
+ answers = collected;
135
+ } else {
136
+ answers = {
137
+ contentDir: args["content-dir"] ?? "docs",
138
+ directory: args.dir ?? ".",
139
+ packageManager:
140
+ pm ?? detectPackageManager(process.env.npm_config_user_agent),
141
+ sources: ["filesystem"],
142
+ template: template ?? "docs",
143
+ title: "My Docs",
144
+ };
145
+ }
146
+
147
+ const root = resolve(cwd, answers.directory);
148
+ // Interactive runs validate this inline, but an explicit --content-dir
149
+ // flag skips that prompt, so guard here in both modes.
150
+ if (validateContentDir(root, answers.contentDir) !== undefined) {
190
151
  logger.error(
191
- `Unknown package manager "${args["package-manager"]}" (use ${PACKAGE_MANAGERS.join(" | ")}).`
152
+ `Invalid --content-dir "${answers.contentDir}" (must be a path inside the project).`
192
153
  );
193
154
  process.exit(1);
194
155
  }
195
156
 
196
- const starter = STARTERS[template];
197
- const createdPackage = await writeFileSafe(
198
- join(root, "package.json"),
199
- blumePackageJson(toPackageName(basename(root)))
200
- );
201
- await writeFileSafe(join(root, "blume.config.ts"), starter.config);
202
- await Promise.all(
203
- starter
204
- .files(contentDir)
205
- .map((file) => writeFileSafe(join(root, file.path), file.content))
206
- );
157
+ const sink = interactive ? clack.log : logger;
158
+ const { createdPackage } = await applyPlan(buildPlan(root, answers), sink);
207
159
 
208
160
  // Keep Blume's generated runtime (`.blume/`) and build output (`dist/`) out
209
161
  // of version control. Idempotent: creates `.gitignore` when absent and skips
210
162
  // entries already present (trailing-slash agnostic).
211
163
  const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
212
164
  if (ignored.length > 0) {
213
- logger.success(`Added ${ignored.join(", ")} to .gitignore`);
165
+ sink.success(`Added ${ignored.join(", ")} to .gitignore`);
214
166
  }
215
167
 
216
- const commands = commandsFor(pm);
217
-
218
168
  if (args.eject) {
219
- // Eject only generates files (no Astro runtime), so it works right after
220
- // scaffolding. The standalone app then runs with Astro directly.
221
- try {
222
- await eject(root);
223
- logger.success("Ejected to a standalone Astro project.");
224
- logger.box(`Next steps:\n\n ${commands.install}\n npx astro dev\n`);
225
- } catch (error) {
226
- logger.warn(
227
- `Scaffolded, but eject failed: ${(error as Error).message}`
228
- );
229
- logger.box(
230
- `Next steps:\n\n ${commands.install}\n blume eject --yes\n`
231
- );
232
- }
169
+ await ejectScaffold(root, answers);
233
170
  return;
234
171
  }
235
172
 
236
- const nextSteps = createdPackage
237
- ? `Next steps:\n\n ${commands.install}\n ${commands.dev}\n`
238
- : `Next steps:\n\n ${commands.dev}\n`;
239
- logger.box(nextSteps);
173
+ const steps = nextSteps(answers, createdPackage);
174
+ if (interactive) {
175
+ clack.note(steps.trimEnd());
176
+ clack.outro("You're all set.");
177
+ } else {
178
+ logger.box(steps);
179
+ }
240
180
  },
241
181
  });
@@ -8,6 +8,7 @@ import { loadConfig } from "../../core/config.ts";
8
8
  import { resolveProjectContext } from "../../core/project.ts";
9
9
  import { parsePort } from "../args.ts";
10
10
  import { logger } from "../log.ts";
11
+ import { normalizeHost } from "./dev.ts";
11
12
 
12
13
  export const previewCommand = defineCommand({
13
14
  args: {
@@ -32,7 +33,9 @@ export const previewCommand = defineCommand({
32
33
  logLevel: "info",
33
34
  root: context.outDir,
34
35
  server: {
35
- host: args.host ?? false,
36
+ // `normalizeHost` maps a bare `--host` (citty parses it as "") to
37
+ // `true` so Vite binds all interfaces instead of the hostname "".
38
+ host: normalizeHost(args.host),
36
39
  port: parsePort(args.port),
37
40
  },
38
41
  });
@@ -3,8 +3,14 @@ import { existsSync } from "node:fs";
3
3
  import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
5
 
6
+ import {
7
+ customStaticRoutes,
8
+ discoverPages,
9
+ hasGeneratedChangelog,
10
+ } from "../../astro/pages.ts";
6
11
  import { BlumeError } from "../../core/diagnostics.ts";
7
12
  import { validateLinks } from "../../core/links.ts";
13
+ import { buildManifest } from "../../core/manifest.ts";
8
14
  import { scanProject } from "../../core/project-graph.ts";
9
15
  import type { Diagnostic } from "../../core/types.ts";
10
16
  import { reportInternalError } from "../internal-error.ts";
@@ -44,11 +50,40 @@ export const validateCommand = defineCommand({
44
50
  // link-validation blind spot, so silently passing would be misleading.
45
51
  diagnostics.push(...project.diagnostics);
46
52
 
53
+ // Custom `.astro` pages and the generated changelog index are servable
54
+ // routes the content graph can't see — without them, a docs link to e.g.
55
+ // a custom landing page fails as BLUME_BROKEN_LINK.
56
+ const userPages = project.context.pagesRoot
57
+ ? await discoverPages(project.context.pagesRoot)
58
+ : [];
59
+ const extraRoutes = customStaticRoutes(userPages);
60
+ if (hasGeneratedChangelog(project, userPages)) {
61
+ extraRoutes.push("/changelog");
62
+ }
63
+
64
+ // Fallback-materialized locale routes (an untranslated page prerendered
65
+ // at its localized URL) are servable but absent from the content graph —
66
+ // without them a link to an untranslated sibling under a non-default
67
+ // locale fails as BLUME_BROKEN_LINK even though the built site serves it.
68
+ if (project.config.i18n) {
69
+ const manifest = buildManifest({
70
+ config: project.config,
71
+ context: project.context,
72
+ graph: project.graph,
73
+ });
74
+ extraRoutes.push(
75
+ ...manifest.routes.flatMap((route) =>
76
+ route.fallback ? [route.path] : []
77
+ )
78
+ );
79
+ }
80
+
47
81
  const publicDir = join(root, "public");
48
82
  diagnostics.push(
49
83
  ...(await validateLinks(project.graph, {
50
84
  basePath: project.config.basePath,
51
85
  checkExternal: Boolean(args.external),
86
+ extraRoutes,
52
87
  publicDir: existsSync(publicDir) ? publicDir : null,
53
88
  redirects: project.config.redirects,
54
89
  }))
@@ -62,12 +97,18 @@ export const validateCommand = defineCommand({
62
97
  }
63
98
  }
64
99
 
100
+ // `--strict` escalates warnings to failures; info-level notes (e.g.
101
+ // BLUME_ASSETS_UNCHECKED when there is no public/ dir) stay advisory.
102
+ const strictFailure =
103
+ Boolean(args.strict) &&
104
+ diagnostics.some((diagnostic) => diagnostic.severity !== "info");
105
+
65
106
  if (args.json) {
66
107
  // Drain stdout before exiting non-zero: `process.exit` would otherwise
67
108
  // truncate the JSON payload mid-write when stdout is a pipe — exactly how
68
109
  // `--json` is consumed in CI/editors.
69
110
  const hadErrors = reportDiagnosticsJson(diagnostics, root);
70
- if (hadErrors || (Boolean(args.strict) && diagnostics.length > 0)) {
111
+ if (hadErrors || strictFailure) {
71
112
  await flushStdout();
72
113
  process.exit(1);
73
114
  }
@@ -78,7 +119,7 @@ export const validateCommand = defineCommand({
78
119
  if (diagnostics.length === 0) {
79
120
  logger.success("No broken links found.");
80
121
  }
81
- if (hadErrors || (Boolean(args.strict) && diagnostics.length > 0)) {
122
+ if (hadErrors || strictFailure) {
82
123
  process.exit(1);
83
124
  }
84
125
  },
@@ -191,17 +191,21 @@ export const describeDevLock = (lock: DevLockInfo): string =>
191
191
  * `action` names the operation being refused (e.g. "building"). `runtimeDir`
192
192
  * relocates the checked dir: an isolated verify (`.blume-verify`) targets a dir
193
193
  * dev never locks, so it proceeds; a default or `--runtime-dir .blume` run still
194
- * refuses.
194
+ * refuses. Only commands that actually accept `--isolated` (build, check) should
195
+ * set `isolatedHint`, so the refusal never suggests a flag the command ignores.
195
196
  */
196
197
  export const refuseIfDevRunning = (
197
198
  root: string,
198
199
  action: string,
199
- runtimeDir?: string
200
+ options: { runtimeDir?: string; isolatedHint?: boolean } = {}
200
201
  ): void => {
201
- const lock = readDevLock(resolveRuntimeDir(root, runtimeDir));
202
+ const lock = readDevLock(resolveRuntimeDir(root, options.runtimeDir));
202
203
  if (lock) {
204
+ const remedies = options.isolatedHint
205
+ ? "Reuse that server, stop it first, or re-run with --isolated to build/verify against .blume-verify without touching it."
206
+ : "Reuse that server or stop it first.";
203
207
  logger.error(
204
- `A \`blume dev\` server is running${describeDevLock(lock)}; ${action} would corrupt its .blume runtime. Reuse that server, stop it first, or re-run with --isolated to build/verify against .blume-verify without touching it.`
208
+ `A \`blume dev\` server is running${describeDevLock(lock)}; ${action} would corrupt its .blume runtime. ${remedies}`
205
209
  );
206
210
  process.exit(1);
207
211
  }
@@ -0,0 +1,72 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+
3
+ import { join } from "pathe";
4
+
5
+ import type { ResolvedConfig } from "../core/schema.ts";
6
+ import { searchProviderMeta } from "../search/providers.ts";
7
+
8
+ /**
9
+ * The `blume build`-only artifacts this project's config actually produces, as
10
+ * notice lines for the eject command. After an eject the build script runs
11
+ * plain `astro build`, which emits none of them — printing the config-aware
12
+ * list makes the loss explicit instead of silent (a Pagefind site would
13
+ * otherwise eject into a build whose search fails at runtime). Each gate
14
+ * mirrors the artifact's producer in `blume build` (cli/commands/build.ts).
15
+ */
16
+ export const droppedArtifactNotices = (config: ResolvedConfig): string[] => {
17
+ const notices: string[] = [];
18
+ if (config.search.provider === "pagefind") {
19
+ notices.push(
20
+ 'the Pagefind search index — the search UI loads it from the built site, so search will break in production. Add a post-build step: `"build": "astro build && pagefind --site dist"` (with `pagefind` installed as a devDependency).'
21
+ );
22
+ }
23
+ if (searchProviderMeta(config.search.provider).syncs) {
24
+ notices.push(
25
+ `the hosted ${config.search.provider} index sync — new and updated pages stop being pushed; re-upload your search records after each build with the provider's API or CLI.`
26
+ );
27
+ }
28
+ if (config.ai.llmsTxt.enabled) {
29
+ notices.push("llms.txt and llms-full.txt");
30
+ }
31
+ if (config.deployment.site && config.seo.sitemap) {
32
+ notices.push(
33
+ "sitemap.xml — recreate it with the @astrojs/sitemap integration."
34
+ );
35
+ }
36
+ if (config.seo.robots) {
37
+ notices.push("robots.txt — recreate it as a public/robots.txt file.");
38
+ }
39
+ if (config.seo.agentReadability) {
40
+ notices.push("agent-readability.json");
41
+ }
42
+ if (config.redirects.length > 0 && config.deployment.output === "static") {
43
+ notices.push(
44
+ "the platform redirect files (_redirects, vercel.json) — your redirects still work as Astro-generated meta-refresh pages."
45
+ );
46
+ }
47
+ return notices;
48
+ };
49
+
50
+ /**
51
+ * Rewrite the project's package.json scripts to run Astro directly. After an
52
+ * eject the Blume CLI no longer manages the runtime, so scaffolded scripts like
53
+ * `"dev": "blume dev"` would rebuild the removed `.blume` tree instead of
54
+ * serving the ejected app. A missing or unreadable package.json is left alone.
55
+ */
56
+ export const updatePackageScripts = async (root: string): Promise<void> => {
57
+ const pkgPath = join(root, "package.json");
58
+ let pkg: Record<string, unknown>;
59
+ try {
60
+ pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
61
+ } catch {
62
+ return;
63
+ }
64
+ const scripts = (pkg.scripts ?? {}) as Record<string, string>;
65
+ pkg.scripts = {
66
+ ...scripts,
67
+ build: "astro build",
68
+ dev: "astro dev",
69
+ preview: "astro preview",
70
+ };
71
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
72
+ };
package/src/cli/env.ts CHANGED
@@ -13,15 +13,25 @@ const ENV_LINE =
13
13
  const DOUBLE_QUOTED = /^"(?<body>[\s\S]*)"$/u;
14
14
  const SINGLE_QUOTED = /^'(?<body>[\s\S]*)'$/u;
15
15
 
16
+ const ESCAPE = /\\(?<char>[\\nt"])/gu;
17
+ const UNESCAPED: Record<string, string> = {
18
+ '"': '"',
19
+ "\\": "\\",
20
+ n: "\n",
21
+ t: "\t",
22
+ };
23
+
16
24
  /** Unquote a value, expanding `\n`/`\t`/escapes inside double quotes only. */
17
25
  const unquote = (raw: string): string => {
18
26
  const double = raw.match(DOUBLE_QUOTED)?.groups?.body;
19
27
  if (double !== undefined) {
20
- return double
21
- .replaceAll("\\n", "\n")
22
- .replaceAll("\\t", "\t")
23
- .replaceAll('\\"', '"')
24
- .replaceAll("\\\\", "\\");
28
+ // A single pass so each backslash is consumed exactly once — sequential
29
+ // replaceAll calls would expand the `n` in `\\n` (an escaped backslash
30
+ // followed by a literal `n`) into a newline.
31
+ return double.replaceAll(
32
+ ESCAPE,
33
+ (match, char: string) => UNESCAPED[char] ?? match
34
+ );
25
35
  }
26
36
  const single = raw.match(SINGLE_QUOTED)?.groups?.body;
27
37
  if (single !== undefined) {