blume 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1286 -63
  4. package/dist/cli/index.js.map +32 -21
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +1 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +19 -8
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +25 -2
  26. package/src/astro/templates.ts +114 -22
  27. package/src/cli/commands/eval.ts +291 -0
  28. package/src/cli/commands/init.ts +9 -4
  29. package/src/cli/commands/mcp-stdio.ts +36 -0
  30. package/src/cli/index.ts +4 -0
  31. package/src/cli/required-secrets.ts +1 -1
  32. package/src/components/content/AccordionItem.astro +2 -2
  33. package/src/components/content/TreeFolder.astro +1 -2
  34. package/src/components/islands/AskAI.astro +9 -2
  35. package/src/components/islands/ask-ai.tsx +4 -2
  36. package/src/components/islands/hooks.ts +10 -4
  37. package/src/components/layout/NavTree.astro +37 -19
  38. package/src/components/layout/ReferenceLayout.astro +4 -0
  39. package/src/components/layout/RootLayout.astro +1 -1
  40. package/src/components/openapi/SchemaProperty.astro +3 -3
  41. package/src/core/config-input.ts +18 -0
  42. package/src/core/config.ts +4 -0
  43. package/src/core/data.ts +1 -0
  44. package/src/core/graph.ts +1 -0
  45. package/src/core/navigation.ts +9 -2
  46. package/src/core/schema.ts +51 -4
  47. package/src/core/server-features.ts +1 -1
  48. package/src/core/types.ts +5 -3
  49. package/src/eval/agents.ts +340 -0
  50. package/src/eval/findings.ts +103 -0
  51. package/src/eval/prompts.ts +78 -0
  52. package/src/eval/report.ts +214 -0
  53. package/src/eval/run.ts +290 -0
  54. package/src/eval/schema.ts +124 -0
  55. package/src/openapi/references.ts +23 -2
  56. package/src/openapi/render-mdx.ts +27 -4
  57. package/src/openapi/scalar.ts +1 -0
  58. package/src/openapi/source.ts +11 -4
  59. package/src/registry/eject.ts +23 -1
  60. package/src/search/build.ts +4 -3
