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
@@ -7,6 +7,11 @@ import { glob } from "tinyglobby";
7
7
  import { BlumeError } from "../diagnostics.ts";
8
8
  import matter from "../frontmatter.ts";
9
9
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
10
+ import {
11
+ BLUME_WATCH_IGNORE_DIRS,
12
+ excludeDirSegments,
13
+ ignoringWatchListener,
14
+ } from "./watch.ts";
10
15
 
11
16
  /** Options for the built-in filesystem source. */
12
17
  export interface FilesystemSourceOptions {
@@ -75,13 +80,26 @@ export const filesystemSource = (
75
80
  }
76
81
  };
77
82
 
83
+ // When `content.root` is the project root (a migrated `.`-rooted project),
84
+ // the recursive dev watcher would otherwise see Blume's own `.blume/` output
85
+ // and loop; skip it, VCS/dependency trees, and every excluded dir so the
86
+ // watcher stays in sync with what `load()` globs. See {@link ignoringWatchListener}.
87
+ const watchIgnoreDirs = new Set([
88
+ ...BLUME_WATCH_IGNORE_DIRS,
89
+ ...excludeDirSegments(options.exclude),
90
+ ]);
91
+
78
92
  const watch = (onChange: () => void): (() => void) => {
79
93
  if (!existsSync(contentRoot)) {
80
94
  return () => {
81
95
  // Nothing to dispose when the root doesn't exist yet.
82
96
  };
83
97
  }
84
- const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
98
+ const watcher = fsWatch(
99
+ contentRoot,
100
+ { recursive: true },
101
+ ignoringWatchListener(onChange, watchIgnoreDirs)
102
+ );
85
103
  return () => watcher.close();
86
104
  };
87
105
 
@@ -91,9 +91,23 @@ interface RemoteRef {
91
91
  editUrl?: string;
92
92
  }
93
93
 
