blume 1.0.1 → 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.
@@ -8,6 +8,7 @@ import type { AskBackend } from "../ai/ask.ts";
8
8
  import { normalizeBasePath } from "../core/base-path.ts";
9
9
  import type { ResolvedConfig } from "../core/schema.ts";
10
10
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
11
+ import { trimChar } from "../core/trim.ts";
11
12
  import type { ProjectContext } from "../core/types.ts";
12
13
  import { applyBaseToRedirects } from "../deploy/redirects.ts";
13
14
  import { hasScalarReferences } from "../openapi/references.ts";
@@ -221,6 +222,8 @@ export const astroConfigTemplate = (options: {
221
222
  needsSvelte?: boolean;
222
223
  pages: BlumePageRoute[];
223
224
  contentRoutes: string[];
225
+ /** The generated Ask trigger (`blume:ask`); renders nothing when Ask is off. */
226
+ askPath: string;
224
227
  dataPath: string;
225
228
  examplesPath: string;
226
229
  /** The example-preview Tailwind entry (`blume:examples-theme`). */
@@ -239,6 +242,7 @@ export const astroConfigTemplate = (options: {
239
242
  }): string => {
240
243
  const { context, config, needsReact, pages, dataPath, themePath } = options;
241
244
  const {
245
+ askPath,
242
246
  contentRoutes,
243
247
  examplesPath,
244
248
  examplesThemePath,
@@ -424,6 +428,7 @@ export default defineConfig({
424
428
  },
425
429
  resolve: {
426
430
  alias: {
431
+ "blume:ask": ${JSON.stringify(askPath)},
427
432
  "blume:data": ${JSON.stringify(dataPath)},
428
433
  "blume:examples": ${JSON.stringify(examplesPath)},
429
434
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
@@ -693,6 +698,42 @@ ${handler}
693
698
  `;
694
699
  };
695
700
 
701
+ /**
702
+ * Generate `.blume/src/generated/Ask.astro` — the component behind the
703
+ * `blume:ask` alias that the shared header renders in place of a per-page slot.
704
+ *
705
+ * The header can't import the Ask AI island directly: it's a React component, so
706
+ * the import alone would drag the JSX renderer into the module graph of every
707
+ * project — including the ones that never enable Ask AI and therefore have no
708
+ * React integration wired into their generated Astro config (see `needsReact`).
709
+ * Routing the import through a generated component keeps that dependency behind
710
+ * the config switch: enabled projects get the island, disabled ones get a
711
+ * component that renders nothing and imports no React.
712
+ *
713
+ * `strings` comes from the header (the active locale's dictionary); the empty-
714
+ * state suggestions are read straight from the data snapshot, which is why no
715
+ * page has to pass them.
716
+ */
717
+ export const askComponentTemplate = (askEnabled: boolean): string =>
718
+ askEnabled
719
+ ? `---
720
+ // Generated by Blume. Do not edit.
721
+ import AskAI from "blume/components/islands/AskAI.astro";
722
+ import data from "blume:data";
723
+
724
+ const { strings } = Astro.props;
725
+ ---
726
+
727
+ <AskAI strings={strings ?? data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />
728
+ `
729
+ : `---
730
+ // Generated by Blume. Do not edit.
731
+ // Ask AI is off (\`ai.ask.enabled\`), so the header's Ask trigger renders nothing.
732
+ // Deliberately imports no React island, keeping the JSX renderer out of projects
733
+ // that don't need it.
734
+ ---
735
+ `;
736
+
696
737
  /** Generate the static search index endpoint (`/blume-search.json`). */
697
738
  export const searchEndpointTemplate = (): string =>
698
739
  `// Generated by Blume. Do not edit.
@@ -879,10 +920,8 @@ export function GET({ props }) {
879
920
  `;
880
921
 
881
922
  /** The `src/pages` file that serves a route, e.g. `/mcp` -> `mcp.ts`. */
882
- export const mcpPageFile = (route: string): string => {
883
- const clean = route.replace(/^\/+/u, "").replace(/\/+$/u, "");
884
- return `${clean}.ts`;
885
- };
923
+ export const mcpPageFile = (route: string): string =>
924
+ `${trimChar(route, "/")}.ts`;
886
925
 
887
926
  /**
888
927
  * Generate the hosted MCP server endpoint (e.g. `.blume/src/pages/mcp.ts`). A
@@ -891,7 +930,7 @@ export const mcpPageFile = (route: string): string => {
891
930
  * the docs over Streamable HTTP.
892
931
  */
893
932
  export const mcpEndpointTemplate = (route: string): string => {
894
- const clean = route.replace(/^\/+/u, "").replace(/\/+$/u, "");
933
+ const clean = trimChar(route, "/");
895
934
  const up = "../".repeat(clean.split("/").length);
896
935
  return `// Generated by Blume. Do not edit.
897
936
  import type { APIRoute } from "astro";
@@ -1070,19 +1109,12 @@ const htmlLang = i18n ? i18n.defaultLocale : "en";
1070
1109
  `;
1071
1110
 
1072
1111
  export const catchAllPageTemplate = (options: {
1073
- askEnabled: boolean;
1074
1112
  exportEpub: boolean;
1075
1113
  exportPdf: boolean;
1076
1114
  mathEnabled: boolean;
1077
1115
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1078
1116
  needsReact: boolean;
1079
1117
  }): string => {
1080
- const askImport = options.askEnabled
1081
- ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1082
- : "";
1083
- const askSlot = options.askEnabled
1084
- ? '\n <AskAI slot="ask" strings={ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1085
- : "";
1086
1118
  const mathImport = options.mathEnabled
1087
1119
  ? 'import Math from "blume/components/content/Math.astro";\n'
1088
1120
  : "";
@@ -1098,7 +1130,6 @@ import { getEntry, render } from "astro:content";
1098
1130
  import RootLayout from "blume/components/layout/RootLayout.astro";
1099
1131
  import { withBase } from "blume/components/islands/base-path.ts";
1100
1132
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1101
- ${askImport}
1102
1133
  import Accordion from "blume/components/content/Accordion.astro";
1103
1134
  import AccordionItem from "blume/components/content/AccordionItem.astro";
1104
1135
  import AutoTypeTable from "blume/components/content/AutoTypeTable.astro";
@@ -1335,7 +1366,6 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1335
1366
  canonical={canonical}
1336
1367
  editUrl={editUrl}
1337
1368
  feedback={data.config.feedback}
1338
- askEnabled={${options.askEnabled}}
1339
1369
  exportPdf={${options.exportPdf}}
1340
1370
  exportEpub={${options.exportEpub}}
1341
1371
  feeds={data.feeds}
@@ -1345,7 +1375,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1345
1375
  lastModified={lastModified}
1346
1376
  noindex={seo.noindex}
1347
1377
  structuredDataEnabled={data.config.structuredData}
1348
- >${askSlot}
1378
+ >
1349
1379
  <h1>{title}</h1>
1350
1380
  {frontmatter.description && <p class="text-lg text-muted-foreground">{frontmatter.description}</p>}
1351
1381
  <Content components={components} />
@@ -1360,7 +1390,6 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1360
1390
  * by {@link generateAstroProject} when changelog entries exist.
1361
1391
  */
1362
1392
  export const changelogIndexTemplate = (options: {
1363
- askEnabled: boolean;
1364
1393
  exportEpub: boolean;
1365
1394
  exportPdf: boolean;
1366
1395
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
@@ -1368,12 +1397,6 @@ export const changelogIndexTemplate = (options: {
1368
1397
  /** Whether a `staged` collection exists (non-filesystem changelog sources). */
1369
1398
  staged: boolean;
1370
1399
  }): string => {
1371
- const askImport = options.askEnabled
1372
- ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1373
- : "";
1374
- const askSlot = options.askEnabled
1375
- ? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1376
- : "";
1377
1400
  const clientData = options.needsReact
1378
1401
  ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: pageTitle } }}'
1379
1402
  : "";
@@ -1391,7 +1414,7 @@ import Update from "blume/components/content/Update.astro";
1391
1414
  import { withBase } from "blume/components/islands/base-path.ts";
1392
1415
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1393
1416
  import { layoutOverrides } from "../generated/components.ts";
1394
- ${askImport}import data from "../generated/data.json";
1417
+ import data from "../generated/data.json";
1395
1418
 
1396
1419
  export const prerender = true;
1397
1420
 
@@ -1560,14 +1583,13 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1560
1583
  ogImage={null}
1561
1584
  x={data.config.x}
1562
1585
  canonical={canonical}
1563
- askEnabled={${options.askEnabled}}
1564
1586
  exportPdf={${options.exportPdf}}
1565
1587
  exportEpub={${options.exportEpub}}
1566
1588
  feeds={data.feeds}
1567
1589
  siteUrl={data.config.site}
1568
1590
  noindex={false}
1569
1591
  structuredDataEnabled={data.config.structuredData}
1570
- >${askSlot}
1592
+ >
1571
1593
  <h1>{changelogTitle}</h1>
1572
1594
  {
1573
1595
  items.length === 0 ? (
@@ -1892,6 +1914,11 @@ export const envTemplate =
1892
1914
  (): string => `/// <reference path="../.astro/types.d.ts" />
1893
1915
  /// <reference types="astro/client" />
1894
1916
 
1917
+ declare module "blume:ask" {
1918
+ const Ask: typeof import("blume/components/islands/AskAI.astro").default;
1919
+ export default Ask;
1920
+ }
1921
+
1895
1922
  declare module "blume:data" {
1896
1923
  const data: import("blume").BlumeData;
1897
1924
  export default data;
@@ -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} />
@@ -37,7 +37,7 @@ import Analytics from "./Analytics.astro";
37
37
  import Banner from "./Banner.astro";
38
38
  import Favicon from "./Favicon.astro";
39
39
  import Fonts from "./Fonts.astro";
40
- import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
40
+ import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
41
41
  import Header from "./Header.astro";
42
42
  import { isUnderPath } from "./nav-utils.ts";
43
43
 
@@ -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
@@ -169,10 +174,7 @@ const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
169
174
  const xSite = normalizeXHandle(x?.handle);
170
175
  const xCreator = normalizeXHandle(x?.creator);
171
176
 
172
- const initialThemeScript = themeInitScript(themeMode);
173
- const bannerScript = banner?.dismissible
174
- ? bannerInitScript(banner.key)
175
- : null;
177
+ const bannerKey = banner?.dismissible ? banner.key : null;
176
178
  ---
177
179
 
178
180
  <!doctype html>
@@ -216,8 +218,12 @@ const bannerScript = banner?.dismissible
216
218
  {description && <meta content={description} name="twitter:description" />}
217
219
  {xSite && <meta content={xSite} name="twitter:site" />}
218
220
  {xCreator && <meta content={xCreator} name="twitter:creator" />}
219
- {bannerScript && <script is:inline set:html={bannerScript} />}
220
- <script is:inline set:html={initialThemeScript} />
221
+ {
222
+ bannerKey && (
223
+ <script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
224
+ )
225
+ }
226
+ <script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
221
227
  <Analytics analytics={analytics} />
222
228
  </head>
223
229
  <body class="bg-background font-sans text-foreground antialiased">
@@ -228,6 +234,7 @@ const bannerScript = banner?.dismissible
228
234
  <Banner banner={banner} strings={strings.banner} />
229
235
  <Header
230
236
  askEnabled={askEnabled}
237
+ askStrings={strings.ask}
231
238
  hasSidebar={false}
232
239
  localeSwitch={localeSwitch}
233
240
  logo={logo}
@@ -239,9 +246,7 @@ const bannerScript = banner?.dismissible
239
246
  searchStrings={strings.search}
240
247
  site={site}
241
248
  switcherStrings={strings.languageSwitcher}
242
- >
243
- <slot name="ask" slot="ask" />
244
- </Header>
249
+ />
245
250
  <main id="blume-content"><slot /></main>
246
251
  <slot name="footer" />
247
252
  {
@@ -7,6 +7,7 @@ import Analytics from "./Analytics.astro";
7
7
  import Banner from "./Banner.astro";
8
8
  import Favicon from "./Favicon.astro";
9
9
  import Fonts from "./Fonts.astro";
10
+ import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
10
11
  import Header from "./Header.astro";
11
12
 
12
13
  // A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
@@ -80,14 +81,7 @@ const {
80
81
 
81
82
  const strings = ui ?? EN_UI;
82
83
 
83
- // Set the theme before paint so the navbar never flashes the wrong colors
84
- // (mirrors RootLayout's pre-paint script).
85
- const initialThemeScript = `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
86
-
87
- // Pre-paint: hide a previously-dismissed banner before it flashes in.
88
- const bannerScript = banner?.dismissible
89
- ? `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(banner.key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`
90
- : null;
84
+ const bannerKey = banner?.dismissible ? banner.key : null;
91
85
  ---
92
86
 
93
87
  <!doctype html>
@@ -98,8 +92,12 @@ const bannerScript = banner?.dismissible
98
92
  <title>{pageTitle}</title>
99
93
  <Favicon favicon={favicon} appleIcon={appleIcon} />
100
94
  <Fonts cssVars={fontCssVars ?? []} />
101
- {bannerScript && <script is:inline set:html={bannerScript} />}
102
- <script is:inline set:html={initialThemeScript} />
95
+ {
96
+ bannerKey && (
97
+ <script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
98
+ )
99
+ }
100
+ <script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
103
101
  <Analytics analytics={analytics} />
104
102
  </head>
105
103
  <body class="bg-background font-sans text-foreground antialiased">
@@ -109,6 +107,7 @@ const bannerScript = banner?.dismissible
109
107
  >
110
108
  <Banner banner={banner} strings={strings.banner} />
111
109
  <Header
110
+ askStrings={strings.ask}
112
111
  hasDrawer={false}
113
112
  hasSidebar={false}
114
113
  logo={logo}
@@ -23,7 +23,7 @@ import Breadcrumbs from "./Breadcrumbs.astro";
23
23
  import Empty from "./Empty.astro";
24
24
  import Favicon from "./Favicon.astro";
25
25
  import Fonts from "./Fonts.astro";
26
- import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
26
+ 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 {
@@ -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;
@@ -329,10 +333,7 @@ const structuredDataJson = structuredData
329
333
  ? JSON.stringify(structuredData).replaceAll("<", "\\u003c")
330
334
  : null;
331
335
 
332
- const initialThemeScript = themeInitScript(themeMode);
333
- const bannerScript = banner?.dismissible
334
- ? bannerInitScript(banner.key)
335
- : null;
336
+ const bannerKey = banner?.dismissible ? banner.key : null;
336
337
  ---
337
338
 
338
339
  <!doctype html>
@@ -411,8 +412,12 @@ const bannerScript = banner?.dismissible
411
412
  />
412
413
  )
413
414
  }
414
- {bannerScript && <script is:inline set:html={bannerScript} />}
415
- <script is:inline set:html={initialThemeScript} />
415
+ {
416
+ bannerKey && (
417
+ <script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
418
+ )
419
+ }
420
+ <script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
416
421
  <Analytics analytics={analytics} />
417
422
  </head>
418
423
  <body
@@ -429,6 +434,7 @@ const bannerScript = banner?.dismissible
429
434
  <Banner banner={banner} strings={strings.banner} />
430
435
  <HeaderSlot
431
436
  askEnabled={askEnabled}
437
+ askStrings={strings.ask}
432
438
  layout={layout}
433
439
  localeSwitch={localeSwitch}
434
440
  logo={logo}
@@ -440,9 +446,7 @@ const bannerScript = banner?.dismissible
440
446
  searchStrings={strings.search}
441
447
  site={site}
442
448
  switcherStrings={strings.languageSwitcher}
443
- >
444
- <slot name="ask" slot="ask" />
445
- </HeaderSlot>
449
+ />
446
450
  <div
447
451
  class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}
448
452
  data-blume-doc-grid
@@ -1,19 +1,29 @@
1
1
  /**
2
2
  * Pre-paint inline scripts shared by the document layouts (`RootLayout`,
3
- * `SplashLayout`). They run synchronously in `<head>`, before first paint, so
4
- * the page never flashes the wrong theme or a since-dismissed banner. Kept in
5
- * one place so the two layouts can't drift on this timing-critical logic.
3
+ * `PageLayout`, `ReferenceLayout`). They run synchronously in `<head>`, before
4
+ * first paint, so the page never flashes the wrong theme or a since-dismissed
5
+ * banner. Kept in one place so the layouts can't drift on this timing-critical
6
+ * logic.
7
+ *
8
+ * Both are constants, never built by interpolating config into source text: the
9
+ * values they need ride in as `data-*` attributes on the script tag and are read
10
+ * back through `document.currentScript`. Baking a config string into JS — even
11
+ * via `JSON.stringify` — is code construction, and JSON escaping does not cover
12
+ * a script context (`</script>`, U+2028/U+2029 all survive it). Attributes are
13
+ * HTML-escaped by Astro, so the value can never be parsed as code.
6
14
  */
7
15
 
8
16
  /**
9
17
  * Set `data-theme` from the stored preference (or the configured default, or the
10
18
  * OS setting for `"system"`) before the body paints, avoiding a theme flash.
19
+ *
20
+ * Reads `data-mode` — `"system" | "light" | "dark"`.
11
21
  */
12
- export const themeInitScript = (
13
- themeMode: "system" | "light" | "dark"
14
- ): string =>
15
- `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
22
+ export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.mode??"system";const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
16
23
 
17
- /** Hide a previously-dismissed banner before it can flash in. */
18
- export const bannerInitScript = (key: string): string =>
19
- `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
24
+ /**
25
+ * Hide a previously-dismissed banner before it can flash in.
26
+ *
27
+ * Reads `data-key` — the banner's dismissal key.
28
+ */
29
+ export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(k&&localStorage.getItem("blume-banner:"+k))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
@@ -21,10 +21,10 @@ export const normalizeBasePath = (input?: string): string => {
21
21
  if (!input) {
22
22
  return "";
23
23
  }
24
- const trimmed = input
25
- .trim()
26
- .replaceAll(/^\/+|\/+$/gu, "")
27
- .replaceAll(/\/{2,}/gu, "/");
24
+ // Splitting on "/" and dropping the empty parts trims the edges and collapses
25
+ // inner runs in one linear pass; the regex spellings of both are quadratic on
26
+ // a long run of slashes (see `core/trim.ts`).
27
+ const trimmed = input.trim().split("/").filter(Boolean).join("/");
28
28
  return trimmed === "" ? "" : `/${trimmed}`;
29
29
  };
30
30
 
@@ -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
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Single-character trimming, done without regular expressions.
3
+ *
4
+ * The obvious spellings — `/^\/+/`, `/\/+$/`, `/^\/+|\/+$/g` — take quadratic
5
+ * time on a run of the trimmed character, because a failed match at one start
6
+ * position tells the engine nothing about the next one. Every caller here trims
7
+ * a value that comes from outside Blume (a configured route, a spec URL, a site
8
+ * origin), so the slow path is reachable from user input rather than only from
9
+ * our own literals. These loops are linear and allocation-free.
10
+ */
11
+
12
+ /** Drop every leading `char` (`"///a"` -> `"a"` for `"/"`). */
13
+ export const trimStart = (text: string, char: string): string => {
14
+ let start = 0;
15
+ while (start < text.length && text[start] === char) {
16
+ start += 1;
17
+ }
18
+ return text.slice(start);
19
+ };
20
+
21
+ /** Drop every trailing `char` (`"a///"` -> `"a"` for `"/"`). */
22
+ export const trimEnd = (text: string, char: string): string => {
23
+ let end = text.length;
24
+ while (end > 0 && text[end - 1] === char) {
25
+ end -= 1;
26
+ }
27
+ return text.slice(0, end);
28
+ };
29
+
30
+ /** Drop every leading *and* trailing `char` (`"///a///"` -> `"a"`). */
31
+ export const trimChar = (text: string, char: string): string =>
32
+ trimEnd(trimStart(text, char), char);