blume 0.5.4 → 0.6.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 +380 -157
- package/dist/cli/index.js.map +23 -22
- 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 +328 -39
- package/dist/types/core/types.d.ts +8 -0
- package/docs/configuration/ai.mdx +56 -0
- 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/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 +4 -0
- package/src/astro/templates.ts +24 -5
- package/src/cli/commands/build.ts +15 -0
- 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/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 +48 -2
- 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 +124 -9
- 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/robots.ts +37 -4
- package/src/openapi/scalar.ts +1 -1
- package/src/search/documents.ts +9 -2
- package/src/theme/palette.ts +21 -14
|
@@ -18,6 +18,7 @@ import Favicon from "./Favicon.astro";
|
|
|
18
18
|
import Fonts from "./Fonts.astro";
|
|
19
19
|
import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
|
|
20
20
|
import Header from "./Header.astro";
|
|
21
|
+
import Icon from "../Icon.astro";
|
|
21
22
|
import {
|
|
22
23
|
findBreadcrumbs,
|
|
23
24
|
flattenPages,
|
|
@@ -342,11 +343,56 @@ const bannerScript = banner?.dismissible
|
|
|
342
343
|
>
|
|
343
344
|
<slot name="ask" slot="ask" />
|
|
344
345
|
</HeaderSlot>
|
|
345
|
-
<div
|
|
346
|
+
<div
|
|
347
|
+
class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}
|
|
348
|
+
data-blume-doc-grid
|
|
349
|
+
>
|
|
346
350
|
<aside
|
|
347
351
|
aria-label="Primary"
|
|
348
352
|
class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4"
|
|
349
353
|
>
|
|
354
|
+
{
|
|
355
|
+
// Pinned links (blog, changelog, contact…) sit above every section on
|
|
356
|
+
// all breakpoints, outside the tab-scoped sidebar so they never change
|
|
357
|
+
// with the active tab. External hrefs open in a new tab.
|
|
358
|
+
navigation.featured.length > 0 && (
|
|
359
|
+
<nav aria-label="Featured" class="mb-4 border-border border-b pb-4">
|
|
360
|
+
<ul class="m-0 list-none p-0">
|
|
361
|
+
{navigation.featured.map((link) => {
|
|
362
|
+
const external = /^https?:\/\//u.test(link.href);
|
|
363
|
+
return (
|
|
364
|
+
<li>
|
|
365
|
+
<a
|
|
366
|
+
class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
|
367
|
+
href={link.href}
|
|
368
|
+
rel={external ? "noreferrer" : undefined}
|
|
369
|
+
target={external ? "_blank" : undefined}
|
|
370
|
+
>
|
|
371
|
+
<span class="flex items-center gap-2">
|
|
372
|
+
{link.icon && (
|
|
373
|
+
<Icon
|
|
374
|
+
class="shrink-0 text-muted-foreground"
|
|
375
|
+
name={link.icon}
|
|
376
|
+
size={14}
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
<span class="flex-1 truncate">{link.label}</span>
|
|
380
|
+
{external && (
|
|
381
|
+
<Icon
|
|
382
|
+
class="shrink-0 text-muted-foreground opacity-60"
|
|
383
|
+
name="arrow-up-right"
|
|
384
|
+
size={12}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
</span>
|
|
388
|
+
</a>
|
|
389
|
+
</li>
|
|
390
|
+
);
|
|
391
|
+
})}
|
|
392
|
+
</ul>
|
|
393
|
+
</nav>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
350
396
|
{
|
|
351
397
|
// The header's tab bar is hidden below `md`, so the drawer is the only
|
|
352
398
|
// way to move between sections on mobile: list the tabs above the
|
|
@@ -427,6 +473,7 @@ const bannerScript = banner?.dismissible
|
|
|
427
473
|
<aside
|
|
428
474
|
aria-label={strings.toc.title}
|
|
429
475
|
class="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
|
|
476
|
+
data-blume-toc
|
|
430
477
|
>
|
|
431
478
|
<TableOfContentsSlot
|
|
432
479
|
headings={tocHeadings}
|
|
@@ -434,7 +481,6 @@ const bannerScript = banner?.dismissible
|
|
|
434
481
|
variant="desktop"
|
|
435
482
|
/>
|
|
436
483
|
<PageActions
|
|
437
|
-
askEnabled={askEnabled}
|
|
438
484
|
editUrl={editUrl}
|
|
439
485
|
exportEpub={exportEpub}
|
|
440
486
|
exportPdf={exportPdf}
|
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),
|
|
@@ -990,3 +1101,7 @@ export type LocaleConfig = z.infer<typeof localeSchema>;
|
|
|
990
1101
|
export type BlumeConfig = z.input<typeof blumeConfigSchema>;
|
|
991
1102
|
/** A configured search backend. */
|
|
992
1103
|
export type SearchProvider = (typeof searchProviders)[number];
|
|
1104
|
+
/** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
|
|
1105
|
+
export type ContentSignals = z.infer<typeof contentSignalsSchema>;
|
|
1106
|
+
/** The resolved per-signal policy object (present when signals are enabled). */
|
|
1107
|
+
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();
|
|
@@ -1,20 +1,51 @@
|
|
|
1
1
|
import type { WatchListener } from "node:fs";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Directory segments
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
4
|
+
* Directory segments that are never authored content: VCS, dependency trees,
|
|
5
|
+
* Blume's own generated project and build output, and framework/deploy caches.
|
|
6
|
+
* Both the content scan and the dev watcher skip these unconditionally, on top
|
|
7
|
+
* of the user's `content.exclude`.
|
|
8
|
+
*
|
|
9
|
+
* The scan needs them because a broadly-scoped `content.root` — `"."` or an app
|
|
10
|
+
* dir that also holds `node_modules`/`dist`, the common shape when migrating a
|
|
11
|
+
* docs app that lives at the repo or app root — would otherwise glob thousands
|
|
12
|
+
* of stray markdown files out of dependencies and build artifacts. `content.root`
|
|
13
|
+
* defaults to `docs/` where this rarely bites, but any wider root hits it.
|
|
14
|
+
*
|
|
15
|
+
* The watcher needs them because a recursive `fs.watch` rooted at the project
|
|
16
|
+
* also sees Blume's own `.blume/` output, which the dev server rewrites on every
|
|
17
|
+
* render (e.g. `.blume/.astro/data-store.json`). Left unfiltered, each such write
|
|
18
|
+
* re-triggers a rescan + runtime regeneration whose writes land back under
|
|
19
|
+
* `.blume/` and fire the watcher again: a self-sustaining loop that stalls page
|
|
20
|
+
* renders and floods the console (and, mid-render, corrupts Astro's dev module
|
|
21
|
+
* graph so `astro:server-app.js` fails to load). `fs.watch` has no ignore
|
|
15
22
|
* option, so we filter by the changed path in the callback.
|
|
16
23
|
*/
|
|
17
|
-
export const
|
|
24
|
+
export const BLUME_IGNORE_DIRS = [
|
|
25
|
+
".blume",
|
|
26
|
+
".cache",
|
|
27
|
+
".git",
|
|
28
|
+
".next",
|
|
29
|
+
".turbo",
|
|
30
|
+
".vercel",
|
|
31
|
+
"dist",
|
|
32
|
+
"node_modules",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Baseline scan-ignore globs applied to every filesystem source, unioned with
|
|
37
|
+
* the user's `content.exclude`. Kept in sync with the watcher via the shared
|
|
38
|
+
* {@link BLUME_IGNORE_DIRS} so `load()` and `watch()` never disagree on what is
|
|
39
|
+
* content. `**\/<dir>/**` matches the directory at the content root or nested.
|
|
40
|
+
*/
|
|
41
|
+
export const baselineScanIgnore = (): string[] =>
|
|
42
|
+
BLUME_IGNORE_DIRS.map((dir) => `**/${dir}/**`);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Alias retained for the dev watcher's call site and its tests; the watcher and
|
|
46
|
+
* the scan share the same never-content directory set.
|
|
47
|
+
*/
|
|
48
|
+
export const BLUME_WATCH_IGNORE_DIRS = BLUME_IGNORE_DIRS;
|
|
18
49
|
|
|
19
50
|
/** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
|
|
20
51
|
export const excludeDirSegments = (patterns: readonly string[]): string[] =>
|
package/src/core/types.ts
CHANGED
|
@@ -180,11 +180,20 @@ export interface NavSelector {
|
|
|
180
180
|
items: NavSelectorItem[];
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/** A pinned link rendered above the sidebar sections (external or internal). */
|
|
184
|
+
export interface FeaturedLink {
|
|
185
|
+
label: string;
|
|
186
|
+
href: string;
|
|
187
|
+
icon?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
183
190
|
/** The complete navigation model derived from the content graph. */
|
|
184
191
|
export interface Navigation {
|
|
185
192
|
tabs: NavTab[];
|
|
186
193
|
selectors: NavSelector[];
|
|
187
194
|
sidebar: NavNode[];
|
|
195
|
+
/** Pinned links shown above the sidebar sections, unscoped by tab. */
|
|
196
|
+
featured: FeaturedLink[];
|
|
188
197
|
/** Repo URL for the header link, or null when hidden (`navigation.repo`). */
|
|
189
198
|
repoUrl?: string | null;
|
|
190
199
|
}
|
package/src/deploy/robots.ts
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
|
+
import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
5
|
+
* Ordered mapping from config field to its `Content-Signal` token. The order
|
|
6
|
+
* fixes the emitted sequence (`search`, then `ai-input`, then `ai-train`).
|
|
7
|
+
*/
|
|
8
|
+
const SIGNAL_TOKENS: [keyof ContentSignalPolicy, string][] = [
|
|
9
|
+
["search", "search"],
|
|
10
|
+
["aiInput", "ai-input"],
|
|
11
|
+
["aiTrain", "ai-train"],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `Content-Signal:` line declaring how crawlers may reuse the site, or null
|
|
16
|
+
* when the declaration is disabled (`contentSignals: false`). Otherwise every
|
|
17
|
+
* signal is emitted with its resolved yes/no value.
|
|
18
|
+
*/
|
|
19
|
+
const contentSignalLine = (signals: ContentSignals): string | null => {
|
|
20
|
+
if (!signals) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const tokens = SIGNAL_TOKENS.map(
|
|
24
|
+
([key, token]) => `${token}=${signals[key] ? "yes" : "no"}`
|
|
25
|
+
);
|
|
26
|
+
return `Content-Signal: ${tokens.join(", ")}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a robots.txt that allows all crawlers, declares any configured
|
|
31
|
+
* `Content-Signal` usage preferences, and points to the sitemap when one is
|
|
32
|
+
* available (a `site` is set and the sitemap is enabled). Returns null when
|
|
33
|
+
* robots generation is disabled.
|
|
7
34
|
*/
|
|
8
35
|
export const buildRobots = (project: BlumeProject): string | null => {
|
|
9
36
|
const { config } = project;
|
|
@@ -11,7 +38,13 @@ export const buildRobots = (project: BlumeProject): string | null => {
|
|
|
11
38
|
return null;
|
|
12
39
|
}
|
|
13
40
|
|
|
14
|
-
const lines = ["User-agent: *"
|
|
41
|
+
const lines = ["User-agent: *"];
|
|
42
|
+
const signal = contentSignalLine(config.seo.contentSignals);
|
|
43
|
+
if (signal) {
|
|
44
|
+
lines.push(signal);
|
|
45
|
+
}
|
|
46
|
+
lines.push("Allow: /");
|
|
47
|
+
|
|
15
48
|
const { site } = config.deployment;
|
|
16
49
|
if (site && config.seo.sitemap) {
|
|
17
50
|
lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}/sitemap.xml`);
|
package/src/openapi/scalar.ts
CHANGED
|
@@ -61,7 +61,7 @@ const themeConfiguration = (
|
|
|
61
61
|
const accent = resolveAccent(config.theme);
|
|
62
62
|
const radius = resolveRadius(config.theme);
|
|
63
63
|
return {
|
|
64
|
-
customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent};--scalar-radius:${radius};}`,
|
|
64
|
+
customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent.light};--scalar-radius:${radius};}.dark-mode{--scalar-color-accent:${accent.dark};}`,
|
|
65
65
|
...darkModeConfig(config.theme.mode),
|
|
66
66
|
};
|
|
67
67
|
};
|