blume 1.5.0 → 1.5.2

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 (89) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +449 -135
  4. package/dist/cli/index.js.map +24 -23
  5. package/dist/types/ai/ask-context.d.ts +78 -0
  6. package/dist/types/core/config-input.d.ts +54 -2
  7. package/dist/types/core/data.d.ts +19 -2
  8. package/dist/types/core/open-in-chat.d.ts +9 -0
  9. package/dist/types/core/schema.d.ts +48 -1
  10. package/dist/types/core/types.d.ts +10 -3
  11. package/dist/types/openapi/references.d.ts +9 -0
  12. package/dist/types/search/orama-index.d.ts +70 -0
  13. package/dist/types/theme/fonts.d.ts +11 -2
  14. package/docs/advanced/api-reference.mdx +67 -5
  15. package/docs/advanced/custom-pages.mdx +5 -1
  16. package/docs/configuration/ai.mdx +35 -0
  17. package/docs/configuration/index.mdx +14 -2
  18. package/docs/configuration/search.mdx +4 -4
  19. package/docs/configuration/theming.mdx +4 -2
  20. package/docs/reference/cli.mdx +2 -2
  21. package/package.json +1 -1
  22. package/skills/blume-migrate/SKILL.md +1 -1
  23. package/skills/blume-migrate/references/mintlify.md +1 -1
  24. package/src/ai/ask-context.ts +51 -11
  25. package/src/ai/mcp/data.ts +3 -2
  26. package/src/ai/mcp/server.ts +3 -2
  27. package/src/assets/icon-dark.png +0 -0
  28. package/src/astro/generate.ts +172 -18
  29. package/src/astro/templates.ts +89 -15
  30. package/src/components/content/AccordionItem.astro +4 -0
  31. package/src/components/content/Update.astro +3 -0
  32. package/src/components/islands/AskAI.astro +6 -0
  33. package/src/components/islands/ask-ai.tsx +39 -9
  34. package/src/components/layout/Analytics.astro +9 -1
  35. package/src/components/layout/Favicon.astro +29 -8
  36. package/src/components/layout/Fonts.astro +23 -3
  37. package/src/components/layout/Header.astro +2 -2
  38. package/src/components/layout/NavSelector.astro +1 -1
  39. package/src/components/layout/PageActions.astro +120 -78
  40. package/src/components/layout/PageFeedback.astro +12 -3
  41. package/src/components/layout/PageLayout.astro +79 -5
  42. package/src/components/layout/ReferenceLayout.astro +12 -9
  43. package/src/components/layout/RootLayout.astro +153 -121
  44. package/src/components/layout/Search.astro +41 -26
  45. package/src/components/layout/drawer-inert.ts +10 -5
  46. package/src/components/layout/head-scripts.ts +34 -16
  47. package/src/components/layout/nav-utils.ts +34 -15
  48. package/src/components/layout/search/orama.ts +3 -2
  49. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  50. package/src/components/openapi/MessageComposer.astro +238 -0
  51. package/src/components/openapi/Operation.astro +26 -12
  52. package/src/components/openapi/PanelTabs.astro +7 -0
  53. package/src/components/openapi/Playground.astro +320 -0
  54. package/src/components/openapi/RequestPanel.astro +1 -0
  55. package/src/components/openapi/async-snippets.ts +20 -7
  56. package/src/components/openapi/async.ts +13 -2
  57. package/src/components/openapi/message-composer.ts +242 -0
  58. package/src/components/openapi/message-model.ts +108 -0
  59. package/src/components/openapi/message.ts +153 -0
  60. package/src/components/openapi/operation-model.ts +260 -0
  61. package/src/components/openapi/playground-client.ts +486 -0
  62. package/src/components/openapi/playground-schema.ts +109 -0
  63. package/src/components/openapi/request.ts +287 -0
  64. package/src/components/openapi/security.ts +0 -56
  65. package/src/components/openapi/snippets.ts +23 -136
  66. package/src/components/openapi/validate-json.ts +144 -0
  67. package/src/components/openapi/ws-client.ts +194 -0
  68. package/src/core/config-input.ts +67 -1
  69. package/src/core/content-assets.ts +66 -15
  70. package/src/core/data.ts +16 -2
  71. package/src/core/last-modified.ts +76 -2
  72. package/src/core/links.ts +30 -4
  73. package/src/core/navigation.ts +26 -1
  74. package/src/core/open-in-chat.ts +17 -0
  75. package/src/core/project-graph.ts +11 -0
  76. package/src/core/schema.ts +60 -1
  77. package/src/core/server-features.ts +11 -0
  78. package/src/core/sources/normalize.ts +10 -2
  79. package/src/core/types.ts +10 -3
  80. package/src/deploy/vercel-negotiation.ts +34 -14
  81. package/src/og/card.ts +3 -1
  82. package/src/openapi/model.ts +7 -0
  83. package/src/openapi/proxy.ts +217 -0
  84. package/src/openapi/references.ts +8 -0
  85. package/src/openapi/source.ts +13 -0
  86. package/src/registry/eject.ts +4 -5
  87. package/src/search/orama-index.ts +109 -36
  88. package/src/theme/entry.ts +15 -2
  89. package/src/theme/fonts.ts +75 -3
