blume 0.5.2 → 0.5.4

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 (137) hide show
  1. package/dist/cli/index.js +2235 -5942
  2. package/dist/cli/index.js.map +36 -65
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +2 -2
  5. package/dist/types/core/schema.d.ts +58 -453
  6. package/dist/types/core/types.d.ts +1 -7
  7. package/docs/advanced/meta.ts +1 -8
  8. package/docs/advanced/skills.mdx +28 -0
  9. package/docs/configuration/ai.mdx +3 -1
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +3 -11
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +1 -5
  14. package/docs/content/sources.mdx +1 -1
  15. package/docs/content/syntax.mdx +1 -1
  16. package/docs/index.mdx +0 -1
  17. package/docs/reference/cli.mdx +12 -13
  18. package/package.json +4 -6
  19. package/skills/blume/SKILL.md +71 -0
  20. package/skills/blume-update-docs/SKILL.md +52 -0
  21. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  22. package/src/ai/mcp/discovery.ts +3 -1
  23. package/src/ai/mcp/server.ts +3 -1
  24. package/src/astro/component-slots.ts +10 -2
  25. package/src/astro/generate.ts +15 -12
  26. package/src/astro/integration.ts +0 -21
  27. package/src/astro/templates.ts +62 -37
  28. package/src/cli/coalesce.ts +43 -0
  29. package/src/cli/commands/dev.ts +30 -22
  30. package/src/cli/commands/validate.ts +0 -2
  31. package/src/cli/dev-lock.ts +4 -2
  32. package/src/cli/index.ts +0 -2
  33. package/src/components/BlumePage.astro +0 -6
  34. package/src/components/Icon.astro +1 -12
  35. package/src/components/content/AccordionItem.astro +3 -6
  36. package/src/components/content/Badge.astro +1 -3
  37. package/src/components/content/Callout.astro +3 -9
  38. package/src/components/content/Card.astro +2 -3
  39. package/src/components/content/ColorItem.astro +8 -5
  40. package/src/components/content/Column.astro +1 -1
  41. package/src/components/content/Prompt.astro +8 -4
  42. package/src/components/content/Step.astro +3 -4
  43. package/src/components/content/Tab.astro +2 -3
  44. package/src/components/content/Tabs.astro +13 -2
  45. package/src/components/content/TypeTable.astro +5 -5
  46. package/src/components/content/Update.astro +1 -1
  47. package/src/components/content/mermaid-element.ts +20 -2
  48. package/src/components/islands/ask-ai.tsx +4 -8
  49. package/src/components/islands/base-path.ts +30 -0
  50. package/src/components/islands/hooks.ts +12 -8
  51. package/src/components/layout/Header.astro +26 -3
  52. package/src/components/layout/Logo.astro +5 -1
  53. package/src/components/layout/NavSelector.astro +1 -1
  54. package/src/components/layout/NavTree.astro +14 -14
  55. package/src/components/layout/PageActions.astro +17 -11
  56. package/src/components/layout/PageLayout.astro +42 -0
  57. package/src/components/layout/ReferenceLayout.astro +1 -0
  58. package/src/components/layout/RootLayout.astro +31 -2
  59. package/src/components/layout/Search.astro +9 -6
  60. package/src/components/layout/nav-utils.ts +9 -4
  61. package/src/components/layout/search/types.ts +16 -5
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/components/openapi/ParametersTable.astro +1 -1
  65. package/src/components/openapi/SchemaProperty.astro +1 -1
  66. package/src/components/openapi/SchemaTable.astro +3 -3
  67. package/src/components/openapi/helpers.ts +17 -8
  68. package/src/components/openapi/snippets.ts +17 -4
  69. package/src/core/builtin-tags.ts +1 -3
  70. package/src/core/config.ts +19 -33
  71. package/src/core/data.ts +2 -2
  72. package/src/core/graph.ts +7 -5
  73. package/src/core/links.ts +5 -19
  74. package/src/core/meta.ts +1 -1
  75. package/src/core/navigation.ts +5 -6
  76. package/src/core/project-graph.ts +0 -5
  77. package/src/core/schema.ts +13 -90
  78. package/src/core/sources/filesystem.ts +19 -1
  79. package/src/core/sources/mdx-remote.ts +20 -4
  80. package/src/core/sources/normalize.ts +28 -6
  81. package/src/core/sources/resolve.ts +0 -13
  82. package/src/core/sources/watch.ts +45 -0
  83. package/src/core/types.ts +1 -8
  84. package/src/markdown/code-title.ts +6 -3
  85. package/src/markdown/package-install.ts +3 -1
  86. package/src/og/card.ts +16 -4
  87. package/src/openapi/parse.ts +197 -14
  88. package/src/openapi/render-mdx.ts +54 -11
  89. package/src/openapi/source.ts +19 -2
  90. package/src/search/sync/orama-cloud.ts +2 -0
  91. package/src/search/sync/typesense.ts +4 -0
  92. package/src/theme/entry.ts +45 -17
  93. package/src/theme/icons.ts +22 -104
  94. package/src/theme/palette.ts +35 -47
  95. package/src/theme/twoslash.ts +6 -1
  96. package/dist/types/core/bridge.d.ts +0 -24
  97. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  98. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  99. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  100. package/docs/advanced/bridge.mdx +0 -76
  101. package/docs/advanced/migrate.mdx +0 -123
  102. package/src/astro/static-assets.ts +0 -117
  103. package/src/cli/commands/migrate.ts +0 -39
  104. package/src/components/content/ApiField.astro +0 -75
  105. package/src/components/content/ParamField.astro +0 -39
  106. package/src/components/content/RequestField.astro +0 -23
  107. package/src/components/content/ResponseField.astro +0 -23
  108. package/src/components/content/Warning.astro +0 -9
  109. package/src/core/assets.ts +0 -31
  110. package/src/core/bridge.ts +0 -102
  111. package/src/core/sources/mintlify.ts +0 -204
  112. package/src/migrate/fumadocs/config.ts +0 -155
  113. package/src/migrate/fumadocs/content.ts +0 -378
  114. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  115. package/src/migrate/fumadocs/groups.ts +0 -237
  116. package/src/migrate/fumadocs/index.ts +0 -340
  117. package/src/migrate/fumadocs/meta.ts +0 -244
  118. package/src/migrate/migrate.ts +0 -53
  119. package/src/migrate/mintlify/assets.ts +0 -46
  120. package/src/migrate/mintlify/config.ts +0 -958
  121. package/src/migrate/mintlify/content.ts +0 -120
  122. package/src/migrate/mintlify/frontmatter.ts +0 -126
  123. package/src/migrate/mintlify/i18n.ts +0 -51
  124. package/src/migrate/mintlify/icons.ts +0 -128
  125. package/src/migrate/mintlify/index.ts +0 -372
  126. package/src/migrate/mintlify/snippets.ts +0 -306
  127. package/src/migrate/mintlify/transform.ts +0 -82
  128. package/src/migrate/nextra/content.ts +0 -46
  129. package/src/migrate/nextra/frontmatter.ts +0 -40
  130. package/src/migrate/nextra/index.ts +0 -374
  131. package/src/migrate/nextra/meta.ts +0 -266
  132. package/src/migrate/shared.ts +0 -728
  133. package/src/migrate/starlight/config.ts +0 -455
  134. package/src/migrate/starlight/content.ts +0 -78
  135. package/src/migrate/starlight/frontmatter.ts +0 -111
  136. package/src/migrate/starlight/i18n.ts +0 -54
  137. package/src/migrate/starlight/index.ts +0 -131
