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.
Files changed (41) hide show
  1. package/dist/cli/index.js +380 -157
  2. package/dist/cli/index.js.map +23 -22
  3. package/dist/types/core/data.d.ts +4 -0
  4. package/dist/types/core/i18n-ui.d.ts +50 -0
  5. package/dist/types/core/schema.d.ts +328 -39
  6. package/dist/types/core/types.d.ts +8 -0
  7. package/docs/configuration/ai.mdx +56 -0
  8. package/docs/configuration/seo.mdx +59 -1
  9. package/docs/configuration/theming.mdx +14 -9
  10. package/docs/content/meta.mdx +3 -17
  11. package/docs/content/navigation.mdx +41 -4
  12. package/package.json +3 -1
  13. package/src/ai/agent-readability.ts +97 -0
  14. package/src/ai/ask-context.ts +131 -8
  15. package/src/ai/ask-data.ts +4 -1
  16. package/src/astro/generate.ts +4 -0
  17. package/src/astro/templates.ts +24 -5
  18. package/src/cli/commands/build.ts +15 -0
  19. package/src/cli/commands/dev.ts +31 -14
  20. package/src/cli/dev-lock.ts +94 -21
  21. package/src/components/content/GithubInfo.astro +11 -10
  22. package/src/components/content/TypeTable.astro +8 -3
  23. package/src/components/islands/AskAI.astro +66 -2
  24. package/src/components/islands/ask-ai.tsx +289 -53
  25. package/src/components/layout/Header.astro +1 -1
  26. package/src/components/layout/NavTree.astro +1 -1
  27. package/src/components/layout/PageActions.astro +73 -30
  28. package/src/components/layout/RootLayout.astro +48 -2
  29. package/src/core/data.ts +4 -0
  30. package/src/core/graph.ts +7 -2
  31. package/src/core/i18n-ui.ts +5 -0
  32. package/src/core/nav-diagnostics.ts +7 -0
  33. package/src/core/navigation.ts +38 -12
  34. package/src/core/schema.ts +124 -9
  35. package/src/core/sources/filesystem.ts +5 -1
  36. package/src/core/sources/watch.ts +43 -12
  37. package/src/core/types.ts +9 -0
  38. package/src/deploy/robots.ts +37 -4
  39. package/src/openapi/scalar.ts +1 -1
  40. package/src/search/documents.ts +9 -2
  41. 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 class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}>
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
  }
@@ -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),
@@ -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
- 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();
@@ -1,20 +1,51 @@
1
1
  import type { WatchListener } from "node:fs";
2
2
 
3
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
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 BLUME_WATCH_IGNORE_DIRS = [".blume", ".git", "node_modules"];
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
  }
@@ -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
- * Build a robots.txt that allows all crawlers and points to the sitemap when
5
- * one is available (a `site` is set and the sitemap is enabled). Returns null
6
- * when robots generation is disabled.
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: *", "Allow: /"];
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`);
@@ -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
  };