blume 1.4.2 → 1.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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -54,7 +54,7 @@ import {
54
54
  platformRedirects,
55
55
  } from "../../deploy/redirects.ts";
56
56
  import { buildRobots } from "../../deploy/robots.ts";
57
- import { buildSitemap } from "../../deploy/sitemap.ts";
57
+ import { buildSitemapFiles } from "../../deploy/sitemap.ts";
58
58
  import { injectNegotiationRoutes } from "../../deploy/vercel-negotiation.ts";
59
59
  import { buildSearchIndex } from "../../search/build.ts";
60
60
  import { syncSearchProvider } from "../../search/sync/index.ts";
@@ -64,6 +64,9 @@ import { prepareProject } from "../prepare.ts";
64
64
 
65
65
  const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
66
66
 
67
+ const isAdapter = (value: string): value is (typeof ADAPTERS)[number] =>
68
+ ADAPTERS.some((adapter) => adapter === value);
69
+
67
70
  const BUDGET_JS = "budget-js";
68
71
  const BUDGET_CSS = "budget-css";
69
72
 
@@ -295,14 +298,13 @@ const emitVercelNegotiation = async (
295
298
  if (!existsSync(configPath)) {
296
299
  return;
297
300
  }
298
- const overrides: Record<string, string> = {
299
- ...(hasApiCatalog(config)
300
- ? { [API_CATALOG_PATH.slice(1)]: API_CATALOG_TYPE }
301
- : {}),
302
- ...(config.ai.webBotAuth.keys.length > 0
303
- ? { [SIGNATURES_DIRECTORY_PATH.slice(1)]: SIGNATURES_DIRECTORY_TYPE }
304
- : {}),
305
- };
301
+ const overrides: Record<string, string> = {};
302
+ if (hasApiCatalog(config)) {
303
+ overrides[API_CATALOG_PATH.slice(1)] = API_CATALOG_TYPE;
304
+ }
305
+ if (config.ai.webBotAuth.keys.length > 0) {
306
+ overrides[SIGNATURES_DIRECTORY_PATH.slice(1)] = SIGNATURES_DIRECTORY_TYPE;
307
+ }
306
308
  // The homepage rewrite serves `/index.md` from the static layer, so its
307
309
  // `x-markdown-tokens` estimate has to ride the routing config; the runtime
308
310
  // endpoint stamps it on dev/server-rendered responses itself.
@@ -616,10 +618,18 @@ const publishBuildArtifacts = async (
616
618
  }
617
619
 
618
620
  // A user's own public/ file (copied into dist by Astro) always wins.
619
- const sitemap = buildSitemap(project);
620
- if (sitemap && !existsSync(join(distDir, "sitemap.xml"))) {
621
- await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
622
- logger.success("Generated sitemap.xml");
621
+ const sitemapFiles = buildSitemapFiles(project);
622
+ if (sitemapFiles && !existsSync(join(distDir, "sitemap.xml"))) {
623
+ await Promise.all(
624
+ sitemapFiles.map((file) =>
625
+ writeFile(join(distDir, file.name), file.xml, "utf-8")
626
+ )
627
+ );
628
+ logger.success(
629
+ sitemapFiles.length === 1
630
+ ? "Generated sitemap.xml"
631
+ : `Generated sitemap.xml (index of ${sitemapFiles.length - 1} sitemap files)`
632
+ );
623
633
  }
624
634
 
625
635
  const robots = buildRobots(project);
@@ -649,7 +659,7 @@ const publishBuildArtifacts = async (
649
659
 
650
660
  const { config } = project;
651
661
  const features = serverFeatures(config);
652
- // `buildSitemap` returns null both when the sitemap is disabled and when no
662
+ // `buildSitemapFiles` returns null both when the sitemap is disabled and when no
653
663
  // `site` is configured — only the latter deserves the remediation hint.
654
664
  const sitemapNote = config.seo.sitemap
655
665
  ? "no (set deployment.site)"
@@ -661,7 +671,7 @@ const publishBuildArtifacts = async (
661
671
  `Site ${config.deployment.site ?? "not set"}`,
662
672
  `Search ${config.search.provider}`,
663
673
  `Redirects ${config.redirects.length}`,
664
- `Sitemap ${sitemap ? "yes" : sitemapNote}`,
674
+ `Sitemap ${sitemapFiles ? "yes" : sitemapNote}`,
665
675
  `Robots ${robots ? "yes" : "no"}`,
666
676
  `Agent JSON ${agentReadability ? "yes" : "no"}`,
667
677
  `LLM files ${config.ai.llmsTxt.enabled ? "yes" : "no"}`,
@@ -746,7 +756,7 @@ export const buildCommand = defineCommand({
746
756
  logger.error(`Invalid --output "${args.output}" (use static | server).`);
747
757
  process.exit(1);
748
758
  }
749
- if (args.adapter && !ADAPTERS.includes(args.adapter as never)) {
759
+ if (args.adapter && !isAdapter(args.adapter)) {
750
760
  logger.error(
751
761
  `Invalid --adapter "${args.adapter}" (use ${ADAPTERS.join(" | ")}).`
752
762
  );
@@ -757,8 +767,12 @@ export const buildCommand = defineCommand({
757
767
  const project = await prepareProject({
758
768
  mode: "build",
759
769
  overrides: {
770
+ // SAFETY: an invalid --adapter exited above; a set flag is an ADAPTERS
771
+ // member.
760
772
  adapter: args.adapter as (typeof ADAPTERS)[number] | undefined,
761
773
  base: args.base,
774
+ // SAFETY: an invalid --output exited above; a set flag is static or
775
+ // server.
762
776
  output: args.output as "server" | "static" | undefined,
763
777
  },
764
778
  preview: args.preview,
@@ -1,13 +1,13 @@
1
1
  import { dev } from "astro";
2
2
  import { watch } from "chokidar";
3
3
  import { defineCommand } from "citty";
4
+ import { debounce } from "perfect-debounce";
4
5
 
5
6
  import { generateRuntime } from "../../astro/generate.ts";
6
7
  import { showBlumeErrorOverlay } from "../../astro/integration.ts";
7
8
  import { scanProject } from "../../core/project-graph.ts";
8
9
  import { resolveRuntimeDir } from "../../core/project.ts";
9
10
  import { parsePort } from "../args.ts";
10
- import { coalescedRunner } from "../coalesce.ts";
11
11
  import {
12
12
  acquireDevLock,
13
13
  describeDevLock,
@@ -146,10 +146,16 @@ export const devCommand = defineCommand({
146
146
  // watcher misses directory renames, so a renamed page 404s (`getEntry` reads
147
147
  // a stale in-memory store) until the server is restarted. We restart it
148
148
  // ourselves — stop, regenerate while down (no watcher races), then bring up
149
- // a fresh container whose cold sync re-globs everything. `coalescedRunner`
150
- // single-flights the scan so a burst of watch events can never stack
151
- // overlapping regenerations (piled-up scans exhaust the heap).
152
- const runRegenerate = coalescedRunner(async () => {
149
+ // a fresh container whose cold sync re-globs everything. perfect-debounce
150
+ // both debounces the watch burst (80ms) and single-flights the scan: a
151
+ // trigger during a run never starts a second run, only marks one trailing
152
+ // rerun after the current settles. Both halves are load-bearing — a plain
153
+ // debounce once let bursts stack overlapping scans until the heap was
154
+ // exhausted (observed as an OOM after minutes of looping). The contract is
155
+ // pinned by test/dev-debounce.test.ts. The task must not reject (the
156
+ // library re-invokes it from an unhandled .finally), so the body catches
157
+ // its own errors and always resolves.
158
+ const regenerate = debounce(async () => {
153
159
  try {
154
160
  const next = await scanProject(root, {
155
161
  devServerUrl,
@@ -179,23 +185,17 @@ export const devCommand = defineCommand({
179
185
  reportDiagnostics(next.diagnostics, root);
180
186
  showBlumeErrorOverlay(next.diagnostics);
181
187
  } catch (error) {
188
+ // SAFETY: regeneration failures come from the generator and Astro's
189
+ // server API, which raise Error instances; only the message is shown.
182
190
  logger.error(`Regeneration failed: ${(error as Error).message}`);
183
191
  }
184
- });
185
-
186
- let timer: ReturnType<typeof setTimeout> | null = null;
187
- const regenerate = () => {
188
- if (timer) {
189
- clearTimeout(timer);
190
- }
191
- timer = setTimeout(runRegenerate, 80);
192
- };
192
+ }, 80);
193
193
 
194
194
  // The runtime prepared above baked the *requested* port into the site
195
195
  // fallback; if Vite bumped it, regenerate so OG images, canonicals, and
196
196
  // other site-gated URLs point at the port actually serving.
197
197
  if (boundPort !== port) {
198
- void runRegenerate();
198
+ void regenerate();
199
199
  }
200
200
 
201
201
  // Content is watched per source (filesystem uses fs.watch; remote sources
@@ -23,6 +23,8 @@ const FALLBACK_NODE_RANGE = ">=22.12.0";
23
23
  * doctor can never drift from what the package actually declares. */
24
24
  const supportedNodeRange = (): string => {
25
25
  try {
26
+ // SAFETY: this parses blume's own package.json; the optional fields cover
27
+ // an `engines` block going missing, and a bad read falls to the catch.
26
28
  const pkg = JSON.parse(
27
29
  readFileSync(join(packageRoot(), "package.json"), "utf-8")
28
30
  ) as { engines?: { node?: string } };
@@ -8,7 +8,7 @@ import {
8
8
  droppedArtifactNotices,
9
9
  updatePackageScripts,
10
10
  } from "../eject-scripts.ts";
11
- import { commandsFor, detectPackageManager } from "../init/scaffold.ts";
11
+ import { commandsFor, detectProjectPackageManager } from "../init/scaffold.ts";
12
12
  import { logger } from "../log.ts";
13
13
 
14
14
  /**
@@ -76,9 +76,9 @@ export const ejectCommand = defineCommand({
76
76
  process.stdout.write(` ${relative(root, file)}\n`);
77
77
  }
78
78
  reportDroppedArtifacts(notices);
79
- // Print run commands matching the user's package manager, detected the
80
- // same way as `blume init`'s next-steps hint.
81
- const pm = detectPackageManager(process.env.npm_config_user_agent);
79
+ // Print run commands matching the project's package manager (lockfile
80
+ // detection, since eject runs inside an existing project).
81
+ const pm = await detectProjectPackageManager(root);
82
82
  const { build, dev } = commandsFor(pm);
83
83
  logger.box(
84
84
  `Your project is now a standalone Astro app.\n\n ${dev}\n ${build}\n\nThe blume package remains importable.`
@@ -3,11 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
5
 
6
- import {
7
- AGENTS,
8
- launchAgent,
9
- WINDOWS_COMMAND_NOT_FOUND,
10
- } from "../../audit/agent.ts";
6
+ import { AGENTS, launchAgent } from "../../audit/agent.ts";
11
7
  import type { AgentKind } from "../../audit/agent.ts";
12
8
  import { BlumeError } from "../../core/diagnostics.ts";
13
9
  import { scanProject } from "../../core/project-graph.ts";
@@ -37,32 +33,35 @@ const DEFAULT_TIMEOUT_S = 180;
37
33
  const isAgentKind = (value: string): value is AgentKind => value in AGENTS;
38
34
 
39
35
  /**
40
- * Launch the interactive agent CLI, translating a missing executable into the
41
- * Windows not-found sentinel. Only `ENOENT` means "not installed" — any other
42
- * spawn failure (`EACCES`, `EMFILE`, …) must surface as itself.
36
+ * Launch the interactive agent CLI, turning a missing executable into the
37
+ * install hint. Only `ENOENT` means "not installed" — any other spawn failure
38
+ * (`EACCES`, `EMFILE`, …) must surface as itself.
43
39
  */
40
+ const notInstalled = (agent: AgentKind): never => {
41
+ const cli = AGENTS[agent];
42
+ logger.error(
43
+ `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
44
+ );
45
+ return process.exit(1);
46
+ };
47
+
44
48
  const launchAgentCode = async (
45
- bin: string,
49
+ agent: AgentKind,
46
50
  prompt: string
47
51
  ): Promise<number> => {
48
52
  try {
49
- return await launchAgent(bin, prompt);
53
+ return await launchAgent(AGENTS[agent].bin, prompt);
50
54
  } catch (error) {
55
+ // SAFETY: only the `code` tag is inspected; a spawn failure throws an
56
+ // ErrnoException, and any other thrown value fails the comparison and
57
+ // rethrows unchanged.
51
58
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
52
59
  throw error;
53
60
  }
54
- return WINDOWS_COMMAND_NOT_FOUND;
61
+ return notInstalled(agent);
55
62
  }
56
63
  };
57
64
 
58
- const notInstalled = (agent: AgentKind): never => {
59
- const cli = AGENTS[agent];
60
- logger.error(
61
- `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
62
- );
63
- return process.exit(1);
64
- };
65
-
66
65
  /** The fraction of run (non-skipped) questions that passed. */
67
66
  export const passFraction = (result: EvalResult): number => {
68
67
  const ran = result.results.length - result.counts.skip;
@@ -80,9 +79,7 @@ interface EvalFlags {
80
79
  }
81
80
 
82
81
  /** Validate the flag surface, exiting with a message on the first offense. */
83
- const parseFlags = (
84
- args: EvalFlags
85
- ): { agent: AgentKind; threshold: number; timeoutS: number } => {
82
+ const parseFlags = (args: EvalFlags) => {
86
83
  if (!isAgentKind(args.agent)) {
87
84
  logger.error(`Invalid --agent "${args.agent}" (use claude | codex).`);
88
85
  process.exit(1);
@@ -125,10 +122,7 @@ const runFixHandoff = async (
125
122
  process.stderr.write(
126
123
  ` Handing ${count} failed question${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
127
124
  );
128
- const code = await launchAgentCode(cli.bin, evalFixPrompt(report));
129
- if (code === WINDOWS_COMMAND_NOT_FOUND) {
130
- notInstalled(agent);
131
- }
125
+ const code = await launchAgentCode(agent, evalFixPrompt(report));
132
126
  if (code !== 0) {
133
127
  process.exit(code);
134
128
  }
@@ -143,10 +137,7 @@ const runInit = async (agent: AgentKind, file: string): Promise<void> => {
143
137
  );
144
138
  process.exit(1);
145
139
  }
146
- const code = await launchAgentCode(AGENTS[agent].bin, initPrompt(file));
147
- if (code === WINDOWS_COMMAND_NOT_FOUND) {
148
- notInstalled(agent);
149
- }
140
+ const code = await launchAgentCode(agent, initPrompt(file));
150
141
  if (code !== 0) {
151
142
  process.exit(code);
152
143
  }
@@ -247,6 +238,9 @@ export const evalCommand = defineCommand({
247
238
  logger.error(error.diagnostic.message);
248
239
  process.exit(1);
249
240
  }
241
+ // SAFETY: only the `code` tag is inspected; a spawn failure throws an
242
+ // ErrnoException, and any other thrown value fails the comparison and
243
+ // falls through to the internal-error report.
250
244
  if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
251
245
  notInstalled(agent);
252
246
  }
@@ -16,11 +16,7 @@ import {
16
16
  TEMPLATES,
17
17
  validateContentDir,
18
18
  } from "../init/scaffold.ts";
19
- import type {
20
- InitAnswers,
21
- PackageManager,
22
- Template,
23
- } from "../init/scaffold.ts";
19
+ import type { InitAnswers } from "../init/scaffold.ts";
24
20
  import { logger } from "../log.ts";
25
21
 
26
22
  /**
@@ -45,6 +41,8 @@ const ejectScaffold = async (
45
41
  const steps = [...cd, commands.install, commands.dev];
46
42
  logger.box(`Next steps:\n\n ${steps.join("\n ")}\n`);
47
43
  } catch (error) {
44
+ // SAFETY: eject and the script rewrite throw Error instances; only the
45
+ // message is surfaced in the fallback hint.
48
46
  logger.warn(
49
47
  `Scaffolded, but eject needs the project's dependencies installed to load blume.config.ts: ${(error as Error).message}`
50
48
  );
@@ -93,15 +91,17 @@ export const initCommand = defineCommand({
93
91
  async run({ args }) {
94
92
  const cwd = process.cwd();
95
93
 
96
- const template = args.template as Template | undefined;
97
- if (template !== undefined && !TEMPLATES.includes(template)) {
94
+ const template = TEMPLATES.find((candidate) => candidate === args.template);
95
+ if (args.template !== undefined && template === undefined) {
98
96
  logger.error(
99
97
  `Unknown template "${args.template}" (use ${TEMPLATES.join(" | ")}).`
100
98
  );
101
99
  process.exit(1);
102
100
  }
103
- const pm = args["package-manager"] as PackageManager | undefined;
104
- if (pm !== undefined && !PACKAGE_MANAGERS.includes(pm)) {
101
+ const pm = PACKAGE_MANAGERS.find(
102
+ (candidate) => candidate === args["package-manager"]
103
+ );
104
+ if (args["package-manager"] !== undefined && pm === undefined) {
105
105
  logger.error(
106
106
  `Unknown package manager "${args["package-manager"]}" (use ${PACKAGE_MANAGERS.join(" | ")}).`
107
107
  );
@@ -23,6 +23,9 @@ export const mcpStdioCommand = defineCommand({
23
23
  // must go to stderr or the MCP client chokes on the stray line.
24
24
  let data: McpData;
25
25
  try {
26
+ // SAFETY: the snapshot is written by `blume eval` itself as
27
+ // JSON.stringify of an McpData; a hand-mangled file fails the parse and
28
+ // lands in the catch below.
26
29
  data = JSON.parse(await readFile(args.data, "utf-8")) as McpData;
27
30
  } catch (error) {
28
31
  const detail = error instanceof Error ? error.message : String(error);
@@ -47,10 +47,17 @@ interface TranslateFlags {
47
47
  timeout?: string;
48
48
  }
49
49
 
50
+ /** The validated flag surface `parseFlags` hands the command body. */
51
+ interface ParsedTranslateFlags {
52
+ agent: AgentKind | undefined;
53
+ concurrency: number;
54
+ timeoutS: number;
55
+ }
56
+
50
57
  /** Validate the flag surface, exiting with a message on the first offense. */
51
- const parseFlags = (
52
- args: TranslateFlags
53
- ): { agent: AgentKind | undefined; concurrency: number; timeoutS: number } => {
58
+ const parseFlags = (args: TranslateFlags): ParsedTranslateFlags => {
59
+ // SAFETY: Object.keys of the AGENTS table returns exactly its declared
60
+ // AgentKind keys.
54
61
  const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
55
62
  (kind) => args[kind]
56
63
  );
@@ -227,6 +234,8 @@ export const translateCommand = defineCommand({
227
234
  return;
228
235
  }
229
236
 
237
+ // SAFETY: parseFlags exits unless --check was passed or exactly one
238
+ // agent flag was; on this non-check path the agent is therefore set.
230
239
  const kind = agent as AgentKind;
231
240
  process.stderr.write(
232
241
  `${translateHeaderLine(workList.items.length, workList.targetLocales.length, kind)}\n\n`
@@ -290,6 +299,8 @@ export const translateCommand = defineCommand({
290
299
  logger.error(error.diagnostic.message);
291
300
  process.exit(1);
292
301
  }
302
+ // SAFETY: an ENOENT from spawning the agent CLI is an ErrnoException;
303
+ // any other thrown shape reads `code` as undefined and falls through.
293
304
  if ((error as NodeJS.ErrnoException)?.code === "ENOENT" && agent) {
294
305
  notInstalled(agent);
295
306
  }
@@ -0,0 +1,85 @@
1
+ import { defineCommand } from "citty";
2
+
3
+ import { loadConfig } from "../../core/config.ts";
4
+ import { BlumeError } from "../../core/diagnostics.ts";
5
+ import { CutError, cutVersion } from "../../core/version-cut.ts";
6
+ import { reportInternalError } from "../internal-error.ts";
7
+ import { logger } from "../log.ts";
8
+
9
+ export const versionCommand = defineCommand({
10
+ args: {
11
+ force: {
12
+ description: "Overwrite an existing snapshot directory.",
13
+ type: "boolean",
14
+ },
15
+ id: {
16
+ description: 'Version id to cut (e.g. "v1.0").',
17
+ required: false,
18
+ type: "positional",
19
+ },
20
+ },
21
+ meta: {
22
+ description: "Freeze the current docs as an archived version.",
23
+ name: "version",
24
+ },
25
+ async run({ args }) {
26
+ const root = process.cwd();
27
+
28
+ if (!args.id) {
29
+ const { config } = await loadConfig(root);
30
+ if (!config.versions) {
31
+ logger.info(
32
+ "Versioning is not configured. Cut the first version with `blume version <id>` (e.g. `blume version v1.0`)."
33
+ );
34
+ return;
35
+ }
36
+ const { current, archived } = config.versions;
37
+ process.stdout.write(
38
+ ` ${current.label} (current)${current.badge ? ` — ${current.badge}` : ""}\n`
39
+ );
40
+ for (const version of archived) {
41
+ process.stdout.write(
42
+ ` ${version.label ?? version.id} — ${version.id}/\n`
43
+ );
44
+ }
45
+ return;
46
+ }
47
+
48
+ try {
49
+ const result = await cutVersion(root, args.id, { force: args.force });
50
+ logger.success(
51
+ `Snapshot ${result.dir} (${result.copied} file(s) copied)`
52
+ );
53
+ const totalRewrites = result.rewritten.reduce(
54
+ (sum, entry) => sum + entry.count,
55
+ 0
56
+ );
57
+ if (totalRewrites > 0) {
58
+ logger.info(
59
+ `Rewrote root-absolute links in ${result.rewritten.length} page(s) (${totalRewrites} line(s)).`
60
+ );
61
+ }
62
+ if (result.configUpdated) {
63
+ logger.success(
64
+ `Added "${args.id}" to versions.archived in blume.config.ts`
65
+ );
66
+ } else if (result.configSnippet) {
67
+ logger.info(result.configSnippet);
68
+ }
69
+ logger.info(
70
+ "Archived versions are frozen — future edits belong in the live tree. Restart `blume dev` to pick up the snapshot."
71
+ );
72
+ } catch (error) {
73
+ if (error instanceof CutError) {
74
+ logger.error(error.message);
75
+ process.exit(1);
76
+ }
77
+ if (error instanceof BlumeError) {
78
+ logger.error(error.diagnostic.message);
79
+ process.exit(1);
80
+ }
81
+ reportInternalError(error);
82
+ process.exit(1);
83
+ }
84
+ },
85
+ });
@@ -29,15 +29,32 @@ export interface DevLockInfo {
29
29
 
30
30
  const lockPath = (outDir: string): string => join(outDir, "dev.lock");
31
31
 
32
- const isValidPid = (pid: unknown): pid is number =>
32
+ /** What `JSON.parse` can yield for a lock file body. */
33
+ type LockFileValue =
34
+ | string
35
+ | number
36
+ | boolean
37
+ | null
38
+ | LockFileValue[]
39
+ | { [key: string]: LockFileValue };
40
+
41
+ const isValidPid = (pid: LockFileValue | undefined): pid is number =>
33
42
  typeof pid === "number" && Number.isInteger(pid) && pid > 0;
34
43
 
44
+ const isPortNumber = (port: LockFileValue | undefined): port is number =>
45
+ typeof port === "number";
46
+
47
+ const isLockRecord = (
48
+ data: LockFileValue
49
+ ): data is { pid?: LockFileValue; port?: LockFileValue } =>
50
+ typeof data === "object" && data !== null;
51
+
35
52
  /**
36
53
  * Parse a lock file body. Current locks are JSON (`{"pid":123,"port":3001}`);
37
54
  * a bare integer (the pre-port format) still parses as a pid-only lock.
38
55
  */
39
56
  const parseLock = (raw: string): DevLockInfo | null => {
40
- let data: unknown;
57
+ let data: LockFileValue;
41
58
  try {
42
59
  data = JSON.parse(raw.trim());
43
60
  } catch {
@@ -46,10 +63,10 @@ const parseLock = (raw: string): DevLockInfo | null => {
46
63
  if (isValidPid(data)) {
47
64
  return { pid: data };
48
65
  }
49
- if (typeof data === "object" && data !== null) {
50
- const { pid, port } = data as { pid?: unknown; port?: unknown };
66
+ if (isLockRecord(data)) {
67
+ const { pid, port } = data;
51
68
  if (isValidPid(pid)) {
52
- return typeof port === "number" ? { pid, port } : { pid };
69
+ return isPortNumber(port) ? { pid, port } : { pid };
53
70
  }
54
71
  }
55
72
  return null;
@@ -63,6 +80,7 @@ const isProcessAlive = (pid: number): boolean => {
63
80
  } catch (error) {
64
81
  // EPERM means the process exists but belongs to another user — still
65
82
  // live, so the lock must hold (only ESRCH proves it's gone).
83
+ // SAFETY: `process.kill` failures are errno exceptions.
66
84
  return (error as NodeJS.ErrnoException).code === "EPERM";
67
85
  }
68
86
  };
@@ -84,11 +102,13 @@ export const readDevLock = (outDir: string): DevLockInfo | null => {
84
102
  export const isDevLocked = (outDir: string): boolean =>
85
103
  readDevLock(outDir) !== null;
86
104
 
87
- const lockPayload = (port?: number): string =>
88
- JSON.stringify({
89
- pid: process.pid,
90
- ...(port === undefined ? {} : { port }),
91
- });
105
+ const lockPayload = (port?: number): string => {
106
+ const info: DevLockInfo = { pid: process.pid };
107
+ if (port !== undefined) {
108
+ info.port = port;
109
+ }
110
+ return JSON.stringify(info);
111
+ };
92
112
 
93
113
  const writeLock = (outDir: string, port?: number): void => {
94
114
  writeFileSync(lockPath(outDir), lockPayload(port));
@@ -128,6 +148,7 @@ const tryClaimLock = (outDir: string, port?: number): boolean => {
128
148
  writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
129
149
  return true;
130
150
  } catch (error) {
151
+ // SAFETY: `writeFileSync` failures are errno exceptions.
131
152
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
132
153
  throw error;
133
154
  }
@@ -5,6 +5,21 @@ import { join } from "pathe";
5
5
  import type { ResolvedConfig } from "../core/schema.ts";
6
6
  import { searchProviderMeta } from "../search/providers.ts";
7
7
 
8
+ /** A JSON value, as `JSON.parse` of a manifest can return. */
9
+ type JsonValue =
10
+ | string
11
+ | number
12
+ | boolean
13
+ | null
14
+ | JsonValue[]
15
+ | { [key: string]: JsonValue };
16
+
17
+ /** The slice of package.json the rewrite touches; the rest rides along. */
18
+ interface PackageManifest {
19
+ [key: string]: JsonValue | undefined;
20
+ scripts?: Record<string, string>;
21
+ }
22
+
8
23
  /**
9
24
  * The `blume build`-only artifacts this project's config actually produces, as
10
25
  * notice lines for the eject command. After an eject the build script runs
@@ -55,13 +70,13 @@ export const droppedArtifactNotices = (config: ResolvedConfig): string[] => {
55
70
  */
56
71
  export const updatePackageScripts = async (root: string): Promise<void> => {
57
72
  const pkgPath = join(root, "package.json");
58
- let pkg: Record<string, unknown>;
73
+ let pkg: PackageManifest;
59
74
  try {
60
75
  pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
61
76
  } catch {
62
77
  return;
63
78
  }
64
- const scripts = (pkg.scripts ?? {}) as Record<string, string>;
79
+ const scripts = pkg.scripts ?? {};
65
80
  pkg.scripts = {
66
81
  ...scripts,
67
82
  build: "astro build",
package/src/cli/env.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
 
3
- import { parse } from "dotenv";
3
+ import { config } from "dotenv";
4
4
  import { dirname, join, resolve } from "pathe";
5
5
 
6
6
  // Blume's remote sources (GitHub Releases, mdx-remote, Sanity, Notion…) read
@@ -9,44 +9,27 @@ import { dirname, join, resolve } from "pathe";
9
9
  // that gap: it cascades `.env`/`.env.local` from the working dir up to the repo
10
10
  // root, so a monorepo can keep one `.env` at the root and every app picks it up.
11
11
 
12
- /** Apply parsed vars without clobbering anything already in `process.env`. */
13
- const applyEnv = (parsed: Record<string, string>): void => {
14
- for (const [key, value] of Object.entries(parsed)) {
15
- if (!(key in process.env)) {
16
- process.env[key] = value;
17
- }
18
- }
19
- };
20
-
21
- const loadFile = (path: string): void => {
22
- try {
23
- if (existsSync(path)) {
24
- // dotenv is the same parser Vite runs over these files at build time,
25
- // so a value means the same thing to the pre-boot content scan and the
26
- // built site — including multi-line double-quoted values (PEM keys),
27
- // which a line-based parser silently truncates.
28
- applyEnv(parse(readFileSync(path, "utf-8")));
29
- }
30
- } catch {
31
- // Env files are best-effort; a read/parse failure must not abort a build.
32
- }
33
- };
34
-
35
12
  /**
36
13
  * Load `.env`/`.env.local`, cascading from `startDir` up to the repository root
37
- * (the first ancestor containing a `.git`) or the filesystem root. Nearer files
38
- * and existing `process.env` values win, so shell/CI overrides are never lost
39
- * and `.env.local` layers over `.env`.
14
+ * (the first ancestor containing a `.git`) or the filesystem root. The ordered
15
+ * path list is handed to dotenv, whose `config` is first-wins, never clobbers
16
+ * existing `process.env` values, and treats unreadable files as best-effort —
17
+ * so shell/CI overrides are never lost, `.env.local` layers over `.env`, and a
18
+ * bad file never aborts a build. dotenv is also the parser Vite runs over
19
+ * these files at build time, so a value means the same thing to the pre-boot
20
+ * content scan and the built site — including multi-line double-quoted values
21
+ * (PEM keys), which a line-based parser silently truncates.
40
22
  */
41
23
  export const loadEnvFiles = (startDir: string): void => {
24
+ const paths: string[] = [];
42
25
  let dir = resolve(startDir);
43
26
  let done = false;
44
27
  while (!done) {
45
- loadFile(join(dir, ".env.local"));
46
- loadFile(join(dir, ".env"));
28
+ paths.push(join(dir, ".env.local"), join(dir, ".env"));
47
29
  const parent = dirname(dir);
48
30
  // Stop at the repo root (nearest `.git`) or the filesystem root.
49
31
  done = existsSync(join(dir, ".git")) || parent === dir;
50
32
  dir = parent;
51
33
  }
34
+ config({ path: paths, quiet: true });
52
35
  };