blume 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/index.js +496 -295
  3. package/dist/cli/index.js.map +18 -17
  4. package/dist/types/core/config-input.d.ts +44 -22
  5. package/dist/types/core/config.d.ts +3 -3
  6. package/dist/types/core/data.d.ts +12 -0
  7. package/dist/types/core/i18n-ui.d.ts +136 -136
  8. package/dist/types/core/schema.d.ts +502 -384
  9. package/dist/types/core/types.d.ts +10 -0
  10. package/dist/types/openapi/references.d.ts +12 -7
  11. package/docs/advanced/api-reference.mdx +11 -3
  12. package/docs/advanced/custom-pages.mdx +2 -0
  13. package/docs/configuration/ai.mdx +6 -4
  14. package/docs/configuration/index.mdx +6 -8
  15. package/docs/configuration/seo.mdx +20 -1
  16. package/docs/content/components.mdx +26 -5
  17. package/docs/content/navigation.mdx +10 -0
  18. package/docs/content/syntax.mdx +116 -4
  19. package/package.json +1 -1
  20. package/skills/blume-migrate/SKILL.md +170 -0
  21. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  22. package/skills/blume-migrate/references/docusaurus.md +95 -0
  23. package/skills/blume-migrate/references/fumadocs.md +95 -0
  24. package/skills/blume-migrate/references/mintlify.md +155 -0
  25. package/skills/blume-migrate/references/monorepo.md +224 -0
  26. package/skills/blume-migrate/references/nextra.md +76 -0
  27. package/skills/blume-migrate/references/starlight.md +116 -0
  28. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  29. package/src/ai/agent-readability.ts +3 -3
  30. package/src/ai/mcp/data.ts +2 -2
  31. package/src/astro/component-slots.ts +3 -2
  32. package/src/astro/generate.ts +97 -30
  33. package/src/astro/templates.ts +140 -53
  34. package/src/blume-modules.d.ts +6 -0
  35. package/src/components/content/Callout.astro +8 -2
  36. package/src/components/content/Prompt.astro +25 -13
  37. package/src/components/layout/Header.astro +19 -10
  38. package/src/components/layout/Logo.astro +13 -1
  39. package/src/components/layout/PageFeedback.astro +1 -1
  40. package/src/components/layout/PageLayout.astro +11 -11
  41. package/src/components/layout/Pagination.astro +6 -6
  42. package/src/components/layout/ReferenceLayout.astro +1 -0
  43. package/src/components/layout/RootLayout.astro +10 -11
  44. package/src/components/layout/Search.astro +1 -1
  45. package/src/components/layout/nav-utils.ts +9 -7
  46. package/src/core/config-input.ts +47 -27
  47. package/src/core/config.ts +3 -3
  48. package/src/core/data.ts +9 -1
  49. package/src/core/navigation.ts +55 -13
  50. package/src/core/schema.ts +32 -15
  51. package/src/core/server-features.ts +1 -1
  52. package/src/core/sources/watch.ts +5 -0
  53. package/src/core/types.ts +10 -0
  54. package/src/deploy/adapter-output.ts +11 -1
  55. package/src/markdown/index.ts +2 -0
  56. package/src/markdown/language-icon.ts +2 -1
  57. package/src/markdown/table-wrap.ts +43 -0
  58. package/src/og/card.ts +39 -12
  59. package/src/og/index.ts +1 -1
  60. package/src/og/logo.ts +21 -0
  61. package/src/openapi/references.ts +19 -16
  62. package/src/registry/eject.ts +11 -5
  63. package/src/theme/entry.ts +50 -5
@@ -39,7 +39,7 @@ import Favicon from "./Favicon.astro";
39
39
  import Fonts from "./Fonts.astro";
40
40
  import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
41
41
  import Header from "./Header.astro";
42
- import { isUnderPath } from "./nav-utils.ts";
42
+ import { activeTabForRoute } from "./nav-utils.ts";
43
43
 
