blume 0.5.0 → 0.5.2

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.
@@ -8,7 +8,6 @@ import type {
8
8
  import type {
9
9
  NavChromeVariant,
10
10
  NavNode,
11
- NavSidebarVariant,
12
11
  Navigation,
13
12
  NavSelector,
14
13
  NavTab,
@@ -348,7 +347,6 @@ export const buildNavigation = (
348
347
  selectors?: NavSelector[];
349
348
  tabs?: NavTab[];
350
349
  sidebar?: SidebarItemConfig[];
351
- sidebarVariants?: { path: string; items: SidebarItemConfig[] }[];
352
350
  /** Locale dir prefix for folder-meta lookup (`""` for the default locale). */
353
351
  metaPrefix?: string;
354
352
  /**
@@ -372,19 +370,12 @@ export const buildNavigation = (
372
370
  page,
373
371
  ])
374
372
  );
375
- const sidebarVariants: NavSidebarVariant[] = (
376
- options.sidebarVariants ?? []
377
- ).map((variant) => ({
378
- path: variant.path,
379
- sidebar: buildConfigSidebar(variant.items, byRoute),
380
- }));
381
373
 
382
374
  if (options.sidebar) {
383
375
  return {
384
376
  chromeVariants,
385
377
  selectors,
386
378
  sidebar: buildConfigSidebar(options.sidebar, byRoute),
387
- sidebarVariants,
388
379
  tabs,
389
380
  };
390
381
  }
@@ -398,7 +389,6 @@ export const buildNavigation = (
398
389
  sharedFolderMeta,
399
390
  metaPrefix
400
391
  ),
401
- sidebarVariants,
402
392
  tabs,
403
393
  };
404
394
  };
@@ -0,0 +1,32 @@
1
+ import { getBlumeVersion } from "./version.ts";
2
+
3
+ /**
4
+ * Derive a valid npm package name from a directory name, falling back to
5
+ * `docs` when nothing usable remains.
6
+ */
7
+ export const toPackageName = (raw: string): string =>
8
+ raw
9
+ .toLowerCase()
10
+ .replaceAll(/[^a-z0-9._-]+/gu, "-")
11
+ .replaceAll(/^[-_.]+|[-_.]+$/gu, "") || "docs";
12
+
13
+ /**
14
+ * A minimal, runnable `package.json` body for a Blume project: the `blume`
15
+ * dependency pinned to the installed version plus `dev`/`build`/`doctor`
16
+ * scripts, so `npm install && npm run dev` works immediately. Shared by
17
+ * `blume init` and the migrators, which scaffold one when a project has none.
18
+ */
19
+ export const blumePackageJson = (name: string): string => `{
20
+ "name": ${JSON.stringify(name)},
21
+ "private": true,
22
+ "type": "module",
23
+ "scripts": {
24
+ "dev": "blume dev",
25
+ "build": "blume build",
26
+ "doctor": "blume doctor"
27
+ },
28
+ "dependencies": {
29
+ "blume": "^${getBlumeVersion()}"
30
+ }
31
+ }
32
+ `;
@@ -483,13 +483,6 @@ const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
483
483
  ])
484
484
  );
485
485
 
486
- const sidebarVariantSchema = z
487
- .object({
488
- items: z.array(sidebarItemSchema).default([]),
489
- path: z.string(),
490
- })
491
- .strict();
492
-
493
486
  const variablesConfigSchema = z
494
487
  .record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
495
488
  .default({});
@@ -666,7 +659,6 @@ const navigationConfigSchema = z
666
659
  selectors: z.array(navSelectorSchema).default([]),
667
660
  /** Explicit sidebar override; when omitted the sidebar is generated. */
668
661
  sidebar: z.array(sidebarItemSchema).optional(),
669
- sidebarVariants: z.array(sidebarVariantSchema).default([]),
670
662
  tabs: z.array(navTabSchema).optional(),
671
663
  })
672
664
  .strict();
@@ -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";
@@ -42,6 +43,41 @@ const MINTLIFY_SOURCE_IGNORES = [
42
43
  "snippets/**",
43
44
  ];
44
45
 
