blume 0.5.2 → 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 (57) hide show
  1. package/dist/cli/index.js +2755 -2564
  2. package/dist/cli/index.js.map +33 -31
  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/static-assets.ts +10 -3
  15. package/src/astro/templates.ts +53 -21
  16. package/src/cli/coalesce.ts +43 -0
  17. package/src/cli/commands/dev.ts +31 -17
  18. package/src/cli/dev-lock.ts +4 -2
  19. package/src/components/content/ColorItem.astro +6 -3
  20. package/src/components/content/Prompt.astro +7 -3
  21. package/src/components/content/Tabs.astro +13 -2
  22. package/src/components/content/mermaid-element.ts +20 -2
  23. package/src/components/islands/ask-ai.tsx +4 -8
  24. package/src/components/islands/base-path.ts +30 -0
  25. package/src/components/islands/hooks.ts +12 -8
  26. package/src/components/layout/PageActions.astro +17 -11
  27. package/src/components/layout/Search.astro +4 -1
  28. package/src/components/layout/search/types.ts +16 -5
  29. package/src/components/openapi/ParametersTable.astro +1 -1
  30. package/src/components/openapi/SchemaProperty.astro +1 -1
  31. package/src/components/openapi/SchemaTable.astro +3 -3
  32. package/src/components/openapi/helpers.ts +17 -8
  33. package/src/components/openapi/snippets.ts +17 -4
  34. package/src/core/config.ts +15 -6
  35. package/src/core/graph.ts +6 -1
  36. package/src/core/navigation.ts +5 -1
  37. package/src/core/sources/filesystem.ts +19 -1
  38. package/src/core/sources/mdx-remote.ts +20 -4
  39. package/src/core/sources/mintlify.ts +14 -28
  40. package/src/core/sources/normalize.ts +28 -6
  41. package/src/core/sources/watch.ts +44 -0
  42. package/src/markdown/code-title.ts +6 -3
  43. package/src/markdown/package-install.ts +3 -1
  44. package/src/migrate/fumadocs/content.ts +3 -5
  45. package/src/migrate/fumadocs/index.ts +24 -9
  46. package/src/migrate/mintlify/config.ts +2 -6
  47. package/src/migrate/mintlify/index.ts +119 -32
  48. package/src/migrate/mintlify/snippets.ts +17 -8
  49. package/src/migrate/nextra/index.ts +16 -1
  50. package/src/migrate/shared.ts +77 -4
  51. package/src/migrate/starlight/content.ts +3 -6
  52. package/src/og/card.ts +16 -4
  53. package/src/openapi/render-mdx.ts +10 -1
  54. package/src/search/sync/orama-cloud.ts +2 -0
  55. package/src/search/sync/typesense.ts +4 -0
  56. package/src/theme/icons.ts +13 -4
  57. package/src/theme/palette.ts +38 -17
@@ -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,7 +1,7 @@
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
7
  import { ensureGitignore } from "../../core/gitignore.ts";
@@ -190,6 +190,35 @@ const applyRelocatedAssets = (
190
190
  }
191
191
  };
192
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
+
193
222
  /**
194
223
  * Scaffold the project files a config-only Mintlify repo lacks: a runnable
195
224
  * `package.json` (it ships no npm manifest) and a `.gitignore` for Blume's
@@ -245,6 +274,84 @@ const cleanupSnippets = async (
245
274
  }
246
275
  };
247
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
+
248
355
  /**
249
356
  * Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
250
357
  * `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
@@ -308,38 +415,15 @@ export const migrateMintlifyProject = async (
308
415
  ],
309
416
  });
310
417
 
311
- let moved = 0;
312
- const removedKeys = new Set<string>();
313
- const unsupported = new Set<string>();
314
- const keptComponents = 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
- // oxlint-disable-next-line no-await-in-loop -- sequential transforms
319
- const result = await transformMintlifyContent(raw, {
320
- filePath: file,
321
- root,
322
- variables,
323
- });
324
- if (result.content !== raw) {
325
- // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
326
- await mkdir(dirname(file), { recursive: true });
327
- // oxlint-disable-next-line no-await-in-loop -- sequential fs writes
328
- await writeFile(file, result.content, "utf-8");
329
- }
330
- for (const key of result.removed) {
331
- removedKeys.add(key);
332
- }
333
- for (const name of result.unsupported) {
334
- unsupported.add(name);
335
- }
336
- for (const name of result.components) {
337
- keptComponents.add(name);
338
- }
339
- moved += 1;
340
- }
418
+ const { moved, removedKeys, unsupported, keptComponents, assetDirs } =
419
+ await rewritePagesInPlace(files, { root, variables, warnings });
341
420
 
342
- 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
+ ]);
343
427
  await cleanupSnippets(root, keptComponents, warnings);
344
428
 
345
429
  if (config.content?.exclude) {
@@ -347,6 +431,9 @@ export const migrateMintlifyProject = async (
347
431
  }
348
432
  applyRelocatedAssets(config, assets, warnings);
349
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);
350
437
  await scaffoldProjectFiles(root, warnings);
351
438
 
352
439
  if (Object.keys(variables).length > 0) {
@@ -3,6 +3,7 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
3
3
  import { dirname, relative, resolve } from "pathe";
4
4
 
5
5
  import matter from "../../core/frontmatter.ts";
6
+ import { isInsideRoot, stripImports } from "../shared.ts";
6
7
 
7
8
  const MARKDOWN_SNIPPET_IMPORT =
8
9
  /^import\s+(?<name>[$A-Z_a-z][$\w]*)\s+from\s+["'](?<source>[^"']+\.mdx?)["'];?\s*$/gmu;
@@ -37,11 +38,6 @@ interface SnippetTransformOptions {
37
38
  trail?: string[];
38
39
  }
39
40
 
40
- const isInsideRoot = (root: string, candidate: string): boolean => {
41
- const rel = relative(root, candidate);
42
- return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
43
- };
44
-
45
41
  const escapeRegExp = (value: string): string =>
46
42
  value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
47
43
 
@@ -151,7 +147,9 @@ const interpolateProps = (
151
147
  source.replaceAll(PLACEHOLDER, (value, name: string) => props[name] ?? value);
152
148
 
153
149
  const stripImport = (source: string, importText: string): string =>
154
- source.replace(importText, "").replaceAll(/\n{3,}/gu, "\n\n");
150
+ // Seam-targeted removal: a whole-document `\n{3,}` collapse would rewrite
151
+ // real double blank lines inside the page's code fences.
152
+ stripImports(source, new RegExp(escapeRegExp(importText), "gu"));
155
153
 
156
154
  const replacePlaceholder = (
157
155
  source: string,
@@ -189,9 +187,20 @@ export const rewriteMintlifyMarkdownSnippets = async (
189
187
  );
190
188
  }
191
189
  seen.add(file);
192
- const readFile = options.readFile ?? readFileFromDisk;
190
+ const readFile =
191
+ options.readFile ??
192
+ ((path: string): Promise<string> => readFileFromDisk(path, "utf-8"));
193
193
  try {
194
- const raw = await readFile(file);
194
+ let raw: string;
195
+ try {
196
+ raw = await readFile(file);
197
+ } catch {
198
+ // A dangling import must fail with a message that names both ends —
199
+ // the caller downgrades it to a per-page warning and moves on.
200
+ throw new Error(
201
+ `snippet ${rootRelativePath(options.root, file)} (imported by ${rootRelativePath(options.root, options.filePath)}) does not exist`
202
+ );
203
+ }
195
204
  const content = matter(raw).content.trim();
196
205
  const transformed = await rewriteMintlifyMarkdownSnippets(content, {
197
206
  ...options,
@@ -5,8 +5,9 @@ import { basename, dirname, extname, join, relative } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import matter from "../../core/frontmatter.ts";
8
+ import { ensureGitignore } from "../../core/gitignore.ts";
8
9
  import type { BlumeConfig, FolderMeta } from "../../core/schema.ts";
9
- import { writeBlumeConfig } from "../shared.ts";
10
+ import { rewriteFrameworkScripts, writeBlumeConfig } from "../shared.ts";
10
11
  import {
11
12
  rewriteNextraCallouts,
12
13
  stripNextraImports,
@@ -353,11 +354,25 @@ export const migrateNextraProject = async (
353
354
  );
354
355
  await writeBlumeConfig(root, buildConfig(plan.tabs));
355
356
 
357
+ // Tear down the Next scaffolding like the Fumadocs migrator does — without
358
+ // this, `npm run dev` still launches Next against the gutted content tree.
359
+ const scriptsRewritten = await rewriteFrameworkScripts(
360
+ root,
361
+ /\bnext\b/u,
362
+ /\bnextra\b/u
363
+ );
364
+ await ensureGitignore(root, [".blume/", "dist/"]);
365
+
356
366
  const warnings = [
357
367
  ...plan.warnings,
358
368
  ...pages.skipped.map((rel) => `Skipped ${rel} (target already exists)`),
359
369
  ...relocateWarnings,
360
370
  ];
371
+ if (scriptsRewritten) {
372
+ warnings.push(
373
+ "Repointed package.json scripts at Blume (dev/build/preview); remove the leftover Next/Nextra dependencies and next.config/theme.config by hand."
374
+ );
375
+ }
361
376
  if (pages.removedKeys.length > 0) {
362
377
  warnings.push(
363
378
  `Dropped unsupported page frontmatter keys: ${pages.removedKeys.join(", ")}.`
@@ -125,6 +125,27 @@ export const ensurePackageJson = async (root: string): Promise<boolean> => {
125
125
  return true;
126
126
  };
127
127
 
128
+ // A marker no markdown document contains, planted where an import was removed
129
+ // so the blank-gap collapse can target the seams alone.
130
+ const IMPORT_SEAM = "\u0000blume-import-seam\u0000";
131
+
132
+ /**
133
+ * Remove every match of an import pattern, collapsing the blank gap each
134
+ * removal leaves behind — without touching blank runs elsewhere in the
135
+ * document (double blank lines inside code fences are real content that a
136
+ * whole-document `\n{3,}` collapse used to corrupt).
137
+ */
138
+ export const stripImports = (source: string, pattern: RegExp): string => {
139
+ const marked = source.replaceAll(pattern, IMPORT_SEAM);
140
+ if (marked === source) {
141
+ return source;
142
+ }
143
+ return marked
144
+ .replaceAll(new RegExp(String.raw`\n*(?:${IMPORT_SEAM})+\n*`, "gu"), "\n\n")
145
+ .replace(/^\n+/u, "")
146
+ .replace(/\n+$/u, "\n");
147
+ };
148
+
128
149
  // ---------------------------------------------------------------------------