44
44
  interface Props {
45
45
  site: { title: string; description?: string };
@@ -58,6 +58,11 @@ interface Props {
58
58
  themeMode: "system" | "light" | "dark";
59
59
  fontCssVars?: string[];
60
60
  searchEnabled: boolean;
61
+ /**
62
+ * Opt this page out of the header's Ask AI trigger. Defaults to whether Ask
63
+ * AI is configured, so a custom page gets the same trigger the docs pages have
64
+ * without wiring anything up.
65
+ */
61
66
  askEnabled?: boolean;
62
67
  /**
63
68
  * Absolute site URL (`data.config.site`). When set, `canonical` and the
@@ -132,6 +137,7 @@ const searchLocale =
132
137
  const pageTitle = page?.title ?? site.title;
133
138
  const description = page?.description ?? site.description;
134
139
  const route = page?.route ?? "/";
140
+ const activeTab = activeTabForRoute(navigation.tabs, route);
135
141
 
136
142
  // Derive canonical + og:image from the site URL the same way the catch-all does
137
143
  // for content pages, so a custom page gets both for free. The matching OG card
@@ -229,6 +235,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
229
235
  <Banner banner={banner} strings={strings.banner} />
230
236
  <Header
231
237
  askEnabled={askEnabled}
238
+ askStrings={strings.ask}
232
239
  hasSidebar={false}
233
240
  localeSwitch={localeSwitch}
234
241
  logo={logo}
@@ -240,9 +247,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
240
247
  searchStrings={strings.search}
241
248
  site={site}
242
249
  switcherStrings={strings.languageSwitcher}
243
- >
244
- <slot name="ask" slot="ask" />
245
- </Header>
250
+ />
246
251
  <main id="blume-content"><slot /></main>
247
252
  <slot name="footer" />
248
253
  {
@@ -263,14 +268,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
263
268
  {navigation.tabs.map((tab) => (
264
269
  <li>
265
270
  <a
266
- aria-current={
267
- route === tab.path ||
268
- (tab.path !== "/" && isUnderPath(route, tab.path))
269
- ? "page"
270
- : undefined
271
- }
271
+ aria-current={tab === activeTab ? "page" : undefined}
272
272
  class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
273
- href={withBase(tab.path)}
273
+ href={withBase(tab.href ?? tab.path)}
274
274
  >
275
275
  {tab.label}
276
276
  </a>
@@ -30,15 +30,15 @@ const s = { ...EN_UI.page, ...strings };
30
30
  >
31
31
  {prev ? (
32
32
  <a
33
- class="flex max-w-[48%] flex-1 items-center gap-2 rounded-full border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
33
+ class="flex max-w-[48%] flex-1 items-center gap-2 rounded-blume border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
34
34
  href={withBase(prev.route)}
35
35
  >
36
36
  <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
37
- <span>
37
+ <span class="min-w-0">
38
38
  <span class="block text-muted-foreground text-xs max-md:hidden">
39
39
  {s.previous}
40
40
  </span>
41
- <span class="block font-medium">{prev.label}</span>
41
+ <span class="block truncate font-medium">{prev.label}</span>
42
42
  </span>
43
43
  </a>
44
44
  ) : (
@@ -46,14 +46,14 @@ const s = { ...EN_UI.page, ...strings };
46
46
  )}
47
47
  {next && (
48
48
  <a
49
- class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-full border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
49
+ class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-blume border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
50
50
  href={withBase(next.route)}
51
51
  >
52
- <span>
52
+ <span class="min-w-0">
53
53
  <span class="block text-muted-foreground text-xs max-md:hidden">
54
54
  {s.next}
55
55
  </span>
56
- <span class="block font-medium">{next.label}</span>
56
+ <span class="block truncate font-medium">{next.label}</span>
57
57
  </span>
58
58
  <Icon class="rtl:-scale-x-100" name="arrow-right" size={16} />
59
59
  </a>
@@ -107,6 +107,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
107
107
  >
108
108
  <Banner banner={banner} strings={strings.banner} />
109
109
  <Header
110
+ askStrings={strings.ask}
110
111
  hasDrawer={false}
111
112
  hasSidebar={false}
112
113
  logo={logo}
@@ -27,10 +27,10 @@ import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
27
27
  import Header from "./Header.astro";
28
28
  import Icon from "../Icon.astro";
29
29
  import {
30
+ activeTabForRoute,
30
31
  findBreadcrumbs,
31
32
  flattenPages,
32
33
  getPagination,
33
- isUnderPath,
34
34
  sidebarForRoute,
35
35
  } from "./nav-utils.ts";
36
36
  import NavTree from "./NavTree.astro";
@@ -100,6 +100,10 @@ interface Props {
100
100
  x?: { creator?: string; handle?: string };
101
101
  canonical?: string | null;
102
102
  editUrl?: string | null;
103
+ /**
104
+ * Opt this page out of the header's Ask AI trigger. Defaults to whether Ask
105
+ * AI is configured, so pages never wire the trigger up themselves.
106
+ */
103
107
  askEnabled?: boolean;
104
108
  /** Show the "Was this page helpful?" rating below the content. */
105
109
  feedback?: boolean;
@@ -303,6 +307,7 @@ const mcpUrl =
303
307
  // active tab's section, so a multi-section site drills each tab into its own
304
308
  // pages. Without tabs — or on a route under none — this is the full sidebar.
305
309
  const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
310
+ const activeTab = activeTabForRoute(navigation.tabs, page.route);
306
311
  const crumbs = findBreadcrumbs(sidebar, page.route);
307
312
  const { prev, next } = getPagination(flattenPages(sidebar), page.route);
308
313
 
@@ -430,6 +435,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
430
435
  <Banner banner={banner} strings={strings.banner} />
431
436
  <HeaderSlot
432
437
  askEnabled={askEnabled}
438
+ askStrings={strings.ask}
433
439
  layout={layout}
434
440
  localeSwitch={localeSwitch}
435
441
  logo={logo}
@@ -441,9 +447,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
441
447
  searchStrings={strings.search}
442
448
  site={site}
443
449
  switcherStrings={strings.languageSwitcher}
444
- >
445
- <slot name="ask" slot="ask" />
446
- </HeaderSlot>
450
+ />
447
451
  <div
448
452
  class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}
449
453
  data-blume-doc-grid
@@ -523,14 +527,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
523
527
  {navigation.tabs.map((tab) => (
524
528
  <li>
525
529
  <a
526
- aria-current={
527
- page.route === tab.path ||
528
- (tab.path !== "/" && isUnderPath(page.route, tab.path))
529
- ? "page"
530
- : undefined
531
- }
530
+ aria-current={tab === activeTab ? "page" : undefined}
532
531
  class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
533
- href={withBase(tab.path)}
532
+ href={withBase(tab.href ?? tab.path)}
534
533
  >
535
534
  {tab.label}
536
535
  </a>
@@ -58,7 +58,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
58
58
 
59
59
  <dialog
60
60
  aria-label={s.label}
61
- class="m-auto h-[min(480px,90vh)] w-[min(62.5rem,94vw)] flex-col overflow-hidden rounded-blume border border-border bg-background/80 p-0 text-foreground shadow-2xl outline-none backdrop-blur-xl backdrop:bg-black/30 open:flex"
61
+ class="mx-auto mt-[8vh] mb-auto h-[min(480px,90dvh)] w-[min(62.5rem,94vw)] flex-col overflow-hidden rounded-blume border border-border bg-background/80 p-0 text-foreground shadow-2xl outline-none backdrop-blur-xl backdrop:bg-black/30 open:flex sm:mt-auto"
62
62
  data-blume-search-dialog
63
63
  >
64
64
  <div
@@ -80,14 +80,16 @@ export const isUnderPath = (route: string, base: string): boolean =>
80
80
  base === "/" || route === base || route.startsWith(`${base}/`);
81
81
 
82
82
  /**
83
- * The tab whose `path` is the longest prefix of `route`, mirroring the header's
84
- * active-tab highlight. The root tab (`/`) is skipped it spans everything and
85
- * so never scopes the sidebar.
83
+ * The tab whose `path` is the longest prefix of `route`. The root tab (`/`)
84
+ * acts as the fallback when no more specific tab matches.
86
85
  */
87
- const activeTab = (tabs: NavTab[], route: string): NavTab | null => {
86
+ export const activeTabForRoute = (
87
+ tabs: NavTab[],
88
+ route: string
89
+ ): NavTab | null => {
88
90
  let match: NavTab | null = null;
89
91
  for (const tab of tabs) {
90
- if (tab.path === "/" || !isUnderPath(route, tab.path)) {
92
+ if (!isUnderPath(route, tab.path)) {
91
93
  continue;
92
94
  }
93
95
  if (!match || tab.path.length > match.path.length) {
@@ -187,8 +189,8 @@ export const sidebarForRoute = (
187
189
  tabs: NavTab[],
188
190
  route: string
189
191
  ): NavNode[] => {
190
- const tab = activeTab(tabs, route);
191
- if (tab) {
192
+ const tab = activeTabForRoute(tabs, route);
193
+ if (tab && tab.path !== "/") {
192
194
  return sectionChildren(sidebar, tab.path) ?? [];
193
195
  }
194
196
  const scoped = withoutTabSections(sidebar, tabs);
@@ -525,7 +525,22 @@ export interface LlmsTxtConfig {
525
525
  openapi?: boolean;
526
526
  }
527
527
 
528
- /** AI-facing features: the Ask AI assistant and an `llms.txt` manifest. */
528
+ /** Expose the docs as an MCP server for connecting agents. */
529
+ export interface McpConfig {
530
+ /** Turn the MCP server on. Defaults to `false`. */
531
+ enabled?: boolean;
532
+ /** Optional system hint passed to connecting agents. */
533
+ instructions?: string;
534
+ /** Server name shown to clients; defaults to the site title. */
535
+ name?: string;
536
+ /** Route the server mounts at. Defaults to `/mcp`. */
537
+ route?: string;
538
+ }
539
+
540
+ /**
541
+ * AI-facing features: the Ask AI assistant, an `llms.txt` manifest, and the
542
+ * hosted MCP server.
543
+ */
529
544
  export interface AiConfig {
530
545
  /** The Ask AI chat assistant. */
531
546
  ask?: AskConfig;
@@ -553,6 +568,8 @@ export interface AiConfig {
553
568
  * ```
554
569
  */
555
570
  markdownComponents?: Record<string, ComponentMarkdown>;
571
+ /** Expose the docs as an MCP server for agents. */
572
+ mcp?: McpConfig;
556
573
  }
557
574
 
558
575
  // ---------------------------------------------------------------------------
@@ -586,22 +603,6 @@ export interface AnalyticsConfig {
586
603
  vercel?: boolean;
587
604
  }
588
605
 
589
- // ---------------------------------------------------------------------------
590
- // MCP
591
- // ---------------------------------------------------------------------------
592
-
593
- /** Expose the docs as an MCP server for connecting agents. */
594
- export interface McpConfig {
595
- /** Turn the MCP server on. Defaults to `false`. */
596
- enabled?: boolean;
597
- /** Optional system hint passed to connecting agents. */
598
- instructions?: string;
599
- /** Server name shown to clients; defaults to the site title. */
600
- name?: string;
601
- /** Route the server mounts at. Defaults to `/mcp`. */
602
- route?: string;
603
- }
604
-
605
606
  // ---------------------------------------------------------------------------
606
607
  // i18n
607
608
  // ---------------------------------------------------------------------------
@@ -710,6 +711,34 @@ export interface RssConfig {
710
711
  types?: string[];
711
712
  }
712
713
 
714
+ /** Colors used by generated Open Graph cards. Values must be hex colors. */
715
+ export interface OgPaletteConfig {
716
+ /** Fallback mark color. Defaults to the light theme accent. */
717
+ accent?: string;
718
+ /** Card background. */
719
+ background?: string;
720
+ /** Footer divider. */
721
+ border?: string;
722
+ /** Headline and `currentColor` logo color. */
723
+ foreground?: string;
724
+ /** Description and footer text. */
725
+ muted?: string;
726
+ }
727
+
728
+ /** Per-page Open Graph image generation. */
729
+ export interface OgConfig {
730
+ /**
731
+ * Generate an OG image per page. Defaults to on once a deployment `site`
732
+ * URL is known and off otherwise (`og:image` must be absolute). An explicit
733
+ * value always wins.
734
+ */
735
+ enabled?: boolean;
736
+ /** Local SVG used in the generated card instead of the site logo. */
737
+ logo?: string;
738
+ /** Optional generated-card colors. */
739
+ palette?: OgPaletteConfig;
740
+ }
741
+
713
742
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
714
743
  export interface SeoConfig {
715
744
  /**
@@ -720,14 +749,7 @@ export interface SeoConfig {
720
749
  /** robots.txt `Content-Signal` usage declaration. Defaults to `true`. */
721
750
  contentSignals?: ContentSignalsConfig;
722
751
  /** Per-page Open Graph image generation. */
723
- og?: {
724
- /**
725
- * Generate an OG image per page. Defaults to on once a deployment `site`
726
- * URL is known and off otherwise (`og:image` must be absolute). An explicit
727
- * value always wins.
728
- */
729
- enabled?: boolean;
730
- };
752
+ og?: OgConfig;
731
753
  /** Generate robots.txt (with a Sitemap reference when available). Defaults to `true`. */
732
754
  robots?: boolean;
733
755
  /** RSS/Atom feeds. */
@@ -979,8 +1001,6 @@ export interface BlumeConfig {
979
1001
  logo?: LogoConfig;
980
1002
  /** Markdown / MDX rendering behavior. */
981
1003
  markdown?: MarkdownConfig;
982
- /** Expose the docs as an MCP server for agents. */
983
- mcp?: McpConfig;
984
1004
  /** Header, sidebar, tabs, and switchers. */
985
1005
  navigation?: NavigationConfig;
986
1006
  /** Native OpenAPI reference. */
@@ -74,9 +74,9 @@ import type { Diagnostic } from "./types.ts";
74
74
  * `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
75
75
  * credential block.
76
76
  * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model), `llmsTxt`
77
- * (emit `llms.txt`), and `markdownComponents` (Markdown serializers for
78
- * custom components in agent-facing output).
79
- * - `mcp` — expose the docs as an MCP server for connecting agents.
77
+ * (emit `llms.txt`), `mcp` (expose the docs as an MCP server for connecting
78
+ * agents), and `markdownComponents` (Markdown serializers for custom
79
+ * components in agent-facing output).
80
80
  *
81
81
  * **SEO, feeds & analytics**
82
82
  * - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
package/src/core/data.ts CHANGED
@@ -15,6 +15,10 @@ export interface BlumeLogo {
15
15
  svg?: string;
16
16
  light?: string;
17
17
  dark?: string;
18
+ dimensions?: {
19
+ dark?: { height: number; width: number };
20
+ light?: { height: number; width: number };
21
+ };
18
22
  alt: string;
19
23
  href: string;
20
24
  /** Wordmark text beside the mark; `undefined` falls back to the site title. */
@@ -109,7 +113,11 @@ export interface BlumeDataConfig {
109
113
  /** Hosted MCP server, or `null` when MCP is off. */
110
114
  mcp: { name: string; route: string } | null;
111
115
  /** Open Graph image generation. */
112
- og: { enabled: boolean };
116
+ og: {
117
+ enabled: boolean;
118
+ logo?: string;
119
+ palette?: ResolvedConfig["seo"]["og"]["palette"];
120
+ };
113
121
  /** Repository URL for header/edit links, or `null`. */
114
122
  repoUrl: string | null;
115
123
  search: { enabled: boolean; provider: SearchProvider };
@@ -433,6 +433,43 @@ const buildConfigSidebar = (
433
433
  return nodes;
434
434
  };
435
435
 
436
+ /**
437
+ * Resolve a tab's clickable target. A tab's `path` scopes its sidebar section
438
+ * but need not be a real route — a section with no index page would 404 if the
439
+ * tab linked straight to it. Prefer an exact page/group at the path; otherwise
440
+ * fall back to the first linkable route in the section (sidebar order).
441
+ */
442
+ const resolveTabHref = (sidebar: NavNode[], path: string): string => {
443
+ let first: string | undefined;
444
+ const walk = (nodes: NavNode[]): boolean => {
445
+ for (const node of nodes) {
446
+ const { route } = node;
447
+ if (route === path) {
448
+ return true;
449
+ }
450
+ if (
451
+ first === undefined &&
452
+ route !== undefined &&
453
+ route.startsWith(`${path}/`)
454
+ ) {
455
+ first = route;
456
+ }
457
+ if (node.kind === "group" && walk(node.children)) {
458
+ return true;
459
+ }
460
+ }
461
+ return false;
462
+ };
463
+ return walk(sidebar) ? path : (first ?? path);
464
+ };
465
+
466
+ /** Attach a resolved `href` to each tab whose section has no index page. */
467
+ const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
468
+ tabs.map((tab) => {
469
+ const href = resolveTabHref(sidebar, tab.path);
470
+ return href === tab.path ? tab : { ...tab, href };
471
+ });
472
+
436
473
  /** Build the complete navigation model from pages, meta, and config. */
437
474
  export const buildNavigation = (
438
475
  pages: PageRecord[],
@@ -519,11 +556,17 @@ export const buildNavigation = (
519
556
  }
520
557
 
521
558
  if (options.sidebar) {
559
+ const sidebar = buildConfigSidebar(
560
+ options.sidebar,
561
+ byRoute,
562
+ display,
563
+ basePath
564
+ );
522
565
  return {
523
566
  featured,
524
567
  selectors,
525
- sidebar: buildConfigSidebar(options.sidebar, byRoute, display, basePath),
526
- tabs,
568
+ sidebar,
569
+ tabs: withTabHrefs(tabs, sidebar),
527
570
  };
528
571
  }
529
572
 
@@ -534,19 +577,18 @@ export const buildNavigation = (
534
577
  // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
535
578
  // under a base, falsely scope a group named like the prefix).
536
579
  const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
580
+ const sidebar = buildFileSystemSidebar(
581
+ pages,
582
+ options.folderMeta,
583
+ sharedFolderMeta,
584
+ metaPrefix,
585
+ display,
586
+ new Set(tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path])))
587
+ );
537
588
  return {
538
589
  featured,
539
590
  selectors,
540
- sidebar: buildFileSystemSidebar(
541
- pages,
542
- options.folderMeta,
543
- sharedFolderMeta,
544
- metaPrefix,
545
- display,
546
- new Set(
547
- tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
548
- )
549
- ),
550
- tabs,
591
+ sidebar,
592
+ tabs: withTabHrefs(tabs, sidebar),
551
593
  };
552
594
  };
@@ -550,6 +550,19 @@ export const askAiProviders = [
550
550
  "openai-compatible",
551
551
  ] as const;
552
552
 
553
+ const mcpConfigSchema = z.strictObject({
554
+ enabled: z.boolean().default(false),
555
+ /** Optional system hint passed to connecting agents. */
556
+ instructions: z.string().optional(),
557
+ /** Server name shown to clients; defaults to the site title. */
558
+ name: z.string().optional(),
559
+ /**
560
+ * Normalized like `openapi.route`: a slash-less value would otherwise be
561
+ * string-concatenated onto the site origin (`https://acme.comdocs-mcp`).
562
+ */
563
+ route: z.string().default("/mcp").transform(normalizeRoute),
564
+ });
565
+
553
566
  const aiConfigSchema = z.strictObject({
554
567
  ask: z
555
568
  .strictObject({
@@ -620,6 +633,8 @@ const aiConfigSchema = z.strictObject({
620
633
  })
621
634
  )
622
635
  .default({}),
636
+ /** Expose the docs as an MCP server for connecting agents. */
637
+ mcp: mcpConfigSchema.default({}),
623
638
  });
624
639
 
625
640
  /**
@@ -657,7 +672,7 @@ const navigationConfigSchema = z.strictObject({
657
672
  .transform((value) =>
658
673
  Array.isArray(value) ? { display: "flat" as const, items: value } : value
659
674
  ),
660
- tabs: z.array(navTabSchema).optional(),
675
+ tabs: z.array(navTabSchema).default([]),
661
676
  });
662
677
 
663
678
  export type AskAiProvider = (typeof askAiProviders)[number];
@@ -679,19 +694,6 @@ const exportConfigSchema = z
679
694
  typeof value === "boolean" ? { epub: value, pdf: value } : value
680
695
  );
681
696
 
682
- const mcpConfigSchema = z.strictObject({
683
- enabled: z.boolean().default(false),
684
- /** Optional system hint passed to connecting agents. */
685
- instructions: z.string().optional(),
686
- /** Server name shown to clients; defaults to the site title. */
687
- name: z.string().optional(),
688
- /**
689
- * Normalized like `openapi.route`: a slash-less value would otherwise be
690
- * string-concatenated onto the site origin (`https://acme.comdocs-mcp`).
691
- */
692
- route: z.string().default("/mcp").transform(normalizeRoute),
693
- });
694
-
695
697
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
696
698
  const localeSchema = z.strictObject({
697
699
  code: z.string().min(1),
@@ -800,6 +802,18 @@ const xConfigSchema = z.strictObject({
800
802
  handle: xHandleSchema,
801
803
  });
802
804
 
805
+ const ogColorSchema = z
806
+ .string()
807
+ .regex(/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu);
808
+
809
+ const ogPaletteSchema = z.strictObject({
810
+ accent: ogColorSchema.optional(),
811
+ background: ogColorSchema.optional(),
812
+ border: ogColorSchema.optional(),
813
+ foreground: ogColorSchema.optional(),
814
+ muted: ogColorSchema.optional(),
815
+ });
816
+
803
817
  const ogConfigSchema = z.strictObject({
804
818
  /**
805
819
  * Generate a per-page Open Graph image. Defaults to on once a deployment
@@ -808,6 +822,10 @@ const ogConfigSchema = z.strictObject({
808
822
  * `loadConfig`. An explicit value here always wins.
809
823
  */
810
824
  enabled: z.boolean().optional(),
825
+ /** Local SVG used in the generated card instead of the site logo. */
826
+ logo: z.string().optional(),
827
+ /** Optional generated-card colors. */
828
+ palette: ogPaletteSchema.optional(),
811
829
  });
812
830
 
813
831
  const rssConfigSchema = z.strictObject({
@@ -1088,7 +1106,6 @@ export const blumeConfigSchema = z.strictObject({
1088
1106
  lastModified: lastModifiedConfigSchema.default(false),
1089
1107
  logo: logoConfigSchema.optional(),
1090
1108
  markdown: markdownConfigSchema.default({}),
1091
- mcp: mcpConfigSchema.default({}),
1092
1109
  navigation: navigationConfigSchema.default({}),
1093
1110
  openapi: openapiConfigSchema.default({}),
1094
1111
  react: reactConfigSchema.default({}),
@@ -11,7 +11,7 @@ export const serverFeatures = (config: ResolvedConfig): string[] => {
11
11
  features.push("Ask AI");
12
12
  }
13
13
  // The hosted MCP server is a live JSON-RPC endpoint, so it needs a runtime.
14
- if (config.mcp.enabled) {
14
+ if (config.ai.mcp.enabled) {
15
15
  features.push("MCP server");
16
16
  }
17
17
  // Mixedbread (and any future provider) that proxies queries through a secret
@@ -23,6 +23,11 @@ import type { WatchListener } from "node:fs";
23
23
  */
24
24
  export const BLUME_IGNORE_DIRS = [
25
25
  ".blume",
26
+ // The isolated `blume check --isolated` runtime. A sibling of `.blume`, it is
27
+ // written while a dev server runs; without this the content-layer watcher (or
28
+ // a `.`-rooted fs.watch) would treat its generation as a content change and
29
+ // reload — the very thing `--isolated` promises not to do.
30
+ ".blume-verify",
26
31
  ".cache",
27
32
  ".git",
28
33
  ".next",
package/src/core/types.ts CHANGED
@@ -159,7 +159,17 @@ export type NavNode =
159
159
  /** Top-level tab/section. */
160
160
  export interface NavTab {
161
161
  label: string;
162
+ /**
163
+ * The tab's section prefix, used to scope the sidebar and match the active
164
+ * tab. Not necessarily a linkable route — a section may have no index page.
165
+ */
162
166
  path: string;
167
+ /**
168
+ * The clickable target. Equals `path` when the section has an index page;
169
+ * otherwise it's resolved to the section's first page so the tab never links
170
+ * to a 404. Absent when it matches `path`.
171
+ */
172
+ href?: string;
163
173
  icon?: string;
164
174
  items?: NavSelectorItem[];
165
175
  }
@@ -83,7 +83,17 @@ export const surfaceAdapterOutput = async (
83
83
  }
84
84
  await mkdir(dirname(to), { recursive: true });
85
85
  await rm(to, { force: true, recursive: true });
86
- await cp(from, to, { recursive: true });
86
+ // `verbatimSymlinks` keeps each symlink's target text as written. Without it,
87
+ // `cp` resolves every relative target against the *source*, rewriting it to an
88
+ // absolute path under `.blume` — which this function then deletes. Adapters
89
+ // that trace dependencies into their function bundle link one package to
90
+ // another that way (under an isolated linker — Bun's `isolated` mode, pnpm —
91
+ // that is every external dependency the function imports), so the resolved
92
+ // links leave the deployed function dying on its first external import with
93
+ // ERR_MODULE_NOT_FOUND. Verbatim, the links stay relative and internal to the
94
+ // bundle, surviving both this move and the platform's own (Vercel mounts the
95
+ // bundle at `/var/task`).
96
+ await cp(from, to, { recursive: true, verbatimSymlinks: true });
87
97
  await rm(from, { force: true, recursive: true });
88
98
  // The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
89
99
  // never the moved sub-path — the platform's own state (`.vercel/project.json`,
@@ -17,6 +17,7 @@ import { languageIconTransformer } from "./language-icon.ts";
17
17
  import { mathPlugin } from "./math.ts";
18
18
  import { mermaidPlugin } from "./mermaid.ts";
19
19
  import { packageInstallPlugin } from "./package-install.ts";
20
+ import { tableWrapPlugin } from "./table-wrap.ts";
20
21
  import { DEFAULT_CODE_THEMES } from "./themes.ts";
21
22
  import type { CodeThemes } from "./themes.ts";
22
23
 
@@ -58,6 +59,7 @@ type HastPlugin = NonNullable<
58
59
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
59
60
  const plugins: HastPlugin[] = [
60
61
  inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
62
+ tableWrapPlugin() as unknown as HastPlugin,
61
63
  ];
62
64
  if (options.headingAnchors !== false) {
63
65
  plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
@@ -7,7 +7,8 @@
7
7
  * hex colors are skipped because dark-on-dark logos (Next.js, Rust…) vanish.
8
8
  *
9
9
  * The theme styles `.blume-lang-icon` and shifts the language label
10
- * (`pre[data-icon]::before`) to make room.
10
+ * (`pre[data-language][data-icon]::before`) to make room — gated on
11
+ * `data-language` so the icon only shows when a header bar exists to hold it.
11
12
  */
12
13
 
13
14
  import {