blume 0.5.1 → 0.5.3

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 (60) hide show
  1. package/dist/cli/index.js +2996 -2762
  2. package/dist/cli/index.js.map +35 -32
  3. package/dist/types/core/package-json.d.ts +12 -0
  4. package/dist/types/migrate/shared.d.ts +153 -0
  5. package/docs/advanced/migrate.mdx +1 -0
  6. package/docs/configuration/ai.mdx +1 -1
  7. package/docs/configuration/theming.mdx +1 -1
  8. package/docs/content/i18n.mdx +1 -1
  9. package/docs/content/sources.mdx +1 -1
  10. package/package.json +1 -1
  11. package/src/ai/mcp/discovery.ts +3 -1
  12. package/src/ai/mcp/server.ts +3 -1
  13. package/src/astro/component-slots.ts +10 -2
  14. package/src/astro/generate.ts +11 -1
  15. package/src/astro/static-assets.ts +10 -3
  16. package/src/astro/templates.ts +87 -29
  17. package/src/cli/coalesce.ts +43 -0
  18. package/src/cli/commands/dev.ts +31 -17
  19. package/src/cli/commands/init.ts +2 -27
  20. package/src/cli/dev-lock.ts +4 -2
  21. package/src/components/content/ColorItem.astro +6 -3
  22. package/src/components/content/Prompt.astro +7 -3
  23. package/src/components/content/Tabs.astro +13 -2
  24. package/src/components/content/mermaid-element.ts +20 -2
  25. package/src/components/islands/ask-ai.tsx +4 -8
  26. package/src/components/islands/base-path.ts +30 -0
  27. package/src/components/islands/hooks.ts +12 -8
  28. package/src/components/layout/PageActions.astro +17 -11
  29. package/src/components/layout/Search.astro +4 -1
  30. package/src/components/layout/search/types.ts +16 -5
  31. package/src/components/openapi/ParametersTable.astro +1 -1
  32. package/src/components/openapi/SchemaProperty.astro +1 -1
  33. package/src/components/openapi/SchemaTable.astro +3 -3
  34. package/src/components/openapi/helpers.ts +17 -8
  35. package/src/components/openapi/snippets.ts +17 -4
  36. package/src/core/config.ts +15 -6
  37. package/src/core/graph.ts +6 -1
  38. package/src/core/navigation.ts +5 -1
  39. package/src/core/package-json.ts +32 -0
  40. package/src/core/sources/filesystem.ts +19 -1
  41. package/src/core/sources/mdx-remote.ts +20 -4
  42. package/src/core/sources/mintlify.ts +30 -1
  43. package/src/core/sources/normalize.ts +28 -6
  44. package/src/core/sources/watch.ts +44 -0
  45. package/src/markdown/code-title.ts +6 -3
  46. package/src/markdown/package-install.ts +3 -1
  47. package/src/migrate/fumadocs/content.ts +3 -5
  48. package/src/migrate/fumadocs/index.ts +24 -9
  49. package/src/migrate/mintlify/config.ts +2 -6
  50. package/src/migrate/mintlify/index.ts +143 -32
  51. package/src/migrate/mintlify/snippets.ts +17 -8
  52. package/src/migrate/nextra/index.ts +16 -1
  53. package/src/migrate/shared.ts +101 -5
  54. package/src/migrate/starlight/content.ts +3 -6
  55. package/src/og/card.ts +16 -4
  56. package/src/openapi/render-mdx.ts +10 -1
  57. package/src/search/sync/orama-cloud.ts +2 -0
  58. package/src/search/sync/typesense.ts +4 -0
  59. package/src/theme/icons.ts +13 -4
  60. package/src/theme/palette.ts +38 -17
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Derive a valid npm package name from a directory name, falling back to
3
+ * `docs` when nothing usable remains.
4
+ */
5
+ export declare const toPackageName: (raw: string) => string;
6
+ /**
7
+ * A minimal, runnable `package.json` body for a Blume project: the `blume`
8
+ * dependency pinned to the installed version plus `dev`/`build`/`doctor`
9
+ * scripts, so `npm install && npm run dev` works immediately. Shared by
10
+ * `blume init` and the migrators, which scaffold one when a project has none.
11
+ */
12
+ export declare const blumePackageJson: (name: string) => string;
@@ -0,0 +1,153 @@
1
+ import type { BlumeConfig } from "../core/schema.ts";
2
+ /**
3
+ * Whether `candidate` resolves to a path inside `root` (or is `root` itself).
4
+ * Guards migrators against `../` traversal in author-controlled source paths
5
+ * (`pages` entries, `<include>` targets) that would otherwise read or move
6
+ * files outside the docs tree.
7
+ */
8
+ export declare const isInsideRoot: (root: string, candidate: string) => boolean;
9
+ /**
10
+ * Framework-agnostic helpers shared by more than one migrator. Each piece here
11
+ * was generalized from a migrator-specific implementation so Mintlify, Nextra,
12
+ * and future migrators converge on a single copy.
13
+ */
14
+ /** Serialize a `BlumeConfig` to a `blume.config.ts` at the project root. */
15
+ export declare const writeBlumeConfig: (root: string, config: BlumeConfig) => Promise<void>;
16
+ /**
17
+ * Rewrite a migrated project's npm scripts off the old framework's CLI. A
18
+ * `dev`/`build`/`start` script whose command invokes `cli` (e.g. `/\bnext\b/`)
19
+ * is repointed at the matching Blume command (`start` -> `blume preview`); a
20
+ * script whose command matches `remove` (e.g. a `fumadocs-mdx` postinstall) is
21
+ * dropped. Scripts that don't match either are left untouched, so custom tasks
22
+ * survive. Returns true when `package.json` changed.
23
+ */
24
+ export declare const rewriteFrameworkScripts: (root: string, cli: RegExp, remove?: RegExp) => Promise<boolean>;
25
+ /** Of the candidate project-relative paths, the ones that still exist — the old
26
+ * framework files a migration leaves behind for the user to remove by hand. */
27
+ export declare const leftoverFiles: (root: string, candidates: string[]) => string[];
28
+ /**
29
+ * Scaffold a minimal, runnable `package.json` when the migrated project has
30
+ * none. Config-only sources (e.g. a Mintlify `docs.json`) ship no npm manifest,
31
+ * so a fresh migration has nothing to run `blume dev` with; this writes a stub
32
+ * with `blume` as a dependency and `dev`/`build`/`doctor` scripts, making
33
+ * `npm install && npm run dev` work immediately. A pre-existing `package.json`
34
+ * is left untouched — {@link rewriteFrameworkScripts} repoints those instead.
35
+ * Returns true when a file was created.
36
+ */
37
+ export declare const ensurePackageJson: (root: string) => Promise<boolean>;
38
+ /**
39
+ * Remove every match of an import pattern, collapsing the blank gap each
40
+ * removal leaves behind — without touching blank runs elsewhere in the
41
+ * document (double blank lines inside code fences are real content that a
42
+ * whole-document `\n{3,}` collapse used to corrupt).
43
+ */
44
+ export declare const stripImports: (source: string, pattern: RegExp) => string;
45
+ export interface CalloutRewriteOptions {
46
+ /** Directive for a type-bearing tag with no `type` attribute (e.g. bare `<Callout>`). */
47
+ defaultDirective: string;
48
+ /** Tag names whose directive is fixed by the tag itself (e.g. `<Warning>`). */
49
+ tagDirectives: Record<string, string>;
50
+ /** Component tag names to convert. */
51
+ tags: string[];
52
+ /** `type="…"` values mapped to Blume directive names. */
53
+ typeDirectives: Record<string, string>;
54
+ }
55
+ /** Read a quoted string attribute (`name="…"` or `name='…'`) from a tag. */
56
+ export declare const attribute: (attrs: string, name: string) => string | undefined;
57
+ /**
58
+ * Find the `>` that closes an opening JSX tag, honoring quotes and `{…}`
59
+ * expression attributes (so a `>` inside `icon={"<svg…>"}` is not mistaken for
60
+ * the tag end). Returns -1 if unterminated.
61
+ */
62
+ export declare const findOpenTagEnd: (source: string, from: number) => number;
63
+ /**
64
+ * Convert callout-style JSX components into Blume `:::` directives. Uses a
65
+ * quote/brace-aware tag scanner so callouts carrying JSX-expression attributes
66
+ * (e.g. inline-SVG icons) convert cleanly; non-convertible attributes (icons,
67
+ * colors, emoji) are dropped. A tag whose resolved directive is unknown is left
68
+ * untouched.
69
+ */
70
+ export declare const rewriteCallouts: (source: string, options: CalloutRewriteOptions) => string;
71
+ /**
72
+ * Remove frontmatter keys Blume's strict page schema would reject (e.g. stray
73
+ * `og:*`/`twitter:*` metatags) so the migrated page validates, reporting what
74
+ * was dropped. Validation errors other than stray keys are left for `blume dev`
75
+ * to surface.
76
+ */
77
+ export declare const stripUnknownPageMeta: (data: Record<string, unknown>) => {
78
+ data: Record<string, unknown>;
79
+ removed: string[];
80
+ };
81
+ /**
82
+ * Rename a JSX tag (open and close) while preserving its attributes. The
83
+ * trailing lookahead means a longer tag (e.g. `CardGrid`) is never matched by a
84
+ * rule for its shorter prefix (`Card`), so prefix-sharing renames can be chained
85
+ * — run the item-level rename before the container rename.
86
+ */
87
+ export declare const renameTag: (source: string, from: string, to: string) => string;
88
+ /**
89
+ * Static readers for JS/TS config files (Nextra `_meta`, Starlight
90
+ * `astro.config`). Config is parsed by walking the source as text — quote-,
91
+ * comment-, and bracket-aware — rather than executing user code, matching the
92
+ * other migrators (which never eval). Values that aren't pure literals (an
93
+ * identifier, call, JSX, or interpolated template) are reported as `UNPARSEABLE`
94
+ * so the caller can drop the field and warn.
95
+ */
96
+ /** Index of a string within a JS source: the close quote matching `s[open]`. */
97
+ export declare const findStringEnd: (s: string, open: number) => number;
98
+ export declare const unescapeString: (inner: string) => string;
99
+ export interface ObjectScanResult {
100
+ end: number;
101
+ entries: string[];
102
+ }
103
+ /**
104
+ * Walk a `{…}` object literal starting at `openIndex`, returning the matching
105
+ * close-brace index and the raw `key: value` text of each top-level entry.
106
+ * Quote-, comment-, and bracket-aware so commas/braces nested in strings,
107
+ * arrays, or child objects don't split entries. Returns null if unterminated.
108
+ */
109
+ export declare const scanObject: (source: string, openIndex: number) => ObjectScanResult | null;
110
+ export interface ArrayScanResult {
111
+ elements: string[];
112
+ end: number;
113
+ }
114
+ /**
115
+ * Walk a `[…]` array literal starting at `openIndex`, returning the matching
116
+ * close-bracket index and the raw text of each top-level element. The sibling of
117
+ * {@link scanObject}; a trailing comma yields no empty element.
118
+ */
119
+ export declare const scanArray: (source: string, openIndex: number) => ArrayScanResult | null;
120
+ /**
121
+ * Strip `//` and block comments so they don't leak into entry text (the scanner
122
+ * splits on slices, so an inter-entry comment would otherwise attach to the next
123
+ * entry). String literals are preserved verbatim.
124
+ */
125
+ export declare const stripJsComments: (source: string) => string;
126
+ export interface KeyValue {
127
+ key: string;
128
+ value: string;
129
+ }
130
+ /** Split a raw `key: value` entry at its top-level colon. */
131
+ export declare const splitKeyValue: (entry: string) => KeyValue | null;
132
+ /** Read an object key, unquoting it when it is a string literal. */
133
+ export declare const parseKey: (key: string) => string;
134
+ /** Read a clean string literal value, or null if it's an expression. */
135
+ export declare const readString: (value: string) => string | null;
136
+ /** A value that isn't a pure literal (identifier, call, JSX, computed, …). */
137
+ export declare const UNPARSEABLE: unique symbol;
138
+ export type LiteralValue = LiteralValue[] | boolean | null | number | string | typeof UNPARSEABLE | {
139
+ [key: string]: LiteralValue;
140
+ };
141
+ /**
142
+ * Evaluate a JS literal expression (string / number / boolean / null / array /
143
+ * object) into its value without executing it. Anything else resolves to
144
+ * {@link UNPARSEABLE}; inside arrays the sentinel keeps the element's position,
145
+ * inside objects it stays as the field's value so the caller can warn and drop.
146
+ */
147
+ export declare const parseLiteral: (source: string) => LiteralValue;
148
+ /** Narrow a parsed literal to a string. */
149
+ export declare const asLiteralString: (value: LiteralValue | undefined) => string | undefined;
150
+ /** Narrow a parsed literal to a plain object (not an array or `UNPARSEABLE`). */
151
+ export declare const isLiteralObject: (value: LiteralValue | undefined) => value is Record<string, LiteralValue>;
152
+ /** Narrow a parsed literal to an array. */
153
+ export declare const asLiteralArray: (value: LiteralValue | undefined) => LiteralValue[] | undefined;
@@ -59,6 +59,7 @@ Reads `docs.json` (or legacy `mint.json`) and rewrites every page **in place**
59
59
  - Snippets under `/snippets` are inlined as includes; component snippets (e.g. `.jsx`) are kept and their imports rewritten to relative paths.