@@ -0,0 +1,78 @@
1
+ import type { OramaDoc } from "../search/orama-index.ts";
2
+ /** A chat message as posted by the Ask AI island (`{ role, content }`). */
3
+ export interface AskMessage {
4
+ content: string;
5
+ role: string;
6
+ }
7
+ /** The current-page hint the island forwards so the endpoint can prioritize it. */
8
+ export interface AskPage {
9
+ path?: string;
10
+ }
11
+ /**
12
+ * The self-contained snapshot the grounded Ask AI endpoint imports. Bundles the
13
+ * search documents so retrieval works regardless of the configured search
14
+ * provider and needs no filesystem access at request time. Serialized to
15
+ * `generated/ask-data.json` and built by {@link buildAskData}.
16
+ */
17
+ export interface AskData {
18
+ /**
19
+ * The site's `i18n.defaultLocale`, when i18n is configured. Selects a
20
+ * word-segmenting Orama tokenizer for every non-Latin script, so retrieval
21
+ * can match CJK, Cyrillic, Greek, Hebrew, or Devanagari content.
22
+ */
23
+ defaultLocale?: string;
24
+ documents: OramaDoc[];
25
+ site: string | null;
26
+ }
27
+ /**
28
+ * How much retrieved documentation a question carries (the `ai.ask.retrieval`
29
+ * config). Every field falls back to the built-in default, so a partial object
30
+ * only changes what it names. Injected characters dominate time-to-first-token
31
+ * on a self-hosted backend, and the three knobs aren't interchangeable: the
32
+ * budget caps the total, `excerptChars` decides how deep into one long page the
33
+ * excerpt reaches, and `maxResults` decides how many pages retrieval adds (the
34
+ * page the reader is viewing is injected on top of them).
35
+ */
36
+ export interface AskRetrievalOptions {
37
+ /** Overall cap on injected documentation characters. Defaults to `10000`. */
38
+ contextBudget?: number;
39
+ /** Characters kept per injected excerpt. Defaults to `2000`. */
40
+ excerptChars?: number;
41
+ /**
42
+ * Documents retrieved per question. Defaults to `6`. The current page is
43
+ * injected in addition when it isn't among the hits.
44
+ */
45
+ maxResults?: number;
46
+ }
47
+ /**
48
+ * Excerpt the region of `content` most relevant to `query`, not just its head.
49
+ *
50
+ * Pages are indexed whole (one document each), so a naive head slice of a long
51
+ * page returns its intro and misses sections below the fold — the exact failure
52
+ * where "How does Ask AI work?" retrieves the right page but only sees its
53
+ * opening paragraph. This centers the window on the densest cluster of query
54
+ * terms so the injected text is the part that actually answers the question.
55
+ * Exported for testing; {@link createAskContext} is the runtime entry point.
56
+ */
57
+ export declare const relevantExcerpt: (content: string, query: string, max: number) => string;
58
+ /**
59
+ * Build the request-time grounding function for the Ask AI endpoint.
60
+ *
61
+ * Lexical retrieval over Orama (the same index/ranking the search dialog and MCP
62
+ * server use). The index is built once and memoized across requests. Returns a
63
+ * grounded system prompt — the retrieved excerpts plus the page the user is
64
+ * viewing — or `undefined` when there is nothing to ground on, so the endpoint
65
+ * can fall back to its plain prompt.
66
+ *
67
+ * `options.instructions` (the `ai.ask.instructions` config) is appended after
68
+ * the base instruction rather than replacing it: the base carries the
69
+ * functional contract (answer only from the excerpts, cite pages as Markdown
70
+ * links) that the panel's citation rendering depends on.
71
+ *
72
+ * `options.retrieval` (the `ai.ask.retrieval` config) sizes how much
73
+ * documentation each question carries; omitted fields keep today's defaults.
74
+ */
75
+ export declare const createAskContext: (data: AskData, options?: {
76
+ instructions?: string;
77
+ retrieval?: AskRetrievalOptions;
78
+ }) => ((messages: AskMessage[], page?: AskPage) => Promise<string | undefined>);
@@ -2,7 +2,7 @@ import type { AstroIntegration } from "astro";
2
2
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
3
3
  import type { CodeTheme } from "../markdown/themes.ts";
4
4
  import type { FontSlug } from "../theme/fonts.ts";
5
- import type { OpenApiSource, SearchProvider, SidebarDisplay, SidebarItemConfig } from "./schema.ts";
5
+ import type { OpenApiSource, OpenInChatProvider, SearchProvider, SidebarDisplay, SidebarItemConfig } from "./schema.ts";
6
6
  import type { ContentSource } from "./sources/types.ts";
7
7
  import type { StandardSchema } from "./standard-schema.ts";
8
8
  /**
@@ -438,7 +438,7 @@ export type FontInput = LiteralUnion<FontSlug> | RemoteFontInput | LocalFontInpu
438
438
  export interface FontsConfig {
439
439
  /** Body / prose font. Defaults to `inter`. */
440
440
  body?: FontInput;
441
- /** Display / heading font. Defaults to `inter-tight`. */
441
+ /** Display / heading font. Defaults to `inter` (shared with the body). */
442
442
  display?: FontInput;
443
443
  /** Monospace / code font. Defaults to `ibm-plex-mono`. */
444
444
  mono?: FontInput;
