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.
- package/CHANGELOG.md +18 -0
- package/dist/cli/index.js +87 -57
- package/dist/cli/index.js.map +16 -15
- package/dist/types/core/config-input.d.ts +17 -14
- package/dist/types/core/config.d.ts +3 -3
- package/dist/types/core/schema.d.ts +82 -34
- package/dist/types/core/trim.d.ts +16 -0
- package/docs/advanced/custom-pages.mdx +2 -0
- package/docs/configuration/ai.mdx +6 -4
- package/docs/configuration/index.mdx +6 -8
- package/docs/content/components.mdx +25 -3
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +3 -3
- package/src/ai/mcp/data.ts +2 -2
- package/src/ai/mcp/discovery.ts +2 -1
- package/src/astro/generate.ts +15 -7
- package/src/astro/templates.ts +52 -25
- package/src/blume-modules.d.ts +6 -0
- package/src/components/layout/Header.astro +15 -2
- package/src/components/layout/PageLayout.astro +15 -10
- package/src/components/layout/ReferenceLayout.astro +9 -10
- package/src/components/layout/RootLayout.astro +14 -10
- package/src/components/layout/head-scripts.ts +20 -10
- package/src/core/base-path.ts +4 -4
- package/src/core/config-input.ts +18 -19
- package/src/core/config.ts +3 -3
- package/src/core/schema.ts +15 -14
- package/src/core/server-features.ts +1 -1
- package/src/core/trim.ts +32 -0
- package/src/deploy/adapter-output.ts +11 -1
- package/src/openapi/references.ts +4 -6
- package/src/registry/eject.ts +11 -5
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/blume-modules.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
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
|
-
{
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
102
|
-
|
|
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 {
|
|
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
|
|
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
|
-
{
|
|
415
|
-
|
|
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
|
-
* `
|
|
4
|
-
* the page never flashes the wrong theme or a since-dismissed
|
|
5
|
-
* one place so the
|
|
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
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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","");})();`;
|
package/src/core/base-path.ts
CHANGED
|
@@ -21,10 +21,10 @@ export const normalizeBasePath = (input?: string): string => {
|
|
|
21
21
|
if (!input) {
|
|
22
22
|
return "";
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
package/src/core/config-input.ts
CHANGED
|
@@ -525,7 +525,22 @@ export interface LlmsTxtConfig {
|
|
|
525
525
|
openapi?: boolean;
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
-
/**
|
|
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. */
|
package/src/core/config.ts
CHANGED
|
@@ -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`),
|
|
78
|
-
*
|
|
79
|
-
* -
|
|
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/schema.ts
CHANGED
|
@@ -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
|
package/src/core/trim.ts
ADDED
|
@@ -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);
|