@@ -432,11 +432,16 @@ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
432
432
  if (!logo) {
433
433
  return null;
434
434
  }
435
- const config = typeof logo === "string" ? { light: logo } : logo;
436
- const light = config.light ?? config.dark;
437
- const dark = config.dark ?? config.light;
438
- const alt = config.alt ?? "";
439
- const href = config.href ?? "/";
435
+ const config = typeof logo === "string" ? { image: logo } : logo;
436
+ // `text` is passed through verbatim: `undefined` lets the brand fall back to
437
+ // the site title, `""` renders the mark alone (a logo with the wordmark baked
438
+ // in).
439
+ const { href, image: source, text } = config;
440
+ const image = typeof source === "string" ? { light: source } : source;
441
+ const light = image?.light ?? image?.dark;
442
+ const dark = image?.dark ?? image?.light;
443
+ const alt = image?.alt ?? "";
444
+ const brandHref = href ?? "/";
440
445
 
441
446
  if (light && light === dark && light.toLowerCase().endsWith(".svg")) {
442
447
  const rel = light.replace(/^\//u, "");
@@ -445,10 +450,10 @@ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
445
450
  join(project.context.root, rel),
446
451
  ].find((path) => existsSync(path));
447
452
  if (file) {
448
- return { alt, href, svg: readFileSync(file, "utf-8") };
453
+ return { alt, href: brandHref, svg: readFileSync(file, "utf-8"), text };
449
454
  }
450
455
  }