@@ -548,6 +548,27 @@ export interface AskSuggestion {
548
548
  /** Backends that can route an Ask AI request. */
549
549
  type AskProviderGateway = "gateway" | "openrouter" | "llmgateway";
550
550
  type AskProvider = AskProviderGateway | "inkeep" | "openai-compatible";
551
+ /** How much retrieved documentation each Ask AI question carries. */
552
+ export interface AskRetrievalConfig {
553
+ /**
554
+ * Total injected documentation characters, across all excerpts. Defaults to
555
+ * `10000`. The single biggest lever on time-to-first-token — the model reads
556
+ * every injected character before it emits a token.
557
+ */
558
+ contextBudget?: number;
559
+ /**
560
+ * Characters kept per excerpt. Defaults to `2000`. Raise it when one long
561
+ * page holds the whole answer (a table the excerpt cuts in half); the
562
+ * `contextBudget` still caps the total.
563
+ */
564
+ excerptChars?: number;
565
+ /**
566
+ * Documents retrieved per question. Defaults to `6`. The page the reader is
567
+ * viewing is injected on top of the retrieved ones, so an answer can cite up
568
+ * to one page more than this.
569
+ */
570
+ maxResults?: number;
571
+ }
551
572
  export interface AskConfig {
552
573
  /**
553
574
  * Name of the env var holding the provider API key. Each provider has a
@@ -577,6 +598,12 @@ export interface AskConfig {
577
598
  model?: string;
578
599
  /** Which backend routes the request. Defaults to `gateway`. */
579
600
  provider?: AskProvider;
601
+ /**
602
+ * How much documentation each question carries into the model's prompt.
603
+ * Lower values cut time-to-first-token — which dominates on a self-hosted
604
+ * backend — at the cost of recall. Defaults keep the built-in behavior.
605
+ */
606
+ retrieval?: AskRetrievalConfig;
580
607
  /** Starter prompts shown before the first question. */
581
608
  suggestions?: AskSuggestion[];
582
609
  }
@@ -637,6 +664,19 @@ export interface AiConfig {
637
664
  markdownComponents?: Record<string, ComponentMarkdown>;
638
665
  /** Expose the docs as an MCP server for agents. */
639
666
  mcp?: McpConfig;
667
+ /**
668
+ * The "Open in chat" page action, which opens the current page in an AI
669
+ * assistant pre-filled with a prompt pointing at its raw Markdown.
670
+ * Defaults to `true` (every provider). Set `false` to hide the action, or
671
+ * list a subset of providers to show, in order.
672
+ *
673
+ * ```ts
674
+ * ai: {
675
+ * openInChat: ["claude", "chatgpt", "cursor"],
676
+ * }
677
+ * ```
678
+ */
679
+ openInChat?: boolean | OpenInChatProvider[];
640
680
  /**
641
681
  * Publish Agent Skills for discovery: a directory (resolved against the
642
682
  * project root) whose subdirectories each hold a `SKILL.md`. Skills are
@@ -1022,6 +1062,18 @@ interface ReferenceConfig {
1022
1062
  enabled?: boolean;
1023
1063
  /** Start nested schema rows expanded (Blume renderer). Defaults to `false`. */
1024
1064
  expandSchemas?: boolean;
1065
+ /**
1066
+ * The interactive "Try it" panel on operation pages (Blume renderer). On by
1067
+ * default; `false` hides it. `proxy` is the CORS escape hatch the OpenAPI
1068
+ * Send button routes requests through: a proxy URL, or `true` for the
1069
+ * built-in `/_api-proxy` endpoint (which requires
1070
+ * `deployment.output: "server"`). `proxy` is OpenAPI-only — an event
1071
+ * composer's WebSocket connect is direct.
1072
+ */
1073
+ playground?: boolean | {
1074
+ enabled?: boolean;
1075
+ proxy?: boolean | string;
1076
+ };
1025
1077
  /** Who renders the reference. Defaults to `blume`. */
1026
1078
  renderer?: "blume" | "scalar";
1027
1079
  /**
@@ -1,3 +1,4 @@
1
+ import type { FontHead } from "../theme/fonts.ts";
1
2
  import type { UIStrings } from "./i18n-ui.ts";
2
3
  import type { ResolvedConfig, SearchProvider } from "./schema.ts";
3
4
  import type { Navigation, RouteAlternate, VersionAlternate } from "./types.ts";
@@ -32,6 +33,17 @@ export interface BlumeLogo {
32
33
  export interface BlumeFavicon {
33
34
  href: string;
34
35
  type?: string;
36
+ /**
37
+ * Dark-scheme variant: the `-dark` sibling of the resolved icon file (e.g.
38
+ * `icon.svg` → `icon-dark.svg`), or the bundled default pair. When set, the
39
+ * layout emits an unconditional light link plus both icons behind
40
+ * `media="(prefers-color-scheme: …)"` so a dark mark doesn't vanish against
41
+ * dark browser chrome.
42
+ */
43
+ dark?: {
44
+ href: string;
45
+ type?: string;
46
+ };
35
47
  }
36
48
  /** Announcement banner, normalized from its config (string shorthand or object). */
37
49
  export interface BlumeBanner {
@@ -156,6 +168,11 @@ export interface BlumeDataConfig {
156
168
  */
157
169
  site?: string;
158
170
  };
171
+ /**
172
+ * "Open in chat" page-action providers (`ai.openInChat`), in display order;
173
+ * empty hides the action.
174
+ */
175
+ openInChat: ResolvedConfig["ai"]["openInChat"];
159
176
  /** Repository URL for header/edit links, or `null`. */
160
177
  repoUrl: string | null;
161
178
  search: {
@@ -209,8 +226,8 @@ export interface BlumeClientData {
209
226
  export interface BlumeData {
210
227
  config: BlumeDataConfig;
211
228
  feeds: BlumeFeed[];
212
- /** CSS variable names for the configured fonts (Astro `<Font>` integration). */
213
- fontCssVars: string[];
229
+ /** Configured fonts for the head: CSS variable + preload weights per family. */
230
+ fontCssVars: FontHead[];
214
231
  /** Sidebar + tab tree for the default locale. */
215
232
  navigation: Navigation;
216
233
  /** Per-locale navigation trees, keyed by locale code (empty without i18n). */
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Providers for the "Open in chat" page action, in display order. Shared by
3
+ * the config schema (`ai.openInChat` subsets validate against this list) and
4
+ * the PageActions menu (which renders the full list when the config is `true`).
5
+ * Lives outside `schema.ts` so the component can import the list without
6
+ * pulling the whole config schema into the layout module graph.
7
+ */
8
+ export declare const openInChatProviders: readonly ["v0", "chatgpt", "claude", "t3", "scira", "cursor"];
9
+ export type OpenInChatProvider = (typeof openInChatProviders)[number];
@@ -269,6 +269,11 @@ declare const aiConfigSchema: z.ZodObject<{
269
269
  inkeep: "inkeep";
270
270
  "openai-compatible": "openai-compatible";
271
271
  }>>;
272
+ retrieval: z.ZodOptional<z.ZodObject<{
273
+ contextBudget: z.ZodOptional<z.ZodNumber>;
274
+ excerptChars: z.ZodOptional<z.ZodNumber>;
275
+ maxResults: z.ZodOptional<z.ZodNumber>;
276
+ }, z.core.$strict>>;
272
277
  suggestions: z.ZodDefault<z.ZodArray<z.ZodObject<{
273
278
  icon: z.ZodOptional<z.ZodString>;
274
279
  label: z.ZodString;
@@ -291,6 +296,14 @@ declare const aiConfigSchema: z.ZodObject<{
291
296
  name: z.ZodOptional<z.ZodString>;
292
297
  route: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<string, string>>;
293
298
  }, z.core.$strict>>;
299
+ openInChat: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodEnum<{
300
+ v0: "v0";
301
+ chatgpt: "chatgpt";
302
+ claude: "claude";
303
+ t3: "t3";
304
+ scira: "scira";
305
+ cursor: "cursor";
306
+ }>>]>>, z.ZodTransform<("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[], boolean | ("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[]>>;
294
307
  skills: z.ZodOptional<z.ZodString>;
295
308
  webBotAuth: z.ZodPrefault<z.ZodObject<{
296
309
  keys: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
@@ -299,6 +312,8 @@ declare const aiConfigSchema: z.ZodObject<{
299
312
  }, z.core.$strict>;
300
313
  export type AskAiProvider = (typeof askAiProviders)[number];
301
314
  export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
315
+ export { openInChatProviders } from "./open-in-chat.ts";
316
+ export type { OpenInChatProvider } from "./open-in-chat.ts";
302
317
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
303
318
  declare const localeSchema: z.ZodObject<{
304
319
  code: z.ZodString;
@@ -473,6 +488,11 @@ export declare const blumeConfigSchema: z.ZodObject<{
473
488
  inkeep: "inkeep";
474
489
  "openai-compatible": "openai-compatible";
475
490
  }>>;
491
+ retrieval: z.ZodOptional<z.ZodObject<{
492
+ contextBudget: z.ZodOptional<z.ZodNumber>;
493
+ excerptChars: z.ZodOptional<z.ZodNumber>;
494
+ maxResults: z.ZodOptional<z.ZodNumber>;
495
+ }, z.core.$strict>>;
476
496
  suggestions: z.ZodDefault<z.ZodArray<z.ZodObject<{
477
497
  icon: z.ZodOptional<z.ZodString>;
478
498
  label: z.ZodString;
@@ -495,6 +515,14 @@ export declare const blumeConfigSchema: z.ZodObject<{
495
515
  name: z.ZodOptional<z.ZodString>;
496
516
  route: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<string, string>>;
497
517
  }, z.core.$strict>>;
518
+ openInChat: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodEnum<{
519
+ v0: "v0";
520
+ chatgpt: "chatgpt";
521
+ claude: "claude";
522
+ t3: "t3";
523
+ scira: "scira";
524
+ cursor: "cursor";
525
+ }>>]>>, z.ZodTransform<("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[], boolean | ("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[]>>;
498
526
  skills: z.ZodOptional<z.ZodString>;
499
527
  webBotAuth: z.ZodPrefault<z.ZodObject<{
500
528
  keys: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
@@ -521,6 +549,16 @@ export declare const blumeConfigSchema: z.ZodObject<{
521
549
  codeSamples: z.ZodDefault<z.ZodArray<z.ZodString>>;
522
550
  enabled: z.ZodDefault<z.ZodBoolean>;
523
551
  expandSchemas: z.ZodDefault<z.ZodBoolean>;
552
+ playground: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
553
+ enabled: z.ZodDefault<z.ZodBoolean>;
554
+ proxy: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>;
555
+ }, z.core.$strict>]>>, z.ZodTransform<{
556
+ enabled: boolean;
557
+ proxy: string | boolean;
558
+ }, boolean | {
559
+ enabled: boolean;
560
+ proxy: string | boolean;
561
+ }>>;
524
562
  renderer: z.ZodDefault<z.ZodEnum<{
525
563
  blume: "blume";
526
564
  scalar: "scalar";
@@ -814,6 +852,16 @@ export declare const blumeConfigSchema: z.ZodObject<{
814
852
  codeSamples: z.ZodDefault<z.ZodArray<z.ZodString>>;
815
853
  enabled: z.ZodDefault<z.ZodBoolean>;
816
854
  expandSchemas: z.ZodDefault<z.ZodBoolean>;
855
+ playground: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
856
+ enabled: z.ZodDefault<z.ZodBoolean>;
857
+ proxy: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>;
858
+ }, z.core.$strict>]>>, z.ZodTransform<{
859
+ enabled: boolean;
860
+ proxy: string | boolean;
861
+ }, boolean | {
862
+ enabled: boolean;
863
+ proxy: string | boolean;
864
+ }>>;
817
865
  renderer: z.ZodDefault<z.ZodEnum<{
818
866
  blume: "blume";
819
867
  scalar: "scalar";
@@ -1149,4 +1197,3 @@ export type SearchProvider = (typeof searchProviders)[number];
1149
1197
  export type ContentSignals = z.infer<typeof contentSignalsSchema>;
1150
1198
  /** The resolved per-signal policy object (present when signals are enabled). */
1151
1199
  export type ContentSignalPolicy = NonNullable<ContentSignals>;
1152
- export {};
@@ -47,6 +47,10 @@ export interface Heading {
47
47
  export interface PageLink {
48
48
  /** Raw link target as written, e.g. `./foo`, `/api#auth`, `https://x.dev`. */
49
49
  target: string;
50
+ /** Set when the target was written as an image embed (`![alt](target)`) —
51
+ * only those go through the image pipeline; a plain link to the same path
52
+ * resolves as a site route. */
53
+ image?: boolean;
50
54
  /** 1-based line number in the source file. */
51
55
  line: number;
52
56
  /** 1-based column of the target within the line. */
@@ -252,9 +256,12 @@ export interface Navigation {
252
256
  sidebar: NavNode[];
253
257
  /**
254
258
  * The tree root in final path space — localized and based (`/`, `/en`,
255
- * `/docs`). Tab paths arrive in the same space, so the tab sitting at this
256
- * path spans the whole tree and must be scoped as the root tab, not as a
257
- * section tab. Absent on older serialized graphs; treat as `/`.
259
+ * `/docs`), and versionized for an archived version tree (`/v1.0`). Tab
260
+ * paths share that space except under a version, where they stay in
261
+ * current-docs space — so the root tab is the tab this root sits under
262
+ * (`isRootTab`), not necessarily the tab at this exact path, and must be
263
+ * scoped as the root tab, not as a section tab. Absent on older serialized
264
+ * graphs; treat as `/`.
258
265
  */
259
266
  root?: string;
260
267
  /** Pinned links shown above the sidebar sections, unscoped by tab. */
@@ -16,6 +16,15 @@ export interface ReferenceDisplay {
16
16
  codeSamples: string[];
17
17
  /** Whether nested schema rows start expanded. */
18
18
  expandSchemas: boolean;
19
+ /**
20
+ * The "Try it" playground: whether operation pages render it, and the CORS
21
+ * proxy the Send button routes through (`false` off, a URL string, or
22
+ * `true` for the built-in `/_api-proxy` endpoint).
23
+ */
24
+ playground: {
25
+ enabled: boolean;
26
+ proxy: string | boolean;
27
+ };
19
28
  }
20
29
  /** A spec source resolved to a concrete route, label, and renderer. */
21
30
  export interface ReferenceSource {
@@ -0,0 +1,70 @@
1
+ import type { AnyOrama } from "@orama/orama";
2
+ /**
3
+ * The minimal document shape both the client-side search dialog and the
4
+ * server-side MCP `search_docs` tool index. Mirrors the `blume-search.json`
5
+ * entries built by `buildSearchDocuments`.
6
+ */
7
+ export interface OramaDoc {
8
+ content: string;
9
+ description: string;
10
+ route: string;
11
+ title: string;
12
+ /** Locale code; indexed as an enum so queries can filter to one language. */
13
+ locale?: string;
14
+ /**
15
+ * Docs version; indexed as an enum so queries can filter to one version.
16
+ * The current docs carry `""`, which the enum stores and matches exactly.
17
+ */
18
+ version?: string;
19
+ /** Resolved page `type`; indexed as an enum so queries can filter by type. */
20
+ contentType?: string;
21
+ /** Declared facet values (`content.types.<type>.facets`), key → value. */
22
+ facets?: Record<string, string>;
23
+ /** Carried through for the search dialog's breadcrumb + filter pills. Stored
24
+ * but not indexed, so they ride along on the returned document untouched. */
25
+ breadcrumb?: string[];
26
+ section?: string;
27
+ }
28
+ /**
29
+ * Build an in-memory Orama full-text index from search documents. Shared by the
30
+ * Orama client loader (browser), the MCP server, and Ask AI grounding (Node),
31
+ * so ranking is identical wherever docs are queried. `locale` — the site's
32
+ * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for every
33
+ * non-Latin script, all of which Orama's default tokenizer reduces to zero
34
+ * tokens; the tokenizer belongs to the database, so on a mixed-locale site it
35
+ * applies to every document. That is safe in one direction only: Latin words
36
+ * survive segmentation intact, so English pages on a segmented index stay
37
+ * searchable, but non-Latin translations on a Latin-default index still
38
+ * collapse to zero tokens.
39
+ */
40
+ export declare const buildOramaIndex: (documents: OramaDoc[], locale?: string) => Promise<AnyOrama>;
41
+ /** Optional exact-match filters applied to a query via Orama's `where`. */
42
+ export interface OramaQueryFilters {
43
+ /** Keep only documents whose `contentType` is in this list. */
44
+ contentTypes?: string[];
45
+ /**
46
+ * Keep only documents matching every facet, key → required value. Facet
47
+ * keys and values come from the `facets` field on the indexed documents.
48
+ */
49
+ facets?: Record<string, string>;
50
+ /** Keep only documents in this locale. */
51
+ locale?: string;
52
+ /**
53
+ * Keep only documents of this docs version (`""` is the current docs — a
54
+ * meaningful filter value, so absence alone disables version filtering).
55
+ */
56
+ version?: string;
57
+ }
58
+ /**
59
+ * Query the index, returning the matching documents (highest-ranked first).
60
+ * `filters` narrows results by exact `where` matches on the enum fields:
61
+ * `locale` to one language, `contentTypes` to a set of page types, `facets`
62
+ * to documents carrying every requested `key:value` term.
63
+ *
64
+ * On a bigrammed index the strict pass runs first: a term is only meant to
65
+ * match where its bigrams sit together, and scoring them independently lets a
66
+ * page sharing a couple of windows outrank the page the term is about. Terms
67
+ * spanning several words rarely appear in full on one page, so an empty strict
68
+ * result falls back to the default pass rather than reporting no matches.
69
+ */
70
+ export declare const queryOramaIndex: (db: AnyOrama, term: string, limit: number, filters?: OramaQueryFilters) => Promise<OramaDoc[]>;
@@ -203,5 +203,14 @@ export declare const buildFontEntries: (fonts: FontsConfig) => FontEntry[];
203
203
  * config tokens; empty when no fonts are set so defaults stay the system stacks.
204
204
  */
205
205
  export declare const buildFontsCss: (fonts: FontsConfig) => string;
206
- /** The CSS variables to feed Astro's `<Font>` component in the document head. */
207
- export declare const configuredCssVars: (fonts: FontsConfig) => string[];
206
+ /** One `<Font>` render in the head: its CSS variable + weights to preload. */
207
+ export interface FontHead {
208
+ cssVariable: string;
209
+ preloadWeights: number[];
210
+ }
211
+ /**
212
+ * The fonts to feed Astro's `<Font>` component in the document head, deduped
213
+ * by CSS variable with preload weights unioned across the roles that share a
214
+ * family (so `display` and `body` both set to Inter preload 400/500/600 once).
215
+ */
216
+ export declare const configuredFonts: (fonts: FontsConfig) => FontHead[];
@@ -3,7 +3,7 @@ title: OpenAPI / AsyncAPI
3
3
  description: Drop in an OpenAPI or AsyncAPI spec and get a native API reference — one real page per operation, in your sidebar and search.
4
4
  ---
5
5
 
6
- Point Blume at an OpenAPI spec and it generates a native API reference: one **real page per operation**, grouped by tag in a tab-scoped sidebar, with schema tables, request/response examples, and generated code samples. Because each operation is a genuine Blume page, it gets its own URL, shows up in **site search** and `llms.txt`, and gets an Open Graph image — the same as any hand-written doc. Event-driven APIs get the [same treatment from an AsyncAPI spec](#asyncapi). The config below points Blume at the public Petstore spec as an example.
6
+ Point Blume at an OpenAPI spec and it generates a native API reference: one **real page per operation**, grouped by tag in a tab-scoped sidebar, with schema tables, request/response examples, generated code samples, and an interactive [Try it](#try-it-playground) panel. Because each operation is a genuine Blume page, it gets its own URL, shows up in **site search** and `llms.txt`, and gets an Open Graph image — the same as any hand-written doc. The config below points Blume at the public Petstore spec as an example.
7
7
 
8
8
  ```ts blume.config.ts lineNumbers
9
9
  openapi: {
@@ -62,6 +62,42 @@ openapi: {
62
62
  }
63
63
  ```
64
64
 
65
+ ## Try it playground
66
+
67
+ Operation pages rendered natively ship an interactive **Try it** panel by default. Blume generates the form from the operation itself: an input per path, query, and header parameter, a body editor built from the request-body schema, everything prefilled from the spec's examples. A server picker lists the spec's `servers`, with a free-text field for any other base URL, and auth inputs match the operation's [resolved security](#authorization) — bearer token, API key, and basic credentials, with OAuth2 as a token paste field (bring an access token; Blume doesn't run the flow).
68
+
69
+ The panel and the code samples stay in lockstep: values typed into the form update the generated samples live, so a copied curl command always matches exactly what **Send** would do. And it stays out of the way — the panel is server-rendered collapsed, and its JavaScript loads only when a reader first opens it. Readers who never touch it download none of it.
70
+
71
+ `playground: false` is the entire off switch:
72
+
73
+ ```ts blume.config.ts lineNumbers
74
+ openapi: {
75
+ enabled: true,
76
+ spec: "./openapi.yaml",
77
+ playground: false,
78
+ }
79
+ ```
80
+
81
+ ### Credentials
82
+
83
+ Credentials typed into the auth inputs stay in memory and vanish on reload. Checking **Remember on this device** persists them in `localStorage`, scoped to the docs origin — they're never sent anywhere except the API being called. Code samples keep showing placeholders (`YOUR_TOKEN` and friends) whatever's typed, unless the reader toggles **Include my values in samples**.
84
+
85
+ ### CORS and the proxy
86
+
87
+ As with the [Scalar renderer](#the-scalar-renderer), requests go **directly from the browser** to the target API, so the API must allow cross-origin requests from the docs site (`Access-Control-Allow-Origin`). For APIs that can't, set `playground.proxy`: a URL routes requests through a proxy you host, and `true` enables the built-in `/_api-proxy` route — which needs a server build, so it requires [`deployment.output: "server"`](/docs/deployment#server-rendering):
88
+
89
+ ```ts blume.config.ts lineNumbers
90
+ openapi: {
91
+ enabled: true,
92
+ spec: "./openapi.yaml",
93
+ playground: {
94
+ proxy: true, // or a URL of your own
95
+ },
96
+ }
97
+ ```
98
+
99
+ The built-in proxy only forwards requests to the origins your specs declare in `servers` — including across redirects — so a public docs deployment can't be aimed at other hosts on its network. A **Custom base URL** typed into the panel isn't a documented server: with the proxy enabled, requests to it are refused with a 403.
100
+
65
101
  ## Multiple specs
66
102
 
67
103
  Use `sources` to publish more than one spec. Each source gets its own overview route, operation pages, and header tab. Give each a `label` (used for the tab and to derive its route), or set an explicit `route`:
@@ -118,7 +154,7 @@ The OpenAPI semantics carry over as written:
118
154
 
119
155
  ## The Scalar renderer
120
156
 
121
- The native renderer is the default. If you'd rather embed [Scalar](https://scalar.com)'s self-contained API reference — its own sidebar, search, theme, and "Try it" playground on a single route — set `renderer: "scalar"`:
157
+ The native renderer is the default — operation pages, search integration, and the [Try it playground](#try-it-playground) above are all its work. If you'd rather embed [Scalar](https://scalar.com)'s self-contained API reference UI — its own sidebar, search, theme, and request client on a single route — set `renderer: "scalar"`:
122
158
 
123
159
  ```ts blume.config.ts lineNumbers
124
160
  openapi: {
@@ -129,7 +165,7 @@ openapi: {
129
165
  }
130
166
  ```
131
167
 
132
- A Scalar-rendered reference is a self-contained embed on its own route — it doesn't weave into Blume's sidebar, search, or `llms.txt`. Its "Try it" playground calls your **target API directly from the browser** (Blume doesn't proxy), so the API must allow cross-origin requests from the docs site (`Access-Control-Allow-Origin`). `theme` and the playground apply to the Scalar renderer only.
168
+ A Scalar-rendered reference is a self-contained embed on its own route — it doesn't weave into Blume's sidebar, search, or `llms.txt`, and Blume's [`playground`](#try-it-playground) config doesn't apply to it. Scalar brings its own request client, which calls your **target API directly from the browser** (the `playground.proxy` route isn't available here), so the API must allow cross-origin requests from the docs site (`Access-Control-Allow-Origin`). `theme` applies to the Scalar renderer only.
133
169
 
134
170
  ### Passing Scalar options
135
171
 
@@ -153,7 +189,7 @@ Blume's own [`i18n`](/docs/content/i18n) translates the docs chrome, but Scalar
153
189
 
154
190
  ## AsyncAPI
155
191
 
156
- Event-driven APIs use a sibling `asyncapi` block with the same shape — and the same native renderer. Each `send`/`receive` operation becomes a real page with message payload and header schema tables, channel parameters, protocol bindings, and an Authorization section derived from the spec's `securitySchemes` (server-level and operation-level, alternatives as "or" groups). Only the default route differs (`/events`):
192
+ Event-driven APIs use a sibling `asyncapi` block with the same shape — and the same native renderer. Each `send`/`receive` operation becomes a real page with message payload and header schema tables, channel parameters, protocol bindings, an Authorization section derived from the spec's `securitySchemes` (server-level and operation-level, alternatives as "or" groups), and a [Try it](#try-it-for-events) message composer. Only the default route differs (`/events`):
157
193
 
158
194
  ```ts blume.config.ts lineNumbers
159
195
  asyncapi: {
@@ -166,4 +202,30 @@ AsyncAPI **2.x specs are normalized to 3.x automatically** with the official Asy
166
202
 
167
203
  Code samples are **protocol-aware**, keyed off the operation's binding (or its servers' protocol): `wscat` and a browser `WebSocket` snippet for WebSockets, `kcat` for Kafka, `mosquitto_pub`/`mosquitto_sub` for MQTT. `codeSamples` filters that set, the same way it picks languages on the `openapi` block; a protocol without a supported tool renders the message payload example alone rather than a fabricated client.
168
204
 
169
- Everything documented above carries over unchanged: `route`, `sources` with `label`/`route`, `expandSchemas`, the [per-source indexing](#per-source-indexing) flags, and search indexing by operation summary and tag. Setting `renderer: "scalar"` opts back into the embedded Scalar SPA — Scalar auto-detects the document type and renders channels, operations, messages, and a Models section (no interactive playground for events) — where, as with OpenAPI, only `noindex` applies.
205
+ Everything documented above carries over, [`playground`](#try-it-for-events) included: `route`, `sources` with `label`/`route`, `expandSchemas`, the [per-source indexing](#per-source-indexing) flags, and search indexing by operation summary and tag.
206
+
207
+ Setting `renderer: "scalar"` opts back into the embedded Scalar SPA, where — as with OpenAPI — only `noindex` applies. Scalar has no AsyncAPI playground of its own; its embed auto-detects the document type and renders channels, operations, messages, and a Models section, so that swap trades the composer away.
208
+
209
+ ### Try it for events
210
+
211
+ Operation pages rendered natively ship a **Try it** panel here too, on the same terms as the [OpenAPI panel](#try-it-playground): server-rendered collapsed, with its JavaScript loaded only when a reader first opens it.
212
+
213
+ Whatever the protocol, the panel opens with a payload editor prefilled from the message's `examples` — or, when the message declares none, from a value sampled out of the payload schema — validated against the message payload schema as you type. Under it sit an input per channel parameter and a server picker fed by the channel's `servers`, with a free-text field for any other URL. The protocol-aware code samples stay in lockstep with the form exactly as curl, js, and python do on an HTTP operation: the channel address template is filled in with the parameter values you type, so a copied `wscat`, `WebSocket`, `kcat`, or `mosquitto_pub` snippet matches what the form says.
214
+
215
+ Live connect is WebSocket-only. On a `ws` or `wss` binding the panel connects to the resolved channel URL, shows the connection state, and logs every frame with a timestamp. AsyncAPI 3 states an action from the API's side, and the panel follows it: a `receive` operation is one the API receives from you, so it gets a **Send** button that publishes the composed payload; a `send` operation only streams messages at you, so it connects and logs. There's no reconnect logic — once a socket closes, it stays closed until you connect again. Kafka, MQTT, AMQP, and every other protocol get the composer and the copyable CLI samples, and the panel says as much on the page: Blume doesn't fake broker connectivity from a browser tab.
216
+
217
+ `asyncapi.playground` mirrors `openapi.playground` — on by default with the native renderer, and `false` is the entire off switch:
218
+
219
+ ```ts blume.config.ts lineNumbers
220
+ asyncapi: {
221
+ enabled: true,
222
+ spec: "./asyncapi.yaml",
223
+ playground: false,
224
+ }
225
+ ```
226
+
227
+ :::note
228
+ `playground.proxy` is OpenAPI-only. It forwards HTTP requests, and a WebSocket connect goes straight from the browser to the server named in the URL, so there's nothing for a proxy to sit in front of.
229
+ :::
230
+
231
+ The event composer collects no broker credentials. Each operation page's **Authorization** section documents what the broker expects, and a WebSocket connect carries only what's already in the URL. Nothing is persisted for event operations.
@@ -231,13 +231,17 @@ Passing `siteUrl` (and `ogEnabled`) derives the page's `canonical` and a generat
231
231
  siteUrl={config.site}
232
232
  ogEnabled={config.og.enabled}
233
233
  ogImage="/opengraph-image.png"
234
+ ogImageAlt="Acme — the fastest docs"
235
+ ogImageSize={{ width: 1200, height: 630 }}
234
236
  page={{ title: config.title }}
235
237
  >
236
238
  <!-- page content -->
237
239
  </PageLayout>
238
240
  ```
239
241
 
240
- Only this page changes — every other route keeps its generated card — so it's how you give the home page alone a bespoke share image.
242
+ Only this page changes — every other route keeps its generated card — so it's how you give the home page alone a bespoke share image. The generated card declares its size and alt text to crawlers on its own; for your own `ogImage`, pass `ogImageAlt` and `ogImageSize` alongside so the share card gets the same treatment.
243
+
244
+ The page also emits schema.org JSON-LD — the same `WebSite` graph the docs pages carry, so the home page (usually a custom page) isn't the one URL without structured data. Pass `structuredDataEnabled={config.structuredData}` to keep it in sync with the [`structuredData`](/docs/configuration/seo) config, or `structuredDataEnabled={false}` to turn it off for one page.
241
245
 
242
246
  `page.title` is used verbatim as the document title (no `- siteTitle` suffix), since marketing pages usually set their own. To give a custom page the full docs chrome instead — sidebar, TOC, and all — wrap it in `RootLayout`, the layout the generated pages use. Pull the required props straight from `blume:data`:
243
247
 
@@ -101,6 +101,14 @@ The **Open in chat** action opens the current page in an AI assistant — v0, Ch
101
101
 
102
102
  Like Copy as Markdown, it needs no setup. The assistant fetches the page over its public URL, so it works as soon as the page is deployed.
103
103
 
104
+ To tailor the action, set `ai.openInChat`. `false` hides it entirely, and an array of provider keys — `"v0"`, `"chatgpt"`, `"claude"`, `"t3"`, `"scira"`, `"cursor"` — shows just those providers, in the order you list them:
105
+
106
+ ```ts blume.config.ts lineNumbers
107
+ ai: {
108
+ openInChat: ["claude", "chatgpt", "cursor"],
109
+ }
110
+ ```
111
+
104
112
  To embed a ready-to-copy prompt inline in your content — rather than a whole-page action — use the [Prompt component](/docs/content/components#prompt), which renders a labeled row with a **Copy prompt** button and an optional open-in-Cursor link.
105
113
 
106
114
  ## Ask AI
@@ -160,6 +168,33 @@ The page the reader is currently on is added to the context first and used to sc
160
168
 
161
169
  Grounding is on for every backend except **[Inkeep](#backends)**, which runs its own retrieval over the content you've indexed in its dashboard.
162
170
 
171
+ ### Retrieval size
172
+
173
+ How much documentation a question carries is the biggest lever on how long the reader waits for the first word: the model reads every injected character before it emits a token. On a hosted frontier model that's invisible, but on a self-hosted backend it dominates. `retrieval` sizes it:
174
+
175
+ ```ts blume.config.ts lineNumbers
176
+ ai: {
177
+ ask: {
178
+ enabled: true,
179
+ retrieval: {
180
+ maxResults: 3, // fewer pages retrieved per question
181
+ excerptChars: 1200, // shorter excerpt from each one
182
+ contextBudget: 3000, // smaller total injection
183
+ },
184
+ },
185
+ }
186
+ ```
187
+
188
+ | Option | Default | Description |
189
+ | --------------- | ------- | ----------------------------------------------- |
190
+ | `maxResults` | `6` | Documents retrieved per question. |
191
+ | `excerptChars` | `2000` | Characters kept from each retrieved page. |
192
+ | `contextBudget` | `10000` | Total injected characters, across all excerpts. |
193
+
194
+ The three aren't interchangeable. `contextBudget` caps the whole injection, `excerptChars` decides how deep into a single long page its excerpt reaches — raise it when one page holds the whole answer and the excerpt cuts it off — and `maxResults` caps how many pages retrieval adds. The page the reader is viewing is injected on top of the retrieved ones, so an answer can cite up to one page more than `maxResults`.
195
+
196
+ The defaults suit a hosted model. Lower them when you're serving from your own hardware and time-to-first-token matters more than recall; answers stay grounded either way, and the assistant is told to say when something isn't covered rather than fill the gap.
197
+
163
198
  ### External endpoint
164
199
 
165
200
  Already have an API backend for AI? Point the panel at it and keep the docs build static: