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.
- package/dist/cli/index.js +1170 -820
- package/dist/cli/index.js.map +32 -27
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +154 -41
- package/dist/types/core/types.d.ts +7 -6
- package/dist/types/migrate/mintlify/config.d.ts +14 -0
- package/docs/01-quickstart.mdx +6 -2
- package/docs/02-deployment.mdx +3 -1
- package/docs/advanced/api-reference.mdx +37 -23
- package/docs/advanced/bridge.mdx +76 -0
- package/docs/advanced/custom-pages.mdx +3 -1
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +123 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/analytics.mdx +3 -1
- package/docs/configuration/export.mdx +6 -2
- package/docs/configuration/index.mdx +1 -1
- package/docs/configuration/seo.mdx +3 -1
- package/docs/content/components.mdx +55 -2
- package/docs/content/i18n.mdx +6 -2
- package/docs/content/islands.mdx +6 -2
- package/docs/content/meta.mdx +3 -1
- package/docs/content/syntax.mdx +40 -14
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +29 -1
- package/docs/reference/frontmatter.mdx +5 -0
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -9
- package/src/astro/templates.ts +28 -4
- package/src/cli/commands/build.ts +107 -63
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/dev-lock.ts +13 -5
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +6 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/core/builtin-tags.ts +5 -0
- package/src/core/data.ts +2 -0
- package/src/core/graph.ts +0 -3
- package/src/core/nav-diagnostics.ts +2 -12
- package/src/core/navigation.ts +0 -10
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +47 -14
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/types.ts +7 -7
- package/src/migrate/mintlify/config.ts +190 -97
- package/src/migrate/mintlify/content.ts +24 -2
- package/src/migrate/mintlify/index.ts +76 -2
- package/src/migrate/mintlify/transform.ts +2 -0
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +15 -2
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
package/src/core/schema.ts
CHANGED
|
@@ -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
|
|
907
|
-
*
|
|
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
|
|
924
|
-
*
|
|
925
|
-
*
|
|
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
|
|
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(", ")}.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
599
|
-
|
|
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
|
-
|
|
547
|
+
continue;
|
|
603
548
|
}
|
|
604
|
-
const
|
|
605
|
-
const
|
|
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 (!
|
|
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 [
|
|
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
|
-
|
|
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
|
-
/**
|
|
92
|
-
|
|
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[] =>
|