blume 0.5.3 → 0.6.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 (132) hide show
  1. package/dist/cli/index.js +3349 -7024
  2. package/dist/cli/index.js.map +39 -69
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +6 -2
  5. package/dist/types/core/i18n-ui.d.ts +50 -0
  6. package/dist/types/core/schema.d.ts +379 -485
  7. package/dist/types/core/types.d.ts +8 -6
  8. package/docs/advanced/meta.ts +1 -8
  9. package/docs/advanced/skills.mdx +28 -0
  10. package/docs/configuration/ai.mdx +58 -0
  11. package/docs/configuration/index.mdx +13 -17
  12. package/docs/configuration/seo.mdx +59 -1
  13. package/docs/configuration/theming.mdx +15 -18
  14. package/docs/content/components.mdx +2 -53
  15. package/docs/content/i18n.mdx +0 -4
  16. package/docs/content/meta.mdx +3 -17
  17. package/docs/content/navigation.mdx +41 -4
  18. package/docs/content/syntax.mdx +1 -1
  19. package/docs/index.mdx +0 -1
  20. package/docs/reference/cli.mdx +12 -13
  21. package/package.json +6 -6
  22. package/skills/blume/SKILL.md +71 -0
  23. package/skills/blume-update-docs/SKILL.md +52 -0
  24. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  25. package/src/ai/agent-readability.ts +97 -0
  26. package/src/ai/ask-context.ts +131 -8
  27. package/src/ai/ask-data.ts +4 -1
  28. package/src/astro/generate.ts +19 -12
  29. package/src/astro/integration.ts +0 -21
  30. package/src/astro/templates.ts +33 -21
  31. package/src/cli/commands/build.ts +15 -0
  32. package/src/cli/commands/dev.ts +31 -20
  33. package/src/cli/commands/validate.ts +0 -2
  34. package/src/cli/dev-lock.ts +94 -21
  35. package/src/cli/index.ts +0 -2
  36. package/src/components/BlumePage.astro +0 -6
  37. package/src/components/Icon.astro +1 -12
  38. package/src/components/content/AccordionItem.astro +3 -6
  39. package/src/components/content/Badge.astro +1 -3
  40. package/src/components/content/Callout.astro +3 -9
  41. package/src/components/content/Card.astro +2 -3
  42. package/src/components/content/ColorItem.astro +2 -2
  43. package/src/components/content/Column.astro +1 -1
  44. package/src/components/content/GithubInfo.astro +11 -10
  45. package/src/components/content/Prompt.astro +1 -1
  46. package/src/components/content/Step.astro +3 -4
  47. package/src/components/content/Tab.astro +2 -3
  48. package/src/components/content/TypeTable.astro +13 -8
  49. package/src/components/content/Update.astro +1 -1
  50. package/src/components/islands/AskAI.astro +66 -2
  51. package/src/components/islands/ask-ai.tsx +289 -53
  52. package/src/components/layout/Header.astro +27 -4
  53. package/src/components/layout/Logo.astro +5 -1
  54. package/src/components/layout/NavSelector.astro +1 -1
  55. package/src/components/layout/NavTree.astro +15 -15
  56. package/src/components/layout/PageActions.astro +73 -30
  57. package/src/components/layout/PageLayout.astro +42 -0
  58. package/src/components/layout/ReferenceLayout.astro +1 -0
  59. package/src/components/layout/RootLayout.astro +79 -4
  60. package/src/components/layout/Search.astro +5 -5
  61. package/src/components/layout/nav-utils.ts +9 -4
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/core/builtin-tags.ts +1 -3
  65. package/src/core/config.ts +5 -28
  66. package/src/core/data.ts +6 -2
  67. package/src/core/graph.ts +8 -6
  68. package/src/core/i18n-ui.ts +5 -0
  69. package/src/core/links.ts +5 -19
  70. package/src/core/meta.ts +1 -1
  71. package/src/core/nav-diagnostics.ts +7 -0
  72. package/src/core/navigation.ts +38 -17
  73. package/src/core/project-graph.ts +0 -5
  74. package/src/core/schema.ts +133 -95
  75. package/src/core/sources/filesystem.ts +5 -1
  76. package/src/core/sources/resolve.ts +0 -13
  77. package/src/core/sources/watch.ts +43 -11
  78. package/src/core/types.ts +8 -6
  79. package/src/deploy/robots.ts +37 -4
  80. package/src/openapi/parse.ts +197 -14
  81. package/src/openapi/render-mdx.ts +44 -10
  82. package/src/openapi/scalar.ts +1 -1
  83. package/src/openapi/source.ts +19 -2
  84. package/src/search/documents.ts +9 -2
  85. package/src/theme/entry.ts +45 -17
  86. package/src/theme/icons.ts +18 -109
  87. package/src/theme/palette.ts +25 -51
  88. package/src/theme/twoslash.ts +6 -1
  89. package/dist/types/core/bridge.d.ts +0 -24
  90. package/dist/types/core/package-json.d.ts +0 -12
  91. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  92. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  93. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  94. package/dist/types/migrate/shared.d.ts +0 -153
  95. package/docs/advanced/bridge.mdx +0 -76
  96. package/docs/advanced/migrate.mdx +0 -124
  97. package/src/astro/static-assets.ts +0 -124
  98. package/src/cli/commands/migrate.ts +0 -39
  99. package/src/components/content/ApiField.astro +0 -75
  100. package/src/components/content/ParamField.astro +0 -39
  101. package/src/components/content/RequestField.astro +0 -23
  102. package/src/components/content/ResponseField.astro +0 -23
  103. package/src/components/content/Warning.astro +0 -9
  104. package/src/core/assets.ts +0 -31
  105. package/src/core/bridge.ts +0 -102
  106. package/src/core/sources/mintlify.ts +0 -190
  107. package/src/migrate/fumadocs/config.ts +0 -155
  108. package/src/migrate/fumadocs/content.ts +0 -376
  109. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  110. package/src/migrate/fumadocs/groups.ts +0 -237
  111. package/src/migrate/fumadocs/index.ts +0 -355
  112. package/src/migrate/fumadocs/meta.ts +0 -244
  113. package/src/migrate/migrate.ts +0 -53
  114. package/src/migrate/mintlify/assets.ts +0 -46
  115. package/src/migrate/mintlify/config.ts +0 -954
  116. package/src/migrate/mintlify/content.ts +0 -120
  117. package/src/migrate/mintlify/frontmatter.ts +0 -126
  118. package/src/migrate/mintlify/i18n.ts +0 -51
  119. package/src/migrate/mintlify/icons.ts +0 -128
  120. package/src/migrate/mintlify/index.ts +0 -459
  121. package/src/migrate/mintlify/snippets.ts +0 -315
  122. package/src/migrate/mintlify/transform.ts +0 -82
  123. package/src/migrate/nextra/content.ts +0 -46
  124. package/src/migrate/nextra/frontmatter.ts +0 -40
  125. package/src/migrate/nextra/index.ts +0 -389
  126. package/src/migrate/nextra/meta.ts +0 -266
  127. package/src/migrate/shared.ts +0 -801
  128. package/src/migrate/starlight/config.ts +0 -455
  129. package/src/migrate/starlight/content.ts +0 -75
  130. package/src/migrate/starlight/frontmatter.ts +0 -111
  131. package/src/migrate/starlight/i18n.ts +0 -54
  132. package/src/migrate/starlight/index.ts +0 -131