46
+ /**
47
+ * Directory names the recursive dev watcher must ignore. In bridge mode the
48
+ * content root is the project root, so a naive recursive `fs.watch` also sees
49
+ * Blume's own `.blume/` output — which the dev server rewrites on every request
50
+ * (`.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
51
+ * a full rescan + runtime regeneration, whose writes land back under `.blume/`
52
+ * and fire the watcher again: a self-sustaining storm that stalls page renders
53
+ * and floods the console. `fs.watch` has no ignore option, so we filter by the
54
+ * changed path in the callback. Derived from {@link MINTLIFY_SOURCE_IGNORES}
55
+ * (dir prefixes) plus VCS metadata.
56
+ */
57
+ const WATCH_IGNORE_DIRS = new Set([
58
+ ...MINTLIFY_SOURCE_IGNORES.map((pattern) => pattern.replace(/\/\*\*$/u, "")),
59
+ ".git",
60
+ ]);
61
+
62
+ /**
63
+ * Build the recursive-watch listener: fire `onChange` for content changes but
64
+ * ignore events whose path crosses a {@link WATCH_IGNORE_DIRS} segment (Blume's
65
+ * own `.blume/` output, `node_modules`, VCS metadata, …). A missing `filename`
66
+ * — rare; the platform couldn't name the changed path — falls through to
67
+ * regenerate rather than silently dropping a real edit. Exported for testing.
68
+ */
69
+ export const mintlifyWatchListener =
70
+ (onChange: () => void): WatchListener<string> =>
71
+ (_event, filename) => {
72
+ if (
73
+ typeof filename === "string" &&
74
+ filename.split(/[/\\]/u).some((segment) => WATCH_IGNORE_DIRS.has(segment))
75
+ ) {
76
+ return;
77
+ }
78
+ onChange();
79
+ };
80
+
45
81
  /**
46
82
  * The Mintlify bridge content source. Reads an unconverted Mintlify project in
47
83
  * place and transforms each page to Blume MDX at scan time (callouts → `:::`
@@ -126,7 +162,14 @@ export const mintlifySource = (
126
162
  const watch = (onChange: () => void): (() => void) => {
127
163
  const disposers: (() => void)[] = [];
128
164
  if (existsSync(contentRoot)) {
129
- const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
165
+ // Recursively watch the content root, but skip Blume's own output and
166
+ // other non-content trees so the dev server's `.blume/` writes don't feed
167
+ // a regeneration loop (`fs.watch` has no ignore option, so filter here).
168
+ const watcher = fsWatch(
169
+ contentRoot,
170
+ { recursive: true },
171
+ mintlifyWatchListener(onChange)
172
+ );
130
173
  disposers.push(() => watcher.close());
131
174
  }
132
175
  // Watch docs.json directly: it lives at the content root but a non-recursive
package/src/core/types.ts CHANGED
@@ -180,12 +180,6 @@ export interface NavSelector {
180
180
  items: NavSelectorItem[];
181
181
  }
182
182
 
183
- /** Sidebar tree used when the current route belongs to a nav partition. */
184
- export interface NavSidebarVariant {
185
- path: string;
186
- sidebar: NavNode[];
187
- }
188
-
189
183
  /** Chrome overrides used when the current route belongs to a nav partition. */
