blume 0.2.0 → 0.3.0
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 +1921 -560
- package/dist/cli/index.js.map +36 -24
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/define-components.d.ts +9 -2
- package/dist/types/core/diagnostics.d.ts +5 -0
- package/dist/types/core/schema.d.ts +26 -502
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/custom-pages.mdx +63 -1
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +13 -0
- package/docs/configuration/seo.mdx +5 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +42 -0
- package/package.json +3 -1
- package/src/ai/ask-context.ts +131 -0
- package/src/ai/ask-data.ts +25 -0
- package/src/astro/component-slots.ts +165 -0
- package/src/astro/generate.ts +132 -13
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +92 -44
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/commands/build.ts +186 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +21 -1
- package/src/cli/commands/doctor.ts +23 -6
- package/src/cli/commands/init.ts +163 -15
- package/src/cli/commands/validate.ts +16 -2
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +15 -1
- package/src/components/islands/hooks.ts +188 -0
- package/src/components/layout/Empty.astro +6 -0
- package/src/components/layout/Header.astro +24 -39
- package/src/components/layout/Logo.astro +50 -0
- package/src/components/layout/NavSelector.astro +75 -0
- package/src/components/layout/PageLayout.astro +38 -2
- package/src/components/layout/RootLayout.astro +70 -4
- package/src/components/layout/hydration-hint.ts +30 -0
- package/src/components/layout/overrides.ts +6 -4
- package/src/components/props.ts +68 -0
- package/src/core/builtin-tags.ts +39 -0
- package/src/core/component-diagnostics.ts +44 -0
- package/src/core/component-overrides.ts +478 -0
- package/src/core/config.ts +8 -0
- package/src/core/data.ts +14 -0
- package/src/core/define-components.ts +9 -2
- package/src/core/diagnostics.ts +90 -1
- package/src/core/graph.ts +7 -0
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +28 -96
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/migrate/mintlify/config.ts +1 -176
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +163 -38
- package/src/registry/eject.ts +39 -9
- package/src/registry/registry.ts +166 -0
- package/src/runtime/index.ts +61 -0
- package/src/vite-env.d.ts +14 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { hasIcon } from "../theme/icons.ts";
|
|
2
|
+
import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Navigation diagnostics: catch icon typos and structural mistakes (missing
|
|
6
|
+
* pages, duplicate labels) that otherwise fail silently — a wrong icon just
|
|
7
|
+
* doesn't render, a bad tab path just 404s. Run over the built navigation so it
|
|
8
|
+
* covers every source (config, folder meta, frontmatter) at once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const IMAGE_ICON =
|
|
12
|
+
/^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
|
|
13
|
+
|
|
14
|
+
/** Whether an icon string is an asset (image/URL/inline SVG), not a set name. */
|
|
15
|
+
const isAssetIcon = (value: string): boolean =>
|
|
16
|
+
value.startsWith("<") || IMAGE_ICON.test(value);
|
|
17
|
+
|
|
18
|
+
/** Flatten a sidebar tree to every node, descending into groups. */
|
|
19
|
+
const flattenNodes = (nodes: NavNode[]): NavNode[] =>
|
|
20
|
+
nodes.flatMap((node) =>
|
|
21
|
+
node.kind === "group" ? [node, ...flattenNodes(node.children)] : [node]
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** Every icon string referenced anywhere in the navigation, with a label. */
|
|
25
|
+
const collectIcons = (
|
|
26
|
+
navigation: Navigation
|
|
27
|
+
): { icon: string; where: string }[] => {
|
|
28
|
+
const icons: { icon: string; where: string }[] = [];
|
|
29
|
+
const push = (icon: string | undefined, where: string): void => {
|
|
30
|
+
if (icon) {
|
|
31
|
+
icons.push({ icon, where });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
for (const tab of navigation.tabs) {
|
|
35
|
+
push(tab.icon, `tab "${tab.label}"`);
|
|
36
|
+
for (const item of tab.items ?? []) {
|
|
37
|
+
push(item.icon, `tab item "${item.label}"`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const selector of navigation.selectors) {
|
|
41
|
+
for (const item of selector.items) {
|
|
42
|
+
push(item.icon, `selector "${item.label}"`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const sidebars = [
|
|
46
|
+
navigation.sidebar,
|
|
47
|
+
...navigation.sidebarVariants.map((variant) => variant.sidebar),
|
|
48
|
+
];
|
|
49
|
+
for (const sidebar of sidebars) {
|
|
50
|
+
for (const node of flattenNodes(sidebar)) {
|
|
51
|
+
push(node.icon, `"${node.label}"`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return icons;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
|
|
58
|
+
export const validateNavIcons = (navigation: Navigation): Diagnostic[] => {
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const diagnostics: Diagnostic[] = [];
|
|
61
|
+
for (const { icon, where } of collectIcons(navigation)) {
|
|
62
|
+
if (isAssetIcon(icon) || hasIcon(icon) || seen.has(icon)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
seen.add(icon);
|
|
66
|
+
diagnostics.push({
|
|
67
|
+
code: "BLUME_UNKNOWN_ICON",
|
|
68
|
+
message: `Unknown icon "${icon}" (${where}) — it isn't in Blume's icon set.`,
|
|
69
|
+
severity: "warning",
|
|
70
|
+
suggestion:
|
|
71
|
+
"Use a built-in icon name, an image path/URL, or inline SVG markup.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return diagnostics;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Whether an internal path resolves to a page or a section that has pages. */
|
|
78
|
+
const resolvesToPages = (routes: Set<string>, path: string): boolean =>
|
|
79
|
+
routes.has(path) || [...routes].some((route) => route.startsWith(`${path}/`));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Warn when a config-linked tab/selector target has no matching page. `routes`
|
|
83
|
+
* must be the full set of servable routes — content, custom `.astro` pages, and
|
|
84
|
+
* generated routes — so this runs where all three are known (`generateRuntime`),
|
|
85
|
+
* not in the content-only graph build.
|
|
86
|
+
*/
|
|
87
|
+
export const validateNavTargets = (
|
|
88
|
+
navigation: Navigation,
|
|
89
|
+
routes: Set<string>
|
|
90
|
+
): Diagnostic[] => {
|
|
91
|
+
const targets: { label: string; path: string }[] = [
|
|
92
|
+
...navigation.tabs.map((tab) => ({ label: tab.label, path: tab.path })),
|
|
93
|
+
...navigation.selectors.flatMap((selector) =>
|
|
94
|
+
selector.items.map((item) => ({ label: item.label, path: item.path }))
|
|
95
|
+
),
|
|
96
|
+
];
|
|
97
|
+
const diagnostics: Diagnostic[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
for (const { label, path } of targets) {
|
|
100
|
+
// Only internal, non-anchor paths can be checked against routes.
|
|
101
|
+
if (!path.startsWith("/") || path.startsWith("/#") || seen.has(path)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!resolvesToPages(routes, path.split("#")[0] ?? path)) {
|
|
105
|
+
seen.add(path);
|
|
106
|
+
diagnostics.push({
|
|
107
|
+
code: "BLUME_NAV_MISSING_PAGE",
|
|
108
|
+
message: `Navigation entry "${label}" points to ${path}, but no page matches it.`,
|
|
109
|
+
severity: "warning",
|
|
110
|
+
suggestion: "Fix the path, or add a page at that route.",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return diagnostics;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Warn about two nav items sharing a label at the same sidebar level. */
|
|
118
|
+
const duplicateLabelDiagnostics = (navigation: Navigation): Diagnostic[] => {
|
|
119
|
+
const diagnostics: Diagnostic[] = [];
|
|
120
|
+
const checkLevel = (nodes: NavNode[], where: string): void => {
|
|
121
|
+
const counts = new Map<string, number>();
|
|
122
|
+
for (const node of nodes) {
|
|
123
|
+
counts.set(node.label, (counts.get(node.label) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
for (const [label, count] of counts) {
|
|
126
|
+
if (count > 1) {
|
|
127
|
+
diagnostics.push({
|
|
128
|
+
code: "BLUME_NAV_DUPLICATE_LABEL",
|
|
129
|
+
message: `Duplicate sidebar label "${label}" appears ${count} times ${where}.`,
|
|
130
|
+
severity: "warning",
|
|
131
|
+
suggestion: "Give the entries distinct titles.",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const node of nodes) {
|
|
136
|
+
if (node.kind === "group") {
|
|
137
|
+
checkLevel(node.children, `under "${node.label}"`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const sidebars: { nodes: NavNode[]; where: string }[] = [
|
|
142
|
+
{ nodes: navigation.sidebar, where: "at the top level" },
|
|
143
|
+
...navigation.sidebarVariants.map((variant) => ({
|
|
144
|
+
nodes: variant.sidebar,
|
|
145
|
+
where: `in the "${variant.path}" section`,
|
|
146
|
+
})),
|
|
147
|
+
];
|
|
148
|
+
for (const { nodes, where } of sidebars) {
|
|
149
|
+
checkLevel(nodes, where);
|
|
150
|
+
}
|
|
151
|
+
return diagnostics;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Warn when a page shown in the sidebar is marked hidden (so pagination hits it). */
|
|
155
|
+
const hiddenInSidebarDiagnostics = (
|
|
156
|
+
navigation: Navigation,
|
|
157
|
+
pages: PageRecord[]
|
|
158
|
+
): Diagnostic[] => {
|
|
159
|
+
const hidden = new Set(
|
|
160
|
+
pages.filter((page) => page.meta.sidebar.hidden).map((page) => page.id)
|
|
161
|
+
);
|
|
162
|
+
if (hidden.size === 0) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const sidebars = [
|
|
166
|
+
navigation.sidebar,
|
|
167
|
+
...navigation.sidebarVariants.map((variant) => variant.sidebar),
|
|
168
|
+
];
|
|
169
|
+
const diagnostics: Diagnostic[] = [];
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
for (const sidebar of sidebars) {
|
|
172
|
+
for (const node of flattenNodes(sidebar)) {
|
|
173
|
+
if (
|
|
174
|
+
node.kind === "page" &&
|
|
175
|
+
hidden.has(node.pageId) &&
|
|
176
|
+
!seen.has(node.pageId)
|
|
177
|
+
) {
|
|
178
|
+
seen.add(node.pageId);
|
|
179
|
+
diagnostics.push({
|
|
180
|
+
code: "BLUME_NAV_HIDDEN_IN_SIDEBAR",
|
|
181
|
+
message: `Page "${node.label}" is marked hidden but appears in the sidebar (and its pagination).`,
|
|
182
|
+
severity: "warning",
|
|
183
|
+
suggestion:
|
|
184
|
+
"Remove it from the navigation config, or unset sidebar.hidden.",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return diagnostics;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Structural navigation diagnostics that need only the built navigation +
|
|
194
|
+
* content pages: duplicate sidebar labels at a level, and hidden pages that
|
|
195
|
+
* still surface in the sidebar (so pagination lands on them). Target existence
|
|
196
|
+
* is checked separately by {@link validateNavTargets}, which needs the full
|
|
197
|
+
* route set.
|
|
198
|
+
*/
|
|
199
|
+
export const validateNavStructure = (
|
|
200
|
+
navigation: Navigation,
|
|
201
|
+
pages: PageRecord[]
|
|
202
|
+
): Diagnostic[] => [
|
|
203
|
+
...duplicateLabelDiagnostics(navigation),
|
|
204
|
+
...hiddenInSidebarDiagnostics(navigation, pages),
|
|
205
|
+
];
|
|
@@ -24,6 +24,41 @@ import type {
|
|
|
24
24
|
/** Build mode: drafts are kept in `dev` and dropped in `build`. */
|
|
25
25
|
export type BuildMode = "dev" | "build";
|
|
26
26
|
|
|
27
|
+
/** CLI-supplied overrides applied over the loaded config (see `scanProject`). */
|
|
28
|
+
export interface ConfigOverrides {
|
|
29
|
+
/** Override `content.root` (`blume dev --content-dir`). */
|
|
30
|
+
contentRoot?: string;
|
|
31
|
+
/** Override `deployment.adapter` (`blume build --adapter`). */
|
|
32
|
+
adapter?: ResolvedConfig["deployment"]["adapter"];
|
|
33
|
+
/** Override `deployment.base` (`blume build --base`). */
|
|
34
|
+
base?: string;
|
|
35
|
+
/** Override `deployment.output` (`blume build --output`). */
|
|
36
|
+
output?: ResolvedConfig["deployment"]["output"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Apply CLI config overrides onto a resolved config (returns a new object). */
|
|
40
|
+
const applyConfigOverrides = (
|
|
41
|
+
config: ResolvedConfig,
|
|
42
|
+
overrides?: ConfigOverrides
|
|
43
|
+
): ResolvedConfig => {
|
|
44
|
+
if (!overrides) {
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...config,
|
|
49
|
+
content: {
|
|
50
|
+
...config.content,
|
|
51
|
+
root: overrides.contentRoot ?? config.content.root,
|
|
52
|
+
},
|
|
53
|
+
deployment: {
|
|
54
|
+
...config.deployment,
|
|
55
|
+
adapter: overrides.adapter ?? config.deployment.adapter,
|
|
56
|
+
base: overrides.base ?? config.deployment.base,
|
|
57
|
+
output: overrides.output ?? config.deployment.output,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
27
62
|
/** Everything Blume knows about a project after a full scan. */
|
|
28
63
|
export interface BlumeProject {
|
|
29
64
|
mode: BuildMode;
|
|
@@ -51,13 +86,17 @@ export const scanProject = async (
|
|
|
51
86
|
mode?: BuildMode;
|
|
52
87
|
preview?: boolean;
|
|
53
88
|
refresh?: boolean;
|
|
89
|
+
/** CLI overrides applied over the loaded config (e.g. `--output`). */
|
|
90
|
+
overrides?: ConfigOverrides;
|
|
54
91
|
} = {}
|
|
55
92
|
): Promise<BlumeProject> => {
|
|
56
93
|
const mode = options.mode ?? "dev";
|
|
57
94
|
const preview = options.preview ?? false;
|
|
58
|
-
const
|
|
95
|
+
const configResult = await loadConfig(root, {
|
|
59
96
|
devServerUrl: options.devServerUrl,
|
|
60
97
|
});
|
|
98
|
+
const { bridge } = configResult;
|
|
99
|
+
const config = applyConfigOverrides(configResult.config, options.overrides);
|
|
61
100
|
const context = resolveProjectContext(root, config);
|
|
62
101
|
|
|
63
102
|
// Each source validates itself (e.g. the filesystem source checks its root
|
package/src/core/schema.ts
CHANGED
|
@@ -462,38 +462,6 @@ const sidebarVariantSchema = z
|
|
|
462
462
|
})
|
|
463
463
|
.strict();
|
|
464
464
|
|
|
465
|
-
const navbarLinkTypeSchema = z.enum(["github", "discord"]);
|
|
466
|
-
|
|
467
|
-
const navbarLinkSchema = z
|
|
468
|
-
.object({
|
|
469
|
-
href: z.string(),
|
|
470
|
-
icon: iconName.optional(),
|
|
471
|
-
label: z.string().optional(),
|
|
472
|
-
type: navbarLinkTypeSchema.optional(),
|
|
473
|
-
})
|
|
474
|
-
.strict()
|
|
475
|
-
.refine((value) => value.label !== undefined || value.type !== undefined, {
|
|
476
|
-
message: "Navbar links require either label or type.",
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
const navbarPrimarySchema = z
|
|
480
|
-
.object({
|
|
481
|
-
href: z.string(),
|
|
482
|
-
label: z.string().optional(),
|
|
483
|
-
type: z.enum(["button", "github", "discord"]).default("button"),
|
|
484
|
-
})
|
|
485
|
-
.strict()
|
|
486
|
-
.refine((value) => value.label !== undefined || value.type !== "button", {
|
|
487
|
-
message: "Navbar primary button links require a label.",
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
const navbarConfigSchema = z
|
|
491
|
-
.object({
|
|
492
|
-
links: z.array(navbarLinkSchema).default([]),
|
|
493
|
-
primary: navbarPrimarySchema.optional(),
|
|
494
|
-
})
|
|
495
|
-
.strict();
|
|
496
|
-
|
|
497
465
|
const variablesConfigSchema = z
|
|
498
466
|
.record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
|
|
499
467
|
.default({});
|
|
@@ -528,12 +496,6 @@ const themeConfigSchema = z
|
|
|
528
496
|
})
|
|
529
497
|
.strict();
|
|
530
498
|
|
|
531
|
-
const iconsConfigSchema = z
|
|
532
|
-
.object({
|
|
533
|
-
library: z.enum(["fontawesome", "lucide", "tabler"]).default("lucide"),
|
|
534
|
-
})
|
|
535
|
-
.strict();
|
|
536
|
-
|
|
537
499
|
/** Public credentials for the Algolia search backend (sync key is an env var). */
|
|
538
500
|
const algoliaSearchSchema = z
|
|
539
501
|
.object({
|
|
@@ -661,56 +623,9 @@ const aiConfigSchema = z
|
|
|
661
623
|
})
|
|
662
624
|
.strict();
|
|
663
625
|
|
|
664
|
-
const contextualOptionSchema = z.union([
|
|
665
|
-
z.string(),
|
|
666
|
-
z
|
|
667
|
-
.object({
|
|
668
|
-
description: z.string().optional(),
|
|
669
|
-
href: z.string().optional(),
|
|
670
|
-
icon: iconName.optional(),
|
|
671
|
-
title: z.string(),
|
|
672
|
-
})
|
|
673
|
-
.passthrough(),
|
|
674
|
-
]);
|
|
675
|
-
|
|
676
|
-
const contextualConfigSchema = z
|
|
677
|
-
.object({
|
|
678
|
-
display: z.enum(["header", "toc"]).default("header"),
|
|
679
|
-
options: z.array(contextualOptionSchema).default([]),
|
|
680
|
-
})
|
|
681
|
-
.strict();
|
|
682
|
-
|
|
683
|
-
const footerConfigSchema = z
|
|
684
|
-
.object({
|
|
685
|
-
links: z
|
|
686
|
-
.array(
|
|
687
|
-
z
|
|
688
|
-
.object({
|
|
689
|
-
header: z.string().optional(),
|
|
690
|
-
items: z
|
|
691
|
-
.array(
|
|
692
|
-
z
|
|
693
|
-
.object({
|
|
694
|
-
href: z.string(),
|
|
695
|
-
label: z.string(),
|
|
696
|
-
})
|
|
697
|
-
.strict()
|
|
698
|
-
)
|
|
699
|
-
.default([]),
|
|
700
|
-
})
|
|
701
|
-
.strict()
|
|
702
|
-
)
|
|
703
|
-
.max(4)
|
|
704
|
-
.default([]),
|
|
705
|
-
socials: z.record(z.string(), z.string()).default({}),
|
|
706
|
-
})
|
|
707
|
-
.strict();
|
|
708
|
-
|
|
709
626
|
const chromeVariantSchema = z
|
|
710
627
|
.object({
|
|
711
628
|
banner: bannerConfigSchema.optional(),
|
|
712
|
-
footer: footerConfigSchema.optional(),
|
|
713
|
-
navbar: navbarConfigSchema.optional(),
|
|
714
629
|
path: z.string(),
|
|
715
630
|
})
|
|
716
631
|
.strict();
|
|
@@ -980,12 +895,6 @@ const markdownConfigSchema = z
|
|
|
980
895
|
})
|
|
981
896
|
.strict();
|
|
982
897
|
|
|
983
|
-
const stylingConfigSchema = z
|
|
984
|
-
.object({
|
|
985
|
-
eyebrows: z.enum(["breadcrumbs", "section"]).default("section"),
|
|
986
|
-
})
|
|
987
|
-
.strict();
|
|
988
|
-
|
|
989
898
|
/**
|
|
990
899
|
* A single spec rendered by the API reference (Scalar). `spec` is a local path
|
|
991
900
|
* or an `http(s)` URL; Scalar auto-detects OpenAPI vs AsyncAPI documents.
|
|
@@ -1037,6 +946,33 @@ const asyncapiConfigSchema = z
|
|
|
1037
946
|
.strict();
|
|
1038
947
|
|
|
1039
948
|
/** Full user-facing config schema. All fields optional with defaults. */
|
|
949
|
+
/**
|
|
950
|
+
* Table-of-contents config. `true`/`false` toggles it; an object narrows the
|
|
951
|
+
* heading range. Normalized to `{ enabled, minLevel, maxLevel }` (default: on,
|
|
952
|
+
* H2–H3, matching the historical hardcoded range).
|
|
953
|
+
*/
|
|
954
|
+
const tocConfigSchema = z
|
|
955
|
+
.union([
|
|
956
|
+
z.boolean(),
|
|
957
|
+
z
|
|
958
|
+
.object({
|
|
959
|
+
maxHeadingLevel: z.number().int().min(1).max(6).optional(),
|
|
960
|
+
minHeadingLevel: z.number().int().min(1).max(6).optional(),
|
|
961
|
+
})
|
|
962
|
+
.strict(),
|
|
963
|
+
])
|
|
964
|
+
.default(true)
|
|
965
|
+
.transform((value) => {
|
|
966
|
+
if (typeof value === "boolean") {
|
|
967
|
+
return { enabled: value, maxLevel: 3, minLevel: 2 };
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
enabled: true,
|
|
971
|
+
maxLevel: value.maxHeadingLevel ?? 3,
|
|
972
|
+
minLevel: value.minHeadingLevel ?? 2,
|
|
973
|
+
};
|
|
974
|
+
});
|
|
975
|
+
|
|
1040
976
|
export const blumeConfigSchema = z
|
|
1041
977
|
.object({
|
|
1042
978
|
ai: aiConfigSchema.default({}),
|
|
@@ -1044,7 +980,6 @@ export const blumeConfigSchema = z
|
|
|
1044
980
|
asyncapi: asyncapiConfigSchema.default({}),
|
|
1045
981
|
banner: bannerConfigSchema.optional(),
|
|
1046
982
|
content: contentConfigSchema.default({}),
|
|
1047
|
-
contextual: contextualConfigSchema.default({}),
|
|
1048
983
|
deployment: deploymentConfigSchema.default({}),
|
|
1049
984
|
description: z.string().optional(),
|
|
1050
985
|
/**
|
|
@@ -1064,23 +999,20 @@ export const blumeConfigSchema = z
|
|
|
1064
999
|
export: exportConfigSchema.default(false),
|
|
1065
1000
|
favicon: faviconConfigSchema.optional(),
|
|
1066
1001
|
feedback: z.boolean().default(true),
|
|
1067
|
-
footer: footerConfigSchema.default({}),
|
|
1068
1002
|
github: githubConfigSchema.optional(),
|
|
1069
1003
|
i18n: i18nConfigSchema.optional(),
|
|
1070
|
-
icons: iconsConfigSchema.default({}),
|
|
1071
1004
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1072
1005
|
logo: logoConfigSchema.optional(),
|
|
1073
1006
|
markdown: markdownConfigSchema.default({}),
|
|
1074
1007
|
mcp: mcpConfigSchema.default({}),
|
|
1075
|
-
navbar: navbarConfigSchema.default({}),
|
|
1076
1008
|
navigation: navigationConfigSchema.default({}),
|
|
1077
1009
|
openapi: openapiConfigSchema.default({}),
|
|
1078
1010
|
redirects: z.array(redirectSchema).default([]),
|
|
1079
1011
|
search: searchConfigSchema.default({}),
|
|
1080
1012
|
seo: seoConfigSchema.default({}),
|
|
1081
|
-
styling: stylingConfigSchema.default({}),
|
|
1082
1013
|
theme: themeConfigSchema.default({}),
|
|
1083
1014
|
title: z.string().default("Documentation"),
|
|
1015
|
+
toc: tocConfigSchema,
|
|
1084
1016
|
variables: variablesConfigSchema,
|
|
1085
1017
|
})
|
|
1086
1018
|
.strict();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
1
3
|
import GithubSlugger from "github-slugger";
|
|
2
4
|
import { extname } from "pathe";
|
|
3
5
|
|
|
@@ -146,6 +148,44 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
146
148
|
return links;
|
|
147
149
|
};
|
|
148
150
|
|
|
151
|
+
const INLINE_CODE = /`[^`]*`/gu;
|
|
152
|
+
// Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
|
|
153
|
+
// `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
|
|
154
|
+
// isn't a real usage. Single quotes are left alone so prose apostrophes don't
|
|
155
|
+
// swallow a real tag between two words.
|
|
156
|
+
const DOUBLE_QUOTED = /"[^"]*"/gu;
|
|
157
|
+
const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Capitalized JSX component tags used in an `.mdx` body (`<Callout>`,
|
|
161
|
+
* `<Tree.File>` → `Tree`). Skips fenced code, inline code, and double-quoted
|
|
162
|
+
* strings so code samples and prose don't count. Powers the missing-component
|
|
163
|
+
* diagnostic.
|
|
164
|
+
*/
|
|
165
|
+
export const extractComponentTags = (body: string): string[] => {
|
|
166
|
+
const tags = new Set<string>();
|
|
167
|
+
let inFence = false;
|
|
168
|
+
for (const line of body.split("\n")) {
|
|
169
|
+
if (CODE_FENCE.test(line.trimStart())) {
|
|
170
|
+
inFence = !inFence;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (inFence) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const clean = line
|
|
177
|
+
.replaceAll(INLINE_CODE, "")
|
|
178
|
+
.replaceAll(DOUBLE_QUOTED, "");
|
|
179
|
+
for (const match of clean.matchAll(JSX_OPEN)) {
|
|
180
|
+
const tag = match.groups?.tag;
|
|
181
|
+
if (tag) {
|
|
182
|
+
tags.add(tag);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return [...tags];
|
|
187
|
+
};
|
|
188
|
+
|
|
149
189
|
const deriveTitle = (
|
|
150
190
|
meta: PageMeta,
|
|
151
191
|
headings: Heading[],
|
|
@@ -179,10 +219,19 @@ export const normalizeEntry = (
|
|
|
179
219
|
|
|
180
220
|
const result = pageMetaSchema.safeParse(entry.data);
|
|
181
221
|
if (!result.success) {
|
|
222
|
+
// Source text lets the error carry a line/column into the frontmatter block:
|
|
223
|
+
// `entry.raw` for non-filesystem sources, else the file itself (read only on
|
|
224
|
+
// this rare error path, so filesystem entries stay cheap in the happy path).
|
|
225
|
+
const source =
|
|
226
|
+
entry.raw ??
|
|
227
|
+
(entry.sourcePath && existsSync(entry.sourcePath)
|
|
228
|
+
? readFileSync(entry.sourcePath, "utf-8")
|
|
229
|
+
: undefined);
|
|
182
230
|
return {
|
|
183
231
|
diagnostics: diagnosticsFromZod(result.error, {
|
|
184
232
|
code: "BLUME_FRONTMATTER_INVALID",
|
|
185
233
|
file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
|
|
234
|
+
source,
|
|
186
235
|
}),
|
|
187
236
|
pages: [],
|
|
188
237
|
};
|
|
@@ -212,6 +261,8 @@ export const normalizeEntry = (
|
|
|
212
261
|
const base = {
|
|
213
262
|
body: staged ? { format, text: entry.raw ?? entry.body.text } : undefined,
|
|
214
263
|
collection: staged ? "staged" : undefined,
|
|
264
|
+
componentsUsed:
|
|
265
|
+
format === "mdx" ? extractComponentTags(entry.body.text) : undefined,
|
|
215
266
|
contentType: meta.type ?? ctx.defaultType,
|
|
216
267
|
description: meta.description,
|
|
217
268
|
editUrl: entry.editUrl,
|
package/src/core/types.ts
CHANGED
|
@@ -113,6 +113,8 @@ export interface PageRecord {
|
|
|
113
113
|
format: "md" | "mdx";
|
|
114
114
|
/** Internal/asset links discovered in the page (for validation). */
|
|
115
115
|
links: PageLink[];
|
|
116
|
+
/** Capitalized JSX component tags used in the body (`.mdx` only). */
|
|
117
|
+
componentsUsed?: string[];
|
|
116
118
|
/** Resolved "last updated" ISO date, when the feature is enabled. */
|
|
117
119
|
lastModified?: string;
|
|
118
120
|
}
|
|
@@ -181,8 +183,6 @@ export interface NavSidebarVariant {
|
|
|
181
183
|
export interface NavChromeVariant {
|
|
182
184
|
path: string;
|
|
183
185
|
banner?: ResolvedConfig["banner"];
|
|
184
|
-
footer?: ResolvedConfig["footer"];
|
|
185
|
-
navbar?: ResolvedConfig["navbar"];
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/** The complete navigation model derived from the content graph. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform redirect files for a static build. Astro already emits redirect HTML
|
|
5
|
+
* (meta-refresh) pages for `deployment.output: "static"`, but that's a soft
|
|
6
|
+
* client redirect. These give the host a real HTTP 3xx: Netlify/Cloudflare read
|
|
7
|
+
* `_redirects`, Vercel reads `vercel.json`, and `blume-redirects.json` is a
|
|
8
|
+
* structured manifest for anything else (Apache/nginx rules, an edge worker).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type Redirect = ResolvedConfig["redirects"][number];
|
|
12
|
+
|
|
13
|
+
/** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
|
|
14
|
+
export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
|
|
15
|
+
`${redirects
|
|
16
|
+
.map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
|
|
17
|
+
.join("\n")}\n`;
|
|
18
|
+
|
|
19
|
+
/** `vercel.json` contents with a `redirects` array (permanent = 301/308). */
|
|
20
|
+
export const buildVercelConfig = (redirects: Redirect[]): string =>
|
|
21
|
+
`${JSON.stringify(
|
|
22
|
+
{
|
|
23
|
+
redirects: redirects.map((redirect) => ({
|
|
24
|
+
destination: redirect.to,
|
|
25
|
+
permanent: redirect.status === 301 || redirect.status === 308,
|
|
26
|
+
source: redirect.from,
|
|
27
|
+
})),
|
|
28
|
+
},
|
|
29
|
+
null,
|
|
30
|
+
2
|
|
31
|
+
)}\n`;
|
|
32
|
+
|
|
33
|
+
/** Structured manifest for hosts that need manual wiring. */
|
|
34
|
+
export const buildRedirectManifest = (redirects: Redirect[]): string =>
|
|
35
|
+
`${JSON.stringify(
|
|
36
|
+
redirects.map((redirect) => ({
|
|
37
|
+
from: redirect.from,
|
|
38
|
+
status: redirect.status,
|
|
39
|
+
to: redirect.to,
|
|
40
|
+
})),
|
|
41
|
+
null,
|
|
42
|
+
2
|
|
43
|
+
)}\n`;
|