blume 0.4.0 → 0.5.1

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 (81) hide show
  1. package/dist/cli/index.js +1170 -820
  2. package/dist/cli/index.js.map +32 -27
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +154 -41
  6. package/dist/types/core/types.d.ts +7 -6
  7. package/dist/types/migrate/mintlify/config.d.ts +14 -0
  8. package/docs/01-quickstart.mdx +6 -2
  9. package/docs/02-deployment.mdx +3 -1
  10. package/docs/advanced/api-reference.mdx +37 -23
  11. package/docs/advanced/bridge.mdx +76 -0
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/meta.ts +8 -1
  14. package/docs/advanced/migrate.mdx +123 -0
  15. package/docs/configuration/ai.mdx +3 -1
  16. package/docs/configuration/analytics.mdx +3 -1
  17. package/docs/configuration/export.mdx +6 -2
  18. package/docs/configuration/index.mdx +1 -1
  19. package/docs/configuration/seo.mdx +3 -1
  20. package/docs/content/components.mdx +55 -2
  21. package/docs/content/i18n.mdx +6 -2
  22. package/docs/content/islands.mdx +6 -2
  23. package/docs/content/meta.mdx +3 -1
  24. package/docs/content/syntax.mdx +40 -14
  25. package/docs/index.mdx +2 -2
  26. package/docs/reference/cli.mdx +29 -1
  27. package/docs/reference/frontmatter.mdx +5 -0
  28. package/package.json +11 -1
  29. package/src/astro/generate.ts +18 -9
  30. package/src/astro/templates.ts +28 -4
  31. package/src/cli/commands/build.ts +107 -63
  32. package/src/cli/commands/check.ts +20 -0
  33. package/src/cli/dev-lock.ts +13 -5
  34. package/src/cli/prepare.ts +3 -0
  35. package/src/components/BlumePage.astro +6 -0
  36. package/src/components/Icon.astro +13 -10
  37. package/src/components/content/ApiField.astro +75 -0
  38. package/src/components/content/ParamField.astro +39 -0
  39. package/src/components/content/RequestField.astro +23 -0
  40. package/src/components/content/ResponseField.astro +23 -0
  41. package/src/components/content/Step.astro +1 -1
  42. package/src/components/layout/Breadcrumbs.astro +7 -2
  43. package/src/components/layout/NavTree.astro +24 -8
  44. package/src/components/layout/RootLayout.astro +56 -34
  45. package/src/components/layout/Search.astro +1 -1
  46. package/src/components/openapi/ApiOverview.astro +84 -0
  47. package/src/components/openapi/MethodBadge.astro +28 -0
  48. package/src/components/openapi/Operation.astro +140 -0
  49. package/src/components/openapi/ParametersTable.astro +97 -0
  50. package/src/components/openapi/RequestBody.astro +58 -0
  51. package/src/components/openapi/RequestPanel.astro +169 -0
  52. package/src/components/openapi/Responses.astro +91 -0
  53. package/src/components/openapi/SchemaProperty.astro +118 -0
  54. package/src/components/openapi/SchemaTable.astro +86 -0
  55. package/src/components/openapi/helpers.ts +238 -0
  56. package/src/components/openapi/panel.ts +59 -0
  57. package/src/components/openapi/snippets.ts +201 -0
  58. package/src/core/builtin-tags.ts +5 -0
  59. package/src/core/data.ts +2 -0
  60. package/src/core/graph.ts +0 -3
  61. package/src/core/nav-diagnostics.ts +2 -12
  62. package/src/core/navigation.ts +0 -10
  63. package/src/core/project-graph.ts +5 -1
  64. package/src/core/project.ts +25 -3
  65. package/src/core/schema.ts +47 -14
  66. package/src/core/sources/mintlify.ts +1 -1
  67. package/src/core/sources/resolve.ts +28 -6
  68. package/src/core/types.ts +7 -7
  69. package/src/migrate/mintlify/config.ts +190 -97
  70. package/src/migrate/mintlify/content.ts +24 -2
  71. package/src/migrate/mintlify/index.ts +76 -2
  72. package/src/migrate/mintlify/transform.ts +2 -0
  73. package/src/openapi/model.ts +174 -0
  74. package/src/openapi/parse.ts +48 -0
  75. package/src/openapi/references.ts +164 -0
  76. package/src/openapi/render-mdx.ts +76 -0
  77. package/src/openapi/scalar.ts +15 -103
  78. package/src/openapi/source.ts +140 -0
  79. package/src/registry/eject.ts +15 -2
  80. package/src/theme/chrome-icons.ts +22 -0
  81. package/src/theme/icons.ts +151 -161
@@ -69,9 +69,30 @@ const changelogMetaSchema = z
69
69
  })
70
70
  .strict();
71
71
 
72
+ /**
73
+ * A post author: a bare name/handle, or an object with a name plus optional
74
+ * avatar/URL. The object is passthrough so richer author metadata (social
75
+ * handles, roles) survives untouched — Blume doesn't render authors yet, so
76
+ * this exists to preserve the field (common on blog/changelog pages) rather
77
+ * than have a strict scan reject it.
78
+ */
79
+ const authorSchema = z.union([
80
+ z.string(),
81
+ z
82
+ .object({
83
+ avatar: z.string().optional(),
84
+ image: z.string().optional(),
85
+ name: z.string(),
86
+ url: z.string().optional(),
87
+ })
88
+ .passthrough(),
89
+ ]);
90
+
72
91
  /** Frontmatter accepted on any content page. */
73
92
  const pageMetaBaseSchema = z
74
93
  .object({
94
+ /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
95
+ authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
75
96
  changelog: changelogMetaSchema.optional(),
76
97
  /** Publish date for feed-backed content like blog/changelog. */
77
98
  date: dateSchema.optional(),
@@ -462,13 +483,6 @@ const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
462
483
  ])
463
484
  );
464
485
 
465
- const sidebarVariantSchema = z
466
- .object({
467
- items: z.array(sidebarItemSchema).default([]),
468
- path: z.string(),
469
- })
470
- .strict();
471
-
472
486
  const variablesConfigSchema = z
473
487
  .record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
474
488
  .default({});
@@ -645,7 +659,6 @@ const navigationConfigSchema = z
645
659
  selectors: z.array(navSelectorSchema).default([]),
646
660
  /** Explicit sidebar override; when omitted the sidebar is generated. */
647
661
  sidebar: z.array(sidebarItemSchema).optional(),
648
- sidebarVariants: z.array(sidebarVariantSchema).default([]),
649
662
  tabs: z.array(navTabSchema).optional(),
650
663
  })
651
664
  .strict();
@@ -903,8 +916,8 @@ const markdownConfigSchema = z
903
916
  .strict();
904
917
 
905
918
  /**
906
- * A single spec rendered by the API reference (Scalar). `spec` is a local path
907
- * or an `http(s)` URL; Scalar auto-detects OpenAPI vs AsyncAPI documents.
919
+ * A single spec rendered by the API reference. `spec` is a local path or an
920
+ * `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
908
921
  */
909
922
  const openapiSourceSchema = z
910
923
  .object({
@@ -920,20 +933,28 @@ const openapiSourceSchema = z
920
933
  export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
921
934
 
922
935
  /**
923
- * OpenAPI reference, delegated wholesale to Scalar (`@scalar/astro`). The
924
- * reference is a self-contained embed on its own route it does not weave into
925
- * Blume's sidebar, search, or llms. Set `enabled: true` to opt in.
936
+ * OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
937
+ * Scalar's parser and renders its own UI: one real page per operation, grouped
938
+ * by tag in the sidebar and included in site search, llms.txt, and OG. Set
939
+ * `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
940
+ * self-contained route that doesn't weave into the sidebar or search).
926
941
  */
927
942
  const openapiConfigSchema = z
928
943
  .object({
944
+ /** Code-sample languages shown per operation (Blume renderer). */
945
+ codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
929
946
  enabled: z.boolean().default(false),
947
+ /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
948
+ expandSchemas: z.boolean().default(false),
949
+ /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
950
+ renderer: z.enum(["blume", "scalar"]).default("blume"),
930
951
  /** Where the reference mounts. */
931
952
  route: z.string().default("/reference"),
932
953
  /** One or more specs; each renders on its own route by default. */
933
954
  sources: z.array(openapiSourceSchema).default([]),
934
955
  /** Shorthand for a single source: `sources: [{ spec }]`. */
935
956
  spec: z.string().optional(),
936
- /** Scalar theme name; defaults to a Blume-derived accent override. */
957
+ /** Scalar theme name (Scalar renderer only). */
937
958
  theme: z.string().optional(),
938
959
  })
939
960
  .strict();
@@ -980,6 +1001,17 @@ const tocConfigSchema = z
980
1001
  };
981
1002
  });
