blume 0.5.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +759 -406
- package/dist/cli/index.js.map +27 -25
- package/dist/types/core/config-input.d.ts +759 -0
- package/dist/types/core/config.d.ts +126 -3
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +50 -0
- package/dist/types/core/schema.d.ts +334 -62
- package/dist/types/core/types.d.ts +8 -0
- package/dist/types/index.d.ts +2 -1
- package/docs/advanced/changelog.mdx +10 -2
- package/docs/configuration/ai.mdx +56 -0
- package/docs/configuration/index.mdx +0 -2
- package/docs/configuration/seo.mdx +59 -1
- package/docs/configuration/theming.mdx +14 -9
- package/docs/content/meta.mdx +3 -17
- package/docs/content/navigation.mdx +41 -4
- package/docs/content/syntax.mdx +4 -8
- package/package.json +3 -1
- package/src/ai/agent-readability.ts +97 -0
- package/src/ai/ask-context.ts +131 -8
- package/src/ai/ask-data.ts +4 -1
- package/src/astro/generate.ts +40 -11
- package/src/astro/templates.ts +90 -10
- package/src/cli/commands/build.ts +41 -1
- package/src/cli/commands/dev.ts +31 -14
- package/src/cli/dev-lock.ts +94 -21
- package/src/components/content/GithubInfo.astro +11 -10
- package/src/components/content/TypeTable.astro +8 -3
- package/src/components/content/Update.astro +12 -2
- package/src/components/content/changelog-element.ts +62 -0
- package/src/components/islands/AskAI.astro +66 -2
- package/src/components/islands/ask-ai.tsx +289 -53
- package/src/components/layout/Header.astro +1 -1
- package/src/components/layout/NavTree.astro +1 -1
- package/src/components/layout/PageActions.astro +73 -30
- package/src/components/layout/RootLayout.astro +79 -10
- package/src/core/config-input.ts +933 -0
- package/src/core/config.ts +126 -3
- package/src/core/data.ts +4 -0
- package/src/core/graph.ts +7 -2
- package/src/core/i18n-ui.ts +5 -0
- package/src/core/nav-diagnostics.ts +7 -0
- package/src/core/navigation.ts +38 -12
- package/src/core/schema.ts +130 -22
- package/src/core/sources/filesystem.ts +5 -1
- package/src/core/sources/watch.ts +43 -12
- package/src/core/types.ts +9 -0
- package/src/deploy/adapter-output.ts +82 -0
- package/src/deploy/robots.ts +37 -4
- package/src/index.ts +1 -1
- package/src/markdown/index.ts +28 -30
- package/src/markdown/math.ts +3 -2
- package/src/openapi/scalar.ts +1 -1
- package/src/registry/eject.ts +21 -14
- package/src/search/documents.ts +9 -2
- package/src/theme/entry.ts +7 -3
- package/src/theme/palette.ts +21 -14
package/src/core/config.ts
CHANGED
|
@@ -1,16 +1,139 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
+
import type { BlumeConfig } from "./config-input.ts";
|
|
3
4
|
import { applyDeploymentEnv } from "./deployment-env.ts";
|
|
4
5
|
import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
|
|
5
6
|
import { createModuleLoader } from "./load-module.ts";
|
|
6
7
|
import { findConfigFile } from "./project.ts";
|
|
7
8
|
import { blumeConfigSchema } from "./schema.ts";
|
|
8
|
-
import type {
|
|
9
|
+
import type { ResolvedConfig } from "./schema.ts";
|
|
9
10
|
import type { Diagnostic } from "./types.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Define a Blume site's configuration with full type-checking and editor
|
|
14
|
+
* autocomplete. Place the call in `blume.config.ts` at your project root and
|
|
15
|
+
* `export default` the result:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { defineConfig } from "blume";
|
|
19
|
+
*
|
|
20
|
+
* export default defineConfig({
|
|
21
|
+
* title: "Acme Docs",
|
|
22
|
+
* description: "Everything you need to build with Acme.",
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Every field is optional — an empty `defineConfig({})` produces a working
|
|
27
|
+
* site from the Markdown/MDX in your `docs/` directory. Configure only what you
|
|
28
|
+
* want to change; sensible defaults fill in the rest.
|
|
29
|
+
*
|
|
30
|
+
* This is an identity helper: it returns its input unchanged and exists purely
|
|
31
|
+
* for type inference (and as a stable home for future plugin hooks). The object
|
|
32
|
+
* is validated against the Blume schema when the CLI loads it.
|
|
33
|
+
*
|
|
34
|
+
* ## Top-level fields
|
|
35
|
+
*
|
|
36
|
+
* **Site identity**
|
|
37
|
+
* - `title` — site title, shown in the header, `<title>`, and OG images.
|
|
38
|
+
* Defaults to `"Documentation"`.
|
|
39
|
+
* - `description` — default meta description, used where a page sets none.
|
|
40
|
+
* - `logo` — brand mark. A string is an image path/URL; the object form splits
|
|
41
|
+
* an `image` mark from wordmark `text` and can override the brand `href`.
|
|
42
|
+
* - `banner` — site-wide announcement bar; a string, or `{ content, link,
|
|
43
|
+
* dismissible }`.
|
|
44
|
+
*
|
|
45
|
+
* **Content & navigation**
|
|
46
|
+
* - `content` — where content lives (`root`, defaults to `docs`) and pluggable
|
|
47
|
+
* `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
|
|
48
|
+
* custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
|
|
49
|
+
* implicit filesystem source.
|
|
50
|
+
* - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
|
|
51
|
+
* switchers), pinned `featured` links, and the `repo` link toggle. Omit
|
|
52
|
+
* `sidebar` to generate it from the content tree.
|
|
53
|
+
* - `redirects` — `{ from, to, status }` rules (301 by default).
|
|
54
|
+
* - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
|
|
55
|
+
* and the header repo link.
|
|
56
|
+
*
|
|
57
|
+
* **Appearance**
|
|
58
|
+
* - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
|
|
59
|
+
* `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
|
|
60
|
+
* - `markdown` — `code` (language icons, inline highlighting, line wrap),
|
|
61
|
+
* `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
|
|
62
|
+
* - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
|
|
63
|
+
* - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
|
|
64
|
+
* - `feedback` — the per-page "Was this helpful?" widget (on by default).
|
|
65
|
+
* - `export` — reader-facing PDF/EPUB export actions (off by default).
|
|
66
|
+
*
|
|
67
|
+
* **Reference docs**
|
|
68
|
+
* - `openapi` — native OpenAPI reference: one real page per operation, woven
|
|
69
|
+
* into the sidebar and search. Point `sources`/`spec` at your spec.
|
|
70
|
+
* - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
|
|
71
|
+
*
|
|
72
|
+
* **Search & AI**
|
|
73
|
+
* - `search` — search backend `provider` (`orama` by default; `pagefind`,
|
|
74
|
+
* `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
|
|
75
|
+
* credential block.
|
|
76
|
+
* - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
|
|
77
|
+
* (emit `llms.txt`).
|
|
78
|
+
* - `mcp` — expose the docs as an MCP server for connecting agents.
|
|
79
|
+
*
|
|
80
|
+
* **SEO, feeds & analytics**
|
|
81
|
+
* - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
|
|
82
|
+
* JSON-LD, `agentReadability`, and robots `contentSignals`.
|
|
83
|
+
* - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
|
|
84
|
+
* GA, …).
|
|
85
|
+
*
|
|
86
|
+
* **Deployment & i18n**
|
|
87
|
+
* - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
|
|
88
|
+
* `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
|
|
89
|
+
* (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
|
|
90
|
+
* Cloudflare from the platform env.
|
|
91
|
+
* - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
|
|
92
|
+
* (`dir` vs filename `dot` suffix), and per-locale UI overrides.
|
|
93
|
+
*
|
|
94
|
+
* - `examples` — where `<Component path>` previews resolve their source from
|
|
95
|
+
* (defaults to `examples/`; supports a glob for colocated registries).
|
|
96
|
+
*
|
|
97
|
+
* @example Zero-config — just render the Markdown under `docs/`.
|
|
98
|
+
* ```ts
|
|
99
|
+
* export default defineConfig({});
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @example A production docs site with theming, search, and deployment.
|
|
103
|
+
* ```ts
|
|
104
|
+
* export default defineConfig({
|
|
105
|
+
* title: "Acme Docs",
|
|
106
|
+
* description: "Build faster with Acme.",
|
|
107
|
+
* logo: { image: "/logo.svg", text: "Acme" },
|
|
108
|
+
* github: { owner: "acme", repo: "acme" },
|
|
109
|
+
* theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
|
|
110
|
+
* navigation: {
|
|
111
|
+
* tabs: [
|
|
112
|
+
* { label: "Guides", path: "/guides" },
|
|
113
|
+
* { label: "API", path: "/api" },
|
|
114
|
+
* ],
|
|
115
|
+
* },
|
|
116
|
+
* search: { provider: "orama" },
|
|
117
|
+
* deployment: { site: "https://docs.acme.com", adapter: "vercel" },
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @example An OpenAPI reference with the Ask AI assistant enabled.
|
|
122
|
+
* ```ts
|
|
123
|
+
* export default defineConfig({
|
|
124
|
+
* title: "Acme API",
|
|
125
|
+
* openapi: {
|
|
126
|
+
* enabled: true,
|
|
127
|
+
* route: "/reference",
|
|
128
|
+
* sources: [{ label: "Core", spec: "./openapi.json" }],
|
|
129
|
+
* },
|
|
130
|
+
* ai: { ask: { enabled: true }, llmsTxt: true },
|
|
131
|
+
* });
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @param config - The site configuration. All fields are optional.
|
|
135
|
+
* @returns The same config object, typed for inference.
|
|
136
|
+
* @see https://useblume.dev/docs for the full configuration reference.
|
|
14
137
|
*/
|
|
15
138
|
export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
|
|
16
139
|
|
package/src/core/data.ts
CHANGED
|
@@ -88,6 +88,10 @@ export interface BlumeDataConfig {
|
|
|
88
88
|
analytics: NonNullable<ResolvedConfig["analytics"]> | null;
|
|
89
89
|
/** Apple touch icon, or `null` when none is configured/detected. */
|
|
90
90
|
appleIcon: BlumeFavicon | null;
|
|
91
|
+
/** Ask AI empty-state suggestions, or `null` when Ask AI is off. */
|
|
92
|
+
ask: {
|
|
93
|
+
suggestions: NonNullable<ResolvedConfig["ai"]["ask"]>["suggestions"];
|
|
94
|
+
} | null;
|
|
91
95
|
banner: BlumeBanner | null;
|
|
92
96
|
/** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
|
|
93
97
|
codeWrap: boolean;
|
package/src/core/graph.ts
CHANGED
|
@@ -89,6 +89,8 @@ export const buildContentGraph = (
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
navigationByLocale[code] = buildNavigation(localePages, {
|
|
92
|
+
display: options.navigation.sidebar.display,
|
|
93
|
+
featured: options.navigation.featured,
|
|
92
94
|
folderMeta: options.folderMeta,
|
|
93
95
|
// Meta files live in locale directories only under the `dir` parser
|
|
94
96
|
// (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations
|
|
@@ -99,21 +101,24 @@ export const buildContentGraph = (
|
|
|
99
101
|
refByLogical: true,
|
|
100
102
|
selectors: options.navigation.selectors,
|
|
101
103
|
sharedFolderMeta: options.sharedFolderMeta,
|
|
102
|
-
sidebar: options.navigation.sidebar,
|
|
104
|
+
sidebar: options.navigation.sidebar.items,
|
|
103
105
|
tabs,
|
|
104
106
|
});
|
|
105
107
|
}
|
|
106
108
|
navigation = navigationByLocale[i18n.defaultLocale] ?? {
|
|
109
|
+
featured: [],
|
|
107
110
|
selectors: [],
|
|
108
111
|
sidebar: [],
|
|
109
112
|
tabs: [],
|
|
110
113
|
};
|
|
111
114
|
} else {
|
|
112
115
|
navigation = buildNavigation(pages, {
|
|
116
|
+
display: options.navigation.sidebar.display,
|
|
117
|
+
featured: options.navigation.featured,
|
|
113
118
|
folderMeta: options.folderMeta,
|
|
114
119
|
selectors: options.navigation.selectors,
|
|
115
120
|
sharedFolderMeta: options.sharedFolderMeta,
|
|
116
|
-
sidebar: options.navigation.sidebar,
|
|
121
|
+
sidebar: options.navigation.sidebar.items,
|
|
117
122
|
tabs: options.navigation.tabs,
|
|
118
123
|
});
|
|
119
124
|
}
|
package/src/core/i18n-ui.ts
CHANGED
|
@@ -19,6 +19,7 @@ const uiStringsObject = z.object({
|
|
|
19
19
|
connectMcp: z.string().default("Connect to MCP"),
|
|
20
20
|
copied: z.string().default("Copied!"),
|
|
21
21
|
copyClaudeCode: z.string().default("Copy Claude Code command"),
|
|
22
|
+
copyCodex: z.string().default("Copy Codex command"),
|
|
22
23
|
copyMarkdown: z.string().default("Copy as Markdown"),
|
|
23
24
|
copyServerUrl: z.string().default("Copy server URL"),
|
|
24
25
|
edit: z.string().default("Edit on GitHub"),
|
|
@@ -28,11 +29,15 @@ const uiStringsObject = z.object({
|
|
|
28
29
|
.default({}),
|
|
29
30
|
ask: z
|
|
30
31
|
.object({
|
|
32
|
+
clear: z.string().default("Clear conversation"),
|
|
33
|
+
close: z.string().default("Close"),
|
|
34
|
+
copy: z.string().default("Copy conversation"),
|
|
31
35
|
empty: z.string().default("Ask a question about the docs."),
|
|
32
36
|
error: z.string().default("Sorry, something went wrong."),
|
|
33
37
|
label: z.string().default("Ask a question"),
|
|
34
38
|
placeholder: z.string().default("Ask a question…"),
|
|
35
39
|
send: z.string().default("Send"),
|
|
40
|
+
tip: z.string().default("Tip: You can open and close chat with"),
|
|
36
41
|
title: z.string().default("Ask AI"),
|
|
37
42
|
})
|
|
38
43
|
.default({}),
|
|
@@ -42,6 +42,9 @@ const collectIcons = (
|
|
|
42
42
|
push(item.icon, `selector "${item.label}"`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
for (const link of navigation.featured) {
|
|
46
|
+
push(link.icon, `featured link "${link.label}"`);
|
|
47
|
+
}
|
|
45
48
|
const sidebars = [navigation.sidebar];
|
|
46
49
|
for (const sidebar of sidebars) {
|
|
47
50
|
for (const node of flattenNodes(sidebar)) {
|
|
@@ -90,6 +93,10 @@ export const validateNavTargets = (
|
|
|
90
93
|
...navigation.selectors.flatMap((selector) =>
|
|
91
94
|
selector.items.map((item) => ({ label: item.label, path: item.path }))
|
|
92
95
|
),
|
|
96
|
+
...navigation.featured.map((link) => ({
|
|
97
|
+
label: link.label,
|
|
98
|
+
path: link.href,
|
|
99
|
+
})),
|
|
93
100
|
];
|
|
94
101
|
const diagnostics: Diagnostic[] = [];
|
|
95
102
|
const seen = new Set<string>();
|
package/src/core/navigation.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
SidebarItemConfig,
|
|
7
7
|
} from "./schema.ts";
|
|
8
8
|
import type {
|
|
9
|
+
FeaturedLink,
|
|
9
10
|
NavNode,
|
|
10
11
|
Navigation,
|
|
11
12
|
NavSelector,
|
|
@@ -58,7 +59,6 @@ interface MutableGroup {
|
|
|
58
59
|
label: string;
|
|
59
60
|
icon?: string;
|
|
60
61
|
collapsed?: boolean;
|
|
61
|
-
display?: SidebarDisplay;
|
|
62
62
|
order: number;
|
|
63
63
|
children: MutableNode[];
|
|
64
64
|
index: Map<string, MutableGroup>;
|
|
@@ -140,7 +140,6 @@ const applyFolderMeta = (
|
|
|
140
140
|
group.icon = meta.icon ?? group.icon;
|
|
141
141
|
group.order = meta.order ?? group.order;
|
|
142
142
|
group.collapsed = meta.collapsed ?? group.collapsed;
|
|
143
|
-
group.display = meta.display ?? group.display;
|
|
144
143
|
|
|
145
144
|
if (meta.pages) {
|
|
146
145
|
const rank = new Map(meta.pages.map((key, i) => [key, i]));
|
|
@@ -174,7 +173,21 @@ const sortNodes = (nodes: MutableNode[]): void => {
|
|
|
174
173
|
}
|
|
175
174
|
};
|
|
176
175
|
|
|
177
|
-
|
|
176
|
+
/**
|
|
177
|
+
* In flat display a group renders as a plain section header, so a loose page
|
|
178
|
+
* sorted after a group would visually read as that group's last child. Hoist
|
|
179
|
+
* pages above groups at every level (relative order otherwise preserved).
|
|
180
|
+
*/
|
|
181
|
+
const hoistPages = (nodes: MutableNode[]): void => {
|
|
182
|
+
const pages = nodes.filter((node) => node.kind === "page");
|
|
183
|
+
const groups = nodes.filter((node) => node.kind === "group");
|
|
184
|
+
nodes.splice(0, nodes.length, ...pages, ...groups);
|
|
185
|
+
for (const group of groups) {
|
|
186
|
+
hoistPages(group.children);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const toNavNode = (node: MutableNode, display: SidebarDisplay): NavNode => {
|
|
178
191
|
if (node.kind === "page") {
|
|
179
192
|
return {
|
|
180
193
|
badge: node.badge,
|
|
@@ -188,9 +201,9 @@ const toNavNode = (node: MutableNode): NavNode => {
|
|
|
188
201
|
};
|
|
189
202
|
}
|
|
190
203
|
return {
|
|
191
|
-
children: node.children.map(toNavNode),
|
|
204
|
+
children: node.children.map((child) => toNavNode(child, display)),
|
|
192
205
|
collapsed: node.collapsed,
|
|
193
|
-
display
|
|
206
|
+
display,
|
|
194
207
|
icon: node.icon,
|
|
195
208
|
kind: "group",
|
|
196
209
|
label: node.label,
|
|
@@ -203,7 +216,8 @@ const buildFileSystemSidebar = (
|
|
|
203
216
|
pages: PageRecord[],
|
|
204
217
|
folderMeta: Map<string, FolderMeta>,
|
|
205
218
|
sharedMeta: Map<string, FolderMeta>,
|
|
206
|
-
metaPrefix: string
|
|
219
|
+
metaPrefix: string,
|
|
220
|
+
display: SidebarDisplay
|
|
207
221
|
): NavNode[] => {
|
|
208
222
|
const root = createGroup("", "", "", 0);
|
|
209
223
|
|
|
@@ -246,7 +260,10 @@ const buildFileSystemSidebar = (
|
|
|
246
260
|
|
|
247
261
|
applyFolderMeta(root, folderMeta, sharedMeta, metaPrefix);
|
|
248
262
|
sortNodes(root.children);
|
|
249
|
-
|
|
263
|
+
if (display === "flat") {
|
|
264
|
+
hoistPages(root.children);
|
|
265
|
+
}
|
|
266
|
+
return root.children.map((child) => toNavNode(child, display));
|
|
250
267
|
};
|
|
251
268
|
|
|
252
269
|
const normalizeRef = (ref: string): string => {
|
|
@@ -275,7 +292,8 @@ const routeForRef = (
|
|
|
275
292
|
/** Build the sidebar tree from an explicit config spec. */
|
|
276
293
|
const buildConfigSidebar = (
|
|
277
294
|
items: SidebarItemConfig[],
|
|
278
|
-
byRoute: Map<string, PageRecord
|
|
295
|
+
byRoute: Map<string, PageRecord>,
|
|
296
|
+
display: SidebarDisplay
|
|
279
297
|
): NavNode[] => {
|
|
280
298
|
const nodes: NavNode[] = [];
|
|
281
299
|
|
|
@@ -300,10 +318,10 @@ const buildConfigSidebar = (
|
|
|
300
318
|
if (item.items) {
|
|
301
319
|
nodes.push({
|
|
302
320
|
badge: item.badge,
|
|
303
|
-
children: buildConfigSidebar(item.items, byRoute),
|
|
321
|
+
children: buildConfigSidebar(item.items, byRoute, display),
|
|
304
322
|
collapsed: item.collapsed,
|
|
305
323
|
directory: item.directory,
|
|
306
|
-
display: item.display,
|
|
324
|
+
display: item.display ?? display,
|
|
307
325
|
icon: item.icon,
|
|
308
326
|
kind: "group",
|
|
309
327
|
label: item.label,
|
|
@@ -346,6 +364,9 @@ export const buildNavigation = (
|
|
|
346
364
|
pages: PageRecord[],
|
|
347
365
|
options: {
|
|
348
366
|
folderMeta: Map<string, FolderMeta>;
|
|
367
|
+
/** Global display mode for every sidebar group (default `flat`). */
|
|
368
|
+
display?: SidebarDisplay;
|
|
369
|
+
featured?: FeaturedLink[];
|
|
349
370
|
selectors?: NavSelector[];
|
|
350
371
|
tabs?: NavTab[];
|
|
351
372
|
sidebar?: SidebarItemConfig[];
|
|
@@ -361,8 +382,10 @@ export const buildNavigation = (
|
|
|
361
382
|
sharedFolderMeta?: Map<string, FolderMeta>;
|
|
362
383
|
}
|
|
363
384
|
): Navigation => {
|
|
385
|
+
const featured = options.featured ?? [];
|
|
364
386
|
const selectors = options.selectors ?? [];
|
|
365
387
|
const tabs = options.tabs ?? [];
|
|
388
|
+
const display = options.display ?? "flat";
|
|
366
389
|
const metaPrefix = options.metaPrefix ?? "";
|
|
367
390
|
const sharedFolderMeta = options.sharedFolderMeta ?? new Map();
|
|
368
391
|
const byRoute = new Map(
|
|
@@ -374,19 +397,22 @@ export const buildNavigation = (
|
|
|
374
397
|
|
|
375
398
|
if (options.sidebar) {
|
|
376
399
|
return {
|
|
400
|
+
featured,
|
|
377
401
|
selectors,
|
|
378
|
-
sidebar: buildConfigSidebar(options.sidebar, byRoute),
|
|
402
|
+
sidebar: buildConfigSidebar(options.sidebar, byRoute, display),
|
|
379
403
|
tabs,
|
|
380
404
|
};
|
|
381
405
|
}
|
|
382
406
|
|
|
383
407
|
return {
|
|
408
|
+
featured,
|
|
384
409
|
selectors,
|
|
385
410
|
sidebar: buildFileSystemSidebar(
|
|
386
411
|
pages,
|
|
387
412
|
options.folderMeta,
|
|
388
413
|
sharedFolderMeta,
|
|
389
|
-
metaPrefix
|
|
414
|
+
metaPrefix,
|
|
415
|
+
display
|
|
390
416
|
),
|
|
391
417
|
tabs,
|
|
392
418
|
};
|
package/src/core/schema.ts
CHANGED
|
@@ -135,7 +135,6 @@ export type SidebarDisplay = z.infer<typeof sidebarDisplaySchema>;
|
|
|
135
135
|
export const folderMetaSchema = z
|
|
136
136
|
.object({
|
|
137
137
|
collapsed: z.boolean().optional(),
|
|
138
|
-
display: sidebarDisplaySchema.optional(),
|
|
139
138
|
icon: iconName.optional(),
|
|
140
139
|
order: z.number().optional(),
|
|
141
140
|
/** Explicit child ordering by slug segment (without numeric prefix). */
|
|
@@ -440,15 +439,37 @@ const fontSlug = z.string().refine(isFontSlug, (value) => ({
|
|
|
440
439
|
message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
|
|
441
440
|
}));
|
|
442
441
|
|
|
442
|
+
/**
|
|
443
|
+
* An optional per-mode theme value: a string applies to both color modes; a
|
|
444
|
+
* `{ light, dark }` object sets each mode individually (either may be
|
|
445
|
+
* omitted to override a single mode).
|
|
446
|
+
*/
|
|
447
|
+
const perModeValueSchema = z
|
|
448
|
+
.union([
|
|
449
|
+
z.string(),
|
|
450
|
+
z
|
|
451
|
+
.object({ dark: z.string().optional(), light: z.string().optional() })
|
|
452
|
+
.strict(),
|
|
453
|
+
])
|
|
454
|
+
.optional()
|
|
455
|
+
.transform((value) =>
|
|
456
|
+
typeof value === "string" ? { dark: value, light: value } : value
|
|
457
|
+
);
|
|
458
|
+
|
|
443
459
|
const themeConfigSchema = z
|
|
444
460
|
.object({
|
|
445
|
-
accent: z
|
|
446
|
-
|
|
461
|
+
accent: z
|
|
462
|
+
.union([
|
|
463
|
+
z.string(),
|
|
464
|
+
z.object({ dark: z.string(), light: z.string() }).strict(),
|
|
465
|
+
])
|
|
466
|
+
.default("blue")
|
|
467
|
+
.transform((value) =>
|
|
468
|
+
typeof value === "string" ? { dark: value, light: value } : value
|
|
469
|
+
),
|
|
447
470
|
action: z.string().optional(),
|
|
448
|
-
background:
|
|
449
|
-
|
|
450
|
-
backgroundImage: z.string().optional(),
|
|
451
|
-
backgroundImageDark: z.string().optional(),
|
|
471
|
+
background: perModeValueSchema,
|
|
472
|
+
backgroundImage: perModeValueSchema,
|
|
452
473
|
fonts: z
|
|
453
474
|
.object({
|
|
454
475
|
body: fontSlug.default("inter"),
|
|
@@ -571,6 +592,18 @@ const aiConfigSchema = z
|
|
|
571
592
|
enabled: z.boolean().default(false),
|
|
572
593
|
model: z.string().default("openai/gpt-5.5"),
|
|
573
594
|
provider: z.enum(askAiProviders).default("gateway"),
|
|
595
|
+
// Empty-state prompts shown before the first question. Each renders as a
|
|
596
|
+
// clickable suggestion; `icon` is an optional Lucide name beside it.
|
|
597
|
+
suggestions: z
|
|
598
|
+
.array(
|
|
599
|
+
z
|
|
600
|
+
.object({
|
|
601
|
+
icon: iconName.optional(),
|
|
602
|
+
label: z.string().min(1),
|
|
603
|
+
})
|
|
604
|
+
.strict()
|
|
605
|
+
)
|
|
606
|
+
.default([]),
|
|
574
607
|
})
|
|
575
608
|
.strict()
|
|
576
609
|
.superRefine((value, ctx) => {
|
|
@@ -590,13 +623,48 @@ const aiConfigSchema = z
|
|
|
590
623
|
})
|
|
591
624
|
.strict();
|
|
592
625
|
|
|
626
|
+
/**
|
|
627
|
+
* A pinned link rendered above the sidebar sections — a blog, changelog, or
|
|
628
|
+
* contact page that should always be reachable, regardless of the active tab.
|
|
629
|
+
* `href` may be an external URL or an internal route.
|
|
630
|
+
*/
|
|
631
|
+
const featuredLinkSchema = z
|
|
632
|
+
.object({
|
|
633
|
+
href: z.string(),
|
|
634
|
+
icon: iconName.optional(),
|
|
635
|
+
label: z.string(),
|
|
636
|
+
})
|
|
637
|
+
.strict();
|
|
638
|
+
|
|
593
639
|
const navigationConfigSchema = z
|
|
594
640
|
.object({
|
|
641
|
+
/** Pinned links shown above the generated sidebar sections. */
|
|
642
|
+
featured: z.array(featuredLinkSchema).default([]),
|
|
595
643
|
/** Show a GitHub repo link in the header (requires `github` configured). */
|
|
596
644
|
repo: z.boolean().default(true),
|
|
597
645
|
selectors: z.array(navSelectorSchema).default([]),
|
|
598
|
-
/**
|
|
599
|
-
|
|
646
|
+
/**
|
|
647
|
+
* Sidebar behavior. `display` sets how every group renders (a group in an
|
|
648
|
+
* explicit `items` config may still override it); `items` is an explicit
|
|
649
|
+
* sidebar — when omitted the sidebar is generated from the content tree.
|
|
650
|
+
* A bare array is shorthand for `{ items }`.
|
|
651
|
+
*/
|
|
652
|
+
sidebar: z
|
|
653
|
+
.union([
|
|
654
|
+
z.array(sidebarItemSchema),
|
|
655
|
+
z
|
|
656
|
+
.object({
|
|
657
|
+
display: sidebarDisplaySchema.default("flat"),
|
|
658
|
+
items: z.array(sidebarItemSchema).optional(),
|
|
659
|
+
})
|
|
660
|
+
.strict(),
|
|
661
|
+
])
|
|
662
|
+
.default({})
|
|
663
|
+
.transform((value) =>
|
|
664
|
+
Array.isArray(value)
|
|
665
|
+
? { display: "flat" as const, items: value }
|
|
666
|
+
: value
|
|
667
|
+
),
|
|
600
668
|
tabs: z.array(navTabSchema).optional(),
|
|
601
669
|
})
|
|
602
670
|
.strict();
|
|
@@ -759,9 +827,52 @@ const rssConfigSchema = z
|
|
|
759
827
|
})
|
|
760
828
|
.strict();
|
|
761
829
|
|
|
830
|
+
/**
|
|
831
|
+
* robots.txt `Content-Signal` preferences — the emerging content-usage
|
|
832
|
+
* declaration for how crawlers may reuse the site. Each field maps to one
|
|
833
|
+
* signal:
|
|
834
|
+
* - `search` → `search` (traditional and AI search indexing)
|
|
835
|
+
* - `aiInput` → `ai-input` (grounding / RAG use at answer time)
|
|
836
|
+
* - `aiTrain` → `ai-train` (model training)
|
|
837
|
+
*/
|
|
838
|
+
const contentSignalsObjectSchema = z
|
|
839
|
+
.object({
|
|
840
|
+
aiInput: z.boolean().default(true),
|
|
841
|
+
aiTrain: z.boolean().default(true),
|
|
842
|
+
search: z.boolean().default(true),
|
|
843
|
+
})
|
|
844
|
+
.strict();
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Content signals accept a boolean shorthand or a per-signal object, and
|
|
848
|
+
* normalize to `{ search, aiInput, aiTrain }` — or `null` when disabled, so
|
|
849
|
+
* robots.txt omits the declaration entirely. On by default (`true`): Blume
|
|
850
|
+
* declares the docs open to search and agents. `false` opts out; an object
|
|
851
|
+
* restricts individual signals (unset signals stay `yes`).
|
|
852
|
+
*/
|
|
853
|
+
const contentSignalsSchema = z
|
|
854
|
+
.union([z.boolean(), contentSignalsObjectSchema])
|
|
855
|
+
.transform((value) => {
|
|
856
|
+
if (value === true) {
|
|
857
|
+
return contentSignalsObjectSchema.parse({});
|
|
858
|
+
}
|
|
859
|
+
if (value === false) {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
return value;
|
|
863
|
+
});
|
|
864
|
+
|
|
762
865
|
/** Discoverability features: OG images, feeds, sitemap, structured data. */
|
|
763
866
|
const seoConfigSchema = z
|
|
764
867
|
.object({
|
|
868
|
+
/**
|
|
869
|
+
* Emit `agent-readability.json` at the site root: a manifest that indexes
|
|
870
|
+
* the agent-facing surface (llms.txt, Markdown mirrors, MCP server, feeds)
|
|
871
|
+
* so agents can discover it without scraping HTML.
|
|
872
|
+
*/
|
|
873
|
+
agentReadability: z.boolean().default(true),
|
|
874
|
+
/** robots.txt `Content-Signal` usage declaration (on by default). */
|
|
875
|
+
contentSignals: contentSignalsSchema.default(true),
|
|
765
876
|
og: ogConfigSchema.default({}),
|
|
766
877
|
/** Generate robots.txt (with a Sitemap reference when available). */
|
|
767
878
|
robots: z.boolean().default(true),
|
|
@@ -814,12 +925,6 @@ const codeConfigSchema = z
|
|
|
814
925
|
* …). On by default; recognized languages only.
|
|
815
926
|
*/
|
|
816
927
|
icons: z.boolean().default(true),
|
|
817
|
-
/**
|
|
818
|
-
* Syntax-highlight inline `` `code{:lang}` `` snippets. Off by default — most
|
|
819
|
-
* inline code (flags, file names) reads better plain; opt a snippet in with
|
|
820
|
-
* a trailing `{:lang}` marker.
|
|
821
|
-
*/
|
|
822
|
-
inline: z.boolean().default(false),
|
|
823
928
|
/**
|
|
824
929
|
* Wrap long lines instead of scrolling horizontally. Off by default, so
|
|
825
930
|
* code keeps its original line breaks and overflows into a scroll area.
|
|
@@ -844,11 +949,6 @@ const markdownConfigSchema = z
|
|
|
844
949
|
* opt a single image out with `data-no-zoom`.
|
|
845
950
|
*/
|
|
846
951
|
imageZoom: z.boolean().default(true),
|
|
847
|
-
/**
|
|
848
|
-
* Enable LaTeX math (`$…$` inline, `$$…$$` block) rendered with KaTeX.
|
|
849
|
-
* Off by default since `$` is common in prose, shell, and code. MDX only.
|
|
850
|
-
*/
|
|
851
|
-
math: z.boolean().default(false),
|
|
852
952
|
})
|
|
853
953
|
.strict();
|
|
854
954
|
|
|
@@ -986,7 +1086,15 @@ export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
|
|
|
986
1086
|
export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
|
|
987
1087
|
/** A configured locale with display metadata. */
|
|
988
1088
|
export type LocaleConfig = z.infer<typeof localeSchema>;
|
|
989
|
-
/**
|
|
990
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* User-authored config, straight off the schema. The public, hand-documented
|
|
1091
|
+
* authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
|
|
1092
|
+
* guard keeps structurally identical to this.
|
|
1093
|
+
*/
|
|
1094
|
+
export type BlumeConfigInput = z.input<typeof blumeConfigSchema>;
|
|
991
1095
|
/** A configured search backend. */
|
|
992
1096
|
export type SearchProvider = (typeof searchProviders)[number];
|
|
1097
|
+
/** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
|
|
1098
|
+
export type ContentSignals = z.infer<typeof contentSignalsSchema>;
|
|
1099
|
+
/** The resolved per-signal policy object (present when signals are enabled). */
|
|
1100
|
+
export type ContentSignalPolicy = NonNullable<ContentSignals>;
|
|
@@ -8,6 +8,7 @@ import { BlumeError } from "../diagnostics.ts";
|
|
|
8
8
|
import matter from "../frontmatter.ts";
|
|
9
9
|
import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
|
|
10
10
|
import {
|
|
11
|
+
baselineScanIgnore,
|
|
11
12
|
BLUME_WATCH_IGNORE_DIRS,
|
|
12
13
|
excludeDirSegments,
|
|
13
14
|
ignoringWatchListener,
|
|
@@ -45,7 +46,10 @@ export const filesystemSource = (
|
|
|
45
46
|
const files = await glob(options.include, {
|
|
46
47
|
absolute: true,
|
|
47
48
|
cwd: contentRoot,
|
|
48
|
-
|
|
49
|
+
// Union the user's `exclude` with the baseline never-content dirs so a
|
|
50
|
+
// broadly-scoped root (`.` or an app dir) can't glob `node_modules`,
|
|
51
|
+
// `dist`, `.blume`, etc. — even when the user overrode `exclude`.
|
|
52
|
+
ignore: [...options.exclude, ...baselineScanIgnore()],
|
|
49
53
|
onlyFiles: true,
|
|
50
54
|
});
|
|
51
55
|
files.sort();
|