blume 1.0.2 → 1.0.3

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.
@@ -15,6 +15,12 @@ declare module "blume:search-client" {
15
15
  export const createSearch: () => Fn | Promise<Fn>;
16
16
  }
17
17
 
18
+ declare module "blume:ask" {
19
+ /** The generated Ask trigger (see `askComponentTemplate`); empty when Ask is off. */
20
+ const Ask: (props: Record<string, unknown>) => unknown;
21
+ export default Ask;
22
+ }
23
+
18
24
  declare module "blume:data" {
19
25
  /** The generated per-project data snapshot (see `core/data.ts`). */
20
26
  // biome-ignore lint/style/useImportType: ambient module must stay a global script
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import Ask from "blume:ask";
3
+ import data from "blume:data";
2
4
  import { withBase } from "../islands/base-path.ts";
3
5
  import type { ComponentOverride } from "../../core/define-components.ts";
4
6
  import { EN_UI } from "../../core/i18n-ui.ts";
@@ -26,7 +28,14 @@ interface Props {
26
28
  navigation: Navigation;
27
29
  route: string;
28
30
  searchEnabled: boolean;
31
+ /**
32
+ * Whether the search modal offers an "Ask AI" hand-off. Defaults to whether
33
+ * Ask AI is configured, so no page has to pass it; a layout can still opt a
34
+ * shell out explicitly.
35
+ */
29
36
  askEnabled?: boolean;
37
+ /** Localized Ask AI strings for the active locale. */
38
+ askStrings?: UIStrings["ask"];
30
39
  // The mobile menu button toggles the docs sidebar drawer; custom pages
31
40
  // without a sidebar (e.g. a landing page) pass `false` to hide it.
32
41
  hasSidebar?: boolean;
@@ -57,7 +66,11 @@ const {
57
66
  navigation,
58
67
  route,
59
68
  searchEnabled,
60
- askEnabled = false,
69
+ // `config.ask` is null whenever Ask AI is off, so the header is the one place
70
+ // that has to know — the Ask trigger below and the search modal's hand-off to
71
+ // it both switch on this, and every page gets both for free.
72
+ askEnabled = Boolean(data.config.ask),
73
+ askStrings,
61
74
  hasSidebar = true,
62
75
  hasDrawer = true,
63
76
  searchStrings,
@@ -208,7 +221,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
208
221
  <span class="inline-flex dark:hidden"><Icon name="sun" size={18} /></span>
209
222
  <span class="hidden dark:inline-flex"><Icon name="moon" size={18} /></span>
210
223
  </button>
211
- <slot name="ask" />
224
+ {askEnabled && <Ask strings={askStrings} />}
212
225
  </div>
213
226
  </header>
214
227
  <script is:inline set:html={clickScript} />
@@ -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
@@ -229,6 +234,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
229
234
  <Banner banner={banner} strings={strings.banner} />
230
235
  <Header
231
236
  askEnabled={askEnabled}
237
+ askStrings={strings.ask}
232
238
  hasSidebar={false}
233
239
  localeSwitch={localeSwitch}
234
240
  logo={logo}
@@ -240,9 +246,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
240
246
  searchStrings={strings.search}
241
247
  site={site}
242
248
  switcherStrings={strings.languageSwitcher}
243
- >
244
- <slot name="ask" slot="ask" />
245
- </Header>
249
+ />
246
250
  <main id="blume-content"><slot /></main>
247
251
  <slot name="footer" />
248
252
  {
@@ -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}
@@ -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;
@@ -430,6 +434,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
430
434
  <Banner banner={banner} strings={strings.banner} />
431
435
  <HeaderSlot
432
436
  askEnabled={askEnabled}
437
+ askStrings={strings.ask}
433
438
  layout={layout}
434
439
  localeSwitch={localeSwitch}
435
440
  logo={logo}
@@ -441,9 +446,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
441
446
  searchStrings={strings.search}
442
447
  site={site}
443
448
  switcherStrings={strings.languageSwitcher}
444
- >
445
- <slot name="ask" slot="ask" />
446
- </HeaderSlot>
449
+ />
447
450
  <div
448
451
  class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}
449
452
  data-blume-doc-grid
@@ -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
  // ---------------------------------------------------------------------------
@@ -979,8 +980,6 @@ export interface BlumeConfig {
979
980
  logo?: LogoConfig;
980
981
  /** Markdown / MDX rendering behavior. */
981
982
  markdown?: MarkdownConfig;
982
- /** Expose the docs as an MCP server for agents. */
983
- mcp?: McpConfig;
984
983
  /** Header, sidebar, tabs, and switchers. */
985
984
  navigation?: NavigationConfig;
986
985
  /** 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`
@@ -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
  /**
@@ -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),
@@ -1088,7 +1090,6 @@ export const blumeConfigSchema = z.strictObject({
1088
1090
  lastModified: lastModifiedConfigSchema.default(false),
1089
1091
  logo: logoConfigSchema.optional(),
1090
1092
  markdown: markdownConfigSchema.default({}),
1091
- mcp: mcpConfigSchema.default({}),
1092
1093
  navigation: navigationConfigSchema.default({}),
1093
1094
  openapi: openapiConfigSchema.default({}),
1094
1095
  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
@@ -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`,
@@ -20,6 +20,7 @@ import { discoverIslands } from "../astro/islands.ts";
20
20
  import { customOgRoutes, discoverPages, routeIsTaken } from "../astro/pages.ts";
21
21
  import {
22
22
  askEndpointTemplate,
23
+ askComponentTemplate,
23
24
  astroConfigTemplate,
24
25
  catchAllPageTemplate,
25
26
  changelogIndexTemplate,
@@ -135,8 +136,8 @@ const hostsMcp = (
135
136
  project: BlumeProject,
136
137
  userPages: { pattern: string }[]
137
138
  ): boolean =>
138
- project.config.mcp.enabled &&
139
- !routeIsTaken(userPages, project.graph.pages, project.config.mcp.route);
139
+ project.config.ai.mcp.enabled &&
140
+ !routeIsTaken(userPages, project.graph.pages, project.config.ai.mcp.route);
140
141
 
141
142
  /**
142
143
  * The `.well-known` MCP discovery routes, injected as prerendered pages
@@ -174,7 +175,7 @@ const mcpFiles = async (
174
175
  if (!hostsMcp(project, userPages)) {
175
176
  return [];
176
177
  }
177
- const { route } = project.config.mcp;
178
+ const { route } = project.config.ai.mcp;
178
179
  const data = await buildMcpData(project);
179
180
  const discoveryInput = {
180
181
  base: data.base,
@@ -356,6 +357,7 @@ export const eject = async (
356
357
  }[] = [
357
358
  {
358
359
  content: astroConfigTemplate({
360
+ askPath: "./src/generated/Ask.astro",
359
361
  config,
360
362
  contentRoutes: project.manifest.routes.map((route) => route.path),
361
363
  context: relContext,
@@ -390,7 +392,6 @@ export const eject = async (
390
392
  },
391
393
  {
392
394
  content: catchAllPageTemplate({
393
- askEnabled,
394
395
  exportEpub,
395
396
  exportPdf,
396
397
  mathEnabled: usesMath,
@@ -441,6 +442,12 @@ export const eject = async (
441
442
  path: join(genDir, "app.css"),
442
443
  },
443
444
  { content: buildRuntimeData(project), path: join(genDir, "data.json") },
445
+ // The header's Ask trigger behind the `blume:ask` alias. Always written — it
446
+ // renders nothing when Ask is off — so the alias always resolves.
447
+ {
448
+ content: askComponentTemplate(askEnabled),
449
+ path: join(genDir, "Ask.astro"),
450
+ },
444
451
  {
445
452
  content: `${JSON.stringify(ejectOpenApiData(project))}\n`,
446
453
  path: join(genDir, "openapi.json"),
@@ -475,7 +482,6 @@ export const eject = async (
475
482
  files.push(
476
483
  ...(await mcpFiles(project, pages, srcDir, genDir)),
477
484
  ...changelogFiles(project, pages, srcDir, {
478
- askEnabled,
479
485
  exportEpub,
480
486
  exportPdf,
481
487
  needsReact,