60
60
  - A Mintlify `openapi` spec — declared top-level or on a nav group (a path, URL, or `{ source, directory }`) — maps to Blume's [native OpenAPI reference](/docs/advanced/api-reference) (`openapi.sources`), which renders one real page per operation. A group's `directory` becomes the reference's route; endpoint refs like `GET /users` are dropped since Blume generates them from the spec.
61
61
  - `docs.json` `variables` are inlined into content — Blume has no runtime `{{variable}}` substitution.
62
+ - The source `docs.json`/`mint.json` is removed once `blume.config.ts` is safely written. Leaving it around would keep the project a [Bridge mode](/docs/advanced/bridge) candidate — a later run without a Blume config would silently serve the un-migrated Mintlify site.
62
63
  - Multi-language projects map to [`i18n.locales`](/docs/content/i18n); the language nav selector is dropped in favor of Blume's locale switching.
63
64
  - Icons resolve against the real bundled libraries — Font Awesome (free), Lucide, and Tabler. The migrator sets [`icons.library: fontawesome`](/docs/content/components#default-library) (Mintlify's default), so Font Awesome names (`shield-halved`, `gauge-high`, `layer-group`, …) and `iconType` styles render unchanged. Pro-only FA styles (`light`/`thin`/`duotone`/`sharp-solid`) fall back to solid.
64
65
  - Fonts map to [`theme.fonts`](/docs/configuration/theming) when the family is one of Blume's curated Google Fonts (`fonts.family`, or a `heading`/`body` split); a family outside that set is warned about, not guessed. Header links (`navbar.links`/`navbar.primary`) and footer socials (`footer.socials`) have no `blume.config` equivalent yet, so they're reported as warnings rather than dropped silently — re-add them with [`navigation.tabs`](/docs/content/navigation) or a Header/Footer [layout override](/docs/advanced/custom-pages). The contextual page menu and last-updated timestamp are already Blume defaults.
@@ -133,7 +133,7 @@ Keys are read with `process.env`, which covers the Node, Vercel, and Netlify ada
133
133
 
134
134
  ### Rate limiting
135
135
 
136
- The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request (rejecting malformed bodies and capping it to 1–40 messages) to bound how much a single call can spend against your model, but it can't stop someone from calling the endpoint repeatedly. If cost abuse is a concern, put the route behind a rate limiter — your host's (e.g. Vercel's) edge rate limiting, a middleware, or your model provider's per-key spend limits.
136
+ The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request rejecting malformed bodies, capping it to 1–40 messages, and accepting only `user`/`assistant` roles so a caller can't inject their own system prompt and repurpose the route as a general LLM proxy — to bound how much a single call can spend against your model, but it can't stop someone from calling the endpoint repeatedly. If cost abuse is a concern, put the route behind a rate limiter — your host's (e.g. Vercel's) edge rate limiting, a middleware, or your model provider's per-key spend limits.
137
137
 
138
138
  ## MCP server
139
139
 
@@ -146,7 +146,7 @@ Drop a `theme.css` in your project root to override any design token. It's the l
146
146
  }