451
- return { alt, dark, href, light };
456
+ return { alt, dark, href: brandHref, light, text };
452
457
  };
453
458
 
454
459
  /**
@@ -615,7 +620,6 @@ export const buildRuntimeData = (project: BlumeProject): string => {
615
620
  code,
616
621
  withReferenceTabs(
617
622
  graph.navigationByLocale[code] ?? {
618
- chromeVariants: [],
619
623
  selectors: [],
620
624
  sidebar: [],
621
625
  tabs: [],
@@ -648,7 +652,6 @@ export const buildRuntimeData = (project: BlumeProject): string => {
648
652
  })),
649
653
  }
650
654
  : null,
651
- icons: config.icons,
652
655
  imageZoom: config.markdown.imageZoom,
653
656
  logo: resolveLogo(project),
654
657
  mcp: config.mcp.enabled
@@ -974,9 +977,9 @@ export const generateRuntime = async (
974
977
  const staged = collectStaged(project);
975
978
  const hasStaged = staged.size > 0;
976
979
  // Only emit a project-scanning `docs` collection when a filesystem source
977
- // actually feeds it. Bridge mode has just the staged Mintlify source, so the
978
- // `docs` glob would otherwise scan (and watch) the whole project root for
979
- // nothing — see contentConfigTemplate.
980
+ // actually feeds it. An all-staged project (openapi/notion/…) has only staged
981
+ // sources, so the `docs` glob would otherwise scan (and watch) the whole
982
+ // project root for nothing — see contentConfigTemplate.
980
983
  const hasFilesystemSource = project.sources.some((source) => !source.staged);
981
984
 
982
985
  const structural = await Promise.all([
@@ -1,13 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { fileURLToPath } from "node:url";
3
2
 
4
3
  import type { AstroIntegration } from "astro";
5
4
 
6
- import type { AssetMount } from "../core/assets.ts";
7
5
  import { enrichDiagnostic } from "../core/diagnostics.ts";
8
6
  import type { Diagnostic } from "../core/types.ts";
9
7
  import { markdownVariantUrl, prefersMarkdown } from "./markdown-negotiation.ts";
10
- import { copyAssetMounts, serveAssetMounts } from "./static-assets.ts";
11
8
 
12
9
  /** The dev server's HMR channel — either `.ws` (Vite ≤5) or `.hot` (Vite 6+). */