129
150
  // Callout components -> Blume `:::` directives
130
151
  // ---------------------------------------------------------------------------
@@ -161,14 +182,30 @@ const dedent = (value: string): string => {
161
182
  return lines.map((line) => line.slice(common)).join("\n");
162
183
  };
163
184
 
185
+ /**
186
+ * A colon fence one longer than any directive fence in the body — nested
187
+ * container directives require the outer fence to be longer than the inner
188
+ * ones, or the inner `:::` closes the outer block.
189
+ */
190
+ const fenceOver = (body: string): string => {
191
+ let max = 2;
192
+ for (const match of body.matchAll(/^ {0,3}(?<colons>:{3,})/gmu)) {
193
+ max = Math.max(max, match.groups?.colons?.length ?? 0);
194
+ }
195
+ return ":".repeat(Math.max(3, max + 1));
196
+ };
197
+
164
198
  const directiveBlock = (
165
199
  directive: string,
166
200
  title: string | undefined,
167
201
  inner: string
168
202
  ): string => {
169
- const head = title ? `:::${directive}[${title}]` : `:::${directive}`;
170
203
  const body = dedent(inner);
171
- return `${head}\n${body}\n:::`;
204
+ const fence = fenceOver(body);
205
+ const head = title
206
+ ? `${fence}${directive}[${title}]`
207
+ : `${fence}${directive}`;
208
+ return `${head}\n${body}\n${fence}`;
172
209
  };
173
210
 
174
211
  /**
@@ -200,6 +237,40 @@ export const findOpenTagEnd = (source: string, from: number): number => {
200
237
  return -1;
201
238
  };
202
239
 
240
+ /**
241
+ * Find the close tag matching an open tag, honoring same-tag nesting
242
+ * (`<Note>a<Note>b</Note>c</Note>` must close at the *outer* `</Note>`).
243
+ * Returns the index of the matching `</Tag>`, or -1 when unterminated.
244
+ */
245
+ const findMatchingClose = (
246
+ source: string,
247
+ tag: string,
248
+ from: number
249
+ ): number => {
250
+ const scanner = new RegExp(`<(?<closing>/)?${tag}(?=[\\s/>])`, "gu");
251
+ scanner.lastIndex = from;
252
+ let depth = 0;
253
+ for (let match = scanner.exec(source); match; match = scanner.exec(source)) {
254
+ if (match.groups?.closing) {
255
+ if (depth === 0) {
256
+ return match.index;
257
+ }
258
+ depth -= 1;
259
+ continue;
260
+ }
261
+ // A nested open tag: skip its attributes; self-closing ones don't nest.
262
+ const end = findOpenTagEnd(source, match.index + tag.length + 1);
263
+ if (end === -1) {
264
+ return -1;
265
+ }
266
+ if (source[end - 1] !== "/") {
267
+ depth += 1;
268
+ }
269
+ scanner.lastIndex = end + 1;
270
+ }
271
+ return -1;
272
+ };
273
+
203
274
  const directiveFor = (
204
275
  tag: string,
205
276
  attrs: string,
@@ -256,7 +327,7 @@ export const rewriteCallouts = (
256
327
  const selfClosing = attrs.trimEnd().endsWith("/");
257
328
  const closeIndex = selfClosing
258
329
  ? openEnd
259
- : source.indexOf(closeTag, openEnd + 1);
330
+ : findMatchingClose(source, tag, openEnd + 1);
260
331
 
261
332
  if (!directive || (!selfClosing && closeIndex === -1)) {
262
333
  output += source.slice(cursor, openEnd + 1);
@@ -272,10 +343,12 @@ export const rewriteCallouts = (
272
343
  : `:::${directive}\n:::`;
273
344
  cursor = openEnd + 1;
274
345
  } else {
346
+ // Recurse so nested callouts (of any tag) convert too — they'd
347
+ // otherwise survive as components Blume doesn't ship.
275
348
  output += directiveBlock(
276
349
  directive,
277
350
  title,
278
- source.slice(openEnd + 1, closeIndex)
351
+ rewriteCallouts(source.slice(openEnd + 1, closeIndex), options)
279
352
  );
280
353
  cursor = closeIndex + closeTag.length;
281
354
  }