147
147
  ```
148
148
 
149
- Set a token under `:root` for light mode and under `:root[data-theme="dark"]` for dark mode.
149
+ Set a token under `:root` for light mode and under `:root[data-theme="dark"]` for dark mode. Color tokens have distinct built-in dark values declared at the dark selector's higher specificity, so a `:root`-only override of `--blume-accent`, `--blume-background`, and friends applies to light mode only — declare the dark block too when both modes should change.
150
150
 
151
151
  ### Design tokens
152
152
 
@@ -85,7 +85,7 @@ i18n: {
85
85
 
86
86
  ## Per-locale navigation
87
87
 
88
- Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: put a `meta.ts` under `fr/guides/` to order the French group independently. Everything else about [navigation](/docs/content/navigation) works the same, per language.
88
+ Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: under the default `dir` parser, put a `meta.ts` under `fr/guides/` to order the French group independently. Under the `dot` parser translations sit next to the originals, so a folder's `meta.ts` applies to every locale. Everything else about [navigation](/docs/content/navigation) works the same, per language.
89
89
 
90
90
  ## Fallbacks
91
91
 
@@ -56,7 +56,7 @@ The built-in `mdx-remote` source fetches raw `.md`/`.mdx` over HTTP. Enumerate f
56
56
  }
57
57
  ```
