blume 0.1.5 → 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 +2123 -555
- package/dist/cli/index.js.map +39 -25
- 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 +136 -508
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/changelog.mdx +28 -1
- package/docs/advanced/custom-pages.mdx +63 -2
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +25 -11
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +5 -0
- package/docs/configuration/theming.mdx +51 -0
- package/docs/content/components.mdx +18 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +43 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +49 -1
- package/docs/reference/frontmatter.mdx +9 -1
- 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 +162 -26
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +102 -45
- 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/env.ts +84 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +22 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/content/CodeBlock.astro +7 -2
- 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 +54 -96
- package/src/core/sources/github-releases.ts +200 -0
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/sources/resolve.ts +16 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/markdown/index.ts +24 -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
- package/docs/changelog/v0-1-0.mdx +0 -12
- package/docs/changelog/v0-2-0.mdx +0 -16
|
@@ -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
|
@@ -283,6 +283,31 @@ const notionSourceSchema = z.object({
|
|
|
283
283
|
type: z.literal("notion"),
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* A repo's GitHub Releases, materialized as `type: changelog` entries — release
|
|
288
|
+
* notes become the changelog with no files to maintain. A private repo reads a
|
|
289
|
+
* token from `GITHUB_TOKEN`; it is never inlined here.
|
|
290
|
+
*/
|
|
291
|
+
const githubReleasesSourceSchema = z
|
|
292
|
+
.object({
|
|
293
|
+
/** Include draft releases (needs a token with repo write access). */
|
|
294
|
+
drafts: z.boolean().optional(),
|
|
295
|
+
/** Cap the number of releases materialized, newest-first. Default 100. */
|
|
296
|
+
limit: z.number().positive().optional(),
|
|
297
|
+
/** Repository owner (user or org). */
|
|
298
|
+
owner: z.string(),
|
|
299
|
+
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
300
|
+
pollInterval: z.number().positive().optional(),
|
|
301
|
+
/** Namespaces the source's routes under `/<prefix>/`; e.g. `changelog`. */
|
|
302
|
+
prefix: z.string().optional(),
|
|
303
|
+
/** Include prereleases. */
|
|
304
|
+
prereleases: z.boolean().optional(),
|
|
305
|
+
/** Repository name. */
|
|
306
|
+
repo: z.string(),
|
|
307
|
+
type: z.literal("github-releases"),
|
|
308
|
+
})
|
|
309
|
+
.strict();
|
|
310
|
+
|
|
286
311
|
/**
|
|
287
312
|
* In-place Mintlify content (`docs.json` + MDX). Powers "bridge mode": Blume
|
|
288
313
|
* reads an unconverted Mintlify project, transforming each page to Blume MDX at
|
|
@@ -328,6 +353,7 @@ const customSourceSchema = z.object({
|
|
|
328
353
|
const contentSourceSchema = z.discriminatedUnion("type", [
|
|
329
354
|
filesystemSourceSchema,
|
|
330
355
|
mdxRemoteSourceSchema,
|
|
356
|
+
githubReleasesSourceSchema,
|
|
331
357
|
sanitySourceSchema,
|
|
332
358
|
notionSourceSchema,
|
|
333
359
|
mintlifySourceSchema,
|
|
@@ -436,38 +462,6 @@ const sidebarVariantSchema = z
|
|
|
436
462
|
})
|
|
437
463
|
.strict();
|
|
438
464
|
|
|
439
|
-
const navbarLinkTypeSchema = z.enum(["github", "discord"]);
|
|
440
|
-
|
|
441
|
-
const navbarLinkSchema = z
|
|
442
|
-
.object({
|
|
443
|
-
href: z.string(),
|
|
444
|
-
icon: iconName.optional(),
|
|
445
|
-
label: z.string().optional(),
|
|
446
|
-
type: navbarLinkTypeSchema.optional(),
|
|
447
|
-
})
|
|
448
|
-
.strict()
|
|
449
|
-
.refine((value) => value.label !== undefined || value.type !== undefined, {
|
|
450
|
-
message: "Navbar links require either label or type.",
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
const navbarPrimarySchema = z
|
|
454
|
-
.object({
|
|
455
|
-
href: z.string(),
|
|
456
|
-
label: z.string().optional(),
|
|
457
|
-
type: z.enum(["button", "github", "discord"]).default("button"),
|
|
458
|
-
})
|
|
459
|
-
.strict()
|
|
460
|
-
.refine((value) => value.label !== undefined || value.type !== "button", {
|
|
461
|
-
message: "Navbar primary button links require a label.",
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const navbarConfigSchema = z
|
|
465
|
-
.object({
|
|
466
|
-
links: z.array(navbarLinkSchema).default([]),
|
|
467
|
-
primary: navbarPrimarySchema.optional(),
|
|
468
|
-
})
|
|
469
|
-
.strict();
|
|
470
|
-
|
|
471
465
|
const variablesConfigSchema = z
|
|
472
466
|
.record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
|
|
473
467
|
.default({});
|
|
@@ -502,12 +496,6 @@ const themeConfigSchema = z
|
|
|
502
496
|
})
|
|
503
497
|
.strict();
|
|
504
498
|
|
|
505
|
-
const iconsConfigSchema = z
|
|
506
|
-
.object({
|
|
507
|
-
library: z.enum(["fontawesome", "lucide", "tabler"]).default("lucide"),
|
|
508
|
-
})
|
|
509
|
-
.strict();
|
|
510
|
-
|
|
511
499
|
/** Public credentials for the Algolia search backend (sync key is an env var). */
|
|
512
500
|
const algoliaSearchSchema = z
|
|
513
501
|
.object({
|
|
@@ -635,56 +623,9 @@ const aiConfigSchema = z
|
|
|
635
623
|
})
|
|
636
624
|
.strict();
|
|
637
625
|
|
|
638
|
-
const contextualOptionSchema = z.union([
|
|
639
|
-
z.string(),
|
|
640
|
-
z
|
|
641
|
-
.object({
|
|
642
|
-
description: z.string().optional(),
|
|
643
|
-
href: z.string().optional(),
|
|
644
|
-
icon: iconName.optional(),
|
|
645
|
-
title: z.string(),
|
|
646
|
-
})
|
|
647
|
-
.passthrough(),
|
|
648
|
-
]);
|
|
649
|
-
|
|
650
|
-
const contextualConfigSchema = z
|
|
651
|
-
.object({
|
|
652
|
-
display: z.enum(["header", "toc"]).default("header"),
|
|
653
|
-
options: z.array(contextualOptionSchema).default([]),
|
|
654
|
-
})
|
|
655
|
-
.strict();
|
|
656
|
-
|
|
657
|
-
const footerConfigSchema = z
|
|
658
|
-
.object({
|
|
659
|
-
links: z
|
|
660
|
-
.array(
|
|
661
|
-
z
|
|
662
|
-
.object({
|
|
663
|
-
header: z.string().optional(),
|
|
664
|
-
items: z
|
|
665
|
-
.array(
|
|
666
|
-
z
|
|
667
|
-
.object({
|
|
668
|
-
href: z.string(),
|
|
669
|
-
label: z.string(),
|
|
670
|
-
})
|
|
671
|
-
.strict()
|
|
672
|
-
)
|
|
673
|
-
.default([]),
|
|
674
|
-
})
|
|
675
|
-
.strict()
|
|
676
|
-
)
|
|
677
|
-
.max(4)
|
|
678
|
-
.default([]),
|
|
679
|
-
socials: z.record(z.string(), z.string()).default({}),
|
|
680
|
-
})
|
|
681
|
-
.strict();
|
|
682
|
-
|
|
683
626
|
const chromeVariantSchema = z
|
|
684
627
|
.object({
|
|
685
628
|
banner: bannerConfigSchema.optional(),
|
|
686
|
-
footer: footerConfigSchema.optional(),
|
|
687
|
-
navbar: navbarConfigSchema.optional(),
|
|
688
629
|
path: z.string(),
|
|
689
630
|
})
|
|
690
631
|
.strict();
|
|
@@ -954,12 +895,6 @@ const markdownConfigSchema = z
|
|
|
954
895
|
})
|
|
955
896
|
.strict();
|
|
956
897
|
|
|
957
|
-
const stylingConfigSchema = z
|
|
958
|
-
.object({
|
|
959
|
-
eyebrows: z.enum(["breadcrumbs", "section"]).default("section"),
|
|
960
|
-
})
|
|
961
|
-
.strict();
|
|
962
|
-
|
|
963
898
|
/**
|
|
964
899
|
* A single spec rendered by the API reference (Scalar). `spec` is a local path
|
|
965
900
|
* or an `http(s)` URL; Scalar auto-detects OpenAPI vs AsyncAPI documents.
|
|
@@ -1011,6 +946,33 @@ const asyncapiConfigSchema = z
|
|
|
1011
946
|
.strict();
|
|
1012
947
|
|
|
1013
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
|
+
|
|
1014
976
|
export const blumeConfigSchema = z
|
|
1015
977
|
.object({
|
|
1016
978
|
ai: aiConfigSchema.default({}),
|
|
@@ -1018,7 +980,6 @@ export const blumeConfigSchema = z
|
|
|
1018
980
|
asyncapi: asyncapiConfigSchema.default({}),
|
|
1019
981
|
banner: bannerConfigSchema.optional(),
|
|
1020
982
|
content: contentConfigSchema.default({}),
|
|
1021
|
-
contextual: contextualConfigSchema.default({}),
|
|
1022
983
|
deployment: deploymentConfigSchema.default({}),
|
|
1023
984
|
description: z.string().optional(),
|
|
1024
985
|
/**
|
|
@@ -1038,23 +999,20 @@ export const blumeConfigSchema = z
|
|
|
1038
999
|
export: exportConfigSchema.default(false),
|
|
1039
1000
|
favicon: faviconConfigSchema.optional(),
|
|
1040
1001
|
feedback: z.boolean().default(true),
|
|
1041
|
-
footer: footerConfigSchema.default({}),
|
|
1042
1002
|
github: githubConfigSchema.optional(),
|
|
1043
1003
|
i18n: i18nConfigSchema.optional(),
|
|
1044
|
-
icons: iconsConfigSchema.default({}),
|
|
1045
1004
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1046
1005
|
logo: logoConfigSchema.optional(),
|
|
1047
1006
|
markdown: markdownConfigSchema.default({}),
|
|
1048
1007
|
mcp: mcpConfigSchema.default({}),
|
|
1049
|
-
navbar: navbarConfigSchema.default({}),
|
|
1050
1008
|
navigation: navigationConfigSchema.default({}),
|
|
1051
1009
|
openapi: openapiConfigSchema.default({}),
|
|
1052
1010
|
redirects: z.array(redirectSchema).default([]),
|
|
1053
1011
|
search: searchConfigSchema.default({}),
|
|
1054
1012
|
seo: seoConfigSchema.default({}),
|
|
1055
|
-
styling: stylingConfigSchema.default({}),
|
|
1056
1013
|
theme: themeConfigSchema.default({}),
|
|
1057
1014
|
title: z.string().default("Documentation"),
|
|
1015
|
+
toc: tocConfigSchema,
|
|
1058
1016
|
variables: variablesConfigSchema,
|
|
1059
1017
|
})
|
|
1060
1018
|
.strict();
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import matter from "../frontmatter.ts";
|
|
2
|
+
import {
|
|
3
|
+
hashText,
|
|
4
|
+
loadWithCache,
|
|
5
|
+
pollingWatch,
|
|
6
|
+
snapshotCache,
|
|
7
|
+
} from "./cache.ts";
|
|
8
|
+
import type {
|
|
9
|
+
ContentSource,
|
|
10
|
+
SourceContext,
|
|
11
|
+
SourceEntry,
|
|
12
|
+
SourceLoadResult,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** Options for the built-in GitHub Releases changelog source. */
|
|
16
|
+
export interface GithubReleasesSourceOptions {
|
|
17
|
+
/** GitHub REST API base; overridable for GitHub Enterprise / tests. */
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
/** Include draft releases (needs a token with repo write access). Default off. */
|
|
20
|
+
drafts?: boolean;
|
|
21
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
/** Cap the number of releases materialized, newest-first. Default 100. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
name: string;
|
|
26
|
+
/** Repository owner (user or org). */
|
|
27
|
+
owner: string;
|
|
28
|
+
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
29
|
+
pollInterval?: number;
|
|
30
|
+
/** Namespaces the source's routes under `/<prefix>/`; e.g. `changelog`. */
|
|
31
|
+
prefix?: string;
|
|
32
|
+
/** Include prereleases. Default off. */
|
|
33
|
+
prereleases?: boolean;
|
|
34
|
+
/** Repository name. */
|
|
35
|
+
repo: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The subset of the GitHub release payload the adapter reads. */
|
|
39
|
+
interface GithubRelease {
|
|
40
|
+
body: string | null;
|
|
41
|
+
created_at: string;
|
|
42
|
+
draft: boolean;
|
|
43
|
+
html_url: string;
|
|
44
|
+
id: number;
|
|
45
|
+
name: string | null;
|
|
46
|
+
prerelease: boolean;
|
|
47
|
+
published_at: string | null;
|
|
48
|
+
tag_name: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_BASE_URL = "https://api.github.com";
|
|
52
|
+
const DEFAULT_LIMIT = 100;
|
|
53
|
+
const PER_PAGE = 100;
|
|
54
|
+
|
|
55
|
+
const LEADING_V = /^v/iu;
|
|
56
|
+
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
57
|
+
const EDGE_DASHES = /^-+|-+$/gu;
|
|
58
|
+
|
|
59
|
+
/** Slugify a tag into a stable, URL-safe source ref (`v1.2.0` -> `v1-2-0`). */
|
|
60
|
+
const slugifyTag = (tag: string): string =>
|
|
61
|
+
tag.toLowerCase().replaceAll(NON_SLUG, "-").replaceAll(EDGE_DASHES, "");
|
|
62
|
+
|
|
63
|
+
/** Build request headers, reading `GITHUB_TOKEN` fresh at call time. */
|
|
64
|
+
const githubHeaders = (): Headers => {
|
|
65
|
+
const headers = new Headers({ Accept: "application/vnd.github+json" });
|
|
66
|
+
const token = process.env.GITHUB_TOKEN;
|
|
67
|
+
if (token) {
|
|
68
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
69
|
+
}
|
|
70
|
+
return headers;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lower one release to a staged Markdown entry: the notes become the body and
|
|
75
|
+
* `type: changelog` frontmatter (title/date/version/category) drives the
|
|
76
|
+
* generated `/changelog` timeline and RSS feed.
|
|
77
|
+
*/
|
|
78
|
+
const releaseToEntry = (release: GithubRelease): SourceEntry => {
|
|
79
|
+
const version = release.tag_name.replace(LEADING_V, "");
|
|
80
|
+
const title = release.name?.trim() || release.tag_name;
|
|
81
|
+
const date = release.published_at ?? release.created_at;
|
|
82
|
+
const category = release.prerelease ? "Prerelease" : "Release";
|
|
83
|
+
const body = (release.body ?? "").replaceAll("\r\n", "\n").trim();
|
|
84
|
+
const data = {
|
|
85
|
+
changelog: { category, version },
|
|
86
|
+
date,
|
|
87
|
+
title,
|
|
88
|
+
type: "changelog",
|
|
89
|
+
};
|
|
90
|
+
const raw = matter.stringify(`${body}\n`, data);
|
|
91
|
+
const ref = `${slugifyTag(release.tag_name) || `release-${release.id}`}.md`;
|
|
92
|
+
return {
|
|
93
|
+
body: { format: "md", text: body },
|
|
94
|
+
data,
|
|
95
|
+
editUrl: release.html_url,
|
|
96
|
+
hash: hashText(raw),
|
|
97
|
+
lastModified: date,
|
|
98
|
+
raw,
|
|
99
|
+
ref,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GitHub Releases content source. Pulls a repo's releases from the REST API and
|
|
105
|
+
* materializes each as a `type: changelog` entry, so a project's release notes
|
|
106
|
+
* become its changelog with no files to maintain. A private repo authenticates
|
|
107
|
+
* with `GITHUB_TOKEN`. A snapshot under `.blume/cache/<source>/` keeps rebuilds
|
|
108
|
+
* offline-tolerant.
|
|
109
|
+
*/
|
|
110
|
+
export const githubReleasesSource = (
|
|
111
|
+
options: GithubReleasesSourceOptions,
|
|
112
|
+
ctx: SourceContext
|
|
113
|
+
): ContentSource => {
|
|
114
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
115
|
+
const base = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/u, "");
|
|
116
|
+
const max = options.limit ?? DEFAULT_LIMIT;
|
|
117
|
+
const cache = snapshotCache(ctx.cacheDir);
|
|
118
|
+
let snapshot = new Map<string, SourceEntry>();
|
|
119
|
+
|
|
120
|
+
const include = (release: GithubRelease): boolean =>
|
|
121
|
+
(options.drafts || !release.draft) &&
|
|
122
|
+
(options.prereleases || !release.prerelease);
|
|
123
|
+
|
|
124
|
+
const fetchPage = async (page: number): Promise<GithubRelease[]> => {
|
|
125
|
+
const url = `${base}/repos/${options.owner}/${options.repo}/releases?per_page=${PER_PAGE}&page=${page}`;
|
|
126
|
+
const res = await doFetch(url, { headers: githubHeaders() });
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new Error(`${url} -> ${res.status}`);
|
|
129
|
+
}
|
|
130
|
+
return (await res.json()) as GithubRelease[];
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const fetchReleases = async (): Promise<GithubRelease[]> => {
|
|
134
|
+
const collected: GithubRelease[] = [];
|
|
135
|
+
let page = 1;
|
|
136
|
+
while (collected.length < max) {
|
|
137
|
+
// oxlint-disable-next-line no-await-in-loop -- pages are sequential: each page's length decides whether another exists.
|
|
138
|
+
const batch = await fetchPage(page);
|
|
139
|
+
collected.push(...batch.filter(include));
|
|
140
|
+
if (batch.length < PER_PAGE) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
page += 1;
|
|
144
|
+
}
|
|
145
|
+
return collected.slice(0, max);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const load = async (): Promise<SourceLoadResult> => {
|
|
149
|
+
try {
|
|
150
|
+
const result = await loadWithCache(
|
|
151
|
+
options.name,
|
|
152
|
+
cache,
|
|
153
|
+
async () => {
|
|
154
|
+
const releases = await fetchReleases();
|
|
155
|
+
return releases.map(releaseToEntry);
|
|
156
|
+
},
|
|
157
|
+
ctx.refresh ?? true
|
|
158
|
+
);
|
|
159
|
+
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
160
|
+
return result;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A changelog is supplementary. When releases can't be fetched and nothing
|
|
163
|
+
// is cached (e.g. CI or a deploy without a `GITHUB_TOKEN` for a private
|
|
164
|
+
// repo), degrade to an empty changelog with a warning rather than failing
|
|
165
|
+
// the whole build.
|
|
166
|
+
snapshot = new Map();
|
|
167
|
+
return {
|
|
168
|
+
diagnostics: [
|
|
169
|
+
{
|
|
170
|
+
code: "BLUME_SOURCE_UNAVAILABLE",
|
|
171
|
+
message: `Source "${options.name}" could not fetch GitHub releases (${(error as Error).message}); the changelog will be empty. Set GITHUB_TOKEN to include it (required for a private repository).`,
|
|
172
|
+
severity: "warning",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
entries: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const read = async (ref: string): Promise<string> => {
|
|
181
|
+
const cached = snapshot.get(ref);
|
|
182
|
+
if (cached) {
|
|
183
|
+
return cached.raw ?? cached.body.text;
|
|
184
|
+
}
|
|
185
|
+
const all = await cache.read();
|
|
186
|
+
const entry = all.find((e) => e.ref === ref);
|
|
187
|
+
return entry?.raw ?? entry?.body.text ?? "";
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
load,
|
|
192
|
+
name: options.name,
|
|
193
|
+
prefix: options.prefix,
|
|
194
|
+
read,
|
|
195
|
+
staged: true,
|
|
196
|
+
watch: options.pollInterval
|
|
197
|
+
? pollingWatch(load, options.pollInterval)
|
|
198
|
+
: undefined,
|
|
199
|
+
};
|
|
200
|
+
};
|