@@ -4,8 +4,8 @@ import { dirname, isAbsolute, join, relative } from "pathe";
4
4
 
5
5
  import { askBackendRuntimeDep } from "../ai/ask.ts";
6
6
  import type { AskBackend } from "../ai/ask.ts";
7
- import { resolveAssetMounts } from "../core/assets.ts";
8
7
  import type { ResolvedConfig } from "../core/schema.ts";
8
+ import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
9
9
  import type { ProjectContext } from "../core/types.ts";
10
10
  import { hasScalarReferences } from "../openapi/references.ts";
11
11
  import { searchProviderMeta } from "../search/providers.ts";
@@ -313,12 +313,10 @@ export const astroConfigTemplate = (options: {
313
313
  if (needsSvelte) {
314
314
  integrations.push("svelte()");
315
315
  }
316
- // Always mounted: injects user pages (a no-op when there are none), serves
317
- // `content.assets` mounts, and wires up dev-server `Accept: text/markdown`
318
- // negotiation over the content routes.
319
- const assets = resolveAssetMounts(context.root, config.content.assets);
316
+ // Always mounted: injects user pages (a no-op when there are none) and wires
317
+ // up dev-server `Accept: text/markdown` negotiation over the content routes.
320
318
  integrations.push(
321
- `blumeIntegration(${JSON.stringify({ assets, base: deployment.base, contentRoutes, pages })})`
319
+ `blumeIntegration(${JSON.stringify({ base: deployment.base, contentRoutes, pages })})`
322
320
  );
323
321
 
324
322
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
@@ -357,9 +355,17 @@ export default defineConfig({
357
355
  // native bindings resolve at runtime and isolated linkers don't bundle
358
356
  // symlinked store copies (which would surface their children as unresolvable
359
357
  // imports). See RENDER_EXTERNAL_DEPS / prerenderDepsPlugin.
358
+ //
359
+ // The SSR externals go through the legacy \`ssr.external\` key rather than
360
+ // \`environments.ssr\`: defining a user-owned \`environments.ssr\` block
361
+ // collides with the internal environment Astro 7 builds the server under and
362
+ // detaches the adapter's server entrypoint from the rolldown input, so the
363
+ // SSR entry is emitted as \`index.mjs\` instead of the \`entry.mjs\` the
364
+ // Vercel adapter's \`astro:build:done\` hook then fails to find. \`prerender\`
365
+ // is Astro-only and has no legacy equivalent, so it stays under \`environments\`.
366
+ ssr: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} },
360
367
  environments: {
361
368
  prerender: { resolve: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} } },
362
- ssr: { resolve: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} } },
363
369
  },