94
- const githubHeaders = (): Record<string, string> => {
94
+ // Hosts the GITHUB_TOKEN may be sent to. A configured `url` base can point at
95
+ // any server, and leaking the token there would hand a repo credential to an
96
+ // arbitrary third party.
97
+ const GITHUB_HOSTS = new Set(["api.github.com", "raw.githubusercontent.com"]);
98
+
99
+ const githubHeaders = (url: string): Record<string, string> => {
95
100
  const token = process.env.GITHUB_TOKEN;
96
- return token ? { authorization: `Bearer ${token}` } : {};
101
+ if (!token) {
102
+ return {};
103
+ }
104
+ let host = "";
105
+ try {
106
+ host = new URL(url).hostname;
107
+ } catch {
108
+ return {};
109
+ }
110
+ return GITHUB_HOSTS.has(host) ? { authorization: `Bearer ${token}` } : {};
97
111
  };
98
112
 
99
113
  interface GithubTreeEntry {
@@ -110,7 +124,7 @@ const enumerateGithub = async (
110
124
  const { owner, repo, ref } = github;
111
125
  const base = github.path.replaceAll(/^\/|\/$/gu, "");
112
126
  const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
113
- const res = await doFetch(treeUrl, { headers: githubHeaders() });
127
+ const res = await doFetch(treeUrl, { headers: githubHeaders(treeUrl) });
114
128
  if (!res.ok) {
115
129
  throw new Error(`${treeUrl} -> ${res.status}`);
116
130
  }
@@ -172,7 +186,9 @@ export const mdxRemoteSource = (
172
186
  };
173
187
 
174
188
  const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
175
- const res = await doFetch(item.fetchUrl, { headers: githubHeaders() });
189
+ const res = await doFetch(item.fetchUrl, {
190
+ headers: githubHeaders(item.fetchUrl),
191
+ });
176
192
  if (!res.ok) {
177
193
  throw new Error(`${item.fetchUrl} -> ${res.status}`);
178
194
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, watch as fsWatch } from "node:fs";
2
+ import type { WatchListener } from "node:fs";
2
3
  import { readFile } from "node:fs/promises";
3
4
 
4
5
  import { isAbsolute, join, relative, resolve } from "pathe";
@@ -9,6 +10,7 @@ import { BlumeError } from "../diagnostics.ts";
9
10
  import matter from "../frontmatter.ts";
10
11
  import type { Diagnostic } from "../types.ts";
11
12
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
13
+ import { BLUME_WATCH_IGNORE_DIRS, ignoringWatchListener } from "./watch.ts";
12
14
 
13
15
  /** Options for the Mintlify bridge content source. */
14
16
  export interface MintlifySourceOptions {
@@ -42,6 +44,26 @@ const MINTLIFY_SOURCE_IGNORES = [
42
44
  "snippets/**",
43
45
  ];
44
46
 
47
+ /**
48
+ * Directory names the recursive dev watcher must ignore, on top of the shared
49
+ * {@link BLUME_WATCH_IGNORE_DIRS} (Blume's own `.blume/` output, VCS,
50
+ * dependencies). Derived from {@link MINTLIFY_SOURCE_IGNORES} so bridge mode's
51
+ * watcher stays in sync with what its scan skips (snippets, build output, …).
52
+ */
53
+ const WATCH_IGNORE_DIRS = [
54
+ ...BLUME_WATCH_IGNORE_DIRS,
55
+ ...MINTLIFY_SOURCE_IGNORES.map((pattern) => pattern.replace(/\/\*\*$/u, "")),
56
+ ];
57
+
58
+ /**
59
+ * Build the recursive-watch listener for the bridge source: ignore events under
60
+ * {@link WATCH_IGNORE_DIRS} so the dev server's `.blume/` writes don't feed a
61
+ * regeneration loop. Exported for testing.
62
+ */
63
+ export const mintlifyWatchListener = (
64
+ onChange: () => void
65
+ ): WatchListener<string> => ignoringWatchListener(onChange, WATCH_IGNORE_DIRS);
66
+
45
67
  /**
46
68
  * The Mintlify bridge content source. Reads an unconverted Mintlify project in
47
69
  * place and transforms each page to Blume MDX at scan time (callouts → `:::`
@@ -126,7 +148,14 @@ export const mintlifySource = (
126
148
  const watch = (onChange: () => void): (() => void) => {
127
149
  const disposers: (() => void)[] = [];
128
150
  if (existsSync(contentRoot)) {
129
- const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
151
+ // Recursively watch the content root, but skip Blume's own output and
152
+ // other non-content trees so the dev server's `.blume/` writes don't feed
153
+ // a regeneration loop (`fs.watch` has no ignore option, so filter here).
154
+ const watcher = fsWatch(
155
+ contentRoot,
156
+ { recursive: true },
157
+ mintlifyWatchListener(onChange)
158
+ );
130
159
  disposers.push(() => watcher.close());
131
160
  }
132
161
  // Watch docs.json directly: it lives at the content root but a non-recursive
@@ -59,6 +59,11 @@ const mapRoute = (
59
59
  const groups: string[] = [];
60
60
 
61
61
  for (const part of rawParts) {
62
+ // A leading/trailing/double slash yields an empty part; keeping it would
63
+ // produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
64
+ if (part === "") {
65
+ continue;
66
+ }
62
67
  const group = groupLabel(part);
63
68
  if (group !== null) {
64
69
  groups.push(group);
@@ -113,10 +118,11 @@ export const extractHeadings = (body: string): Heading[] => {
113
118
  };
114
119
 
115
120
  const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
121
+ const INLINE_CODE = /`[^`]*`/gu;
116
122
 
117
123
  /**
118
124
  * Extract link targets from a markdown body for later validation, recording the
119
- * 1-based line/column of each target. Skips fenced code blocks.
125
+ * 1-based line/column of each target. Skips fenced code blocks and inline code.
120
126
  */
121
127
  export const extractLinks = (body: string): PageLink[] => {
122
128
  const links: PageLink[] = [];
@@ -132,7 +138,12 @@ export const extractLinks = (body: string): PageLink[] => {
132
138
  if (inFence) {
133
139
  continue;
134
140
  }
135
- for (const match of line.matchAll(MD_LINK)) {
141
+ // Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
142
+ // with same-length padding so recorded columns stay accurate.
143
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
144
+ " ".repeat(span.length)
145
+ );
146
+ for (const match of masked.matchAll(MD_LINK)) {
136
147
  const target = match.groups?.target;
137
148
  if (target === undefined || match.index === undefined) {
138
149
  continue;
@@ -153,7 +164,6 @@ export const extractLinks = (body: string): PageLink[] => {
153
164
  return links;
154
165
  };
155
166
 
156
- const INLINE_CODE = /`[^`]*`/gu;
157
167
  // Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
158
168
  // `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
159
169
  // isn't a real usage. Single quotes are left alone so prose apostrophes don't
@@ -207,8 +217,14 @@ const deriveTitle = (
207
217
  return titleCase(stripNumericPrefix(base.replace(extname(base), "")));
208
218
  };
209
219
 
210
- const withPrefix = (prefix: string | undefined, path: string): string =>
211
- prefix ? `${prefix}/${path}` : path;
220
+ /** Strip habitual leading/trailing slashes (`/getting-started`, `guides/`). */
221
+ const trimSlashes = (value: string): string =>
222
+ value.replaceAll(/^\/+|\/+$/gu, "");
223
+
224
+ const withPrefix = (prefix: string | undefined, path: string): string => {
225
+ const clean = prefix ? trimSlashes(prefix) : "";
226
+ return clean ? `${clean}/${path}` : path;
227
+ };
212
228
 
213
229
  /**
214
230
  * Normalize one source entry into per-locale `PageRecord`s. This is the single
@@ -254,9 +270,15 @@ export const normalizeEntry = (
254
270
  : { locales: [""], navPath: entry.ref };
255
271
 
256
272
  const navPath = withPrefix(ctx.source.prefix, rawNavPath);
273
+ // Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
274
+ // SPI's "logical route input; defaults to ref if omitted"), then the ref.
275
+ // The extension is re-appended so mapRoute's extname strip can't eat a
276
+ // dotted slug segment (`v1.2`). A slug that trims to nothing falls back.
277
+ const slugInput = meta.slug ?? entry.slug;
278
+ const slug = slugInput ? trimSlashes(slugInput) : "";
257
279
  const routeInput = withPrefix(
258
280
  ctx.source.prefix,
259
- meta.slug ? `${meta.slug}${ext}` : rawNavPath
281
+ slug ? `${slug}${ext}` : rawNavPath
260
282
  );
261
283
 
262
284
  const { segments, groups, route: logicalRoute } = mapRoute(routeInput);
@@ -0,0 +1,44 @@
1
+ import type { WatchListener } from "node:fs";
2
+
3
+ /**
4
+ * Directory segments a recursive dev watcher must never react to. When a
5
+ * source's content root is the project root — a migrated `.`-rooted project or a
6
+ * Mintlify bridge — a naive recursive `fs.watch` also sees Blume's own `.blume/`
7
+ * output, which the dev server rewrites on every render (e.g.
8
+ * `.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
9
+ * a rescan + runtime regeneration whose writes land back under `.blume/` and
10
+ * fire the watcher again: a self-sustaining loop that stalls page renders and
11
+ * floods the console (and, mid-render, corrupts Astro's dev module graph so
12
+ * `astro:server-app.js` fails to load). `.git`/`node_modules` are here for the
13
+ * same reason — churn that is never page content. `fs.watch` has no ignore
14
+ * option, so we filter by the changed path in the callback.
15
+ */
16
+ export const BLUME_WATCH_IGNORE_DIRS = [".blume", ".git", "node_modules"];
17
+
18
+ /** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
19
+ export const excludeDirSegments = (patterns: readonly string[]): string[] =>
20
+ patterns
21
+ .map((pattern) => /^(?<dir>[^*/]+)\/\*\*$/u.exec(pattern)?.groups?.dir)
22
+ .filter((dir): dir is string => dir !== undefined);
23
+
24
+ /**
25
+ * Build a recursive-watch listener that fires `onChange` for content changes but
26
+ * ignores events whose path crosses an ignored directory segment. A missing
27
+ * `filename` — rare; the platform couldn't name the changed path — falls through
28
+ * to `onChange` rather than silently dropping a real edit. Exported for testing.
29
+ */
30
+ export const ignoringWatchListener = (
31
+ onChange: () => void,
32
+ ignoreDirs: Iterable<string> = BLUME_WATCH_IGNORE_DIRS
33
+ ): WatchListener<string> => {
34
+ const ignore = new Set(ignoreDirs);
35
+ return (_event, filename) => {
36
+ if (
37
+ typeof filename === "string" &&
38
+ filename.split(/[/\\]/u).some((segment) => ignore.has(segment))
39
+ ) {
40
+ return;
41
+ }
42
+ onChange();
43
+ };
44
+ };
@@ -25,7 +25,9 @@ export interface CodeTitleTransformer {
25
25
  pre: (this: CodeMetaContext, node: PreNode) => void;
26
26
  }
27
27
 
28
- const TITLE_ATTR = /title=(?<quote>["'])(?<title>[^"']*)\k<quote>/u;
28
+ // The body excludes only the delimiting quote, so `title="foo's file.ts"`
29
+ // (an apostrophe inside double quotes) still matches.
30
+ const TITLE_ATTR = /title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
29
31
  const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
30
32
 
31
33
  const parseTitle = (raw: string | undefined): string | undefined => {
@@ -33,8 +35,9 @@ const parseTitle = (raw: string | undefined): string | undefined => {
33
35
  return undefined;
34
36
  }
35
37
  const explicit = raw.match(TITLE_ATTR);
36
- if (explicit?.groups?.title) {
37
- return explicit.groups.title;
38
+ const attrTitle = explicit?.groups?.dq ?? explicit?.groups?.sq;
39
+ if (attrTitle) {
40
+ return attrTitle;
38
41
  }
39
42
  // The first bare token is the title (```ts blume.config.ts), skipping Shiki
40
43
  // line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
@@ -31,7 +31,9 @@ export const packageInstallPlugin = () => ({
31
31
  node,
32
32
  jsxFlowElement(
33
33
  "Tabs",
34
- [],
34
+ // hash off: clicking "pnpm" in an install block must not rewrite the
35
+ // page hash (clobbering the heading anchor the reader arrived with).
36
+ [jsxAttribute("hash", "false")],
35
37
  PACKAGE_MANAGERS.map((manager) => tabNode(manager, commands[manager]))
36
38
  )
37
39
  );
@@ -9,6 +9,7 @@ import {
9
9
  isInsideRoot,
10
10
  renameTag,
11
11
  rewriteCallouts,
12
+ stripImports,
12
13
  } from "../shared.ts";
13
14
 
14
15
  /**
@@ -29,11 +30,8 @@ const FUMADOCS_IMPORT =
29
30
  * injects its components globally, so these imports would fail to resolve once
30
31
  * the Fumadocs packages are gone.
31
32
  */
32
- export const stripFumadocsImports = (source: string): string => {
33
- const stripped = source.replace(FUMADOCS_IMPORT, "");
34
- // Collapse the blank gap a removed import block leaves behind.
35
- return stripped === source ? source : stripped.replaceAll(/\n{3,}/gu, "\n\n");
36
- };
33
+ export const stripFumadocsImports = (source: string): string =>
34
+ stripImports(source, FUMADOCS_IMPORT);
37
35
 
38
36
  // ---------------------------------------------------------------------------
39
37
  // Callouts
@@ -114,22 +114,33 @@ const movePage = async (
114
114
  };
115
115
  };
116
116
 
117
- /** Write a `FolderMeta` to `dest` unless it already exists. */
117
+ /**
118
+ * Write a `FolderMeta` to `dest` unless it already exists. `handled` tells the
119
+ * caller whether the source `meta.json` is safe to delete — a skipped write
120
+ * (target exists) must keep the source, or its title/ordering is lost with
121
+ * nowhere to recover it from.
122
+ */
118
123
  const writeMeta = async (
119
124
  dest: string,
120
125
  meta: FolderMeta,
121
126
  rel: string,
122
127
  warnings: string[]
123
- ): Promise<string[]> => {
128
+ ): Promise<{ handled: boolean; warnings: string[] }> => {
124
129
  if (Object.keys(meta).length === 0) {
125
- return warnings;
130
+ return { handled: true, warnings };
126
131
  }
127
132
  if (existsSync(dest)) {
128
- return [...warnings, `Skipped ${rel} (target already exists)`];
133
+ return {
134
+ handled: false,
135
+ warnings: [
136
+ ...warnings,
137
+ `Skipped ${rel} (target already exists); the source file was kept — merge it into the existing meta.ts by hand.`,
138
+ ],
139
+ };
129
140
  }
130
141
  await mkdir(dirname(dest), { recursive: true });
131
142
  await writeFile(dest, renderMetaModule(meta), "utf-8");
132
- return warnings;
143
+ return { handled: true, warnings };
133
144
  };
134
145
 
135
146
  /** Convert one `meta.json` into a typed `meta.ts`, or relocate it if unparseable. */
@@ -173,14 +184,18 @@ const convertMeta = async (
173
184
  ...self.warnings,
174
185
  ...reshape.warnings,
175
186
  ]);
176
- await rm(abs, { force: true });
177
- return result;
187
+ if (result.handled) {
188
+ await rm(abs, { force: true });
189
+ }
190
+ return result.warnings;
178
191
  }
179
192
 
180
193
  const { meta, warnings } = translateFumadocsMeta(parsed);
181
194
  const result = await writeMeta(dest, meta, rel, warnings);
182
- await rm(abs, { force: true });
183
- return result;
195
+ if (result.handled) {
196
+ await rm(abs, { force: true });
197
+ }
198
+ return result.warnings;
184
199
  };
185
200
 
186
201
  interface PageSummary {
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
- import { dirname, relative, resolve } from "pathe";
3
+ import { dirname, resolve } from "pathe";
4
4
 
5
5
  import { BlumeError } from "../../core/diagnostics.ts";
6
6
  import type {
@@ -10,6 +10,7 @@ import type {
10
10
  SidebarItemConfig,
11
11
  } from "../../core/schema.ts";
12
12
  import { GOOGLE_FONTS } from "../../theme/fonts.ts";
13
+ import { isInsideRoot } from "../shared.ts";
13
14
 
14
15
  type JsonObject = Record<string, unknown>;
15
16
  type NavigationSelectors = ResolvedConfig["navigation"]["selectors"];
@@ -72,11 +73,6 @@ const withoutUndefined = <T extends JsonObject>(value: T): T =>
72
73
  const hasOwn = (object: JsonObject, key: string): boolean =>
73
74
  Object.hasOwn(object, key);
74
75
 
75
- const isInsideRoot = (root: string, candidate: string): boolean => {
76
- const rel = relative(root, candidate);
77
- return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
78
- };
79
-
80
76
  const readJsonFile = async (file: string): Promise<unknown> => {
81
77
  try {
82
78
  return JSON.parse(await readFile(file, "utf-8"));
@@ -1,10 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
 
4
- import { dirname, join } from "pathe";
4
+ import { dirname, join, relative } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
+ import { ensureGitignore } from "../../core/gitignore.ts";
7
8
  import type { BlumeConfig } from "../../core/schema.ts";
9
+ import { ensurePackageJson } from "../shared.ts";
8
10
  import { assetSegments } from "./assets.ts";
9
11
  import { loadMintlifyConfig, partitionMintlifyRedirects } from "./config.ts";
10
12
  import { mintlifyI18n } from "./i18n.ts";
@@ -188,6 +190,56 @@ const applyRelocatedAssets = (
188
190
  }
189
191
  };
190
192
 
193
+ /**
194
+ * Remove the foreign Mintlify config now that it's been translated into
195
+ * `blume.config.ts`. Leaving `docs.json`/`mint.json` on disk keeps the project a
196
+ * bridge-mode candidate: `detectMintlifyBridge` fires on any later run where a
197
+ * `blume.config.*` is absent (e.g. the config is deleted to re-run the
198
+ * migration), silently serving the *un-migrated* Mintlify project instead of the
199
+ * converted one. Removing it makes the conversion permanent — matching the
200
+ * migrator's promise and how it already deletes inlined snippets.
201
+ */
202
+ const removeForeignConfig = async (
203
+ root: string,
204
+ warnings: string[]
205
+ ): Promise<void> => {
206
+ const removed: string[] = [];
207
+ for (const name of ["docs.json", "mint.json"]) {
208
+ const file = join(root, name);
209
+ if (existsSync(file)) {
210
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs removes
211
+ await rm(file, { force: true });
212
+ removed.push(name);
213
+ }
214
+ }
215
+ if (removed.length > 0) {
216
+ warnings.push(
217
+ `Removed ${removed.join(", ")} (translated to blume.config.ts) so "blume dev" no longer falls back to Mintlify bridge mode.`
218
+ );
219
+ }
220
+ };
221
+
222
+ /**
223
+ * Scaffold the project files a config-only Mintlify repo lacks: a runnable
224
+ * `package.json` (it ships no npm manifest) and a `.gitignore` for Blume's
225
+ * generated `.blume/` runtime and `dist/` build output. Both are idempotent —
226
+ * an existing file is extended, not overwritten — and noted in the warnings.
227
+ */
228
+ const scaffoldProjectFiles = async (
229
+ root: string,
230
+ warnings: string[]
231
+ ): Promise<void> => {
232
+ if (await ensurePackageJson(root)) {
233
+ warnings.push(
234
+ "Created a package.json with blume as a dependency; run `npm install`, then `npm run dev`."
235
+ );
236
+ }
237
+ const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
238
+ if (ignored.length > 0) {
239
+ warnings.push(`Added ${ignored.join(", ")} to .gitignore.`);
240
+ }
241
+ };
242
+
191
243
  /**
192
244
  * Delete the inlined markdown snippets. Component files (e.g. `.jsx`) are kept
193
245
  * because their imports were rewritten to resolve against `/snippets`.
@@ -222,6 +274,84 @@ const cleanupSnippets = async (
222
274
  }
223
275
  };
224
276
 
277
+ // Root-absolute asset references in page content (`![…](/screenshots/a.png)`,
278
+ // `src="/img/x.svg"`). Mintlify serves any top-level dir at the site root, so
279
+ // dirs referenced only by content — not the config — must also be mounted.
280
+ const CONTENT_ASSET_REF =
281
+ /(?:\]\(|src=["'])(?<path>\/[^\s"')]+\.(?:avif|bmp|gif|ico|jpe?g|mov|mp4|pdf|png|svg|webm|webp|zip))/giu;
282
+
283
+ const contentAssetSegments = (content: string): string[] =>
284
+ [...content.matchAll(CONTENT_ASSET_REF)].flatMap((match) => {
285
+ const segment = match.groups?.path?.split("/")[1];
286
+ return segment ? [segment] : [];
287
+ });
288
+
289
+ /**
290
+ * Rewrite every page to idiomatic Blume MDX in place, collecting what was
291
+ * dropped or kept for the migration summary. A page whose transform throws
292
+ * (a dangling snippet import, a snippet cycle) must not abort the whole
293
+ * migration mid-rewrite; it is left as-is with a warning.
294
+ */
295
+ const rewritePagesInPlace = async (
296
+ files: string[],
297
+ options: {
298
+ root: string;
299
+ variables: Record<string, string>;
300
+ warnings: string[];
301
+ }
302
+ ): Promise<{
303
+ moved: number;
304
+ removedKeys: Set<string>;
305
+ unsupported: Set<string>;
306
+ keptComponents: Set<string>;
307
+ assetDirs: Set<string>;
308
+ }> => {
309
+ const { root, variables, warnings } = options;
310
+ let moved = 0;
311
+ const removedKeys = new Set<string>();
312
+ const unsupported = new Set<string>();
313
+ const keptComponents = new Set<string>();
314
+ const assetDirs = new Set<string>();
315
+ for (const file of files) {
316
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
317
+ const raw = await readFile(file, "utf-8");
318
+ let result: Awaited<ReturnType<typeof transformMintlifyContent>>;
319
+ try {
320
+ // oxlint-disable-next-line no-await-in-loop -- sequential transforms
321
+ result = await transformMintlifyContent(raw, {
322
+ filePath: file,
323
+ root,
324
+ variables,
325
+ });
326
+ } catch (error) {
327
+ warnings.push(
328
+ `Skipped rewriting ${relative(root, file)}: ${(error as Error).message}. The page was left unconverted — fix it by hand.`
329
+ );
330
+ continue;
331
+ }
332
+ if (result.content !== raw) {
333
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
334
+ await mkdir(dirname(file), { recursive: true });
335
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
336
+ await writeFile(file, result.content, "utf-8");
337
+ }
338
+ for (const key of result.removed) {
339
+ removedKeys.add(key);
340
+ }
341
+ for (const name of result.unsupported) {
342
+ unsupported.add(name);
343
+ }
344
+ for (const name of result.components) {
345
+ keptComponents.add(name);
346
+ }
347
+ for (const segment of contentAssetSegments(result.content)) {
348
+ assetDirs.add(segment);
349
+ }
350
+ moved += 1;
351
+ }
352
+ return { assetDirs, keptComponents, moved, removedKeys, unsupported };
353
+ };
354
+
225
355
  /**
226
356
  * Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
227
357
  * `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
@@ -285,38 +415,15 @@ export const migrateMintlifyProject = async (
285
415
  ],
286
416
  });
287
417
 
288
- let moved = 0;
289
- const removedKeys = new Set<string>();
290
- const unsupported = new Set<string>();
291
- const keptComponents = new Set<string>();
292
- for (const file of files) {
293
- // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
294
- const raw = await readFile(file, "utf-8");
295
- // oxlint-disable-next-line no-await-in-loop -- sequential transforms
296
- const result = await transformMintlifyContent(raw, {
297
- filePath: file,
298
- root,
299
- variables,
300
- });
301
- if (result.content !== raw) {
302
- // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
303
- await mkdir(dirname(file), { recursive: true });
304
- // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
305
- await writeFile(file, result.content, "utf-8");
306
- }
307
- for (const key of result.removed) {
308
- removedKeys.add(key);
309
- }
310
- for (const name of result.unsupported) {
311
- unsupported.add(name);
312
- }
313
- for (const name of result.components) {
314
- keptComponents.add(name);
315
- }
316
- moved += 1;
317
- }
418
+ const { moved, removedKeys, unsupported, keptComponents, assetDirs } =
419
+ await rewritePagesInPlace(files, { root, variables, warnings });
318
420
 
319
- const assets = await relocateAssets(root, assetSegments(config));
421
+ // Config-referenced segments (logo/favicon/backgrounds + /images) plus dirs
422
+ // referenced only by page content — Mintlify serves every top-level dir at
423
+ // the site root, so both kinds must stay resolvable after migration.
424
+ const assets = await relocateAssets(root, [
425
+ ...new Set([...assetSegments(config), ...assetDirs]),
426
+ ]);
320
427
  await cleanupSnippets(root, keptComponents, warnings);
321
428
 
322
429
  if (config.content?.exclude) {
@@ -324,6 +431,10 @@ export const migrateMintlifyProject = async (
324
431
  }
325
432
  applyRelocatedAssets(config, assets, warnings);
326
433
  await writeBlumeConfig(root, config);
434
+ // Drop the source config only after the Blume config is safely on disk, so a
435
+ // mid-migration failure never leaves the project with neither.
436
+ await removeForeignConfig(root, warnings);
437
+ await scaffoldProjectFiles(root, warnings);
327
438
 
328
439
  if (Object.keys(variables).length > 0) {
329
440
  warnings.push(