blume 1.5.1 → 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 (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +340 -132
  3. package/dist/cli/index.js.map +21 -20
  4. package/dist/types/ai/ask-context.d.ts +78 -0
  5. package/dist/types/core/config-input.d.ts +53 -1
  6. package/dist/types/core/data.d.ts +16 -0
  7. package/dist/types/core/open-in-chat.d.ts +9 -0
  8. package/dist/types/core/schema.d.ts +48 -1
  9. package/dist/types/core/types.d.ts +10 -3
  10. package/dist/types/openapi/references.d.ts +9 -0
  11. package/dist/types/search/orama-index.d.ts +70 -0
  12. package/docs/advanced/api-reference.mdx +67 -5
  13. package/docs/configuration/ai.mdx +35 -0
  14. package/docs/configuration/index.mdx +13 -1
  15. package/docs/configuration/search.mdx +4 -4
  16. package/docs/reference/cli.mdx +1 -1
  17. package/package.json +1 -1
  18. package/skills/blume-migrate/SKILL.md +1 -1
  19. package/skills/blume-migrate/references/mintlify.md +1 -1
  20. package/src/ai/ask-context.ts +51 -11
  21. package/src/ai/mcp/data.ts +3 -2
  22. package/src/ai/mcp/server.ts +3 -2
  23. package/src/assets/icon-dark.png +0 -0
  24. package/src/astro/generate.ts +162 -13
  25. package/src/astro/templates.ts +63 -11
  26. package/src/components/content/AccordionItem.astro +4 -0
  27. package/src/components/content/Update.astro +3 -0
  28. package/src/components/islands/AskAI.astro +6 -0
  29. package/src/components/islands/ask-ai.tsx +39 -9
  30. package/src/components/layout/Analytics.astro +9 -1
  31. package/src/components/layout/Favicon.astro +29 -8
  32. package/src/components/layout/Header.astro +2 -2
  33. package/src/components/layout/NavSelector.astro +1 -1
  34. package/src/components/layout/PageActions.astro +120 -78
  35. package/src/components/layout/PageFeedback.astro +12 -3
  36. package/src/components/layout/PageLayout.astro +7 -2
  37. package/src/components/layout/ReferenceLayout.astro +10 -8
  38. package/src/components/layout/RootLayout.astro +151 -120
  39. package/src/components/layout/Search.astro +41 -26
  40. package/src/components/layout/drawer-inert.ts +10 -5
  41. package/src/components/layout/head-scripts.ts +34 -16
  42. package/src/components/layout/nav-utils.ts +34 -15
  43. package/src/components/layout/search/orama.ts +3 -2
  44. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  45. package/src/components/openapi/MessageComposer.astro +238 -0
  46. package/src/components/openapi/Operation.astro +26 -12
  47. package/src/components/openapi/PanelTabs.astro +7 -0
  48. package/src/components/openapi/Playground.astro +320 -0
  49. package/src/components/openapi/RequestPanel.astro +1 -0
  50. package/src/components/openapi/async-snippets.ts +20 -7
  51. package/src/components/openapi/async.ts +13 -2
  52. package/src/components/openapi/message-composer.ts +242 -0
  53. package/src/components/openapi/message-model.ts +108 -0
  54. package/src/components/openapi/message.ts +153 -0
  55. package/src/components/openapi/operation-model.ts +260 -0
  56. package/src/components/openapi/playground-client.ts +486 -0
  57. package/src/components/openapi/playground-schema.ts +109 -0
  58. package/src/components/openapi/request.ts +287 -0
  59. package/src/components/openapi/security.ts +0 -56
  60. package/src/components/openapi/snippets.ts +23 -136
  61. package/src/components/openapi/validate-json.ts +144 -0
  62. package/src/components/openapi/ws-client.ts +194 -0
  63. package/src/core/config-input.ts +66 -0
  64. package/src/core/content-assets.ts +66 -15
  65. package/src/core/data.ts +13 -0
  66. package/src/core/last-modified.ts +28 -3
  67. package/src/core/links.ts +30 -4
  68. package/src/core/navigation.ts +26 -1
  69. package/src/core/open-in-chat.ts +17 -0
  70. package/src/core/schema.ts +59 -0
  71. package/src/core/server-features.ts +11 -0
  72. package/src/core/sources/normalize.ts +10 -2
  73. package/src/core/types.ts +10 -3
  74. package/src/openapi/model.ts +7 -0
  75. package/src/openapi/proxy.ts +217 -0
  76. package/src/openapi/references.ts +8 -0
  77. package/src/openapi/source.ts +13 -0
  78. package/src/registry/eject.ts +4 -5
  79. package/src/search/orama-index.ts +109 -36
  80. package/src/theme/entry.ts +6 -15
@@ -8,6 +8,7 @@ import { normalizeXHandle } from "../seo/x-handle.ts";
8
8
  import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
9
9
  import { normalizeBasePath } from "./base-path.ts";
10
10
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
11
+ import { openInChatProviders } from "./open-in-chat.ts";
11
12
  import type { ContentSource } from "./sources/types.ts";
12
13
  import { isStandardSchema } from "./standard-schema.ts";
13
14
  import type { StandardSchema } from "./standard-schema.ts";
@@ -813,6 +814,19 @@ const aiConfigSchema = z.strictObject({
813
814
  instructions: z.string().trim().min(1).optional(),
814
815
  model: z.string().default("openai/gpt-5.5"),
815
816
  provider: z.enum(askAiProviders).default("gateway"),
817
+ // How much documentation each question carries. Injected characters are
818
+ // the dominant term in time-to-first-token on a self-hosted backend, so
819
+ // these trade recall for latency. No zod defaults here: only what the
820
+ // user set reaches the generated (and ejected) endpoint, so omitted
821
+ // fields keep tracking the installed package's built-in defaults in
822
+ // `ai/ask-context.ts` instead of pinning today's numbers as literals.
823
+ retrieval: z
824
+ .strictObject({
825
+ contextBudget: z.number().int().positive().optional(),
826
+ excerptChars: z.number().int().positive().optional(),
827
+ maxResults: z.number().int().positive().optional(),
828
+ })
829
+ .optional(),
816
830
  // Empty-state prompts shown before the first question. Each renders as a
817
831
  // clickable suggestion; `icon` is an optional Lucide name beside it.
818
832
  suggestions: z
@@ -877,6 +891,28 @@ const aiConfigSchema = z.strictObject({
877
891
  .default({}),
878
892
  /** Expose the docs as an MCP server for connecting agents. */
879
893
  mcp: mcpConfigSchema.prefault({}),
894
+ /**
895
+ * The "Open in chat" page action. `true` (the default) lists every
896
+ * provider, `false` hides the action entirely, and an array of provider
897
+ * keys shows just that subset, in the given order. Normalized to the
898
+ * provider list so consumers read a plain array.
899
+ */
900
+ openInChat: z
901
+ .union([
902
+ z.boolean(),
903
+ z
904
+ .array(z.enum(openInChatProviders))
905
+ .refine((value) => new Set(value).size === value.length, {
906
+ message: "ai.openInChat must not repeat a provider.",
907
+ }),
908
+ ])
909
+ .default(true)
910
+ .transform((value) => {
911
+ if (isBoolean(value)) {
912
+ return value ? [...openInChatProviders] : [];
913
+ }
914
+ return value;
915
+ }),
880
916
  /**
881
917
  * Publish Agent Skills for discovery: a directory (resolved against the
882
918
  * project root) whose subdirectories each hold a `SKILL.md`. The build
@@ -947,6 +983,8 @@ const navigationConfigSchema = z.strictObject({
947
983
 
948
984
  export type AskAiProvider = (typeof askAiProviders)[number];
949
985
  export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
986
+ export { openInChatProviders } from "./open-in-chat.ts";
987
+ export type { OpenInChatProvider } from "./open-in-chat.ts";
950
988
 
951
989
  // Reader-facing "Export" page action (PDF via print, EPUB via client-side
952
990
  // generation). Off by default. Accepts a shorthand boolean to toggle both
@@ -1553,6 +1591,27 @@ const referenceConfigSchema = (defaults: {
1553
1591
  enabled: z.boolean().default(false),
1554
1592
  /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
1555
1593
  expandSchemas: z.boolean().default(false),
1594
+ /**
1595
+ * The interactive "Try it" panel on operation pages (Blume renderer). On by
1596
+ * default; `false` hides it. The object form keeps it on and sets `proxy`,
1597
+ * the CORS escape hatch the OpenAPI Send button routes requests through: a
1598
+ * proxy URL, or `true` for the built-in `/_api-proxy` endpoint (which
1599
+ * requires `deployment.output: "server"`). Booleans normalize to the object
1600
+ * shape so consumers read `{ enabled, proxy }` directly. `proxy` is
1601
+ * OpenAPI-only — an event composer's WebSocket connect is direct.
1602
+ */
1603
+ playground: z
1604
+ .union([
1605
+ z.boolean(),
1606
+ z.strictObject({
1607
+ enabled: z.boolean().default(true),
1608
+ proxy: z.union([z.boolean(), z.string()]).default(false),
1609
+ }),
1610
+ ])
1611
+ .default(true)
1612
+ .transform((value) =>
1613
+ isBoolean(value) ? { enabled: value, proxy: false } : value
1614
+ ),
1556
1615
  /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
1557
1616
  renderer: z.enum(["blume", "scalar"]).default("blume"),
1558
1617
  /** Where the reference mounts. */
@@ -14,6 +14,17 @@ export const serverFeatures = (config: ResolvedConfig): string[] => {
14
14
  if (config.ai.mcp.enabled) {
15
15
  features.push("MCP server");
16
16
  }
17
+ // The built-in playground proxy (`openapi.playground.proxy: true`) is a
18
+ // live fetch endpoint at `/_api-proxy`; an external proxy URL (string) or a
19
+ // proxy-less playground stays fully static.
20
+ if (
21
+ config.openapi.enabled &&
22
+ config.openapi.renderer === "blume" &&
23
+ config.openapi.playground.enabled &&
24
+ config.openapi.playground.proxy === true
25
+ ) {
26
+ features.push("API playground proxy");
27
+ }
17
28
  // Mixedbread (and any future provider) that proxies queries through a secret
18
29
  // server endpoint can't run on a static build.
19
30
  if (searchProviderMeta(config.search.provider).requiresServer) {
@@ -413,11 +413,18 @@ const scanLinkLine = (
413
413
  // for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
414
414
  // would otherwise report the column inside the label.
415
415
  const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
416
- links.push({
416
+ const entry: PageLink = {
417
417
  column: match.index + targetOffset + 1,
418
418
  line: lineNumber,
419
419
  target,
420
- });
420
+ };
421
+ // `MD_LINK` matches the `[label](target)` tail of an image embed too; the
422
+ // preceding `!` is what marks the target as going through the image
423
+ // pipeline rather than resolving as a site route.
424
+ if (masked[match.index - 1] === "!") {
425
+ entry.image = true;
426
+ }
427
+ links.push(entry);
421
428
  // An image nested in the label (`[![alt](/img.png)](/target)`) carries its
422
429
  // own target; surface it too so a missing image is still caught.
423
430
  const label = match[0].slice(0, targetOffset - "](".length);
@@ -432,6 +439,7 @@ const scanLinkLine = (
432
439
  image.index +
433
440
  targetOffsetIn(image[0], imageTarget, image.groups?.title) +
434
441
  1,
442
+ image: true,
435
443
  line: lineNumber,
436
444
  target: imageTarget,
437
445
  });
package/src/core/types.ts CHANGED
@@ -58,6 +58,10 @@ export interface Heading {
58
58
  export interface PageLink {
59
59
  /** Raw link target as written, e.g. `./foo`, `/api#auth`, `https://x.dev`. */
60
60
  target: string;
61
+ /** Set when the target was written as an image embed (`![alt](target)`) —
62
+ * only those go through the image pipeline; a plain link to the same path
63
+ * resolves as a site route. */
64
+ image?: boolean;
61
65
  /** 1-based line number in the source file. */
62
66
  line: number;
63
67
  /** 1-based column of the target within the line. */
@@ -269,9 +273,12 @@ export interface Navigation {
269
273
  sidebar: NavNode[];
270
274
  /**
271
275
  * The tree root in final path space — localized and based (`/`, `/en`,
272
- * `/docs`). Tab paths arrive in the same space, so the tab sitting at this
273
- * path spans the whole tree and must be scoped as the root tab, not as a
274
- * section tab. Absent on older serialized graphs; treat as `/`.
276
+ * `/docs`), and versionized for an archived version tree (`/v1.0`). Tab
277
+ * paths share that space except under a version, where they stay in
278
+ * current-docs space — so the root tab is the tab this root sits under
279
+ * (`isRootTab`), not necessarily the tab at this exact path, and must be
280
+ * scoped as the root tab, not as a section tab. Absent on older serialized
281
+ * graphs; treat as `/`.
275
282
  */
276
283
  root?: string;
277
284
  /** Pinned links shown above the sidebar sections, unscoped by tab. */
@@ -98,6 +98,13 @@ export interface ApiSpecData {
98
98
  codeSamples: string[];
99
99
  /** Whether nested schema rows start expanded. */
100
100
  expandSchemas: boolean;
101
+ /**
102
+ * The "Try it" playground: whether operation pages render it, and the
103
+ * resolved proxy the Send button targets — `false` for direct requests, a
104
+ * URL string otherwise (the built-in `/_api-proxy` route already carries
105
+ * the site `basePath`).
106
+ */
107
+ playground: { enabled: boolean; proxy: string | false };
101
108
  }
102
109
 
103
110
  /** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The playground's CORS proxy. Browsers block the "Try it" panel's `fetch`
3
+ * whenever the documented API doesn't allow cross-origin requests from the
4
+ * docs site, so `openapi.playground.proxy: true` mounts this handler at
5
+ * `/_api-proxy` (server output only) and the client sends its real request
6
+ * here as `?url=<encoded target>` instead. The handler replays the request
7
+ * upstream and mirrors the response back, so the browser only ever talks
8
+ * same-origin.
9
+ *
10
+ * Kept dependency-free and `fetch`-injectable so it unit-tests without a
11
+ * network and stays safe to bundle into the generated endpoint file.
12
+ */
13
+
14
+ /**
15
+ * Request headers never forwarded upstream: hop-by-hop headers describe this
16
+ * connection (not the upstream one), `host`/`origin`/`referer` would leak or
17
+ * misattribute the docs site, and `cookie` would forward reader credentials
18
+ * to an arbitrary target. `accept-encoding`/`content-length` are recomputed
19
+ * by the runtime's own fetch.
20
+ */
21
+ const REQUEST_DROP = {
22
+ "accept-encoding": true,
23
+ connection: true,
24
+ "content-length": true,
25
+ cookie: true,
26
+ host: true,
27
+ origin: true,
28
+ referer: true,
29
+ } satisfies Record<string, true>;
30
+
31
+ /**
32
+ * Upstream response headers never returned to the browser: the runtime's
33
+ * fetch already decoded the body (so `content-encoding`/`content-length` no
34
+ * longer describe it), hop-by-hop headers belong to the upstream connection
35
+ * rather than ours, and `set-cookie` would let the documented API plant
36
+ * cookies on the docs origin — where they'd ride along on every later docs
37
+ * request, including the proxy's own.
38
+ */
39
+ const RESPONSE_DROP = {
40
+ connection: true,
41
+ "content-encoding": true,
42
+ "content-length": true,
43
+ "keep-alive": true,
44
+ "set-cookie": true,
45
+ "transfer-encoding": true,
46
+ } satisfies Record<string, true>;
47
+
48
+ /** Redirect statuses the handler resolves itself; see {@link followUpstream}. */
49
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
50
+
51
+ /** Hops followed before giving up, matching fetch's own redirect limit. */
52
+ const MAX_REDIRECTS = 20;
53
+
54
+ /** A 400 the playground client can render verbatim. */
55
+ const badRequest = (error: string): Response =>
56
+ Response.json({ error }, { status: 400 });
57
+
58
+ /** Copy headers, skipping the given denylist (names are already lowercase). */
59
+ const filterHeaders = (
60
+ source: Headers,
61
+ drop: Record<string, true>
62
+ ): Headers => {
63
+ const headers = new Headers();
64
+ for (const [name, value] of source) {
65
+ if (!drop[name]) {
66
+ headers.set(name, value);
67
+ }
68
+ }
69
+ return headers;
70
+ };
71
+
72
+ /** A 403 for a target no configured spec declares as one of its servers. */
73
+ const forbidden = (origin: string): Response =>
74
+ Response.json(
75
+ {
76
+ error:
77
+ `${origin} is not one of the API servers this documentation declares, ` +
78
+ "so the docs proxy will not request it.",
79
+ },
80
+ { status: 403 }
81
+ );
82
+
83
+ /**
84
+ * Follow the upstream chain by hand, re-checking every hop. Redirects are
85
+ * deliberately NOT delegated to fetch: `redirect: "follow"` would chase a
86
+ * `Location` to any host, so an allowlisted first hop could bounce the docs
87
+ * server onto an internal address it must never reach. The rewrite rules match
88
+ * fetch's own — 303 (and 301/302, as every browser does) degrade to a bodyless
89
+ * GET, 307/308 replay the method and body.
90
+ */
91
+ const followUpstream = async (args: {
92
+ allowed: ReadonlySet<string>;
93
+ body: ArrayBuffer | undefined;
94
+ fetchImpl: typeof fetch;
95
+ headers: Headers;
96
+ /** Hops already followed; the chain is bounded by {@link MAX_REDIRECTS}. */
97
+ hop: number;
98
+ method: string;
99
+ url: URL;
100
+ }): Promise<Response> => {
101
+ const response = await args.fetchImpl(args.url, {
102
+ body: args.body,
103
+ headers: args.headers,
104
+ method: args.method,
105
+ redirect: "manual",
106
+ });
107
+ const location = REDIRECT_STATUS.has(response.status)
108
+ ? response.headers.get("location")
109
+ : null;
110
+ if (location === null) {
111
+ return response;
112
+ }
113
+ if (args.hop >= MAX_REDIRECTS) {
114
+ throw new Error(`Too many redirects from ${args.url.href}.`);
115
+ }
116
+ let next: URL;
117
+ try {
118
+ next = new URL(location, args.url);
119
+ } catch {
120
+ throw new Error(`Upstream redirected to an invalid URL: ${location}`);
121
+ }
122
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
123
+ throw new Error(`Upstream redirected to a non-http(s) URL: ${location}`);
124
+ }
125
+ if (!args.allowed.has(next.origin)) {
126
+ return forbidden(next.origin);
127
+ }
128
+ // 307/308 replay the request as-is; the rest degrade to a bodyless GET.
129
+ const replay = response.status === 307 || response.status === 308;
130
+ return followUpstream({
131
+ ...args,
132
+ body: replay ? args.body : undefined,
133
+ hop: args.hop + 1,
134
+ method: replay || args.method === "HEAD" ? args.method : "GET",
135
+ url: next,
136
+ });
137
+ };
138
+
139
+ /**
140
+ * Build the `/_api-proxy` fetch handler. The client sends its REAL method,
141
+ * headers, and body to `?url=<encodeURIComponent(target)>`; the handler
142
+ * forwards them (minus {@link REQUEST_DROP}) and mirrors the upstream response
143
+ * (minus {@link RESPONSE_DROP}) with an `x-blume-proxy` marker. An unreachable
144
+ * upstream is a 502 with a JSON `error`.
145
+ *
146
+ * `origins` is the allowlist: the origins of the `servers` the documented specs
147
+ * themselves declare (derived at build time). Without it the endpoint would be
148
+ * an open proxy — any visitor could aim the docs deployment at cloud metadata
149
+ * or a service on its private network. Non-absolute and non-http(s) targets are
150
+ * a 400; anything outside the allowlist, including on a redirect hop, is a 403.
151
+ * Loopback and private addresses need no separate rule: they are reachable only
152
+ * when a spec documents them, which is exactly the local-API case that must
153
+ * keep working.
154
+ */
155
+ export const createPlaygroundProxyHandler = (
156
+ origins: readonly string[],
157
+ fetchImpl: typeof fetch = fetch
158
+ ) => {
159
+ const allowed = new Set(origins);
160
+ return async (request: Request): Promise<Response> => {
161
+ const target = new URL(request.url).searchParams.get("url");
162
+ if (!target) {
163
+ return badRequest("Missing `url` query parameter.");
164
+ }
165
+ let parsed: URL;
166
+ try {
167
+ parsed = new URL(target);
168
+ } catch {
169
+ return badRequest(`Invalid target URL: ${target}`);
170
+ }
171
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
172
+ return badRequest("Only http(s) target URLs are allowed.");
173
+ }
174
+ if (!allowed.has(parsed.origin)) {
175
+ return forbidden(parsed.origin);
176
+ }
177
+
178
+ // Buffer the body instead of streaming it: GET/HEAD must not carry one
179
+ // (fetch rejects it), and a buffered body avoids the `duplex` requirement
180
+ // streaming request bodies have in Node.
181
+ const body =
182
+ request.method === "GET" || request.method === "HEAD"
183
+ ? undefined
184
+ : await request.arrayBuffer();
185
+
186
+ let upstream: Response;
187
+ try {
188
+ upstream = await followUpstream({
189
+ allowed,
190
+ body,
191
+ fetchImpl,
192
+ headers: filterHeaders(request.headers, REQUEST_DROP),
193
+ hop: 0,
194
+ method: request.method,
195
+ url: parsed,
196
+ });
197
+ } catch (error) {
198
+ // SAFETY: followUpstream itself only throws `new Error(...)`, and a
199
+ // failed fetch rejects with a TypeError per spec — both are Errors
200
+ // carrying `.message`.
201
+ return Response.json(
202
+ { error: (error as Error).message },
203
+ { status: 502 }
204
+ );
205
+ }
206
+
207
+ const headers = filterHeaders(upstream.headers, RESPONSE_DROP);
208
+ // Marks proxied responses so the client (and debugging humans) can tell
209
+ // them apart from direct responses.
210
+ headers.set("x-blume-proxy", "1");
211
+ return new Response(upstream.body, {
212
+ headers,
213
+ status: upstream.status,
214
+ statusText: upstream.statusText,
215
+ });
216
+ };
217
+ };
@@ -25,6 +25,12 @@ export interface ReferenceDisplay {
25
25
  codeSamples: string[];
26
26
  /** Whether nested schema rows start expanded. */
27
27
  expandSchemas: boolean;
28
+ /**
29
+ * The "Try it" playground: whether operation pages render it, and the CORS
30
+ * proxy the Send button routes through (`false` off, a URL string, or
31
+ * `true` for the built-in `/_api-proxy` endpoint).
32
+ */
33
+ playground: { enabled: boolean; proxy: string | boolean };
28
34
  }
29
35
 
30
36
  /** A spec source resolved to a concrete route, label, and renderer. */
@@ -190,6 +196,7 @@ export const resolveReferences = (
190
196
  {
191
197
  codeSamples: config.openapi.codeSamples,
192
198
  expandSchemas: config.openapi.expandSchemas,
199
+ playground: config.openapi.playground,
193
200
  },
194
201
  config.basePath
195
202
  ),
@@ -201,6 +208,7 @@ export const resolveReferences = (
201
208
  {
202
209
  codeSamples: config.asyncapi.codeSamples,
203
210
  expandSchemas: config.asyncapi.expandSchemas,
211
+ playground: config.asyncapi.playground,
204
212
  },
205
213
  config.basePath
206
214
  ),
@@ -182,6 +182,18 @@ export const openApiSource = (
182
182
  const { document, warnings, operations, tags, extractWarnings } =
183
183
  await parseReference(reference, ctx);
184
184
  const info = document.info ?? { title: reference.label, version: "" };
185
+ // The playground proxy resolves here, not client-side: `true` selects
186
+ // the built-in `/_api-proxy` route (mounted under the site `basePath`,
187
+ // like every served URL this module emits), a non-empty string is an
188
+ // external proxy used verbatim, and anything else (`false`, `""`)
189
+ // means the Send button fetches the API directly.
190
+ const { proxy: configuredProxy } = reference.display.playground;
191
+ let proxy: string | false = false;
192
+ if (configuredProxy === true) {
193
+ proxy = withBasePath(reference.basePath, "/_api-proxy");
194
+ } else if (configuredProxy !== false && configuredProxy !== "") {
195
+ proxy = configuredProxy;
196
+ }
185
197
  const spec: ApiSpecData = {
186
198
  codeSamples: reference.display.codeSamples,
187
199
  description: info.description ?? "",
@@ -202,6 +214,7 @@ export const openApiSource = (
202
214
  },
203
215
  ])
204
216
  ),
217
+ playground: { enabled: reference.display.playground.enabled, proxy },
205
218
  route: reference.route,
206
219
  slug: reference.slug,
207
220
  tags,
@@ -127,11 +127,10 @@ const askFiles = async (
127
127
  const grounded = ask.provider !== "inkeep";
128
128
  const files = [
129
129
  {
130
- content: askEndpointTemplate(
131
- resolveAskBackend(ask),
132
- grounded,
133
- ask.instructions
134
- ),
130
+ content: askEndpointTemplate(resolveAskBackend(ask), grounded, {
131
+ instructions: ask.instructions,
132
+ retrieval: ask.retrieval,
133
+ }),
135
134
  path: join(srcDir, "pages", "api", "ask.ts"),
136
135
  },
137
136
  ];