@@ -65,7 +65,7 @@ const nextId = (): number => {
65
65
 
66
66
  // The endpoint and page path both honor the deployment `base` so grounding works
67
67
  // under a non-root base path (the server matches base-less document routes).
68
- const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
68
+ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
69
69
 
70
70
  /** The current route with the deployment base stripped, for page-context lookup. */
71
71
  const currentPath = (): string =>
@@ -133,10 +133,12 @@ const ANSWER_CLASS =
133
133
  "prose prose-sm max-w-none text-foreground [&_a]:inline-flex [&_a]:items-center [&_a]:gap-1 [&_a]:rounded-full [&_a]:bg-muted [&_a]:px-2 [&_a]:py-1 [&_a]:align-middle [&_a]:font-medium [&_a]:text-[0.7rem] [&_a]:leading-none [&_a]:text-muted-foreground! [&_a]:no-underline! [&_a:hover]:text-foreground!";
134
134
 
135
135
  const AskAI = ({
136
+ endpoint = DEFAULT_ASK_ENDPOINT,
136
137
  icons = EMPTY_ICONS,
137
138
  strings,
138
139
  suggestions = EMPTY_SUGGESTIONS,
139
140
  }: {
141
+ endpoint?: string;
140
142
  icons?: AskIcons;
141
143
  strings?: UIStrings["ask"];
142
144
  suggestions?: Suggestion[];
@@ -268,7 +270,7 @@ const AskAI = ({
268
270
  abortRef.current = controller;
269
271
 
270
272
  try {
271
- const response = await fetch(ASK_ENDPOINT, {
273
+ const response = await fetch(endpoint, {
272
274
  body: JSON.stringify({
273
275
  messages: history.map((m) => ({ content: m.content, role: m.role })),
274
276
  page: { path: currentPath() },
@@ -139,7 +139,12 @@ export interface UseAskAI {
139
139
  reset: () => void;
140
140
  }
141
141
 
142
- const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
142
+ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
143
+
144
+ export interface UseAskAIOptions {
145
+ /** Existing Ask AI endpoint; defaults to Blume's generated `/api/ask`. */
146
+ endpoint?: string;
147
+ }
143
148
 
144
149
  /** Shown as the assistant's answer when the request fails or throws. */
145
150
  const ASK_ERROR = "Something went wrong answering that. Please try again.";
@@ -152,7 +157,8 @@ const currentPath = (): string =>
152
157
  * Stream answers from the Ask AI endpoint. Mirrors the built-in Ask AI island so
153
158
  * a custom chat UI shares the same grounded, page-aware backend.
154
159
  */
155
- export const useAskAI = (): UseAskAI => {
160
+ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
161
+ const endpoint = options.endpoint ?? DEFAULT_ASK_ENDPOINT;
156
162
  const [messages, setMessages] = useState<AskMessage[]>([]);
157
163
  const [loading, setLoading] = useState(false);
158
164
  // The stream writes into the conversation via state updates, so `reset()`
@@ -186,7 +192,7 @@ export const useAskAI = (): UseAskAI => {
186
192
  setMessages([...history, assistant]);
187
193
  setLoading(true);
188
194
  try {
189
- const response = await fetch(ASK_ENDPOINT, {
195
+ const response = await fetch(endpoint, {
190
196
  body: JSON.stringify({
191
197
  messages: history,
192
198
  page: { path: currentPath() },
@@ -239,7 +245,7 @@ export const useAskAI = (): UseAskAI => {
239
245
  }
240
246
  }
241
247
  },
242
- [loading, messages]
248
+ [endpoint, loading, messages]
243
249
  );
244
250
 
245
251
  // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
@@ -129,29 +129,43 @@ const initialId =
129
129
  </div>
130
130
  {panels.map((panel) => (
131
131
  <div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
132
- <div class="mb-3 flex items-center gap-1.5">
133
- <button
134
- aria-label={n.back}
135
- class="-ml-1 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
136
- data-nav-back={panel.parentId}
137
- type="button"
138
- >
139
- <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
140
- </button>
141
- {panel.route ? (
132
+ {/* The title is the only sidebar link to the section's own page, so
133
+ a routed panel keeps it as a link; without a route the whole row
134
+ becomes the back button. Either way every part of the row is
135
+ interactive. */}
136
+ {panel.route ? (
137
+ <div class="mb-3 flex items-center gap-0.5">
138
+ <button
139
+ aria-label={n.back}
140
+ class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
141
+ data-nav-back={panel.parentId}
142
+ type="button"
143
+ >
144
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
145
+ </button>
142
146
  <a
143
147
  aria-current={panel.route === currentRoute ? "page" : undefined}
144
- class="flex-1 truncate font-semibold text-foreground text-sm hover:underline"
148
+ class="flex-1 truncate rounded px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
145
149
  href={withBase(panel.route)}
146
150
  >
147
151
  {panel.label}
148
152
  </a>
149
- ) : (
150
- <span class="flex-1 truncate font-semibold text-foreground text-sm">
151
- {panel.label}
152
- </span>
153
- )}
154
- </div>
153
+ </div>
154
+ ) : (
155
+ <button
156
+ aria-label={`${n.back}: ${panel.label}`}
157
+ class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
158
+ data-nav-back={panel.parentId}
159
+ type="button"
160
+ >
161
+ <Icon
162
+ class="shrink-0 text-muted-foreground rtl:-scale-x-100"
163
+ name="arrow-left"
164
+ size={16}
165
+ />
166
+ <span class="flex-1 truncate">{panel.label}</span>
167
+ </button>
168
+ )}
155
169
  <Self
156
170
  currentRoute={currentRoute}
157
171
  depth={1}
@@ -236,7 +250,7 @@ const initialId =
236
250
  const open = active || item.collapsed === false;
237
251
  return (
238
252
  <li class={spacing}>
239
- <details class="group" open={open}>
253
+ <details open={open}>
240
254
  <summary class="flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
241
255
  {item.route ? (
242
256
  <a
@@ -273,7 +287,11 @@ const initialId =
273
287
  )}
274
288
  </>
275
289
  )}
276
- <span class="shrink-0 text-muted-foreground transition-transform group-open:rotate-90">
290
+ {/* Scope the rotation to this group's own `details` — the
291
+ `group-open` variant matches any open ancestor `.group`,
292
+ so nested chevrons rotated while their own group stayed
293
+ closed. */}
294
+ <span class="shrink-0 text-muted-foreground transition-transform [details[open]>summary_&]:rotate-90">
277
295
  <Icon name="chevron-right" size={13} />
278
296
  </span>
279
297
  </summary>
@@ -53,6 +53,8 @@ interface Props {
53
53
  fontCssVars?: string[];
54
54
  searchEnabled: boolean;
55
55
  pageTitle: string;
56
+ /** Keep the reference route out of crawler indexes. */
57
+ noindex?: boolean;
56
58
  /** Active locale code for `<html lang>` (defaults to `en`). */
57
59
  locale?: string;
58
60
  /** Text direction for `<html dir>` (defaults to `ltr`). */
@@ -74,6 +76,7 @@ const {
74
76
  fontCssVars,
75
77
  searchEnabled,
76
78
  pageTitle,
79
+ noindex = false,
77
80
  locale = "en",
78
81
  dir = "ltr",
79
82
  ui,
@@ -89,6 +92,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
89
92
  <head>
90
93
  <meta charset="utf-8" />
91
94
  <meta name="viewport" content="width=device-width, initial-scale=1" />
95
+ {noindex && <meta name="robots" content="noindex" />}
92
96
  <title>{pageTitle}</title>
93
97
  <Favicon favicon={favicon} appleIcon={appleIcon} />
94
98
  <Fonts cssVars={fontCssVars ?? []} />
@@ -359,7 +359,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
359
359
  ---
360
360
 
361
361
  <!doctype html>
362
- <html dir={dir} lang={locale}>
362
+ <html data-pagefind-ignore={indexable ? undefined : "all"} dir={dir} lang={locale}>
363
363
  <head>
364
364
  <meta charset="utf-8" />
365
365
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -99,10 +99,10 @@ const expandable =
99
99
  }
100
100
  {
101
101
  expandable && (
102
- <details class="group mt-2" open={expandAll}>
102
+ <details class="mt-2" open={expandAll}>
103
103
  <summary class="cursor-pointer select-none text-accent text-xs hover:underline">
104
- <span class="group-open:hidden">Show properties</span>
105
- <span class="hidden group-open:inline">Hide properties</span>
104
+ <span class="[details[open]>summary_&]:hidden">Show properties</span>
105
+ <span class="hidden [details[open]>summary_&]:inline">Hide properties</span>
106
106
  </summary>
107
107
  <div class="mt-2 border-border border-l pl-4">
108
108
  <SchemaTable
@@ -1,3 +1,4 @@
1
+ import type { AstroIntegration } from "astro";
1
2
  import type { z } from "zod";
2
3
 
3
4
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
@@ -290,6 +291,15 @@ export interface NavTabItem {
290
291
 
291
292
  /** A top-level tab in the header, optionally opening a dropdown of items. */
292
293
  export interface NavTab {
294
+ /**
295
+ * Where the tab links to, when that differs from `path`. `path` scopes the
296
+ * sidebar section and matches the active tab; without `href`, a section whose
297
+ * `path` isn't itself a page falls back to the section's first page, or keeps
298
+ * `path` when the section has no linkable page at all. Set this to send
299
+ * readers somewhere else — e.g. a generated `/changelog` index, or a custom
300
+ * `.astro` landing page, neither of which is part of the content tree.
301
+ */
302
+ href?: string;
293
303
  /** Lucide icon name shown beside the label. */
294
304
  icon?: string;
295
305
  /** Dropdown items; omit for a plain link tab. */
@@ -522,6 +532,12 @@ export interface AskConfig {
522
532
  baseUrl?: string;
523
533
  /** Turn Ask AI on. Defaults to `false`. */
524
534
  enabled?: boolean;
535
+ /**
536
+ * Existing Ask AI endpoint to call instead of generating one. This keeps a
537
+ * Blume site static while an API backend owns retrieval, model access, rate
538
+ * limiting, and streaming. Accepts an absolute URL or root-relative path.
539
+ */
540
+ endpoint?: string;
525
541
  /** Model id to use. Defaults to `openai/gpt-5.5`. */
526
542
  model?: string;
527
543
  /** Which backend routes the request. Defaults to `gateway`. */
@@ -1115,6 +1131,8 @@ export interface BlumeConfig {
1115
1131
  github?: GithubConfig;
1116
1132
  /** Internationalization (opt-in multi-locale). */
1117
1133
  i18n?: I18nConfig;
1134
+ /** Astro integrations appended after Blume's built-ins, in declaration order. */
1135
+ integrations?: AstroIntegration[];
1118
1136
  /** "Last updated" timestamps from git history or frontmatter. Defaults to `false`. */
1119
1137
  lastModified?: LastModifiedConfig;
1120
1138
  /** Site logo / brand mark. */
@@ -84,6 +84,10 @@ import type { Diagnostic } from "./types.ts";
84
84
  * - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
85
85
  * GA, …).
86
86
  *
87
+ * **Astro**
88
+ * - `integrations` — Astro integrations appended after Blume's built-ins, in
89
+ * declaration order. Install and maintain each integration in the site.
90
+ *
87
91
  * **Deployment & i18n**
88
92
  * - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
89
93
  * `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
package/src/core/data.ts CHANGED
@@ -95,6 +95,7 @@ export interface BlumeDataConfig {
95
95
  appleIcon: BlumeFavicon | null;
96
96
  /** Ask AI empty-state suggestions, or `null` when Ask AI is off. */
97
97
  ask: {
98
+ endpoint: string | null;
98
99
  suggestions: NonNullable<ResolvedConfig["ai"]["ask"]>["suggestions"];
99
100
  } | null;
100
101
  banner: BlumeBanner | null;
package/src/core/graph.ts CHANGED
@@ -97,6 +97,7 @@ const buildLocaleNavigation = (
97
97
  path.startsWith("/") ? localizeRoute(path, code, i18n) : path;
98
98
  const tabs = options.navigation.tabs?.map((tab) => ({
99
99
  ...tab,
100
+ ...(tab.href ? { href: localizePath(tab.href) } : {}),
100
101
  items: tab.items?.map((item) => ({
101
102
  ...item,
102
103
  path: localizePath(item.path),
@@ -613,10 +613,16 @@ const resolveTabHref = (sidebar: NavNode[], path: string): string => {
613
613
  return walk(sidebar) ? path : (first ?? path);
614
614
  };
615
615
 
616
- /** Attach a resolved `href` to each tab whose section has no index page. */
616
+ /**
617
+ * Attach a resolved `href` to each tab whose section has no index page. An
618
+ * author-declared `href` is the tab's stated target, so it's kept as-is —
619
+ * resolution only fills in the tabs that didn't declare one. That's what lets a
620
+ * tab point at a route outside the content tree (a generated `/changelog`
621
+ * index, a custom `.astro` page), which resolution can't see.
622
+ */
617
623
  const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
618
624
  tabs.map((tab) => {
619
- const href = resolveTabHref(sidebar, tab.path);
625
+ const href = tab.href ?? resolveTabHref(sidebar, tab.path);
620
626
  return href === tab.path ? tab : { ...tab, href };
621
627
  });
622
628
 
@@ -688,6 +694,7 @@ export const buildNavigation = (
688
694
  const tabs = basePath
689
695
  ? (options.tabs ?? []).map((tab) => ({
690
696
  ...tab,
697
+ ...(tab.href ? { href: withBasePath(basePath, tab.href) } : {}),
691
698
  items: tab.items?.map(rebasePath),
692
699
  path: withBasePath(basePath, tab.path),
693
700
  }))
@@ -1,3 +1,4 @@
1
+ import type { AstroIntegration } from "astro";
1
2
  import { z } from "zod";
2
3
 
3
4
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
@@ -78,6 +79,11 @@ const searchMetaSchema = z.strictObject({
78
79
  tags: z.array(z.string()).optional(),
79
80
  });
80
81
 
82
+ const aiMetaSchema = z.strictObject({
83
+ /** Exclude this page from llms.txt and llms-full.txt. */
84
+ exclude: z.boolean().default(false),
85
+ });
86
+
81
87
  const changelogMetaSchema = z.strictObject({
82
88
  category: z.string().optional(),
83
89
  date: dateSchema.optional(),
@@ -105,6 +111,7 @@ const authorSchema = z.union([
105
111
 
106
112
  /** Frontmatter accepted on any content page. */
107
113
  const pageMetaBaseSchema = z.strictObject({
114
+ ai: aiMetaSchema.default({}),
108
115
  /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
109
116
  authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
110
117
  changelog: changelogMetaSchema.optional(),
@@ -351,6 +358,10 @@ const contentConfigSchema = z.strictObject({
351
358
  });
352
359
 
353
360
  const navTabSchema = z.strictObject({
361
+ // Rejected empty rather than accepted: an empty `href` would render a link to
362
+ // nowhere, and it can't mean "resolve it for me" either — that's what
363
+ // omitting the field does.
364
+ href: z.string().min(1).optional(),
354
365
  icon: iconName.optional(),
355
366
  items: z
356
367
  .array(
@@ -575,18 +586,44 @@ const mcpConfigSchema = z.strictObject({
575
586
  route: z.string().default("/mcp").transform(normalizeRoute),
576
587
  });
577
588
 
589
+ const askEndpointSchema = z
590
+ .string()
591
+ .trim()
592
+ .min(1)
593
+ .refine(
594
+ (value) => {
595
+ if (value.startsWith("/") && !value.startsWith("//")) {
596
+ return true;
597
+ }
598
+ try {
599
+ const url = new URL(value);
600
+ return url.protocol === "http:" || url.protocol === "https:";
601
+ } catch {
602
+ return false;
603
+ }
604
+ },
605
+ {
606
+ message:
607
+ "ai.ask.endpoint must be an HTTP(S) URL or a root-relative path.",
608
+ }
609
+ );
610
+
578
611
  const aiConfigSchema = z.strictObject({
579
612
  ask: z
580
613
  .strictObject({
581
614
  // Name of the env var holding the provider's API key; each provider has
582
615
  // a sensible default, so this only needs setting to override it.
583
616
  apiKeyEnv: z.string().optional(),
584
- // Base URL of the backend. Required for `openai-compatible`; for the
585
- // named providers it overrides the built-in preset.
617
+ // Base URL of the backend. Required for `openai-compatible` only when no
618
+ // external endpoint is supplied; for named providers it overrides the preset.
586
619
  // blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
587
620
  // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
588
621
  baseUrl: z.string().url().optional(),
589
622
  enabled: z.boolean().default(false),
623
+ // Optional external endpoint for projects that keep their docs static
624
+ // and host Ask AI in an existing backend. Absolute URLs and root-relative
625
+ // paths are both valid; the built-in request/stream contract is unchanged.
626
+ endpoint: askEndpointSchema.optional(),
590
627
  model: z.string().default("openai/gpt-5.5"),
591
628
  provider: z.enum(askAiProviders).default("gateway"),
592
629
  // Empty-state prompts shown before the first question. Each renders as a
@@ -603,7 +640,10 @@ const aiConfigSchema = z.strictObject({
603
640
  .superRefine((value, ctx) => {
604
641
  // A generic OpenAI-compatible backend has no preset URL, so the user
605
642
  // must supply one; the named providers fall back to their preset.
606
- if (value.provider === "openai-compatible" && !value.baseUrl) {
643
+ if (
644
+ value.provider === "openai-compatible" &&
645
+ !(value.baseUrl || value.endpoint)
646
+ ) {
607
647
  ctx.addIssue({
608
648
  code: z.ZodIssueCode.custom,
609
649
  message:
@@ -1118,15 +1158,21 @@ const reactConfigSchema = z.strictObject({
1118
1158
  * `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
1119
1159
  */
1120
1160
  const openapiSourceSchema = z.strictObject({
1161
+ /** Include generated pages from this spec in llms.txt/llms-full.txt. */
1162
+ includeInLlms: z.boolean().default(true),
1163
+ /** Include generated pages from this spec in site search. */
1164
+ includeInSearch: z.boolean().default(true),
1121
1165
  /** Nav/section label for this source. */
1122
1166
  label: z.string().optional(),
1167
+ /** Emit noindex metadata and omit generated pages from the sitemap. */
1168
+ noindex: z.boolean().default(false),
1123
1169
  /** Per-source route; defaults to the block's `route` (or a derived path). */
1124
1170
  route: z.string().optional(),
1125
1171
  /** Local path or `http(s)` URL to the spec. */
1126
1172
  spec: z.string(),
1127
1173
  });
1128
1174
 
1129
- export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
1175
+ export type OpenApiSource = z.input<typeof openapiSourceSchema>;
1130
1176
 
1131
1177
  /**
1132
1178
  * Arbitrary Scalar API-reference options forwarded verbatim to the generated
@@ -1283,6 +1329,7 @@ export const blumeConfigSchema = z.strictObject({
1283
1329
  frontmatter: frontmatterConfigSchema.default({}),
1284
1330
  github: githubConfigSchema.optional(),
1285
1331
  i18n: i18nConfigSchema.optional(),
1332
+ integrations: z.array(z.custom<AstroIntegration>()).default([]),
1286
1333
  lastModified: lastModifiedConfigSchema.default(false),
1287
1334
  logo: logoConfigSchema.optional(),
1288
1335
  markdown: markdownConfigSchema.default({}),
@@ -7,7 +7,7 @@ import type { ResolvedConfig } from "./schema.ts";
7
7
  */
8
8
  export const serverFeatures = (config: ResolvedConfig): string[] => {
9
9
  const features: string[] = [];
10
- if (config.ai.ask?.enabled) {
10
+ if (config.ai.ask?.enabled && !config.ai.ask.endpoint) {
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.
package/src/core/types.ts CHANGED
@@ -201,9 +201,11 @@ export interface NavTab {
201
201
  */
202
202
  path: string;
203
203
  /**
204
- * The clickable target. Equals `path` when the section has an index page;
205
- * otherwise it's resolved to the section's first page so the tab never links
206
- * to a 404. Absent when it matches `path`.
204
+ * The clickable target. Author-declared when the config sets it; otherwise
205
+ * equals `path` when the section has an index page, and resolves to the
206
+ * section's first page when it doesn't, so the tab doesn't link to a 404 as
207
+ * long as the section has a page to offer — a section with no linkable page at
208
+ * all keeps `path`. Absent when a resolved target matches `path`.
207
209
  */
208
210
  href?: string;
209
211
  icon?: string;