190
184
  export interface NavChromeVariant {
191
185
  path: string;
@@ -198,7 +192,6 @@ export interface Navigation {
198
192
  selectors: NavSelector[];
199
193
  chromeVariants: NavChromeVariant[];
200
194
  sidebar: NavNode[];
201
- sidebarVariants: NavSidebarVariant[];
202
195
  /** Repo URL for the header link, or null when hidden (`navigation.repo`). */
203
196
  repoUrl?: string | null;
204
197
  }
@@ -17,8 +17,6 @@ type NavigationSelectorItem = NavigationSelectors[number]["items"][number];
17
17
  type NavigationChromeVariants = NonNullable<
18
18
  BlumeConfig["navigation"]
19
19
  >["chromeVariants"];
20
- type NavigationSidebarVariants =
21
- ResolvedConfig["navigation"]["sidebarVariants"];
22
20
 
23
21
  const MINTLIFY_DEFAULT_IGNORES = [
24
22
  "**/_*",
@@ -307,18 +305,6 @@ const mintlifyBackgroundDecoration = (
307
305
  return undefined;
308
306
  };
309
307
 
310
- const sidebarItemPaths = (items: SidebarItemConfig[]): string[] =>
311
- items.flatMap((item) => {
312
- if (typeof item === "string") {
313
- return [tabPathFromRef(item)];
314
- }
315
- if (item.href) {
316
- return [item.href];
317
- }
318
- const paths = item.root ? [tabPathFromRef(item.root)] : [];
319
- return item.items ? [...paths, ...sidebarItemPaths(item.items)] : paths;
320
- });
321
-
322
308
  const collapsedFromExpanded = (value: unknown): boolean | undefined => {
323
309
  if (value === false) {
324
310
  return true;
@@ -439,79 +425,6 @@ const isExternalRoute = (path: string): boolean =>
439
425
  path.startsWith("mailto:") ||
440
426
  path.startsWith("tel:");
441
427
 
442
- const hasOwnNavigationContent = (item: JsonObject): boolean =>
443
- Boolean(asString(item.root)) || childItemsFor(item).length > 0;
444
-
445
- const mintlifySidebarVariants = async (
446
- spec: JsonObject
447
- ): Promise<NavigationSidebarVariants> => {
448
- const navigation = asObject(spec.navigation) ?? {};
449
- const global = asObject(navigation.global) ?? {};
450
- interface SidebarVariantCandidate {
451
- item: unknown;
452
- path: string;
453
- }
454
- const candidates: SidebarVariantCandidate[] = [];
455
-
456
- const addCandidate = (item: unknown): void => {
457
- const path = navItemPath(item);
458
- if (!path || isExternalRoute(path)) {
459
- return;
460
- }
461
-
462
- candidates.push({ item, path });
463
- };
464
-
465
- for (const item of [
466
- ...asArray(navigation.tabs),
467
- ...asArray(navigation.anchors),
468
- ...asArray(global.anchors),
469
- ]) {
470
- const object = asObject(item);
471
- if (!object) {
472
- addCandidate(item);
473
- continue;
474
- }
475
-
476
- for (const menuItem of asArray(object.menu)) {
477
- addCandidate(menuItem);
478
- }
479
-
480
- if (hasOwnNavigationContent(object)) {
481
- addCandidate(item);
482
- }
483
- }
484
-
485
- for (const item of [
486
- ...asArray(navigation.dropdowns),
487
- ...asArray(navigation.products),
488
- ...asArray(navigation.versions),
489
- ...asArray(navigation.languages),
490
- ]) {
491
- addCandidate(item);
492
- }
493
-
494
- const resolved = await Promise.all(
495
- candidates.map(async (candidate) => {
496
- const items = await toSidebarItems([candidate.item], {});
497
- if (items.length === 0) {
498
- return [];
499
- }
500
- return [candidate.path, ...sidebarItemPaths(items)]
501
- .filter((path) => !isExternalRoute(path))
502
- .map((path) => ({ items, path }));
503
- })
504
- );
505
- const seen = new Set<string>();
506
- return resolved.flat().flatMap((variant) => {
507
- if (!variant || seen.has(variant.path)) {
508
- return [];
509
- }
510
- seen.add(variant.path);
511
- return [variant];
512
- });
513
- };
514
-
515
428
  const mintlifyTabs = (
516
429
  spec: JsonObject
517
430
  ): NonNullable<BlumeConfig["navigation"]>["tabs"] => {
@@ -604,24 +517,53 @@ const toAstroRedirectPath = (path: string): string =>
604
517
  modifier === "*" || modifier === "+" ? `[...${name}]` : `[${name}]`
605
518
  );
606
519
 
607
- const mintlifyRedirects = (
520
+ // A converted path is dynamic once it holds an Astro segment (`[id]`/`[...slug]`
521
+ // from a `:param`). Blume redirects are static path-to-path: a dynamic segment
522
+ // has no matching route, so Astro aborts the build ("destination does not match
523
+ // any existing route"), and the platform redirect files emit the segment
524
+ // verbatim, which no host understands. Such redirects are dropped, not emitted.
525
+ const isDynamicRedirectPath = (path: string): boolean => path.includes("[");
526
+
527
+ export interface MintlifyRedirectPartition {
528
+ /** Static redirects Blume can honor, translated to Blume's `from`/`to` shape. */
529
+ kept: NonNullable<BlumeConfig["redirects"]>;
530
+ /** Source paths of dynamic redirects dropped because Blume can't model them. */
531
+ dropped: string[];
532
+ }
533
+
534
+ /**
535
+ * Split a spec's redirects into the static ones Blume emits and the dynamic
536
+ * (wildcard/param) ones it drops. Keeping a dynamic redirect crashes the Astro
537
+ * build, so the migrator surfaces the dropped sources as a warning instead.
538
+ */
539
+ export const partitionMintlifyRedirects = (
608
540
  spec: JsonObject
609
- ): NonNullable<BlumeConfig["redirects"]> =>
610
- asArray(spec.redirects).flatMap((redirect) => {
541
+ ): MintlifyRedirectPartition => {
542
+ const kept: NonNullable<BlumeConfig["redirects"]> = [];
543
+ const dropped: string[] = [];
544
+ for (const redirect of asArray(spec.redirects)) {
611
545
  const object = asObject(redirect);
612
546
  if (!object) {
613
- return [];
547
+ continue;
614
548
  }
615
- const from = asString(object.source) ?? asString(object.from);
616
- const to =
549
+ const source = asString(object.source) ?? asString(object.from);
550
+ const destination =
617
551
  asString(object.destination) ??
618
552
  asString(object.to) ??
619
553
  asString(object.redirect);
620
- if (!from || !to) {
621
- return [];
554
+ if (!source || !destination) {
555
+ continue;
622
556
  }
623
- return [{ from: toAstroRedirectPath(from), to: toAstroRedirectPath(to) }];
624
- });
557
+ const from = toAstroRedirectPath(source);
558
+ const to = toAstroRedirectPath(destination);
559
+ if (isDynamicRedirectPath(from) || isDynamicRedirectPath(to)) {
560
+ dropped.push(source);
561
+ } else {
562
+ kept.push({ from, to });
563
+ }
564
+ }
565
+ return { dropped, kept };
566
+ };
625
567
 
626
568
  interface OpenApiSourceDraft {
627
569
  label?: string;
@@ -981,11 +923,10 @@ export const loadMintlifyConfig = async (
981
923
  chromeVariants: mintlifyChromeVariants(spec),
982
924
  selectors: mintlifySelectors(spec),
983
925
  sidebar: await toSidebarItems(mintlifyNavigationItems(navigation), {}),
984
- sidebarVariants: await mintlifySidebarVariants(spec),
985
926
  tabs: mintlifyTabs(spec),
986
927
  },
987
928
  openapi: mintlifyOpenapi(spec),
988
- redirects: mintlifyRedirects(spec),
929
+ redirects: partitionMintlifyRedirects(spec).kept,
989
930
  search: {
990
931
  indexing: {
991
932
  includeHiddenPages: seo.indexing === "all",
@@ -56,6 +56,22 @@ export const rewriteMintlifyExampleBlocks = (source: string): string =>
56
56
  "<$<close>CodeGroup"
57
57
  );
58
58
 
59
+ /**
60
+ * Mintlify nests `<Accordion title="…">` items inside an `<AccordionGroup>`.
61
+ * Blume inverts that: `<Accordion>` is the container and each item is an
62
+ * `<AccordionItem title="…">`. Rewrite both in a single pass — the `\b` after
63
+ * `Accordion` keeps `<AccordionGroup>` (the `Group` branch) distinct from an
64
+ * item, so open/close tags of either map correctly regardless of order. Without
65
+ * this, migrated pages keep `<AccordionGroup>`, which Blume doesn't ship and the
66
+ * MDX build rejects with "Expected component AccordionGroup to be defined".
67
+ */
68
+ export const rewriteMintlifyAccordions = (source: string): string =>
69
+ source.replaceAll(
70
+ /<(?<close>\/?)Accordion(?<group>Group)?\b/gu,
71
+ (_match, close: string, group: string | undefined) =>
72
+ group ? `<${close}Accordion` : `<${close}AccordionItem`
73
+ );
74
+
59
75
  const SNIPPET_IMPORT =
60
76
  /^import\s+[\s\S]*?\s+from\s+["'](?<source>\/snippets\/[^"']+)["'];?[ \t]*\n?/gmu;
61
77
 
@@ -4,9 +4,11 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } 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
- import { loadMintlifyConfig } from "./config.ts";
11
+ import { loadMintlifyConfig, partitionMintlifyRedirects } from "./config.ts";
10
12
  import { mintlifyI18n } from "./i18n.ts";
11
13
  import { transformMintlifyContent } from "./transform.ts";
12
14
 
@@ -65,6 +67,22 @@ const droppedChromeWarnings = (
65
67
  return warnings;
66
68
  };
67
69
 
70
+ /**
71
+ * Warn about dynamic (wildcard/param) redirects the migrator dropped. Blume
72
+ * redirects are static path-to-path, so a `:slug*`/`:id` source becomes an
73
+ * unroutable Astro destination — kept ones crash the build. Point the user at
74
+ * host-level rules that do support wildcards.
75
+ */
76
+ const droppedRedirectWarnings = (spec: Record<string, unknown>): string[] => {
77
+ const { dropped } = partitionMintlifyRedirects(spec);
78
+ if (dropped.length === 0) {
79
+ return [];
80
+ }
81
+ return [
82
+ `Dropped ${dropped.length} dynamic redirect(s) Blume can't model as static path-to-path (${dropped.join(", ")}); re-add them as host-level rules (e.g. _redirects or vercel.json).`,
83
+ ];
84
+ };
85
+
68
86
  /** Recursively drop `undefined`, empty arrays, and empty objects. */
69
87
  const prune = (value: unknown): unknown => {
70
88
  if (Array.isArray(value)) {
@@ -172,6 +190,27 @@ const applyRelocatedAssets = (
172
190
  }
173
191
  };
174
192
 
193
+ /**
194
+ * Scaffold the project files a config-only Mintlify repo lacks: a runnable
195
+ * `package.json` (it ships no npm manifest) and a `.gitignore` for Blume's
196
+ * generated `.blume/` runtime and `dist/` build output. Both are idempotent —
197
+ * an existing file is extended, not overwritten — and noted in the warnings.
198
+ */
199
+ const scaffoldProjectFiles = async (
200
+ root: string,
201
+ warnings: string[]
202
+ ): Promise<void> => {
203
+ if (await ensurePackageJson(root)) {
204
+ warnings.push(
205
+ "Created a package.json with blume as a dependency; run `npm install`, then `npm run dev`."
206
+ );
207
+ }
208
+ const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
209
+ if (ignored.length > 0) {
210
+ warnings.push(`Added ${ignored.join(", ")} to .gitignore.`);
211
+ }
212
+ };
213
+
175
214
  /**
176
215
  * Delete the inlined markdown snippets. Component files (e.g. `.jsx`) are kept
177
216
  * because their imports were rewritten to resolve against `/snippets`.
@@ -247,6 +286,7 @@ export const migrateMintlifyProject = async (
247
286
  );
248
287
  }
249
288
  warnings.push(...droppedChromeWarnings(spec, config));
289
+ warnings.push(...droppedRedirectWarnings(spec));
250
290
  } else {
251
291
  warnings.push("No docs.json or mint.json found; writing a default config.");
252
292
  config = { content: { root: "." }, title: "Documentation" };
@@ -307,6 +347,7 @@ export const migrateMintlifyProject = async (
307
347
  }
308
348
  applyRelocatedAssets(config, assets, warnings);
309
349
  await writeBlumeConfig(root, config);
350
+ await scaffoldProjectFiles(root, warnings);
310
351
 
311
352
  if (Object.keys(variables).length > 0) {
312
353
  warnings.push(
@@ -1,6 +1,7 @@
1
1
  import matter from "../../core/frontmatter.ts";
2
2
  import { stripUnknownPageMeta } from "../shared.ts";
3
3
  import {
4
+ rewriteMintlifyAccordions,
4
5
  rewriteMintlifyCallouts,
5
6
  rewriteMintlifyExampleBlocks,
6
7
  rewriteSnippetImports,
@@ -59,6 +60,7 @@ export const transformMintlifyContent = async (
59
60
  text = snippetImports.source;
60
61
  text = rewriteMintlifySvgIconProps(text);
61
62
  text = rewriteMintlifyExampleBlocks(text);
63
+ text = rewriteMintlifyAccordions(text);
62
64
  text = rewriteMintlifyCallouts(text);
63
65
 
64
66
  const unsupported = unsupportedMintlifyComponents(text);
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
 
4
- import { isAbsolute, join, relative } from "pathe";
4
+ import { basename, isAbsolute, join, relative } from "pathe";
5
5
 
6
+ import { blumePackageJson, toPackageName } from "../core/package-json.ts";
6
7
  import type { BlumeConfig } from "../core/schema.ts";
7
8
  import { pageMetaSchema } from "../core/schema.ts";
8
9
 
@@ -102,6 +103,28 @@ export const rewriteFrameworkScripts = async (
102
103
  export const leftoverFiles = (root: string, candidates: string[]): string[] =>
103
104
  candidates.filter((candidate) => existsSync(join(root, candidate)));
104
105
 
106
+ /**
107
+ * Scaffold a minimal, runnable `package.json` when the migrated project has
108
+ * none. Config-only sources (e.g. a Mintlify `docs.json`) ship no npm manifest,
109
+ * so a fresh migration has nothing to run `blume dev` with; this writes a stub
110
+ * with `blume` as a dependency and `dev`/`build`/`doctor` scripts, making
111
+ * `npm install && npm run dev` work immediately. A pre-existing `package.json`
112
+ * is left untouched — {@link rewriteFrameworkScripts} repoints those instead.
113
+ * Returns true when a file was created.
114
+ */
115
+ export const ensurePackageJson = async (root: string): Promise<boolean> => {
116
+ const pkgPath = join(root, "package.json");
117
+ if (existsSync(pkgPath)) {
118
+ return false;
119
+ }
120
+ await writeFile(
121
+ pkgPath,
122
+ blumePackageJson(toPackageName(basename(root))),
123
+ "utf-8"
124
+ );
125
+ return true;
126
+ };
127
+
105
128
  // ---------------------------------------------------------------------------
106
129
  // Callout components -> Blume `:::` directives
107
130
  // ---------------------------------------------------------------------------