58
58
 
59
- A private repo's token is read from the `GITHUB_TOKEN` environment variable — it is never inlined into your config or generated output.
59
+ A private repo's token is read from the `GITHUB_TOKEN` environment variable — it is never inlined into your config or generated output, and it is only ever sent to GitHub's own hosts (`api.github.com`, `raw.githubusercontent.com`), never to a custom `url` base.
60
60
 
61
61
  Remote pages are rendered with full MDX-plus-component fidelity: their bodies are materialized into a hidden staging directory and rendered through Astro alongside your local docs, so callouts, tabs, and every other Blume component keep working.
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -10,7 +10,9 @@ export interface McpDiscoveryInput {
10
10
 
11
11
  /** The MCP server's address — absolute when a site is configured. */
12
12
  const serverUrl = (input: McpDiscoveryInput): string =>
13
- input.site ? new URL(input.route, input.site).href : input.route;
13
+ // Concatenate rather than `new URL(route, site)` a root-absolute route
14
+ // would drop the base path of a subpath deployment (`acme.com/docs`).
15
+ input.site ? `${input.site.replace(/\/+$/u, "")}${input.route}` : input.route;
14
16
 
15
17
  /**
16
18
  * The `/.well-known/mcp.json` discovery document: the minimal pointer agents use
@@ -90,7 +90,9 @@ const normalizeRoute = (input: string): string => {
90
90
 
91
91
  /** Build the absolute (or root-relative) URL for a route. */
92
92
  const urlFor = (route: string, site: string | null): string =>
93
- site ? new URL(route, site).href : route;
93
+ // Concatenate rather than `new URL(route, site)` a root-absolute route
94
+ // would drop the base path of a subpath deployment (`acme.com/docs`).
95
+ site ? `${site.replace(/\/+$/u, "")}${route}` : route;
94
96
 
95
97
  const text = (value: string, isError = false) => ({
96
98
  content: [{ text: value, type: "text" as const }],
@@ -35,6 +35,12 @@ export const mdxComponents = {};
35
35
  export const layoutOverrides = {};
36
36
  `;
37
37
 
38
+ // A user-supplied attribute value interpolated into a generated .astro tag: a
39
+ // stray quote or newline would produce a malformed component and an opaque
40
+ // Astro parse error pointing at the generated file, not the user's config.
41
+ const attributeValue = (value: string): string =>
42
+ value.replaceAll(/["\n\r]/gu, " ").trim();
43
+
38
44
  /** Astro client directive for a hydrated override. */
39
45
  const directiveFor = (override: NormalizedOverride): string => {
40
46
  const framework = override.source?.framework;
@@ -47,11 +53,13 @@ const directiveFor = (override: NormalizedOverride): string => {
47
53
  }
48
54
  case "media": {
49
55
  return override.media
50
- ? `client:media="${override.media}"`
56
+ ? `client:media="${attributeValue(override.media)}"`
51
57
  : "client:load";
52
58
  }
53
59
  case "only": {
54
- return framework ? `client:only="${framework}"` : "client:load";
60
+ return framework
61
+ ? `client:only="${attributeValue(framework)}"`
62
+ : "client:load";
55
63
  }
56
64
  default: {
57
65
  return "client:load";
@@ -973,6 +973,11 @@ export const generateRuntime = async (
973
973
  // entryId so i18n duplicates of one entry write a single file.
974
974
  const staged = collectStaged(project);
975
975
  const hasStaged = staged.size > 0;
976
+ // 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
+ const hasFilesystemSource = project.sources.some((source) => !source.staged);
976
981
 
977
982
  const structural = await Promise.all([
978
983
  write(
@@ -1003,7 +1008,12 @@ export const generateRuntime = async (
1003
1008
  write(join(srcDir, "env.d.ts"), envTemplate()),
1004
1009
  write(
1005
1010
  join(srcDir, "content.config.ts"),
1006
- contentConfigTemplate({ config, context, staged: hasStaged })
1011
+ contentConfigTemplate({
1012
+ config,
1013
+ context,
1014
+ filesystem: hasFilesystemSource,
1015
+ staged: hasStaged,
1016
+ })
1007
1017
  ),
1008
1018
  write(
1009
1019
  join(srcDir, "pages", "[...slug].astro"),
@@ -41,9 +41,16 @@ const isContained = (parent: string, child: string): boolean => {
41
41
 
42
42
  /** Resolve a request URL to an on-disk file within one of the mounts, if any. */
43
43
  const resolveRequest = (url: string, mounts: AssetMount[]): string | null => {
44
- const pathname = decodeURIComponent(
45
- (url.split("?")[0] ?? "").split("#")[0] ?? ""
46
- );
44
+ let pathname: string;
45
+ try {
46
+ pathname = decodeURIComponent(
47
+ (url.split("?")[0] ?? "").split("#")[0] ?? ""
48
+ );
49
+ } catch {
50
+ // Malformed percent-encoding (`/images/%zz`) throws URIError; treat it as
51
+ // a plain miss (404) rather than a middleware exception.
52
+ return null;
53
+ }
47
54
  for (const mount of mounts) {
48
55
  if (pathname !== mount.url && !pathname.startsWith(`${mount.url}/`)) {
49
56
  continue;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
- import { dirname, join } from "pathe";
3
+ 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";
@@ -374,6 +374,15 @@ export default defineConfig({
374
374
  fs: {
375
375
  allow: ${JSON.stringify(fsAllow)},
376
376
  },
377
+ // Keep the file watcher out of Astro's own cache dir. In a migrated
378
+ // (root-rooted) project the docs collection is rooted at the project dir,
379
+ // so its glob-loader watcher would otherwise fire on every write Astro
380
+ // makes under .blume/.astro (data-store.json, content module manifests,
381
+ // self-hosted fonts) -- pure noise the loader logs as "No entry type
382
+ // found". Vite appends this to its default ignores.
383
+ watch: {
384
+ ignored: ${JSON.stringify([join(context.outDir, ".astro", "**")])},
385
+ },
377
386
  },
378
387
  },
379
388
  });
@@ -392,18 +401,44 @@ export const contentConfigTemplate = (options: {
392
401
  staged?: boolean;
393
402
  /** Base dir for the staged collection; defaults to `<outDir>/content`. */
394
403
  stagedBase?: string;
404
+ /**
405
+ * 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.
408
+ */
409
+ filesystem?: boolean;
395
410
  }): string => {
396
411
  const { context, config } = options;
397
412
  const stagedBase = options.stagedBase ?? stagedContentDir(context.outDir);
398
413
 
399
414
  // Fold the content excludes into the glob as negative patterns so the `docs`
400
- // collection never walks into ignored trees. This matters when `content.root`
401
- // is the project root (Mintlify bridge mode, or a migrated `.`-rooted project):
402
- // without it the collection would scan `node_modules`, `snippets`, etc.
403
- const docsPattern = [
404
- ...config.content.include,
405
- ...(config.content.exclude ?? []).map((pattern) => `!${pattern}`),
406
- ];
415
+ // collection doesn't ingest ignored trees (`node_modules`, `snippets`, the
416
+ // staged bodies under `.blume/content`, …) as entries. This matters when
417
+ // `content.root` is the project root (a migrated `.`-rooted project).
418
+ const outDirRel = relative(context.contentRoot, context.outDir);
419
+ const outDirIgnore =
420
+ outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
421
+ ? [`!${outDirRel}/**`]
422
+ : [];
423
+
424
+ // With no filesystem source, no route renders through `docs`, so glob nothing.
425
+ // 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
428
+ // request), and the watcher's match test is `picomatch.isMatch(path, pattern)`
429
+ // — with array-OR semantics, any `!ignored/**` negation *matches* unrelated
430
+ // files, so negative patterns can't exclude a subtree there. An empty pattern
431
+ // matches nothing, so the watcher stays silent. The collection is still
432
+ // declared below so `getCollection("docs")` / `getEntry` resolve (to empty).
433
+ const filesystem = options.filesystem ?? true;
434
+ const docsPattern = filesystem
435
+ ? [
436
+ ...config.content.include,
437
+ ...(config.content.exclude ?? []).map((pattern) => `!${pattern}`),
438
+ "!**/node_modules/**",
439
+ ...outDirIgnore,
440
+ ]
441
+ : [];
407
442
 
408
443
  // Non-filesystem sources render through a parallel `staged` collection backed
409
444
  // by materialized MDX, so the filesystem `docs` collection stays untouched.
@@ -477,20 +512,35 @@ export const askEndpointTemplate = (
477
512
  }
478
513
  // Validate the client-supplied body and cap its size. The endpoint is
479
514
  // unauthenticated, so bounding message count/length limits how much a caller
480
- // can spend against the model per request; front it with a rate limiter (or
481
- // your provider's limits) for stronger protection.
515
+ // can spend against the model per request, and restricting roles to
516
+ // user/assistant keeps callers from injecting their own system prompt and
517
+ // repurposing the endpoint as a general LLM proxy; front it with a rate
518
+ // limiter (or your provider's limits) for stronger protection.
482
519
  const validate = ` const body = await request.json().catch(() => null);
483
- const messages = body?.messages;
484
- if (
485
- !Array.isArray(messages) ||
486
- messages.length === 0 ||
487
- messages.length > 40 ||
488
- JSON.stringify(messages).length > 24_000
489
- ) {
490
- return new Response("Invalid request: send 1-40 messages.", {
491
- status: 400,
492
- });
493
- }`;
520
+ const raw = body?.messages;
521
+ const valid =
522
+ Array.isArray(raw) &&
523
+ raw.length > 0 &&
524
+ raw.length <= 40 &&
525
+ raw.every(
526
+ (m: unknown) =>
527
+ typeof m === "object" &&
528
+ m !== null &&
529
+ ("role" in m && (m.role === "user" || m.role === "assistant")) &&
530
+ ("content" in m && typeof m.content === "string")
531
+ ) &&
532
+ JSON.stringify(raw).length <= 24_000;
533
+ if (!valid) {
534
+ return new Response(
535
+ "Invalid request: send 1-40 user/assistant messages with string content.",
536
+ { status: 400 }
537
+ );
538
+ }
539
+ // Re-build the array so only role/content ever reach the model.
540
+ const messages = raw.map((m: { role: "user" | "assistant"; content: string }) => ({
541
+ content: m.content,
542
+ role: m.role,
543
+ }));`;
494
544
  const stream = grounded
495
545
  ? ` const system =
496
546
  (await ground(messages, body.page)) ??
@@ -544,10 +594,16 @@ const SEARCH_CLIENT_HEADER = "// Generated by Blume. Do not edit.\n";
544
594
  const searchClientImport = (module: string): string =>
545
595
  `import { createSearch as create } from "blume/components/layout/search/${module}.ts";\n`;
546
596
 
597
+ // Joins a base-relative path onto BASE_URL, which arrives with or without a
598
+ // trailing slash (Astro's default trailingSlash: "ignore" passes `/docs`
599
+ // through bare — naive concatenation would yield `/docsblume-search.json`).
600
+ const SEARCH_BASE_IMPORT =
601
+ 'import { joinBase } from "blume/components/islands/base-path.ts";\n';
602
+
547
603
  /** A client that loads a static `blume-search.json` index (Orama, FlexSearch). */
548
604
  const staticSearchClient = (module: string): string =>
549
- `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}
550
- const indexUrl = \`\${import.meta.env.BASE_URL}blume-search.json\`.replace("//", "/");
605
+ `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
606
+ const indexUrl = joinBase(import.meta.env.BASE_URL, "blume-search.json");
551
607
 
552
608
  export const createSearch = () => create({ indexUrl });
553
609
  `;
@@ -607,16 +663,16 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
607
663
  }
608
664
 
609
665
  if (search.provider === "mixedbread") {
610
- return `${SEARCH_CLIENT_HEADER}${searchClientImport("endpoint")}
611
- const api = \`\${import.meta.env.BASE_URL}api/search\`.replace("//", "/");
666
+ return `${SEARCH_CLIENT_HEADER}${searchClientImport("endpoint")}${SEARCH_BASE_IMPORT}
667
+ const api = joinBase(import.meta.env.BASE_URL, "api/search");
612
668
 
613
669
  export const createSearch = () => create({ api });
614
670
  `;
615
671
  }
616
672
 
617
673
  if (search.provider === "pagefind") {
618
- return `${SEARCH_CLIENT_HEADER}${searchClientImport("pagefind")}
619
- const url = \`\${import.meta.env.BASE_URL}pagefind/pagefind.js\`.replace("//", "/");
674
+ return `${SEARCH_CLIENT_HEADER}${searchClientImport("pagefind")}${SEARCH_BASE_IMPORT}
675
+ const url = joinBase(import.meta.env.BASE_URL, "pagefind/pagefind.js");
620
676
 
621
677
  export const createSearch = () => create({ url });
622
678
  `;
@@ -645,8 +701,10 @@ const client = new Mixedbread({ apiKey: process.env.MIXEDBREAD_API_KEY ?? "" });
645
701
  const STORE_ID = ${JSON.stringify(storeId)};
646
702
 
647
703
  export const POST: APIRoute = async ({ request }) => {
648
- const { query } = await request.json();
649
- if (!query) {
704
+ // The endpoint is public: a malformed body must 200-empty, not 500.
705
+ const body = await request.json().catch(() => null);
706
+ const query = body?.query;
707
+ if (!query || typeof query !== "string") {
650
708
  return new Response("[]", {
651
709
  headers: { "Content-Type": "application/json" },
652
710
  });
@@ -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
 
@@ -62,7 +63,15 @@ export const devCommand = defineCommand({
62
63
  }
63
64
 
64
65
  // 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
+ // 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
+ }
66
75
  const releaseLock = acquireDevLock(project.context.outDir);
67
76
  process.on("exit", releaseLock);
68
77
 
@@ -82,26 +91,31 @@ export const devCommand = defineCommand({
82
91
 
83
92
  // Watch user inputs and regenerate the runtime data on change. Astro/Vite
84
93
  // hot-reloads the generated data module so nav and routes stay in sync.
94
+ // `coalescedRunner` single-flights the scan so a burst of watch events can
95
+ // never stack overlapping regenerations (a large project's scan can outlast
96
+ // the debounce; piled-up scans exhaust the heap).
97
+ const runRegenerate = coalescedRunner(async () => {
98
+ try {
99
+ const next = await scanProject(root, {
100
+ devServerUrl,
101
+ mode: "dev",
102
+ overrides,
103
+ preview,
104
+ });
105
+ await generateRuntime(next);
106
+ // Surface any content/config errors in the browser overlay too.
107
+ showBlumeErrorOverlay(next.diagnostics);
108
+ } catch (error) {
109
+ logger.error(`Regeneration failed: ${(error as Error).message}`);
110
+ }
111
+ });
112
+
85
113
  let timer: ReturnType<typeof setTimeout> | null = null;
86
114
  const regenerate = () => {
87
115
  if (timer) {
88
116
  clearTimeout(timer);
89
117
  }
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);
118
+ timer = setTimeout(runRegenerate, 80);
105
119
  };
106
120
 
107
121
  // Content is watched per source (filesystem uses fs.watch; remote sources