13
10
  interface OverlayChannel {
@@ -77,11 +74,6 @@ export interface BlumeIntegrationOptions {
77
74
  contentRoutes: string[];
78
75
  /** Configured `deployment.base`, stripped from dev URLs before matching. */
79
76
  base?: string;
80
- /**
81
- * `content.assets` mounts: top-level dirs served at the site root in dev and
82
- * copied into the build output, so root-served assets need no relocation.
83
- */
84
- assets?: AssetMount[];
85
77
  }
86
78
 
87
79
  /**
@@ -118,12 +110,6 @@ export const blumeIntegration = (
118
110
  options: BlumeIntegrationOptions
119
111
  ): AstroIntegration => ({
120
112
  hooks: {
121
- "astro:build:done": async ({ dir }) => {
122
- // Copy in-place asset mounts into the build output, mirroring publicDir.
123
- if (options.assets?.length) {
124
- await copyAssetMounts(options.assets, fileURLToPath(dir));
125
- }
126
- },
127
113
  "astro:config:setup": ({ injectRoute }) => {
128
114
  for (const page of options.pages) {
129
115
  injectRoute({
@@ -143,13 +129,6 @@ export const blumeIntegration = (
143
129
  handle: negotiateMarkdown(new Set(options.contentRoutes), options.base),
144
130
  route: "",
145
131
  });
146
- // Serve `content.assets` mounts (Astro only serves publicDir in dev).
147
- if (options.assets?.length) {
148
- server.middlewares.stack.unshift({
149
- handle: serveAssetMounts(options.assets),
150
- route: "",
151
- });
152
- }
153
132
  },
154
133
  },
155
134
  name: "blume",
@@ -4,7 +4,6 @@ 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";
9
8
  import type { ProjectContext } from "../core/types.ts";
10
9
  import { hasScalarReferences } from "../openapi/references.ts";
@@ -313,12 +312,10 @@ export const astroConfigTemplate = (options: {
313
312
  if (needsSvelte) {
314
313
  integrations.push("svelte()");
315
314
  }
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);
315
+ // Always mounted: injects user pages (a no-op when there are none) and wires
316
+ // up dev-server `Accept: text/markdown` negotiation over the content routes.
320
317
  integrations.push(
321
- `blumeIntegration(${JSON.stringify({ assets, base: deployment.base, contentRoutes, pages })})`
318
+ `blumeIntegration(${JSON.stringify({ base: deployment.base, contentRoutes, pages })})`
322
319
  );
323
320
 
324
321
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
@@ -374,6 +371,15 @@ export default defineConfig({
374
371
  fs: {
375
372
  allow: ${JSON.stringify(fsAllow)},
376
373
  },
374
+ // Keep the file watcher out of Astro's own cache dir. In a migrated
375
+ // (root-rooted) project the docs collection is rooted at the project dir,
376
+ // so its glob-loader watcher would otherwise fire on every write Astro
377
+ // makes under .blume/.astro (data-store.json, content module manifests,
378
+ // self-hosted fonts) -- pure noise the loader logs as "No entry type
379
+ // found". Vite appends this to its default ignores.
380
+ watch: {
381
+ ignored: ${JSON.stringify([join(context.outDir, ".astro", "**")])},
382
+ },
377
383
  },
378
384
  },
379
385
  });
@@ -394,8 +400,8 @@ export const contentConfigTemplate = (options: {
394
400
  stagedBase?: string;
395
401
  /**
396
402
  * Whether any filesystem (non-staged) source feeds the `docs` collection.
397
- * When false (e.g. Mintlify bridge mode, where every page is staged), the
398
- * collection globs nothing — see below.
403
+ * When false (e.g. an all-staged project where every page is materialized by
404
+ * a non-filesystem source), the collection globs nothing — see below.
399
405
  */
400
406
  filesystem?: boolean;
401
407
  }): string => {
@@ -414,8 +420,8 @@ export const contentConfigTemplate = (options: {
414
420
 
415
421
  // With no filesystem source, no route renders through `docs`, so glob nothing.
416
422
  // Beyond skipping wasted work, this is the only thing that keeps Astro's
417
- // content-layer *watcher* out of `.blume/`: bridge mode roots the collection
418
- // at the project dir (which contains `.blume/.astro/fonts`, rewritten on every
423
+ // content-layer *watcher* out of `.blume/`: an all-staged project roots the
424
+ // collection at the project dir (which contains `.blume/.astro/fonts`, rewritten on every
419
425
  // request), and the watcher's match test is `picomatch.isMatch(path, pattern)`
420
426
  // — with array-OR semantics, any `!ignored/**` negation *matches* unrelated
421
427
  // files, so negative patterns can't exclude a subtree there. An empty pattern
@@ -503,20 +509,35 @@ export const askEndpointTemplate = (
503
509
  }
504
510
  // Validate the client-supplied body and cap its size. The endpoint is
505
511
  // unauthenticated, so bounding message count/length limits how much a caller
506
- // can spend against the model per request; front it with a rate limiter (or
507
- // your provider's limits) for stronger protection.
512
+ // can spend against the model per request, and restricting roles to
513
+ // user/assistant keeps callers from injecting their own system prompt and
514
+ // repurposing the endpoint as a general LLM proxy; front it with a rate
515
+ // limiter (or your provider's limits) for stronger protection.
508
516
  const validate = ` const body = await request.json().catch(() => null);
509
- const messages = body?.messages;
510
- if (
511
- !Array.isArray(messages) ||
512
- messages.length === 0 ||
513
- messages.length > 40 ||
514
- JSON.stringify(messages).length > 24_000
515
- ) {
516
- return new Response("Invalid request: send 1-40 messages.", {
517
- status: 400,
518
- });
519
- }`;
517
+ const raw = body?.messages;
518
+ const valid =
519
+ Array.isArray(raw) &&
520
+ raw.length > 0 &&
521
+ raw.length <= 40 &&
522
+ raw.every(
523
+ (m: unknown) =>
524
+ typeof m === "object" &&
525
+ m !== null &&
526
+ ("role" in m && (m.role === "user" || m.role === "assistant")) &&
527
+ ("content" in m && typeof m.content === "string")
528
+ ) &&
529
+ JSON.stringify(raw).length <= 24_000;
530
+ if (!valid) {
531
+ return new Response(
532
+ "Invalid request: send 1-40 user/assistant messages with string content.",
533
+ { status: 400 }
534
+ );
535
+ }
536
+ // Re-build the array so only role/content ever reach the model.
537
+ const messages = raw.map((m: { role: "user" | "assistant"; content: string }) => ({
538
+ content: m.content,
539
+ role: m.role,
540
+ }));`;
520
541
  const stream = grounded
521
542
  ? ` const system =
522
543
  (await ground(messages, body.page)) ??
@@ -570,10 +591,16 @@ const SEARCH_CLIENT_HEADER = "// Generated by Blume. Do not edit.\n";
570
591
  const searchClientImport = (module: string): string =>
571
592
  `import { createSearch as create } from "blume/components/layout/search/${module}.ts";\n`;
572
593
 
594
+ // Joins a base-relative path onto BASE_URL, which arrives with or without a
595
+ // trailing slash (Astro's default trailingSlash: "ignore" passes `/docs`
596
+ // through bare — naive concatenation would yield `/docsblume-search.json`).
597
+ const SEARCH_BASE_IMPORT =
598
+ 'import { joinBase } from "blume/components/islands/base-path.ts";\n';
599
+
573
600
  /** A client that loads a static `blume-search.json` index (Orama, FlexSearch). */
574
601
  const staticSearchClient = (module: string): string =>
575
- `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}
576
- const indexUrl = \`\${import.meta.env.BASE_URL}blume-search.json\`.replace("//", "/");
602
+ `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
603
+ const indexUrl = joinBase(import.meta.env.BASE_URL, "blume-search.json");
577
604
 
578
605
  export const createSearch = () => create({ indexUrl });
579
606
  `;
@@ -633,16 +660,16 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
633
660
  }
634
661
 
635
662
  if (search.provider === "mixedbread") {
636
- return `${SEARCH_CLIENT_HEADER}${searchClientImport("endpoint")}
637
- const api = \`\${import.meta.env.BASE_URL}api/search\`.replace("//", "/");
663
+ return `${SEARCH_CLIENT_HEADER}${searchClientImport("endpoint")}${SEARCH_BASE_IMPORT}
664
+ const api = joinBase(import.meta.env.BASE_URL, "api/search");
638
665
 
639
666
  export const createSearch = () => create({ api });
640
667
  `;
641
668
  }
642
669
 
643
670
  if (search.provider === "pagefind") {
644
- return `${SEARCH_CLIENT_HEADER}${searchClientImport("pagefind")}
645
- const url = \`\${import.meta.env.BASE_URL}pagefind/pagefind.js\`.replace("//", "/");
671
+ return `${SEARCH_CLIENT_HEADER}${searchClientImport("pagefind")}${SEARCH_BASE_IMPORT}
672
+ const url = joinBase(import.meta.env.BASE_URL, "pagefind/pagefind.js");
646
673
 
647
674
  export const createSearch = () => create({ url });
648
675
  `;
@@ -671,8 +698,10 @@ const client = new Mixedbread({ apiKey: process.env.MIXEDBREAD_API_KEY ?? "" });
671
698
  const STORE_ID = ${JSON.stringify(storeId)};
672
699
 
673
700
  export const POST: APIRoute = async ({ request }) => {
674
- const { query } = await request.json();
675
- if (!query) {
701
+ // The endpoint is public: a malformed body must 200-empty, not 500.
702
+ const body = await request.json().catch(() => null);
703
+ const query = body?.query;
704
+ if (!query || typeof query !== "string") {
676
705
  return new Response("[]", {
677
706
  headers: { "Content-Type": "application/json" },
678
707
  });
@@ -948,10 +977,7 @@ import FileTree from "blume/components/content/FileTree.astro";
948
977
  import Frame from "blume/components/content/Frame.astro";
949
978
  import GithubInfo from "blume/components/content/GithubInfo.astro";
950
979
  import Panel from "blume/components/content/Panel.astro";
951
- import ParamField from "blume/components/content/ParamField.astro";
952
980
  import Prompt from "blume/components/content/Prompt.astro";
953
- import RequestField from "blume/components/content/RequestField.astro";
954
- import ResponseField from "blume/components/content/ResponseField.astro";
955
981
  import Step from "blume/components/content/Step.astro";
956
982
  import Steps from "blume/components/content/Steps.astro";
957
983
  import Tab from "blume/components/content/Tab.astro";
@@ -966,6 +992,7 @@ import Visibility from "blume/components/content/Visibility.astro";
966
992
  import YouTube from "blume/components/content/YouTube.astro";
967
993
  import Icon from "blume/components/Icon.astro";
968
994
  import ApiOverview from "blume/components/openapi/ApiOverview.astro";
995
+ import ApiTagOperations from "blume/components/openapi/ApiTagOperations.astro";
969
996
  import Operation from "blume/components/openapi/Operation.astro";
970
997
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
971
998
  import { islandComponents } from "../generated/islands.ts";
@@ -984,6 +1011,7 @@ const components = {
984
1011
  Accordion,
985
1012
  AccordionItem,
986
1013
  ApiOverview,
1014
+ ApiTagOperations,
987
1015
  AutoTypeTable,
988
1016
  Badge,
989
1017
  Callout,
@@ -1003,10 +1031,7 @@ const components = {
1003
1031
  Icon,
1004
1032
  Operation,
1005
1033
  Panel,
1006
- ParamField,
1007
1034
  Prompt,
1008
- RequestField,
1009
- ResponseField,
1010
1035
  Step,
1011
1036
  Steps,
1012
1037
  Tab,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Wrap an async task so it never runs concurrently with itself. Triggering the
3
+ * returned function while a run is in flight coalesces into a single trailing
4
+ * run after the current one settles.
5
+ *
6
+ * Dev regeneration (`scanProject` + `generateRuntime`) is expensive on a large
7
+ * project — a full content re-scan that allocates big strings. A plain debounce
8
+ * still lets a fast burst of watch events (or, before it was fixed, a `.blume/`
9
+ * watch storm) start a new scan before the previous finished, piling up
10
+ * overlapping scans until the heap is exhausted (observed as an OOM after
11
+ * minutes of looping). Single-flighting bounds it to one scan at a time while
12
+ * still guaranteeing a final run reflects the latest change.
13
+ *
14
+ * The task must not reject: a rejection would surface as an unhandled promise
15
+ * rejection, so callers handle their own errors and always resolve.
16
+ */
17
+ export const coalescedRunner = (task: () => Promise<void>): (() => void) => {
18
+ let inFlight: Promise<void> | null = null;
19
+ let pending = false;
20
+
21
+ // Drain any run requested during the current run, then release the lock. The
22
+ // `inFlight` promise is assigned synchronously by the caller below, so a
23
+ // re-entrant trigger sees the lock immediately and only sets `pending`.
24
+ const cycle = async (): Promise<void> => {
25
+ try {
26
+ do {
27
+ pending = false;
28
+ // oxlint-disable-next-line no-await-in-loop -- serialized by design
29
+ await task();
30
+ } while (pending);
31
+ } finally {
32
+ inFlight = null;
33
+ }
34
+ };
35
+
36
+ return () => {
37
+ if (inFlight) {
38
+ pending = true;
39
+ return;
40
+ }
41
+ inFlight = cycle();
42
+ };
43
+ };
@@ -7,7 +7,8 @@ import { generateRuntime } from "../../astro/generate.ts";
7
7
  import { showBlumeErrorOverlay } from "../../astro/integration.ts";
8
8
  import { scanProject } from "../../core/project-graph.ts";
9
9
  import { parsePort } from "../args.ts";
10
- import { acquireDevLock } from "../dev-lock.ts";
10
+ import { coalescedRunner } from "../coalesce.ts";
11
+ import { acquireDevLock, isDevLocked } from "../dev-lock.ts";
11
12
  import { logger } from "../log.ts";
12
13
  import { prepareProject } from "../prepare.ts";
13
14
 
@@ -55,14 +56,16 @@ export const devCommand = defineCommand({
55
56
  strict: args.strict,
56
57
  });
57
58
 
58
- if (project.bridge) {
59
- logger.info(
60
- 'Detected docs.json running in Mintlify bridge mode (no migration). Run "blume migrate mintlify" to convert permanently.'
59
+ // Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
60
+ // rather than regenerating or deleting it out from under this server. A
61
+ // second dev server would fight over the same generated tree the same
62
+ // way, so it must refuse too instead of silently clobbering the lock.
63
+ if (isDevLocked(project.context.outDir)) {
64
+ logger.error(
65
+ "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)."
61
66
  );
67
+ process.exit(1);
62
68
  }
63
-
64
- // Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
65
- // rather than regenerating or deleting it out from under this server.
66
69
  const releaseLock = acquireDevLock(project.context.outDir);
67
70
  process.on("exit", releaseLock);
68
71
 
@@ -82,26 +85,31 @@ export const devCommand = defineCommand({
82
85
 
83
86
  // Watch user inputs and regenerate the runtime data on change. Astro/Vite
84
87
  // hot-reloads the generated data module so nav and routes stay in sync.
88
+ // `coalescedRunner` single-flights the scan so a burst of watch events can
89
+ // never stack overlapping regenerations (a large project's scan can outlast
90
+ // the debounce; piled-up scans exhaust the heap).
91
+ const runRegenerate = coalescedRunner(async () => {
92
+ try {
93
+ const next = await scanProject(root, {
94
+ devServerUrl,
95
+ mode: "dev",
96
+ overrides,
97
+ preview,
98
+ });
99
+ await generateRuntime(next);
100
+ // Surface any content/config errors in the browser overlay too.
101
+ showBlumeErrorOverlay(next.diagnostics);
102
+ } catch (error) {
103
+ logger.error(`Regeneration failed: ${(error as Error).message}`);
104
+ }
105
+ });
106
+
85
107
  let timer: ReturnType<typeof setTimeout> | null = null;
86
108
  const regenerate = () => {
87
109
  if (timer) {
88
110
  clearTimeout(timer);
89
111
  }
90
- timer = setTimeout(async () => {
91
- try {
92
- const next = await scanProject(root, {
93
- devServerUrl,
94
- mode: "dev",
95
- overrides,
96
- preview,
97
- });
98
- await generateRuntime(next);
99
- // Surface any content/config errors in the browser overlay too.
100
- showBlumeErrorOverlay(next.diagnostics);
101
- } catch (error) {
102
- logger.error(`Regeneration failed: ${(error as Error).message}`);
103
- }
104
- }, 80);
112
+ timer = setTimeout(runRegenerate, 80);
105
113
  };
106
114
 
107
115
  // Content is watched per source (filesystem uses fs.watch; remote sources
@@ -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,
@@ -38,8 +38,10 @@ export const isDevLocked = (outDir: string): boolean => {
38
38
  // Signal 0 probes liveness without actually signaling the process.
39
39
  process.kill(pid, 0);
40
40
  return true;
41
- } catch {
42
- return false;
41
+ } catch (error) {
42
+ // EPERM means the process exists but belongs to another user — still
43
+ // live, so the lock must hold (only ESRCH proves it's gone).
44
+ return (error as NodeJS.ErrnoException).code === "EPERM";
43
45
  }
44
46
  };
45
47
 
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
  }