982
1003
 
1004
+ /**
1005
+ * Which icon library bare `icon` names resolve against (mirrors Mintlify's
1006
+ * `icons.library`). Names can always opt into a specific set with an explicit
1007
+ * `prefix:name` (`lucide:rocket`, `fa6-brands:github`) regardless of this.
1008
+ */
1009
+ const iconsConfigSchema = z
1010
+ .object({
1011
+ library: z.enum(["lucide", "fontawesome", "tabler"]).default("lucide"),
1012
+ })
1013
+ .strict();
1014
+
983
1015
  export const blumeConfigSchema = z
984
1016
  .object({
985
1017
  ai: aiConfigSchema.default({}),
@@ -1008,6 +1040,7 @@ export const blumeConfigSchema = z
1008
1040
  feedback: z.boolean().default(true),
1009
1041
  github: githubConfigSchema.optional(),
1010
1042
  i18n: i18nConfigSchema.optional(),
1043
+ icons: iconsConfigSchema.default({}),
1011
1044
  lastModified: lastModifiedConfigSchema.default(false),
1012
1045
  logo: logoConfigSchema.optional(),
1013
1046
  markdown: markdownConfigSchema.default({}),
@@ -102,7 +102,7 @@ export const mintlifySource = (
102
102
  ? [
103
103
  {
104
104
  code: "BLUME_MINTLIFY_UNSUPPORTED",
105
- message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}. Use the OpenAPI reference for API parameters.`,
105
+ message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}. Replace them by hand or provide a matching component.`,
106
106
  severity: "warning",
107
107
  },
108
108
  ]
@@ -1,5 +1,7 @@
1
1
  import { join } from "pathe";
2
2
 
3
+ import { blumeReferences } from "../../openapi/references.ts";
4
+ import { openApiSource } from "../../openapi/source.ts";
3
5
  import type { ContentSourceConfig, ResolvedConfig } from "../schema.ts";
4
6
  import type { ProjectContext } from "../types.ts";
5
7
  import { filesystemSource } from "./filesystem.ts";
@@ -144,12 +146,8 @@ const baseName = (def: ContentSourceConfig): string => {
144
146
  return def.prefix ?? def.type;
145
147
  };
146
148
 
147
- /**
148
- * Build the ordered list of content sources for a project. With no
149
- * `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
150
- * to a single implicit filesystem source, so existing projects are untouched.
151
- */
152
- export const resolveSources = (
149
+ /** The content sources declared by config (implicit filesystem when none). */
150
+ const contentSources = (
153
151
  config: ResolvedConfig,
154
152
  context: ProjectContext,
155
153
  runtime: SourceRuntime
@@ -172,3 +170,27 @@ export const resolveSources = (
172
170
  buildSource(def, nameFor(baseName(def)), context, runtime)
173
171
  );
174
172
  };
173
+
174
+ /**
175
+ * Build the ordered list of content sources for a project. With no
176
+ * `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
177
+ * to a single implicit filesystem source, so existing projects are untouched.
178
+ * A Blume-rendered OpenAPI reference contributes an internal staged source that
179
+ * lowers each operation into a real content page (routing/nav/search/OG).
180
+ */
181
+ export const resolveSources = (
182
+ config: ResolvedConfig,
183
+ context: ProjectContext,
184
+ runtime: SourceRuntime
185
+ ): ContentSource[] => {
186
+ const sources = contentSources(config, context, runtime);
187
+
188
+ const references = blumeReferences(config);
189
+ if (references.length > 0) {
190
+ sources.push(
191
+ openApiSource(references, sourceContext(context, "openapi", runtime))
192
+ );
193
+ }
194
+
195
+ return sources;
196
+ };
package/src/core/types.ts CHANGED
@@ -54,6 +54,13 @@ export interface ProjectContext {
54
54
  pagesRoot: string | null;
55
55
  /** Absolute path to the generated runtime (`<root>/.blume`). */
56
56
  outDir: string;
57
+ /**
58
+ * Absolute path to the Astro build output. `<root>/dist` normally; for a
59
+ * relocated runtime (isolated verify build) it lives under the runtime dir so
60
+ * it never empties the real `dist/`. Optional so hand-built test contexts and
61
+ * older callers still typecheck; `resolveProjectContext` always sets it.
62
+ */
63
+ distDir?: string;
57
64
  /** Absolute path to the user `theme.css`, if present. */
58
65
  themeFile: string | null;
59
66
  /** Absolute path to the user `components.ts`/`.tsx`, if present. */
@@ -173,12 +180,6 @@ export interface NavSelector {
173
180
  items: NavSelectorItem[];
174
181
  }
175
182
 
176
- /** Sidebar tree used when the current route belongs to a nav partition. */
177
- export interface NavSidebarVariant {
178
- path: string;
179
- sidebar: NavNode[];
180
- }
181
-
182
183
  /** Chrome overrides used when the current route belongs to a nav partition. */
183
184
  export interface NavChromeVariant {
184
185
  path: string;
@@ -191,7 +192,6 @@ export interface Navigation {
191
192
  selectors: NavSelector[];
192
193
  chromeVariants: NavChromeVariant[];
193
194
  sidebar: NavNode[];
194
- sidebarVariants: NavSidebarVariant[];
195
195
  /** Repo URL for the header link, or null when hidden (`navigation.repo`). */
196
196
  repoUrl?: string | null;
197
197
  }
@@ -9,6 +9,7 @@ import type {
9
9
  ResolvedConfig,
10
10
  SidebarItemConfig,
11
11
  } from "../../core/schema.ts";
12
+ import { GOOGLE_FONTS } from "../../theme/fonts.ts";
12
13
 
13
14
  type JsonObject = Record<string, unknown>;
14
15
  type NavigationSelectors = ResolvedConfig["navigation"]["selectors"];
@@ -16,8 +17,6 @@ type NavigationSelectorItem = NavigationSelectors[number]["items"][number];
16
17
  type NavigationChromeVariants = NonNullable<
17
18
  BlumeConfig["navigation"]
18
19
  >["chromeVariants"];
19
- type NavigationSidebarVariants =
20
- ResolvedConfig["navigation"]["sidebarVariants"];
21
20
 
22
21
  const MINTLIFY_DEFAULT_IGNORES = [
23
22
  "**/_*",
@@ -306,18 +305,6 @@ const mintlifyBackgroundDecoration = (
306
305
  return undefined;
307
306
  };
308
307
 
309
- const sidebarItemPaths = (items: SidebarItemConfig[]): string[] =>
310
- items.flatMap((item) => {
311
- if (typeof item === "string") {
312
- return [tabPathFromRef(item)];
313
- }
314
- if (item.href) {
315
- return [item.href];
316
- }
317
- const paths = item.root ? [tabPathFromRef(item.root)] : [];
318
- return item.items ? [...paths, ...sidebarItemPaths(item.items)] : paths;
319
- });
320
-
321
308
  const collapsedFromExpanded = (value: unknown): boolean | undefined => {
322
309
  if (value === false) {
323
310
  return true;
@@ -438,79 +425,6 @@ const isExternalRoute = (path: string): boolean =>
438
425
  path.startsWith("mailto:") ||
439
426
  path.startsWith("tel:");
440
427
 
441
- const hasOwnNavigationContent = (item: JsonObject): boolean =>
442
- Boolean(asString(item.root)) || childItemsFor(item).length > 0;
443
-
444
- const mintlifySidebarVariants = async (
445
- spec: JsonObject
446
- ): Promise<NavigationSidebarVariants> => {
447
- const navigation = asObject(spec.navigation) ?? {};
448
- const global = asObject(navigation.global) ?? {};
449
- interface SidebarVariantCandidate {
450
- item: unknown;
451
- path: string;
452
- }
453
- const candidates: SidebarVariantCandidate[] = [];
454
-
455
- const addCandidate = (item: unknown): void => {
456
- const path = navItemPath(item);
457
- if (!path || isExternalRoute(path)) {
458
- return;
459
- }
460
-
461
- candidates.push({ item, path });
462
- };
463
-
464
- for (const item of [
465
- ...asArray(navigation.tabs),
466
- ...asArray(navigation.anchors),
467
- ...asArray(global.anchors),
468
- ]) {
469
- const object = asObject(item);
470
- if (!object) {
471
- addCandidate(item);
472
- continue;
473
- }
474
-
475
- for (const menuItem of asArray(object.menu)) {
476
- addCandidate(menuItem);
477
- }
478
-
479
- if (hasOwnNavigationContent(object)) {
480
- addCandidate(item);
481
- }
482
- }
483
-
484
- for (const item of [
485
- ...asArray(navigation.dropdowns),
486
- ...asArray(navigation.products),
487
- ...asArray(navigation.versions),
488
- ...asArray(navigation.languages),
489
- ]) {
490
- addCandidate(item);
491
- }
492
-
493
- const resolved = await Promise.all(
494
- candidates.map(async (candidate) => {
495
- const items = await toSidebarItems([candidate.item], {});
496
- if (items.length === 0) {
497
- return [];
498
- }
499
- return [candidate.path, ...sidebarItemPaths(items)]
500
- .filter((path) => !isExternalRoute(path))
501
- .map((path) => ({ items, path }));
502
- })
503
- );
504
- const seen = new Set<string>();
505
- return resolved.flat().flatMap((variant) => {
506
- if (!variant || seen.has(variant.path)) {
507
- return [];
508
- }
509
- seen.add(variant.path);
510
- return [variant];
511
- });
512
- };
513
-
514
428
  const mintlifyTabs = (
515
429
  spec: JsonObject
516
430
  ): NonNullable<BlumeConfig["navigation"]>["tabs"] => {
@@ -593,24 +507,154 @@ const mintignorePatterns = async (root: string): Promise<string[]> => {
593
507
  }
594
508
  };
595
509
 
596
- const mintlifyRedirects = (
510
+ // path-to-regexp param (:slug, :slug*, :id?) → Astro dynamic segment.
511
+ // *,+ (repeatable) → spread [...name]; bare/? → single [name]. Name must start
512
+ // with a letter/underscore so URL ports (:8080) and protocols (https:) are left alone.
513
+ const REDIRECT_PARAM = /:(?<name>[A-Za-z_]\w*)(?<modifier>[*+?])?/gu;
514
+
515
+ const toAstroRedirectPath = (path: string): string =>
516
+ path.replaceAll(REDIRECT_PARAM, (_match, name: string, modifier?: string) =>
517
+ modifier === "*" || modifier === "+" ? `[...${name}]` : `[${name}]`
518
+ );
519
+
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 = (
597
540
  spec: JsonObject
598
- ): NonNullable<BlumeConfig["redirects"]> =>
599
- 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)) {
600
545
  const object = asObject(redirect);
601
546
  if (!object) {
602
- return [];
547
+ continue;
603
548
  }
604
- const from = asString(object.source) ?? asString(object.from);
605
- const to =
549
+ const source = asString(object.source) ?? asString(object.from);
550
+ const destination =
606
551
  asString(object.destination) ??
607
552
  asString(object.to) ??
608
553
  asString(object.redirect);
609
- if (!from || !to) {
554
+ if (!source || !destination) {
555
+ continue;
556
+ }
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
+ };
567
+
568
+ interface OpenApiSourceDraft {
569
+ label?: string;
570
+ route?: string;
571
+ spec: string;
572
+ }
573
+
574
+ // A Mintlify `{ source, directory }` object's `directory` is the URL path its
575
+ // generated pages mount under; map it to a Blume per-source `route`.
576
+ const openapiRouteFromDirectory = (value: unknown): string | undefined => {
577
+ const directory = normalizeDirectory(asString(value) ?? "");
578
+ return directory.length > 0 ? `/${directory}` : undefined;
579
+ };
580
+
581
+ // Resolve a Mintlify `openapi` value (a spec string, an array, or a
582
+ // `{ source, directory }` object) into spec sources. Endpoint refs (`GET /path`)
583
+ // are skipped — Blume's native renderer generates those pages from the spec.
584
+ const openapiSourcesFromValue = (
585
+ value: unknown,
586
+ context: { label?: string; route?: string }
587
+ ): OpenApiSourceDraft[] => {
588
+ if (typeof value === "string") {
589
+ if (value.length === 0 || API_ENDPOINT_REF.test(value)) {
610
590
  return [];
611
591
  }
612
- return [{ from, to }];
592
+ return [
593
+ withoutUndefined({
594
+ label: context.label,
595
+ route: context.route,
596
+ spec: value,
597
+ }),
598
+ ];
599
+ }
600
+ if (Array.isArray(value)) {
601
+ return value.flatMap((item) => openapiSourcesFromValue(item, context));
602
+ }
603
+ const object = asObject(value);
604
+ if (!object) {
605
+ return [];
606
+ }
607
+ return openapiSourcesFromValue(object.source ?? object.openapi, {
608
+ label: context.label,
609
+ route: openapiRouteFromDirectory(object.directory) ?? context.route,
613
610
  });
611
+ };
612
+
613
+ // Walk the navigation tree collecting every `openapi` source — a group or tab
614
+ // can declare one alongside its pages — then fold in the top-level specs
615
+ // (legacy `mint.json` `openapi`, newer `api.openapi`) and dedupe by spec so a
616
+ // Mintlify API reference maps to Blume's native renderer instead of dropping.
617
+ const mintlifyOpenapi = (spec: JsonObject): BlumeConfig["openapi"] => {
618
+ const drafts: OpenApiSourceDraft[] = [];
619
+
620
+ const visit = (node: unknown): void => {
621
+ if (Array.isArray(node)) {
622
+ for (const item of node) {
623
+ visit(item);
624
+ }
625
+ return;
626
+ }
627
+ const object = asObject(node);
628
+ if (!object) {
629
+ return;
630
+ }
631
+ if (hasOwn(object, "openapi")) {
632
+ drafts.push(
633
+ ...openapiSourcesFromValue(object.openapi, {
634
+ label: labelForNavItem(object),
635
+ })
636
+ );
637
+ }
638
+ for (const children of childNavigationArrays(object)) {
639
+ visit(children);
640
+ }
641
+ };
642
+
643
+ visit(spec.navigation);
644
+ drafts.push(...openapiSourcesFromValue(spec.openapi, {}));
645
+ drafts.push(...openapiSourcesFromValue(asObject(spec.api)?.openapi, {}));
646
+
647
+ const seen = new Set<string>();
648
+ const sources = drafts.flatMap((draft) => {
649
+ if (seen.has(draft.spec)) {
650
+ return [];
651
+ }
652
+ seen.add(draft.spec);
653
+ return [draft];
654
+ });
655
+
656
+ return sources.length > 0 ? { enabled: true, sources } : undefined;
657
+ };
614
658
 
615
659
  const mintlifyLogo = (value: unknown): BlumeConfig["logo"] => {
616
660
  if (typeof value === "string") {
@@ -645,6 +689,16 @@ const mintlifyFavicon = (value: unknown): BlumeConfig["favicon"] => {
645
689
  return withoutUndefined({ dark, light });
646
690
  };
647
691
 
692
+ // Mintlify defaults to Font Awesome, so a migrated site's bare `icon` names are
693
+ // FA names unless it opted into Lucide/Tabler. Set the default library to match.
694
+ const mintlifyIcons = (value: unknown): BlumeConfig["icons"] => {
695
+ const library = asString(asObject(value)?.library);
696
+ return {
697
+ library:
698
+ library === "lucide" || library === "tabler" ? library : "fontawesome",
699
+ };
700
+ };
701
+
648
702
  const mintlifyBanner = (value: unknown): BlumeConfig["banner"] => {
649
703
  const object = asObject(value);
650
704
  const content = object ? asString(object.content) : undefined;
@@ -768,6 +822,43 @@ const mintlifyMarkdown = (
768
822
  });
769
823
  };
770
824
 
825
+ type ThemeFonts = NonNullable<NonNullable<BlumeConfig["theme"]>["fonts"]>;
826
+
827
+ // Blume's curated Google-font family names, indexed by lowercased family so a
828
+ // Mintlify `fonts.family: "Space Grotesk"` resolves to the `space-grotesk` slug.
829
+ const FAMILY_TO_SLUG: Record<string, string> = Object.fromEntries(
830
+ Object.entries(GOOGLE_FONTS).map(([slug, def]) => [
831
+ def.family.toLowerCase(),
832
+ slug,
833
+ ])
834
+ );
835
+
836
+ const fontSlugForFamily = (value: unknown): string | undefined => {
837
+ const family = asString(value);
838
+ return family ? FAMILY_TO_SLUG[family.toLowerCase()] : undefined;
839
+ };
840
+
841
+ /**
842
+ * Map Mintlify `fonts` onto Blume `theme.fonts`. Mintlify sets one family for
843
+ * everything (`fonts.family`) or splits heading/body (`fonts.heading.family`,
844
+ * `fonts.body.family`); each maps to a curated Blume slug when one matches.
845
+ * Families outside Blume's set are left unset (defaults) and warned about.
846
+ */
847
+ const mintlifyFonts = (value: unknown): ThemeFonts | undefined => {
848
+ const object = asObject(value);
849
+ if (!object) {
850
+ return undefined;
851
+ }
852
+ const heading = asObject(object.heading);
853
+ const body = asObject(object.body);
854
+ const fonts = withoutUndefined({
855
+ body: fontSlugForFamily(body?.family) ?? fontSlugForFamily(object.family),
856
+ display:
857
+ fontSlugForFamily(heading?.family) ?? fontSlugForFamily(object.family),
858
+ });
859
+ return Object.keys(fonts).length > 0 ? (fonts as ThemeFonts) : undefined;
860
+ };
861
+
771
862
  const mintlifySeo = (value: unknown): NonNullable<BlumeConfig["seo"]> => {
772
863
  const object = asObject(value);
773
864
  const metatags = asObject(object?.metatags);
@@ -825,16 +916,17 @@ export const loadMintlifyConfig = async (
825
916
  },
826
917
  description: asString(spec.description),
827
918
  favicon: mintlifyFavicon(spec.favicon),
919
+ icons: mintlifyIcons(spec.icons),
828
920
  logo: mintlifyLogo(spec.logo),
829
921
  markdown: mintlifyMarkdown(spec.markdown, styling),
830
922
  navigation: {
831
923
  chromeVariants: mintlifyChromeVariants(spec),
832
924
  selectors: mintlifySelectors(spec),
833
925
  sidebar: await toSidebarItems(mintlifyNavigationItems(navigation), {}),
834
- sidebarVariants: await mintlifySidebarVariants(spec),
835
926
  tabs: mintlifyTabs(spec),
836
927
  },
837
- redirects: mintlifyRedirects(spec),
928
+ openapi: mintlifyOpenapi(spec),
929
+ redirects: partitionMintlifyRedirects(spec).kept,
838
930
  search: {
839
931
  indexing: {
840
932
  includeHiddenPages: seo.indexing === "all",
@@ -851,6 +943,7 @@ export const loadMintlifyConfig = async (
851
943
  backgroundDecoration: mintlifyBackgroundDecoration(spec.background),
852
944
  backgroundImage: backgroundImage.light,
853
945
  backgroundImageDark: backgroundImage.dark,
946
+ fonts: mintlifyFonts(spec.fonts ?? spec.font),
854
947
  mode:
855
948
  appearance.default === "light" ||
856
949
  appearance.default === "dark" ||
@@ -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
 
@@ -88,8 +104,14 @@ export const rewriteSnippetImports = (
88
104
  return { components, source: next };
89
105
  };
90
106
 
91
- /** Component tags Blume has no equivalent for — reported for manual review. */
92
- const UNSUPPORTED_COMPONENTS = ["ParamField", "ResponseField"];
107
+ /**
108
+ * Component tags Blume has no equivalent for — reported for manual review.
109
+ * `<ParamField>`/`<ResponseField>`/`<RequestField>` are no longer here: Blume
110
+ * ships compat components for them, so migrated docs render as-is. Mintlify's
111
+ * `<Update>` changelog entry has no component form in Blume (changelog is
112
+ * frontmatter-driven via `type: changelog`), so it stays flagged.
113
+ */
114
+ const UNSUPPORTED_COMPONENTS = ["Update"];
93
115
 
94
116
  /** Names of Mintlify components in `source` that need manual attention. */
95
117
  export const unsupportedMintlifyComponents = (source: string): string[] =>