364
370
  resolve: {
365
371
  alias: {
@@ -403,8 +409,8 @@ export const contentConfigTemplate = (options: {
403
409
  stagedBase?: string;
404
410
  /**
405
411
  * Whether any filesystem (non-staged) source feeds the `docs` collection.
406
- * When false (e.g. Mintlify bridge mode, where every page is staged), the
407
- * collection globs nothing — see below.
412
+ * When false (e.g. an all-staged project where every page is materialized by
413
+ * a non-filesystem source), the collection globs nothing — see below.
408
414
  */
409
415
  filesystem?: boolean;
410
416
  }): string => {
@@ -423,8 +429,8 @@ export const contentConfigTemplate = (options: {
423
429
 
424
430
  // With no filesystem source, no route renders through `docs`, so glob nothing.
425
431
  // Beyond skipping wasted work, this is the only thing that keeps Astro's
426
- // content-layer *watcher* out of `.blume/`: bridge mode roots the collection
427
- // at the project dir (which contains `.blume/.astro/fonts`, rewritten on every
432
+ // content-layer *watcher* out of `.blume/`: an all-staged project roots the
433
+ // collection at the project dir (which contains `.blume/.astro/fonts`, rewritten on every
428
434
  // request), and the watcher's match test is `picomatch.isMatch(path, pattern)`
429
435
  // — with array-OR semantics, any `!ignored/**` negation *matches* unrelated
430
436
  // files, so negative patterns can't exclude a subtree there. An empty pattern
@@ -435,7 +441,15 @@ export const contentConfigTemplate = (options: {
435
441
  ? [
436
442
  ...config.content.include,
437
443
  ...(config.content.exclude ?? []).map((pattern) => `!${pattern}`),
438
- "!**/node_modules/**",
444
+ // Mirror the filesystem scan's baseline ignores (see BLUME_IGNORE_DIRS):
445
+ // Astro's content layer roots at the project dir, so a `.`-wide content
446
+ // root would otherwise re-ingest dependency trees and build output —
447
+ // e.g. a prior `dist/*.mdx` render — and crash the content-module graph.
448
+ // The runtime dir (`.blume`, or a custom distDir) is excluded precisely
449
+ // by `outDirIgnore` instead, so it's left out of this baseline.
450
+ ...BLUME_IGNORE_DIRS.filter((dir) => dir !== ".blume").map(
451
+ (dir) => `!**/${dir}/**`
452
+ ),
439
453
  ...outDirIgnore,
440
454
  ]
441
455
  : [];
@@ -869,7 +883,7 @@ const siteHost = (() => {
869
883
 
870
884
  export async function GET({ props }) {
871
885
  const png = await renderOgImage({
872
- accent: data.config.theme.accent,
886
+ accent: data.config.theme.accent.light,
873
887
  brand: data.config.title,
874
888
  description: data.config.description,
875
889
  logo: data.config.logo?.svg,
@@ -942,7 +956,7 @@ export const catchAllPageTemplate = (options: {
942
956
  ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
943
957
  : "";
944
958
  const askSlot = options.askEnabled
945
- ? '\n <AskAI slot="ask" strings={ui.ask} />'
959
+ ? '\n <AskAI slot="ask" strings={ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
946
960
  : "";
947
961
  const mathImport = options.mathEnabled
948
962
  ? 'import Math from "blume/components/content/Math.astro";\n'
@@ -980,10 +994,7 @@ import FileTree from "blume/components/content/FileTree.astro";
980
994
  import Frame from "blume/components/content/Frame.astro";
981
995
  import GithubInfo from "blume/components/content/GithubInfo.astro";
982
996
  import Panel from "blume/components/content/Panel.astro";
983
- import ParamField from "blume/components/content/ParamField.astro";
984
997
  import Prompt from "blume/components/content/Prompt.astro";
985
- import RequestField from "blume/components/content/RequestField.astro";
986
- import ResponseField from "blume/components/content/ResponseField.astro";
987
998
  import Step from "blume/components/content/Step.astro";
988
999
  import Steps from "blume/components/content/Steps.astro";
989
1000
  import Tab from "blume/components/content/Tab.astro";
@@ -998,6 +1009,7 @@ import Visibility from "blume/components/content/Visibility.astro";
998
1009
  import YouTube from "blume/components/content/YouTube.astro";
999
1010
  import Icon from "blume/components/Icon.astro";
1000
1011
  import ApiOverview from "blume/components/openapi/ApiOverview.astro";
1012
+ import ApiTagOperations from "blume/components/openapi/ApiTagOperations.astro";
1001
1013
  import Operation from "blume/components/openapi/Operation.astro";
1002
1014
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
1003
1015
  import { islandComponents } from "../generated/islands.ts";
@@ -1016,6 +1028,7 @@ const components = {
1016
1028
  Accordion,
1017
1029
  AccordionItem,
1018
1030
  ApiOverview,
1031
+ ApiTagOperations,
1019
1032
  AutoTypeTable,
1020
1033
  Badge,
1021
1034
  Callout,
@@ -1035,10 +1048,7 @@ const components = {
1035
1048
  Icon,
1036
1049
  Operation,
1037
1050
  Panel,
1038
- ParamField,
1039
1051
  Prompt,
1040
- RequestField,
1041
- ResponseField,
1042
1052
  Step,
1043
1053
  Steps,
1044
1054
  Tab,
@@ -1217,7 +1227,9 @@ export const changelogIndexTemplate = (options: {
1217
1227
  const askImport = options.askEnabled
1218
1228
  ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1219
1229
  : "";
1220
- const askSlot = options.askEnabled ? '\n <AskAI slot="ask" />' : "";
1230
+ const askSlot = options.askEnabled
1231
+ ? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1232
+ : "";
1221
1233
  const clientData = options.needsReact
1222
1234
  ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: data.config.title + " changelog" } }}'
1223
1235
  : "";
@@ -5,6 +5,7 @@ import { build } from "astro";
5
5
  import { defineCommand } from "citty";
6
6
  import { join } from "pathe";
7
7
 
8
+ import { buildAgentReadability } from "../../ai/agent-readability.ts";
8
9
  import { buildLlmsFiles } from "../../ai/llms.ts";
9
10
  import { ensureGitignore } from "../../core/gitignore.ts";
10
11
  import type { BlumeProject } from "../../core/project-graph.ts";
@@ -218,6 +219,19 @@ const publishBuildArtifacts = async (
218
219
  logger.success("Generated robots.txt");
219
220
  }
220
221
 
222
+ const agentReadability = buildAgentReadability(project);
223
+ if (
224
+ agentReadability &&
225
+ !existsSync(join(distDir, "agent-readability.json"))
226
+ ) {
227
+ await writeFile(
228
+ join(distDir, "agent-readability.json"),
229
+ `${JSON.stringify(agentReadability, null, 2)}\n`,
230
+ "utf-8"
231
+ );
232
+ logger.success("Generated agent-readability.json");
233
+ }
234
+
221
235
  await emitRedirectFiles(project.config, distDir);
222
236
 
223
237
  const { config } = project;
@@ -231,6 +245,7 @@ const publishBuildArtifacts = async (
231
245
  `Redirects ${config.redirects.length}`,
232
246
  `Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
233
247
  `Robots ${robots ? "yes" : "no"}`,
248
+ `Agent JSON ${agentReadability ? "yes" : "no"}`,
234
249
  `LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
235
250
  `Server features ${features.length > 0 ? features.join(", ") : "none"}`,
236
251
  ].join("\n")
@@ -6,9 +6,15 @@ import { defineCommand } from "citty";
6
6
  import { generateRuntime } from "../../astro/generate.ts";
7
7
  import { showBlumeErrorOverlay } from "../../astro/integration.ts";
8
8
  import { scanProject } from "../../core/project-graph.ts";
9
+ import { resolveRuntimeDir } from "../../core/project.ts";
9
10
  import { parsePort } from "../args.ts";
10
11
  import { coalescedRunner } from "../coalesce.ts";
11
- import { acquireDevLock, isDevLocked } from "../dev-lock.ts";
12
+ import {
13
+ acquireDevLock,
14
+ describeDevLock,
15
+ readDevLock,
16
+ updateDevLockPort,
17
+ } from "../dev-lock.ts";
12
18
  import { logger } from "../log.ts";
13
19
  import { prepareProject } from "../prepare.ts";
14
20
 
@@ -47,6 +53,23 @@ export const devCommand = defineCommand({
47
53
  const explicitPort = parsePort(args.port);
48
54
  const port = explicitPort ?? 4321;
49
55
  const devServerUrl = `http://localhost:${port}`;
56
+
57
+ // Claim the shared `.blume` dir BEFORE preparing: `prepareProject`
58
+ // regenerates the runtime, so even a refused second dev server would
59
+ // otherwise clobber the running one's generated tree (with this
60
+ // invocation's port baked in) on its way out. Dev never relocates the
61
+ // runtime dir, so the lock always lives at `<root>/.blume`.
62
+ const outDir = resolveRuntimeDir(root);
63
+ const running = readDevLock(outDir);
64
+ if (running) {
65
+ logger.error(
66
+ `A \`blume dev\` server is already running${describeDevLock(running)} in this project. Reuse that server instead of starting a second one — two dev servers would corrupt the shared .blume dir. If it crashed, delete .blume/dev.lock.`
67
+ );
68
+ process.exit(1);
69
+ }
70
+ const releaseLock = acquireDevLock(outDir, port);
71
+ process.on("exit", releaseLock);
72
+
50
73
  const project = await prepareProject({
51
74
  devServerUrl,
52
75
  mode: "dev",
@@ -56,25 +79,6 @@ export const devCommand = defineCommand({
56
79
  strict: args.strict,
57
80
  });
58
81
 
59
- if (project.bridge) {
60
- logger.info(
61
- 'Detected docs.json — running in Mintlify bridge mode (no migration). Run "blume migrate mintlify" to convert permanently.'
62
- );
63
- }
64
-
65
- // Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
66
- // rather than regenerating or deleting it out from under this server. A
67
- // second dev server would fight over the same generated tree the same
68
- // way, so it must refuse too instead of silently clobbering the lock.
69
- if (isDevLocked(project.context.outDir)) {
70
- logger.error(
71
- "Another `blume dev` is already running in this project; two dev servers would corrupt the shared .blume dir. Stop the other one first (or delete .blume/dev.lock if it crashed)."
72
- );
73
- process.exit(1);
74
- }
75
- const releaseLock = acquireDevLock(project.context.outDir);
76
- process.on("exit", releaseLock);
77
-
78
82
  const server = await dev({
79
83
  logLevel: args.debug ? "debug" : "info",
80
84
  root: project.context.outDir,
@@ -85,6 +89,13 @@ export const devCommand = defineCommand({
85
89
  },
86
90
  });
87
91
 
92
+ // Vite bumps to the next free port when the default is taken, so record
93
+ // the port the server actually bound — the lock's URL is what a refused
94
+ // second invocation tells its caller to reuse.
95
+ if (server.address.port !== port) {
96
+ updateDevLockPort(outDir, server.address.port);
97
+ }
98
+
88
99
  // Mirror any initial diagnostics into the browser overlay now the server
89
100
  // (and its HMR channel) is up.
90
101
  showBlumeErrorOverlay(project.diagnostics);
@@ -3,7 +3,6 @@ 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";
7
6
  import { BlumeError } from "../../core/diagnostics.ts";
8
7
  import { validateLinks } from "../../core/links.ts";
9
8
  import { scanProject } from "../../core/project-graph.ts";
@@ -48,7 +47,6 @@ export const validateCommand = defineCommand({
48
47
  const publicDir = join(root, "public");
49
48
  diagnostics.push(
50
49
  ...(await validateLinks(project.graph, {
51
- assetMounts: resolveAssetMounts(root, project.config.content.assets),
52
50
  checkExternal: Boolean(args.external),
53
51
  publicDir: existsSync(publicDir) ? publicDir : null,
54
52
  redirects: project.config.redirects,
@@ -14,26 +14,48 @@ import { logger } from "./log.ts";
14
14
  /**
15
15
  * A best-effort PID lock in the shared `.blume/` runtime dir. `blume dev`
16
16
  * regenerates and serves `.blume` continuously, so a concurrent `build`,
17
- * `eject`, or `sync --force` that regenerates or deletes it out from under the
17
+ * `eject`, or second `dev` that regenerates or deletes it out from under the
18
18
  * running Vite server corrupts the dev session. The lock lets those commands
19
- * detect a live dev server and refuse.
19
+ * detect a live dev server and refuse — and, because it records the server's
20
+ * port, point the caller (often an agent that just tried to start its own
21
+ * server) at the URL to reuse instead.
20
22
  */
21
23
 
24
+ export interface DevLockInfo {
25
+ pid: number;
26
+ /** Port the dev server is bound to, when known. */
27
+ port?: number;
28
+ }
29
+
22
30
  const lockPath = (outDir: string): string => join(outDir, "dev.lock");
23
31
 
32
+ const isValidPid = (pid: unknown): pid is number =>
33
+ typeof pid === "number" && Number.isInteger(pid) && pid > 0;
34
+
24
35
  /**
25
- * Whether another live `blume dev` holds the lock on `outDir`. A lock left by a
26
- * process that has since exited (stale) is treated as absent.
36
+ * Parse a lock file body. Current locks are JSON (`{"pid":123,"port":3001}`);
37
+ * a bare integer (the pre-port format) still parses as a pid-only lock.
27
38
  */
28
- export const isDevLocked = (outDir: string): boolean => {
29
- const path = lockPath(outDir);
30
- if (!existsSync(path)) {
31
- return false;
39
+ const parseLock = (raw: string): DevLockInfo | null => {
40
+ let data: unknown;
41
+ try {
42
+ data = JSON.parse(raw.trim());
43
+ } catch {
44
+ return null;
32
45
  }
33
- const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
34
- if (!(Number.isInteger(pid) && pid > 0)) {
35
- return false;
46
+ if (isValidPid(data)) {
47
+ return { pid: data };
36
48
  }
49
+ if (typeof data === "object" && data !== null) {
50
+ const { pid, port } = data as { pid?: unknown; port?: unknown };
51
+ if (isValidPid(pid)) {
52
+ return typeof port === "number" ? { pid, port } : { pid };
53
+ }
54
+ }
55
+ return null;
56
+ };
57
+
58
+ const isProcessAlive = (pid: number): boolean => {
37
59
  try {
38
60
  // Signal 0 probes liveness without actually signaling the process.
39
61
  process.kill(pid, 0);
@@ -45,15 +67,53 @@ export const isDevLocked = (outDir: string): boolean => {
45
67
  }
46
68
  };
47
69
 
70
+ /**
71
+ * Read the lock on `outDir` held by a live `blume dev`, or null. A lock left
72
+ * by a process that has since exited (stale) is treated as absent.
73
+ */
74
+ export const readDevLock = (outDir: string): DevLockInfo | null => {
75
+ const path = lockPath(outDir);
76
+ if (!existsSync(path)) {
77
+ return null;
78
+ }
79
+ const lock = parseLock(readFileSync(path, "utf-8"));
80
+ return lock && isProcessAlive(lock.pid) ? lock : null;
81
+ };
82
+
83
+ /** Whether another live `blume dev` holds the lock on `outDir`. */
84
+ export const isDevLocked = (outDir: string): boolean =>
85
+ readDevLock(outDir) !== null;
86
+
87
+ const writeLock = (outDir: string, port?: number): void => {
88
+ writeFileSync(
89
+ lockPath(outDir),
90
+ JSON.stringify({
91
+ pid: process.pid,
92
+ ...(port === undefined ? {} : { port }),
93
+ })
94
+ );
95
+ };
96
+
97
+ const ownsLock = (outDir: string): boolean => {
98
+ const path = lockPath(outDir);
99
+ if (!existsSync(path)) {
100
+ return false;
101
+ }
102
+ try {
103
+ return parseLock(readFileSync(path, "utf-8"))?.pid === process.pid;
104
+ } catch {
105
+ return false;
106
+ }
107
+ };
108
+
48
109
  /**
49
110
  * Write the current process's dev lock into `outDir` and return a release
50
111
  * function. The release only removes the file if it's still ours, so a newer
51
112
  * dev server's lock is never clobbered.
52
113
  */
53
- export const acquireDevLock = (outDir: string): (() => void) => {
54
- const path = lockPath(outDir);
114
+ export const acquireDevLock = (outDir: string, port?: number): (() => void) => {
55
115
  mkdirSync(outDir, { recursive: true });
56
- writeFileSync(path, String(process.pid));
116
+ writeLock(outDir, port);
57
117
  let released = false;
58
118
  return () => {
59
119
  if (released) {
@@ -61,11 +121,8 @@ export const acquireDevLock = (outDir: string): (() => void) => {
61
121
  }
62
122
  released = true;
63
123
  try {
64
- if (
65
- existsSync(path) &&
66
- readFileSync(path, "utf-8").trim() === String(process.pid)
67
- ) {
68
- rmSync(path, { force: true });
124
+ if (ownsLock(outDir)) {
125
+ rmSync(lockPath(outDir), { force: true });
69
126
  }
70
127
  } catch {
71
128
  // Best-effort cleanup; a stale lock is handled by the liveness check.
@@ -73,6 +130,21 @@ export const acquireDevLock = (outDir: string): (() => void) => {
73
130
  };
74
131
  };
75
132
 
133
+ /**
134
+ * Rewrite this process's lock with the port the server actually bound (the
135
+ * lock is acquired before the server starts, and Vite may bump a busy port).
136
+ * A lock owned by another process is left alone.
137
+ */
138
+ export const updateDevLockPort = (outDir: string, port: number): void => {
139
+ if (ownsLock(outDir)) {
140
+ writeLock(outDir, port);
141
+ }
142
+ };
143
+
144
+ /** Human-readable location of a locked dev server, e.g. " at http://localhost:3001". */
145
+ export const describeDevLock = (lock: DevLockInfo): string =>
146
+ lock.port === undefined ? "" : ` at http://localhost:${lock.port}`;
147
+
76
148
  /**
77
149
  * Exit with an error when a live `blume dev` owns the runtime dir under `root`.
78
150
  * `action` names the operation being refused (e.g. "building"). `runtimeDir`
@@ -85,9 +157,10 @@ export const refuseIfDevRunning = (
85
157
  action: string,
86
158
  runtimeDir?: string
87
159
  ): void => {
88
- if (isDevLocked(resolveRuntimeDir(root, runtimeDir))) {
160
+ const lock = readDevLock(resolveRuntimeDir(root, runtimeDir));
161
+ if (lock) {
89
162
  logger.error(
90
- `A \`blume dev\` server is running against .blume; ${action} would corrupt it. Stop the dev server, or re-run with --isolated to build/verify against .blume-verify without touching it.`
163
+ `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.`
91
164
  );
92
165
  process.exit(1);
93
166
  }
package/src/cli/index.ts CHANGED
@@ -8,7 +8,6 @@ import { devCommand } from "./commands/dev.ts";
8
8
  import { doctorCommand } from "./commands/doctor.ts";
9
9
  import { ejectCommand } from "./commands/eject.ts";
10
10
  import { initCommand } from "./commands/init.ts";
11
- import { migrateCommand } from "./commands/migrate.ts";
12
11
  import { previewCommand } from "./commands/preview.ts";
13
12
  import { syncCommand } from "./commands/sync.ts";
14
13
  import { validateCommand } from "./commands/validate.ts";
@@ -29,7 +28,6 @@ const main = defineCommand({
29
28
  doctor: doctorCommand,
30
29
  eject: ejectCommand,
31
30
  init: initCommand,
32
- migrate: migrateCommand,
33
31
  preview: previewCommand,
34
32
  sync: syncCommand,
35
33
  validate: validateCommand,
@@ -38,10 +38,7 @@ import FileTree from "./content/FileTree.astro";
38
38
  import Frame from "./content/Frame.astro";
39
39
  import GithubInfo from "./content/GithubInfo.astro";
40
40
  import Panel from "./content/Panel.astro";
41
- import ParamField from "./content/ParamField.astro";
42
41
  import Prompt from "./content/Prompt.astro";
43
- import RequestField from "./content/RequestField.astro";
44
- import ResponseField from "./content/ResponseField.astro";
45
42
  import Step from "./content/Step.astro";
46
43
  import Steps from "./content/Steps.astro";
47
44
  import Tab from "./content/Tab.astro";
@@ -90,10 +87,7 @@ const components = {
90
87
  GithubInfo,
91
88
  Icon,
92
89
  Panel,
93
- ParamField,
94
90
  Prompt,
95
- RequestField,
96
- ResponseField,
97
91
  Step,
98
92
  Steps,
99
93
  Tab,
@@ -1,13 +1,9 @@
1
1
  ---
2
- import data from "blume:data";
3
2
  import { resolveIcon } from "../theme/icons.ts";
4
3
 
5
4
  interface Props {
6
5
  name?: unknown;
7
6
  icon?: unknown;
8
- iconType?: string;
9
- /** Icon library for a bare name; defaults to the project's `icons.library`. */
10
- library?: string;
11
7
  size?: number;
12
8
  class?: string;
13
9
  className?: string;
@@ -18,8 +14,6 @@ interface Props {
18
14
  const {
19
15
  color,
20
16
  icon,
21
- iconType,
22
- library,
23
17
  name,
24
18
  size = 16,
25
19
  class: astroClass,
@@ -41,12 +35,7 @@ const isImageIcon = (value: string): boolean =>
41
35
  /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
42
36
  const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
43
37
  const resolvedIcon =
44
- iconName && !(imageSrc || rawSvg)
45
- ? resolveIcon(iconName, {
46
- iconType,
47
- library: library ?? data.config.icons.library,
48
- })
49
- : null;
38
+ iconName && !(imageSrc || rawSvg) ? resolveIcon(iconName) : null;
50
39
  const resolvedClass = astroClass ?? className;
51
40
  const customStyle = `display:inline-flex;width:${size}px;height:${size}px;${
52
41
  color ? `color:${color}` : ""
@@ -7,11 +7,9 @@ interface Props {
7
7
  defaultOpen?: boolean;
8
8
  id?: string;
9
9
  icon?: string;
10
- iconType?: string;
11
10
  }
12
11
 
13
- const { defaultOpen = false, description, icon, iconType, id, title } =
14
- Astro.props;
12
+ const { defaultOpen = false, description, icon, id, title } = Astro.props;
15
13
  const slugify = (value: string): string =>
16
14
  value
17
15
  .toLowerCase()
@@ -32,18 +30,17 @@ const accordionId = id ?? slugify(title);
32
30
  <summary
33
31
  class="flex cursor-pointer list-none items-center justify-between p-4 font-medium [&::-webkit-details-marker]:hidden"
34
32
  >
35
- <span class="flex min-w-0 items-start gap-2">
33
+ <span class="flex items-start gap-2">
36
34
  {
37
35
  icon && (
38
36
  <Icon
39
37
  class="mt-0.5 shrink-0 text-muted-foreground"
40
- iconType={iconType}
41
38
  name={icon}
42
39
  size={16}
43
40
  />
44
41
  )
45
42
  }
46
- <span class="min-w-0">
43
+ <span>
47
44
  <span class="block">{title}</span>
48
45
  {
49
46
  description && (
@@ -10,7 +10,6 @@ interface Props {
10
10
  color?: string;
11
11
  disabled?: boolean | string;
12
12
  icon?: unknown;
13
- iconType?: string;
14
13
  shape?: BadgeShape;
15
14
  size?: BadgeSize;
16
15
  stroke?: boolean | string;
@@ -23,7 +22,6 @@ const {
23
22
  color,
24
23
  disabled = false,
25
24
  icon,
26
- iconType,
27
25
  shape = "rounded",
28
26
  size = "md",
29
27
  stroke = false,
@@ -119,6 +117,6 @@ const customStyle = customColor
119
117
  style={customStyle}
120
118
  title={tooltip}
121
119
  >
122
- {icon && <Icon icon={icon} iconType={iconType} size={iconSize[size]} />}
120
+ {icon && <Icon icon={icon} size={iconSize[size]} />}
123
121
  <slot />
124
122
  </span>
@@ -14,11 +14,10 @@ interface Props {
14
14
  type?: CalloutType;
15
15
  title?: string;
16
16
  icon?: unknown;
17
- iconType?: string;
18
17
  color?: string;
19
18
  }
20
19
 
21
- const { color, icon, iconType, type = "info", title } = Astro.props;
20
+ const { color, icon, type = "info", title } = Astro.props;
22
21
 
23
22
  const iconByType: Record<CalloutType, string> = {
24
23
  check: "circle-check",
@@ -59,14 +58,9 @@ const iconClass: Record<CalloutType, string> = {
59
58
  style={color ? `border:1px solid ${color};color:${color}` : undefined}
60
59
  >
61
60
  <span class:list={["mt-0.5 shrink-0", color ? "" : iconClass[type]]}>
62
- <Icon
63
- color={color}
64
- icon={icon ?? iconByType[type]}
65
- iconType={iconType}
66
- size={16}
67
- />
61
+ <Icon color={color} icon={icon ?? iconByType[type]} size={16} />
68
62
  </span>
69
- <div class="min-w-0 flex-1 [&>:first-child]:mt-0! [&>:last-child]:mb-0!">
63
+ <div class="flex-1 [&>:first-child]:mt-0! [&>:last-child]:mb-0!">
70
64
  {title && <p class="mb-1 font-semibold text-foreground">{title}</p>}
71
65
  <slot />
72
66
  </div>
@@ -5,7 +5,6 @@ interface Props {
5
5
  title?: string;
6
6
  href?: string;
7
7
  icon?: string;
8
- iconType?: string;
9
8
  img?: string;
10
9
  horizontal?: boolean | string;
11
10
  cta?: string;
@@ -14,7 +13,7 @@ interface Props {
14
13
  color?: string;
15
14
  }
16
15
 
17
- const { arrow, color, cta, horizontal, href, icon, iconType, img, title, type } =
16
+ const { arrow, color, cta, horizontal, href, icon, img, title, type } =
18
17
  Astro.props;
19
18
  const Tag = href ? "a" : "div";
20
19
  const external = href?.startsWith("http");
@@ -75,7 +74,7 @@ const variantClass = {
75
74
  {
76
75
  iconName && (
77
76
  <div class:list={["mb-2.5", color ? "" : type ? iconClass[type] : "text-accent"]}>
78
- <Icon color={color} iconType={iconType} name={iconName} size={20} />
77
+ <Icon color={color} name={iconName} size={20} />
79
78
  </div>
80
79
  )
81
80
  }
@@ -21,7 +21,7 @@ const displayValue =
21
21
 
22
22
  <button
23
23
  aria-label={`Copy ${name} color ${displayValue}`}
24
- class="group flex w-full min-w-0 cursor-pointer flex-col gap-2.5 text-center focus:outline-none"
24
+ class="group flex w-full cursor-pointer flex-col gap-2.5 text-center focus:outline-none"
25
25
  data-blume-color-copy={displayValue}
26
26
  style={`--blume-color-light:${lightValue};--blume-color-dark:${darkValue};`}
27
27
  type="button"
@@ -32,7 +32,7 @@ const displayValue =
32
32
  style="background:var(--blume-color-light)"
33
33
  >
34
34
  </span>
35
- <span class="block min-w-0 leading-tight">
35
+ <span class="block leading-tight">
36
36
  <span class="block truncate font-semibold text-foreground text-sm">
37
37
  {name}
38
38
  </span>
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  ---
3
3
 
4
- <div class="min-w-0">
4
+ <div>
5
5
  <slot />
6
6
  </div>