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.
Files changed (57) hide show
  1. package/dist/cli/index.js +759 -406
  2. package/dist/cli/index.js.map +27 -25
  3. package/dist/types/core/config-input.d.ts +759 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/data.d.ts +4 -0
  6. package/dist/types/core/i18n-ui.d.ts +50 -0
  7. package/dist/types/core/schema.d.ts +334 -62
  8. package/dist/types/core/types.d.ts +8 -0
  9. package/dist/types/index.d.ts +2 -1
  10. package/docs/advanced/changelog.mdx +10 -2
  11. package/docs/configuration/ai.mdx +56 -0
  12. package/docs/configuration/index.mdx +0 -2
  13. package/docs/configuration/seo.mdx +59 -1
  14. package/docs/configuration/theming.mdx +14 -9
  15. package/docs/content/meta.mdx +3 -17
  16. package/docs/content/navigation.mdx +41 -4
  17. package/docs/content/syntax.mdx +4 -8
  18. package/package.json +3 -1
  19. package/src/ai/agent-readability.ts +97 -0
  20. package/src/ai/ask-context.ts +131 -8
  21. package/src/ai/ask-data.ts +4 -1
  22. package/src/astro/generate.ts +40 -11
  23. package/src/astro/templates.ts +90 -10
  24. package/src/cli/commands/build.ts +41 -1
  25. package/src/cli/commands/dev.ts +31 -14
  26. package/src/cli/dev-lock.ts +94 -21
  27. package/src/components/content/GithubInfo.astro +11 -10
  28. package/src/components/content/TypeTable.astro +8 -3
  29. package/src/components/content/Update.astro +12 -2
  30. package/src/components/content/changelog-element.ts +62 -0
  31. package/src/components/islands/AskAI.astro +66 -2
  32. package/src/components/islands/ask-ai.tsx +289 -53
  33. package/src/components/layout/Header.astro +1 -1
  34. package/src/components/layout/NavTree.astro +1 -1
  35. package/src/components/layout/PageActions.astro +73 -30
  36. package/src/components/layout/RootLayout.astro +79 -10
  37. package/src/core/config-input.ts +933 -0
  38. package/src/core/config.ts +126 -3
  39. package/src/core/data.ts +4 -0
  40. package/src/core/graph.ts +7 -2
  41. package/src/core/i18n-ui.ts +5 -0
  42. package/src/core/nav-diagnostics.ts +7 -0
  43. package/src/core/navigation.ts +38 -12
  44. package/src/core/schema.ts +130 -22
  45. package/src/core/sources/filesystem.ts +5 -1
  46. package/src/core/sources/watch.ts +43 -12
  47. package/src/core/types.ts +9 -0
  48. package/src/deploy/adapter-output.ts +82 -0
  49. package/src/deploy/robots.ts +37 -4
  50. package/src/index.ts +1 -1
  51. package/src/markdown/index.ts +28 -30
  52. package/src/markdown/math.ts +3 -2
  53. package/src/openapi/scalar.ts +1 -1
  54. package/src/registry/eject.ts +21 -14
  55. package/src/search/documents.ts +9 -2
  56. package/src/theme/entry.ts +7 -3
  57. package/src/theme/palette.ts +21 -14
@@ -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 { BlumeConfig, ResolvedConfig } from "./schema.ts";
9
+ import type { ResolvedConfig } from "./schema.ts";
9
10
  import type { Diagnostic } from "./types.ts";
10
11
 
11
12
  /**
12
- * Identity helper for authoring `blume.config.ts`. Exists for type inference
13
- * and a stable future home for plugin hooks; it does not transform input.
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
  }
@@ -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>();
@@ -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
- const toNavNode = (node: MutableNode): NavNode => {
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: node.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
- return root.children.map(toNavNode);
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
  };
@@ -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.string().default("blue"),
446
- accentDark: z.string().optional(),
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: z.string().optional(),
449
- backgroundDark: z.string().optional(),
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
- /** Explicit sidebar override; when omitted the sidebar is generated. */
599
- sidebar: z.array(sidebarItemSchema).optional(),
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
- /** User-authored config: the shape accepted by `defineConfig`. */
990
- export type BlumeConfig = z.input<typeof blumeConfigSchema>;
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
- ignore: options.exclude,
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();