blume 0.5.2 → 0.5.4
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 +2235 -5942
- package/dist/cli/index.js.map +36 -65
- package/dist/types/core/config.d.ts +0 -8
- package/dist/types/core/data.d.ts +2 -2
- package/dist/types/core/schema.d.ts +58 -453
- package/dist/types/core/types.d.ts +1 -7
- package/docs/advanced/meta.ts +1 -8
- package/docs/advanced/skills.mdx +28 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/index.mdx +13 -17
- package/docs/configuration/theming.mdx +3 -11
- package/docs/content/components.mdx +2 -53
- package/docs/content/i18n.mdx +1 -5
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +0 -1
- package/docs/reference/cli.mdx +12 -13
- package/package.json +4 -6
- package/skills/blume/SKILL.md +71 -0
- package/skills/blume-update-docs/SKILL.md +52 -0
- package/skills/blume-update-docs/references/audit-checklist.md +46 -0
- package/src/ai/mcp/discovery.ts +3 -1
- package/src/ai/mcp/server.ts +3 -1
- package/src/astro/component-slots.ts +10 -2
- package/src/astro/generate.ts +15 -12
- package/src/astro/integration.ts +0 -21
- package/src/astro/templates.ts +62 -37
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +30 -22
- package/src/cli/commands/validate.ts +0 -2
- package/src/cli/dev-lock.ts +4 -2
- package/src/cli/index.ts +0 -2
- package/src/components/BlumePage.astro +0 -6
- package/src/components/Icon.astro +1 -12
- package/src/components/content/AccordionItem.astro +3 -6
- package/src/components/content/Badge.astro +1 -3
- package/src/components/content/Callout.astro +3 -9
- package/src/components/content/Card.astro +2 -3
- package/src/components/content/ColorItem.astro +8 -5
- package/src/components/content/Column.astro +1 -1
- package/src/components/content/Prompt.astro +8 -4
- package/src/components/content/Step.astro +3 -4
- package/src/components/content/Tab.astro +2 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/TypeTable.astro +5 -5
- package/src/components/content/Update.astro +1 -1
- package/src/components/content/mermaid-element.ts +20 -2
- package/src/components/islands/ask-ai.tsx +4 -8
- package/src/components/islands/base-path.ts +30 -0
- package/src/components/islands/hooks.ts +12 -8
- package/src/components/layout/Header.astro +26 -3
- package/src/components/layout/Logo.astro +5 -1
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/NavTree.astro +14 -14
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/PageLayout.astro +42 -0
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +31 -2
- package/src/components/layout/Search.astro +9 -6
- package/src/components/layout/nav-utils.ts +9 -4
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ApiOverview.astro +4 -50
- package/src/components/openapi/ApiTagOperations.astro +42 -0
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +1 -1
- package/src/components/openapi/SchemaTable.astro +3 -3
- package/src/components/openapi/helpers.ts +17 -8
- package/src/components/openapi/snippets.ts +17 -4
- package/src/core/builtin-tags.ts +1 -3
- package/src/core/config.ts +19 -33
- package/src/core/data.ts +2 -2
- package/src/core/graph.ts +7 -5
- package/src/core/links.ts +5 -19
- package/src/core/meta.ts +1 -1
- package/src/core/navigation.ts +5 -6
- package/src/core/project-graph.ts +0 -5
- package/src/core/schema.ts +13 -90
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/resolve.ts +0 -13
- package/src/core/sources/watch.ts +45 -0
- package/src/core/types.ts +1 -8
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/og/card.ts +16 -4
- package/src/openapi/parse.ts +197 -14
- package/src/openapi/render-mdx.ts +54 -11
- package/src/openapi/source.ts +19 -2
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/entry.ts +45 -17
- package/src/theme/icons.ts +22 -104
- package/src/theme/palette.ts +35 -47
- package/src/theme/twoslash.ts +6 -1
- package/dist/types/core/bridge.d.ts +0 -24
- package/dist/types/migrate/mintlify/assets.d.ts +0 -8
- package/dist/types/migrate/mintlify/config.d.ts +0 -16
- package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
- package/docs/advanced/bridge.mdx +0 -76
- package/docs/advanced/migrate.mdx +0 -123
- package/src/astro/static-assets.ts +0 -117
- package/src/cli/commands/migrate.ts +0 -39
- package/src/components/content/ApiField.astro +0 -75
- package/src/components/content/ParamField.astro +0 -39
- package/src/components/content/RequestField.astro +0 -23
- package/src/components/content/ResponseField.astro +0 -23
- package/src/components/content/Warning.astro +0 -9
- package/src/core/assets.ts +0 -31
- package/src/core/bridge.ts +0 -102
- package/src/core/sources/mintlify.ts +0 -204
- package/src/migrate/fumadocs/config.ts +0 -155
- package/src/migrate/fumadocs/content.ts +0 -378
- package/src/migrate/fumadocs/frontmatter.ts +0 -18
- package/src/migrate/fumadocs/groups.ts +0 -237
- package/src/migrate/fumadocs/index.ts +0 -340
- package/src/migrate/fumadocs/meta.ts +0 -244
- package/src/migrate/migrate.ts +0 -53
- package/src/migrate/mintlify/assets.ts +0 -46
- package/src/migrate/mintlify/config.ts +0 -958
- package/src/migrate/mintlify/content.ts +0 -120
- package/src/migrate/mintlify/frontmatter.ts +0 -126
- package/src/migrate/mintlify/i18n.ts +0 -51
- package/src/migrate/mintlify/icons.ts +0 -128
- package/src/migrate/mintlify/index.ts +0 -372
- package/src/migrate/mintlify/snippets.ts +0 -306
- package/src/migrate/mintlify/transform.ts +0 -82
- package/src/migrate/nextra/content.ts +0 -46
- package/src/migrate/nextra/frontmatter.ts +0 -40
- package/src/migrate/nextra/index.ts +0 -374
- package/src/migrate/nextra/meta.ts +0 -266
- package/src/migrate/shared.ts +0 -728
- package/src/migrate/starlight/config.ts +0 -455
- package/src/migrate/starlight/content.ts +0 -78
- package/src/migrate/starlight/frontmatter.ts +0 -111
- package/src/migrate/starlight/i18n.ts +0 -54
- package/src/migrate/starlight/index.ts +0 -131
package/src/core/graph.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const buildContentGraph = (
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// Each locale gets an independent tree from its own pages and folder meta,
|
|
63
|
-
// so navigation may diverge per language
|
|
63
|
+
// so navigation may diverge per language.
|
|
64
64
|
for (const { code } of i18n.locales) {
|
|
65
65
|
// Localize internal tab paths so a header tab points to its in-locale
|
|
66
66
|
// route (e.g. `/docs` -> `/fr/docs`); external paths pass through.
|
|
@@ -89,9 +89,13 @@ export const buildContentGraph = (
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
navigationByLocale[code] = buildNavigation(localePages, {
|
|
92
|
-
chromeVariants: options.navigation.chromeVariants,
|
|
93
92
|
folderMeta: options.folderMeta,
|
|
94
|
-
|
|
93
|
+
// Meta files live in locale directories only under the `dir` parser
|
|
94
|
+
// (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations
|
|
95
|
+
// sit next to the originals and `guides/meta.ts` applies to every
|
|
96
|
+
// locale — prefixing would look up keys that can never exist.
|
|
97
|
+
metaPrefix:
|
|
98
|
+
i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
|
|
95
99
|
refByLogical: true,
|
|
96
100
|
selectors: options.navigation.selectors,
|
|
97
101
|
sharedFolderMeta: options.sharedFolderMeta,
|
|
@@ -100,14 +104,12 @@ export const buildContentGraph = (
|
|
|
100
104
|
});
|
|
101
105
|
}
|
|
102
106
|
navigation = navigationByLocale[i18n.defaultLocale] ?? {
|
|
103
|
-
chromeVariants: [],
|
|
104
107
|
selectors: [],
|
|
105
108
|
sidebar: [],
|
|
106
109
|
tabs: [],
|
|
107
110
|
};
|
|
108
111
|
} else {
|
|
109
112
|
navigation = buildNavigation(pages, {
|
|
110
|
-
chromeVariants: options.navigation.chromeVariants,
|
|
111
113
|
folderMeta: options.folderMeta,
|
|
112
114
|
selectors: options.navigation.selectors,
|
|
113
115
|
sharedFolderMeta: options.sharedFolderMeta,
|
package/src/core/links.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { basename, join } from "pathe";
|
|
4
4
|
|
|
5
|
-
import type { AssetMount } from "./assets.ts";
|
|
6
5
|
import type {
|
|
7
6
|
ContentGraph,
|
|
8
7
|
Diagnostic,
|
|
@@ -38,25 +37,15 @@ interface ExternalRef extends LinkSite {
|
|
|
38
37
|
/** Lookups derived once from the content graph. */
|
|
39
38
|
interface LinkContext {
|
|
40
39
|
anchors: Map<string, Set<string>>;
|
|
41
|
-
/** `content.assets` mounts served alongside `public/` (checked in place). */
|
|
42
|
-
assetMounts: AssetMount[];
|
|
43
40
|
publicDir: string | null;
|
|
44
41
|
/** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
|
|
45
42
|
redirects: Set<string>;
|
|
46
43
|
routes: Set<string>;
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
/** Whether a resolved asset path exists under `public
|
|
50
|
-
const assetIsPresent = (resolved: string, ctx: LinkContext): boolean =>
|
|
51
|
-
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
return ctx.assetMounts.some(
|
|
55
|
-
(mount) =>
|
|
56
|
-
(resolved === mount.url || resolved.startsWith(`${mount.url}/`)) &&
|
|
57
|
-
existsSync(join(mount.dir, resolved.slice(mount.url.length)))
|
|
58
|
-
);
|
|
59
|
-
};
|
|
46
|
+
/** Whether a resolved asset path exists under `public/`. */
|
|
47
|
+
const assetIsPresent = (resolved: string, ctx: LinkContext): boolean =>
|
|
48
|
+
ctx.publicDir !== null && existsSync(join(ctx.publicDir, resolved));
|
|
60
49
|
|
|
61
50
|
/** Outcome of classifying one link target. */
|
|
62
51
|
type LinkResult = Diagnostic | "asset-unchecked" | null;
|
|
@@ -165,8 +154,8 @@ const checkPathLink = (
|
|
|
165
154
|
if (assetIsPresent(resolved, ctx)) {
|
|
166
155
|
return null;
|
|
167
156
|
}
|
|
168
|
-
// Nowhere to look: no `public/`
|
|
169
|
-
if (ctx.publicDir === null
|
|
157
|
+
// Nowhere to look: no `public/` directory.
|
|
158
|
+
if (ctx.publicDir === null) {
|
|
170
159
|
return "asset-unchecked";
|
|
171
160
|
}
|
|
172
161
|
return {
|
|
@@ -347,15 +336,12 @@ export const validateLinks = async (
|
|
|
347
336
|
options: {
|
|
348
337
|
publicDir: string | null;
|
|
349
338
|
checkExternal?: boolean;
|
|
350
|
-
/** `content.assets` mounts served alongside `public/`. */
|
|
351
|
-
assetMounts?: AssetMount[];
|
|
352
339
|
/** Configured redirects; their `from` paths count as valid link targets. */
|
|
353
340
|
redirects?: { from: string }[];
|
|
354
341
|
}
|
|
355
342
|
): Promise<Diagnostic[]> => {
|
|
356
343
|
const ctx: LinkContext = {
|
|
357
344
|
anchors: buildAnchorIndex(graph.pages),
|
|
358
|
-
assetMounts: options.assetMounts ?? [],
|
|
359
345
|
publicDir: options.publicDir,
|
|
360
346
|
redirects: new Set(
|
|
361
347
|
(options.redirects ?? []).map((redirect) => toRoute(redirect.from))
|
package/src/core/meta.ts
CHANGED
|
@@ -39,7 +39,7 @@ export const discoverFolderMeta = async (
|
|
|
39
39
|
absolute: true,
|
|
40
40
|
cwd: contentRoot,
|
|
41
41
|
// Never descend into dependencies or build output — relevant when the
|
|
42
|
-
// content root is the project root (e.g. a
|
|
42
|
+
// content root is the project root (e.g. a `.`-rooted or all-staged project).
|
|
43
43
|
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
44
44
|
onlyFiles: true,
|
|
45
45
|
});
|
package/src/core/navigation.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
SidebarItemConfig,
|
|
7
7
|
} from "./schema.ts";
|
|
8
8
|
import type {
|
|
9
|
-
NavChromeVariant,
|
|
10
9
|
NavNode,
|
|
11
10
|
Navigation,
|
|
12
11
|
NavSelector,
|
|
@@ -255,7 +254,11 @@ const normalizeRef = (ref: string): string => {
|
|
|
255
254
|
return "/";
|
|
256
255
|
}
|
|
257
256
|
const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
|
|
258
|
-
|
|
257
|
+
const trimmed = withSlash.endsWith("/index")
|
|
258
|
+
? withSlash.slice(0, -"/index".length)
|
|
259
|
+
: withSlash;
|
|
260
|
+
// "/index" trims to "" — that's the root, not an empty route.
|
|
261
|
+
return trimmed === "" ? "/" : trimmed;
|
|
259
262
|
};
|
|
260
263
|
|
|
261
264
|
const routeForRef = (
|
|
@@ -342,7 +345,6 @@ const buildConfigSidebar = (
|
|
|
342
345
|
export const buildNavigation = (
|
|
343
346
|
pages: PageRecord[],
|
|
344
347
|
options: {
|
|
345
|
-
chromeVariants?: NavChromeVariant[];
|
|
346
348
|
folderMeta: Map<string, FolderMeta>;
|
|
347
349
|
selectors?: NavSelector[];
|
|
348
350
|
tabs?: NavTab[];
|
|
@@ -359,7 +361,6 @@ export const buildNavigation = (
|
|
|
359
361
|
sharedFolderMeta?: Map<string, FolderMeta>;
|
|
360
362
|
}
|
|
361
363
|
): Navigation => {
|
|
362
|
-
const chromeVariants = options.chromeVariants ?? [];
|
|
363
364
|
const selectors = options.selectors ?? [];
|
|
364
365
|
const tabs = options.tabs ?? [];
|
|
365
366
|
const metaPrefix = options.metaPrefix ?? "";
|
|
@@ -373,7 +374,6 @@ export const buildNavigation = (
|
|
|
373
374
|
|
|
374
375
|
if (options.sidebar) {
|
|
375
376
|
return {
|
|
376
|
-
chromeVariants,
|
|
377
377
|
selectors,
|
|
378
378
|
sidebar: buildConfigSidebar(options.sidebar, byRoute),
|
|
379
379
|
tabs,
|
|
@@ -381,7 +381,6 @@ export const buildNavigation = (
|
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
return {
|
|
384
|
-
chromeVariants,
|
|
385
384
|
selectors,
|
|
386
385
|
sidebar: buildFileSystemSidebar(
|
|
387
386
|
pages,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { loadConfig } from "./config.ts";
|
|
2
|
-
import type { ConfigBridge } from "./config.ts";
|
|
3
2
|
import { buildContentGraph } from "./graph.ts";
|
|
4
3
|
import { i18nDiagnostics } from "./i18n.ts";
|
|
5
4
|
import {
|
|
@@ -69,8 +68,6 @@ export interface BlumeProject {
|
|
|
69
68
|
diagnostics: Diagnostic[];
|
|
70
69
|
/** The instantiated content sources, for lazy entry reads (search/AI/raw). */
|
|
71
70
|
sources: ContentSource[];
|
|
72
|
-
/** Set when serving a foreign docs config (e.g. Mintlify) without migrating. */
|
|
73
|
-
bridge: ConfigBridge | null;
|
|
74
71
|
}
|
|
75
72
|
|
|
76
73
|
/**
|
|
@@ -97,7 +94,6 @@ export const scanProject = async (
|
|
|
97
94
|
const configResult = await loadConfig(root, {
|
|
98
95
|
devServerUrl: options.devServerUrl,
|
|
99
96
|
});
|
|
100
|
-
const { bridge } = configResult;
|
|
101
97
|
const config = applyConfigOverrides(configResult.config, options.overrides);
|
|
102
98
|
const context = resolveProjectContext(root, config, {
|
|
103
99
|
runtimeDir: options.runtimeDir,
|
|
@@ -179,7 +175,6 @@ export const scanProject = async (
|
|
|
179
175
|
const i18nWarnings = config.i18n ? i18nDiagnostics(pages, config.i18n) : [];
|
|
180
176
|
|
|
181
177
|
return {
|
|
182
|
-
bridge,
|
|
183
178
|
config,
|
|
184
179
|
context,
|
|
185
180
|
diagnostics: [
|
package/src/core/schema.ts
CHANGED
|
@@ -99,25 +99,15 @@ const pageMetaBaseSchema = z
|
|
|
99
99
|
deprecated: z.boolean().default(false),
|
|
100
100
|
description: z.string().optional(),
|
|
101
101
|
draft: z.boolean().default(false),
|
|
102
|
-
groups: z.union([z.string(), z.array(z.string())]).optional(),
|
|
103
102
|
hidden: z.boolean().default(false),
|
|
104
|
-
hideApiMarker: z.boolean().default(false),
|
|
105
|
-
hideFooterPagination: z.boolean().optional(),
|
|
106
103
|
icon: iconName.optional(),
|
|
107
|
-
iconType: z.string().optional(),
|
|
108
|
-
keywords: z.array(z.string()).optional(),
|
|
109
104
|
/** Overrides the git-derived last-modified date when `lastModified` is on. */
|
|
110
105
|
lastModified: dateSchema.optional(),
|
|
111
|
-
mode: z.string().optional(),
|
|
112
106
|
noindex: z.boolean().default(false),
|
|
113
|
-
public: z.boolean().optional(),
|
|
114
|
-
rss: z.boolean().optional(),
|
|
115
107
|
search: searchMetaSchema.default({}),
|
|
116
108
|
seo: seoMetaSchema.default({}),
|
|
117
109
|
sidebar: sidebarMetaSchema.default({}),
|
|
118
|
-
sidebarTitle: z.string().optional(),
|
|
119
110
|
slug: z.string().optional(),
|
|
120
|
-
tag: z.string().optional(),
|
|
121
111
|
title: z.string().optional(),
|
|
122
112
|
type: z.string().default("doc"),
|
|
123
113
|
})
|
|
@@ -160,45 +150,41 @@ export type FolderMeta = z.infer<typeof folderMetaSchema>;
|
|
|
160
150
|
// Project config (blume.config.ts)
|
|
161
151
|
// ---------------------------------------------------------------------------
|
|
162
152
|
|
|
163
|
-
|
|
153
|
+
/** The logo mark: a single image path/URL, or light/dark variants with alt text. */
|
|
154
|
+
const logoImageSchema = z.union([
|
|
164
155
|
z.string(),
|
|
165
156
|
z
|
|
166
157
|
.object({
|
|
167
158
|
alt: z.string().optional(),
|
|
168
159
|
dark: z.string().optional(),
|
|
169
|
-
href: z.string().optional(),
|
|
170
160
|
light: z.string().optional(),
|
|
171
161
|
})
|
|
172
162
|
.strict(),
|
|
173
163
|
]);
|
|
174
164
|
|
|
175
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Site logo. A bare string is the image shorthand. The object form splits the
|
|
167
|
+
* brand into an optional `image` mark and optional wordmark `text` so a site can
|
|
168
|
+
* show an image-only logo (a mark with the wordmark baked in), a text-only logo,
|
|
169
|
+
* or both. Omit `text` to fall back to the site title; set `text: ""` to render
|
|
170
|
+
* the mark alone. `href` overrides the brand link (defaults to `/`).
|
|
171
|
+
*/
|
|
172
|
+
const logoConfigSchema = z.union([
|
|
176
173
|
z.string(),
|
|
177
174
|
z
|
|
178
175
|
.object({
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
href: z.string().optional(),
|
|
177
|
+
image: logoImageSchema.optional(),
|
|
178
|
+
text: z.string().optional(),
|
|
181
179
|
})
|
|
182
180
|
.strict(),
|
|
183
181
|
]);
|
|
184
182
|
|
|
185
|
-
const bannerColorSchema = z
|
|
186
|
-
.object({
|
|
187
|
-
dark: z.string().optional(),
|
|
188
|
-
light: z.string().optional(),
|
|
189
|
-
})
|
|
190
|
-
.strict()
|
|
191
|
-
.refine((value) => value.dark !== undefined || value.light !== undefined, {
|
|
192
|
-
message: "Banner color requires at least one of light or dark.",
|
|
193
|
-
});
|
|
194
|
-
|
|
195
183
|
/** Site-wide announcement banner: a string, or text with an optional link. */
|
|
196
184
|
const bannerConfigSchema = z.union([
|
|
197
185
|
z.string(),
|
|
198
186
|
z
|
|
199
187
|
.object({
|
|
200
|
-
/** Background color override (Mintlify compatibility). */
|
|
201
|
-
color: bannerColorSchema.optional(),
|
|
202
188
|
content: z.string(),
|
|
203
189
|
/** Show a dismiss button; the choice is remembered per visitor. */
|
|
204
190
|
dismissible: z.boolean().default(false),
|
|
@@ -208,8 +194,6 @@ const bannerConfigSchema = z.union([
|
|
|
208
194
|
.object({ href: z.string(), text: z.string() })
|
|
209
195
|
.strict()
|
|
210
196
|
.optional(),
|
|
211
|
-
/** Tone (Mintlify compatibility). */
|
|
212
|
-
type: z.enum(["info", "warning", "critical"]).optional(),
|
|
213
197
|
})
|
|
214
198
|
.strict(),
|
|
215
199
|
]);
|
|
@@ -329,30 +313,6 @@ const githubReleasesSourceSchema = z
|
|
|
329
313
|
})
|
|
330
314
|
.strict();
|
|
331
315
|
|
|
332
|
-
/**
|
|
333
|
-
* In-place Mintlify content (`docs.json` + MDX). Powers "bridge mode": Blume
|
|
334
|
-
* reads an unconverted Mintlify project, transforming each page to Blume MDX at
|
|
335
|
-
* scan time. Injected automatically by `loadConfig` when a `docs.json` is found
|
|
336
|
-
* and no `blume.config.*` exists; can also be configured explicitly.
|
|
337
|
-
*/
|
|
338
|
-
const mintlifySourceSchema = z
|
|
339
|
-
.object({
|
|
340
|
-
/** Absolute path of the `docs.json`/`mint.json`, watched for changes in dev. */
|
|
341
|
-
configFile: z.string().optional(),
|
|
342
|
-
/** Patterns excluded from page discovery (Mintlify defaults are merged in). */
|
|
343
|
-
exclude: z.array(z.string()).default([]),
|
|
344
|
-
/** Glob patterns for Mintlify content files. */
|
|
345
|
-
include: z.array(z.string()).default(["**/*.{md,mdx}"]),
|
|
346
|
-
/** Namespaces the source's routes under `/<prefix>/`. */
|
|
347
|
-
prefix: z.string().optional(),
|
|
348
|
-
/** Content root, absolute or relative to the project root (Mintlify: `.`). */
|
|
349
|
-
root: z.string().default("."),
|
|
350
|
-
type: z.literal("mintlify"),
|
|
351
|
-
/** `docs.json` variables, inlined into content (`{{name}}`) at scan time. */
|
|
352
|
-
variables: z.record(z.string(), z.string()).default({}),
|
|
353
|
-
})
|
|
354
|
-
.strict();
|
|
355
|
-
|
|
356
316
|
/**
|
|
357
317
|
* A user-provided `ContentSource` instance, passed straight through from
|
|
358
318
|
* `blume.config.ts`. This is the extension point that lets adapters with custom
|
|
@@ -377,7 +337,6 @@ const contentSourceSchema = z.discriminatedUnion("type", [
|
|
|
377
337
|
githubReleasesSourceSchema,
|
|
378
338
|
sanitySourceSchema,
|
|
379
339
|
notionSourceSchema,
|
|
380
|
-
mintlifySourceSchema,
|
|
381
340
|
customSourceSchema,
|
|
382
341
|
]);
|
|
383
342
|
|
|
@@ -386,13 +345,6 @@ export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
|
|
|
386
345
|
|
|
387
346
|
const contentConfigSchema = z
|
|
388
347
|
.object({
|
|
389
|
-
/**
|
|
390
|
-
* Extra top-level directories (relative to the project root) served as
|
|
391
|
-
* static assets at the site root, alongside `public/`. Lets projects keep
|
|
392
|
-
* root-served asset folders in place — e.g. a Mintlify migration keeps
|
|
393
|
-
* `images/` where it is instead of relocating it under `public/`.
|
|
394
|
-
*/
|
|
395
|
-
assets: z.array(z.string()).default([]),
|
|
396
348
|
defaultType: z.string().default("doc"),
|
|
397
349
|
exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
|
|
398
350
|
include: z.array(z.string()).default(["**/*.{md,mdx}"]),
|
|
@@ -483,10 +435,6 @@ const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
|
|
|
483
435
|
])
|
|
484
436
|
);
|
|
485
437
|
|
|
486
|
-
const variablesConfigSchema = z
|
|
487
|
-
.record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
|
|
488
|
-
.default({});
|
|
489
|
-
|
|
490
438
|
/** A curated Google Font slug (see `theme/fonts.ts`). */
|
|
491
439
|
const fontSlug = z.string().refine(isFontSlug, (value) => ({
|
|
492
440
|
message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
|
|
@@ -499,7 +447,6 @@ const themeConfigSchema = z
|
|
|
499
447
|
action: z.string().optional(),
|
|
500
448
|
background: z.string().optional(),
|
|
501
449
|
backgroundDark: z.string().optional(),
|
|
502
|
-
backgroundDecoration: z.enum(["gradient", "grid", "windows"]).optional(),
|
|
503
450
|
backgroundImage: z.string().optional(),
|
|
504
451
|
backgroundImageDark: z.string().optional(),
|
|
505
452
|
fonts: z
|
|
@@ -584,7 +531,6 @@ const searchConfigSchema = z
|
|
|
584
531
|
.default({}),
|
|
585
532
|
mixedbread: mixedbreadSearchSchema.optional(),
|
|
586
533
|
oramaCloud: oramaCloudSearchSchema.optional(),
|
|
587
|
-
prompt: z.string().optional(),
|
|
588
534
|
provider: z.enum(searchProviders).default("orama"),
|
|
589
535
|
typesense: typesenseSearchSchema.optional(),
|
|
590
536
|
})
|
|
@@ -644,16 +590,8 @@ const aiConfigSchema = z
|
|
|
644
590
|
})
|
|
645
591
|
.strict();
|
|
646
592
|
|
|
647
|
-
const chromeVariantSchema = z
|
|
648
|
-
.object({
|
|
649
|
-
banner: bannerConfigSchema.optional(),
|
|
650
|
-
path: z.string(),
|
|
651
|
-
})
|
|
652
|
-
.strict();
|
|
653
|
-
|
|
654
593
|
const navigationConfigSchema = z
|
|
655
594
|
.object({
|
|
656
|
-
chromeVariants: z.array(chromeVariantSchema).default([]),
|
|
657
595
|
/** Show a GitHub repo link in the header (requires `github` configured). */
|
|
658
596
|
repo: z.boolean().default(true),
|
|
659
597
|
selectors: z.array(navSelectorSchema).default([]),
|
|
@@ -824,7 +762,6 @@ const rssConfigSchema = z
|
|
|
824
762
|
/** Discoverability features: OG images, feeds, sitemap, structured data. */
|
|
825
763
|
const seoConfigSchema = z
|
|
826
764
|
.object({
|
|
827
|
-
metatags: z.record(z.string(), z.string()).default({}),
|
|
828
765
|
og: ogConfigSchema.default({}),
|
|
829
766
|
/** Generate robots.txt (with a Sitemap reference when available). */
|
|
830
767
|
robots: z.boolean().default(true),
|
|
@@ -1001,17 +938,6 @@ const tocConfigSchema = z
|
|
|
1001
938
|
};
|
|
1002
939
|
});
|
|
1003
940
|
|
|
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
|
-
|
|
1015
941
|
export const blumeConfigSchema = z
|
|
1016
942
|
.object({
|
|
1017
943
|
ai: aiConfigSchema.default({}),
|
|
@@ -1036,11 +962,9 @@ export const blumeConfigSchema = z
|
|
|
1036
962
|
*/
|
|
1037
963
|
examples: z.string().default("examples"),
|
|
1038
964
|
export: exportConfigSchema.default(false),
|
|
1039
|
-
favicon: faviconConfigSchema.optional(),
|
|
1040
965
|
feedback: z.boolean().default(true),
|
|
1041
966
|
github: githubConfigSchema.optional(),
|
|
1042
967
|
i18n: i18nConfigSchema.optional(),
|
|
1043
|
-
icons: iconsConfigSchema.default({}),
|
|
1044
968
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1045
969
|
logo: logoConfigSchema.optional(),
|
|
1046
970
|
markdown: markdownConfigSchema.default({}),
|
|
@@ -1053,7 +977,6 @@ export const blumeConfigSchema = z
|
|
|
1053
977
|
theme: themeConfigSchema.default({}),
|
|
1054
978
|
title: z.string().default("Documentation"),
|
|
1055
979
|
toc: tocConfigSchema,
|
|
1056
|
-
variables: variablesConfigSchema,
|
|
1057
980
|
})
|
|
1058
981
|
.strict();
|
|
1059
982
|
|
|
@@ -7,6 +7,11 @@ import { glob } from "tinyglobby";
|
|
|
7
7
|
import { BlumeError } from "../diagnostics.ts";
|
|
8
8
|
import matter from "../frontmatter.ts";
|
|
9
9
|
import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
|
|
10
|
+
import {
|
|
11
|
+
BLUME_WATCH_IGNORE_DIRS,
|
|
12
|
+
excludeDirSegments,
|
|
13
|
+
ignoringWatchListener,
|
|
14
|
+
} from "./watch.ts";
|
|
10
15
|
|
|
11
16
|
/** Options for the built-in filesystem source. */
|
|
12
17
|
export interface FilesystemSourceOptions {
|
|
@@ -75,13 +80,26 @@ export const filesystemSource = (
|
|
|
75
80
|
}
|
|
76
81
|
};
|
|
77
82
|
|
|
83
|
+
// When `content.root` is the project root (a migrated `.`-rooted project),
|
|
84
|
+
// the recursive dev watcher would otherwise see Blume's own `.blume/` output
|
|
85
|
+
// and loop; skip it, VCS/dependency trees, and every excluded dir so the
|
|
86
|
+
// watcher stays in sync with what `load()` globs. See {@link ignoringWatchListener}.
|
|
87
|
+
const watchIgnoreDirs = new Set([
|
|
88
|
+
...BLUME_WATCH_IGNORE_DIRS,
|
|
89
|
+
...excludeDirSegments(options.exclude),
|
|
90
|
+
]);
|
|
91
|
+
|
|
78
92
|
const watch = (onChange: () => void): (() => void) => {
|
|
79
93
|
if (!existsSync(contentRoot)) {
|
|
80
94
|
return () => {
|
|
81
95
|
// Nothing to dispose when the root doesn't exist yet.
|
|
82
96
|
};
|
|
83
97
|
}
|
|
84
|
-
const watcher = fsWatch(
|
|
98
|
+
const watcher = fsWatch(
|
|
99
|
+
contentRoot,
|
|
100
|
+
{ recursive: true },
|
|
101
|
+
ignoringWatchListener(onChange, watchIgnoreDirs)
|
|
102
|
+
);
|
|
85
103
|
return () => watcher.close();
|
|
86
104
|
};
|
|
87
105
|
|
|
@@ -91,9 +91,23 @@ interface RemoteRef {
|
|
|
91
91
|
editUrl?: string;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// Hosts the GITHUB_TOKEN may be sent to. A configured `url` base can point at
|
|
95
|
+
// any server, and leaking the token there would hand a repo credential to an
|
|
96
|
+
// arbitrary third party.
|
|
97
|
+
const GITHUB_HOSTS = new Set(["api.github.com", "raw.githubusercontent.com"]);
|
|
98
|
+
|
|
99
|
+
const githubHeaders = (url: string): Record<string, string> => {
|
|
95
100
|
const token = process.env.GITHUB_TOKEN;
|
|
96
|
-
|
|
101
|
+
if (!token) {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
let host = "";
|
|
105
|
+
try {
|
|
106
|
+
host = new URL(url).hostname;
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
return GITHUB_HOSTS.has(host) ? { authorization: `Bearer ${token}` } : {};
|
|
97
111
|
};
|
|
98
112
|
|
|
99
113
|
interface GithubTreeEntry {
|
|
@@ -110,7 +124,7 @@ const enumerateGithub = async (
|
|
|
110
124
|
const { owner, repo, ref } = github;
|
|
111
125
|
const base = github.path.replaceAll(/^\/|\/$/gu, "");
|
|
112
126
|
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
|
|
113
|
-
const res = await doFetch(treeUrl, { headers: githubHeaders() });
|
|
127
|
+
const res = await doFetch(treeUrl, { headers: githubHeaders(treeUrl) });
|
|
114
128
|
if (!res.ok) {
|
|
115
129
|
throw new Error(`${treeUrl} -> ${res.status}`);
|
|
116
130
|
}
|
|
@@ -172,7 +186,9 @@ export const mdxRemoteSource = (
|
|
|
172
186
|
};
|
|
173
187
|
|
|
174
188
|
const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
|
|
175
|
-
const res = await doFetch(item.fetchUrl, {
|
|
189
|
+
const res = await doFetch(item.fetchUrl, {
|
|
190
|
+
headers: githubHeaders(item.fetchUrl),
|
|
191
|
+
});
|
|
176
192
|
if (!res.ok) {
|
|
177
193
|
throw new Error(`${item.fetchUrl} -> ${res.status}`);
|
|
178
194
|
}
|
|
@@ -59,6 +59,11 @@ const mapRoute = (
|
|
|
59
59
|
const groups: string[] = [];
|
|
60
60
|
|
|
61
61
|
for (const part of rawParts) {
|
|
62
|
+
// A leading/trailing/double slash yields an empty part; keeping it would
|
|
63
|
+
// produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
|
|
64
|
+
if (part === "") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
62
67
|
const group = groupLabel(part);
|
|
63
68
|
if (group !== null) {
|
|
64
69
|
groups.push(group);
|
|
@@ -113,10 +118,11 @@ export const extractHeadings = (body: string): Heading[] => {
|
|
|
113
118
|
};
|
|
114
119
|
|
|
115
120
|
const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
|
|
121
|
+
const INLINE_CODE = /`[^`]*`/gu;
|
|
116
122
|
|
|
117
123
|
/**
|
|
118
124
|
* Extract link targets from a markdown body for later validation, recording the
|
|
119
|
-
* 1-based line/column of each target. Skips fenced code blocks.
|
|
125
|
+
* 1-based line/column of each target. Skips fenced code blocks and inline code.
|
|
120
126
|
*/
|
|
121
127
|
export const extractLinks = (body: string): PageLink[] => {
|
|
122
128
|
const links: PageLink[] = [];
|
|
@@ -132,7 +138,12 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
132
138
|
if (inFence) {
|
|
133
139
|
continue;
|
|
134
140
|
}
|
|
135
|
-
|
|
141
|
+
// Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
|
|
142
|
+
// with same-length padding so recorded columns stay accurate.
|
|
143
|
+
const masked = line.replaceAll(INLINE_CODE, (span) =>
|
|
144
|
+
" ".repeat(span.length)
|
|
145
|
+
);
|
|
146
|
+
for (const match of masked.matchAll(MD_LINK)) {
|
|
136
147
|
const target = match.groups?.target;
|
|
137
148
|
if (target === undefined || match.index === undefined) {
|
|
138
149
|
continue;
|
|
@@ -153,7 +164,6 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
153
164
|
return links;
|
|
154
165
|
};
|
|
155
166
|
|
|
156
|
-
const INLINE_CODE = /`[^`]*`/gu;
|
|
157
167
|
// Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
|
|
158
168
|
// `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
|
|
159
169
|
// isn't a real usage. Single quotes are left alone so prose apostrophes don't
|
|
@@ -207,8 +217,14 @@ const deriveTitle = (
|
|
|
207
217
|
return titleCase(stripNumericPrefix(base.replace(extname(base), "")));
|
|
208
218
|
};
|
|
209
219
|
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
/** Strip habitual leading/trailing slashes (`/getting-started`, `guides/`). */
|
|
221
|
+
const trimSlashes = (value: string): string =>
|
|
222
|
+
value.replaceAll(/^\/+|\/+$/gu, "");
|
|
223
|
+
|
|
224
|
+
const withPrefix = (prefix: string | undefined, path: string): string => {
|
|
225
|
+
const clean = prefix ? trimSlashes(prefix) : "";
|
|
226
|
+
return clean ? `${clean}/${path}` : path;
|
|
227
|
+
};
|
|
212
228
|
|
|
213
229
|
/**
|
|
214
230
|
* Normalize one source entry into per-locale `PageRecord`s. This is the single
|
|
@@ -254,9 +270,15 @@ export const normalizeEntry = (
|
|
|
254
270
|
: { locales: [""], navPath: entry.ref };
|
|
255
271
|
|
|
256
272
|
const navPath = withPrefix(ctx.source.prefix, rawNavPath);
|
|
273
|
+
// Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
|
|
274
|
+
// SPI's "logical route input; defaults to ref if omitted"), then the ref.
|
|
275
|
+
// The extension is re-appended so mapRoute's extname strip can't eat a
|
|
276
|
+
// dotted slug segment (`v1.2`). A slug that trims to nothing falls back.
|
|
277
|
+
const slugInput = meta.slug ?? entry.slug;
|
|
278
|
+
const slug = slugInput ? trimSlashes(slugInput) : "";
|
|
257
279
|
const routeInput = withPrefix(
|
|
258
280
|
ctx.source.prefix,
|
|
259
|
-
|
|
281
|
+
slug ? `${slug}${ext}` : rawNavPath
|
|
260
282
|
);
|
|
261
283
|
|
|
262
284
|
const { segments, groups, route: logicalRoute } = mapRoute(routeInput);
|
|
@@ -7,7 +7,6 @@ import type { ProjectContext } from "../types.ts";
|
|
|
7
7
|
import { filesystemSource } from "./filesystem.ts";
|
|
8
8
|
import { githubReleasesSource } from "./github-releases.ts";
|
|
9
9
|
import { mdxRemoteSource } from "./mdx-remote.ts";
|
|
10
|
-
import { mintlifySource } from "./mintlify.ts";
|
|
11
10
|
import { notionSource } from "./notion.ts";
|
|
12
11
|
import { sanitySource } from "./sanity.ts";
|
|
13
12
|
import type { ContentSource, SourceContext } from "./types.ts";
|
|
@@ -64,18 +63,6 @@ const buildSource = (
|
|
|
64
63
|
root: def.root,
|
|
65
64
|
});
|
|
66
65
|
}
|
|
67
|
-
if (def.type === "mintlify") {
|
|
68
|
-
return mintlifySource({
|
|
69
|
-
configFile: def.configFile,
|
|
70
|
-
exclude: def.exclude,
|
|
71
|
-
include: def.include,
|
|
72
|
-
name,
|
|
73
|
-
prefix: def.prefix,
|
|
74
|
-
projectRoot: context.root,
|
|
75
|
-
root: def.root,
|
|
76
|
-
variables: def.variables,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
66
|
if (def.type === "custom") {
|
|
80
67
|
// A user-provided instance manages its own context/caching; we only ensure
|
|
81
68
|
// its name is unique across the project for id namespacing.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { WatchListener } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Directory segments a recursive dev watcher must never react to. When a
|
|
5
|
+
* source's content root is the project root — a `.`-rooted layout, or an
|
|
6
|
+
* all-staged project (openapi/notion/github-releases/…) with no filesystem
|
|
7
|
+
* source — a naive recursive `fs.watch` also sees Blume's own `.blume/`
|
|
8
|
+
* output, which the dev server rewrites on every render (e.g.
|
|
9
|
+
* `.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
|
|
10
|
+
* a rescan + runtime regeneration whose writes land back under `.blume/` and
|
|
11
|
+
* fire the watcher again: a self-sustaining loop that stalls page renders and
|
|
12
|
+
* floods the console (and, mid-render, corrupts Astro's dev module graph so
|
|
13
|
+
* `astro:server-app.js` fails to load). `.git`/`node_modules` are here for the
|
|
14
|
+
* same reason — churn that is never page content. `fs.watch` has no ignore
|
|
15
|
+
* option, so we filter by the changed path in the callback.
|
|
16
|
+
*/
|
|
17
|
+
export const BLUME_WATCH_IGNORE_DIRS = [".blume", ".git", "node_modules"];
|
|
18
|
+
|
|
19
|
+
/** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
|
|
20
|
+
export const excludeDirSegments = (patterns: readonly string[]): string[] =>
|
|
21
|
+
patterns
|
|
22
|
+
.map((pattern) => /^(?<dir>[^*/]+)\/\*\*$/u.exec(pattern)?.groups?.dir)
|
|
23
|
+
.filter((dir): dir is string => dir !== undefined);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a recursive-watch listener that fires `onChange` for content changes but
|
|
27
|
+
* ignores events whose path crosses an ignored directory segment. A missing
|
|
28
|
+
* `filename` — rare; the platform couldn't name the changed path — falls through
|
|
29
|
+
* to `onChange` rather than silently dropping a real edit. Exported for testing.
|
|
30
|
+
*/
|
|
31
|
+
export const ignoringWatchListener = (
|
|
32
|
+
onChange: () => void,
|
|
33
|
+
ignoreDirs: Iterable<string> = BLUME_WATCH_IGNORE_DIRS
|
|
34
|
+
): WatchListener<string> => {
|
|
35
|
+
const ignore = new Set(ignoreDirs);
|
|
36
|
+
return (_event, filename) => {
|
|
37
|
+
if (
|
|
38
|
+
typeof filename === "string" &&
|
|
39
|
+
filename.split(/[/\\]/u).some((segment) => ignore.has(segment))
|
|
40
|
+
) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
onChange();
|
|
44
|
+
};
|
|
45
|
+
};
|