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
@@ -67,9 +67,9 @@ search: {
67
67
  }
68
68
  ```
69
69
 
70
- #### Languages written without spaces
70
+ #### Non-Latin scripts
71
71
 
72
- Orama's standard tokenizer splits on word boundaries that only exist in space-separated scripts, so Japanese, Chinese, Korean, and Thai text would otherwise produce no matches at all. Blume handles this for you: when [`i18n.defaultLocale`](/docs/content/i18n) is one of those languages, the index switches to a word-segmenting tokenizer (built on the browser- and Node-native `Intl.Segmenter`). Declaring your site's language is all it takes:
72
+ Orama's standard tokenizer keeps only basic Latin letters, digits and a handful of accented vowels, so text in any other script — Japanese, Chinese, Korean and Thai, but equally Russian, Greek, Hebrew and Hindi — would otherwise produce no matches at all. Blume handles this for you: when [`i18n.defaultLocale`](/docs/content/i18n) resolves to a non-Latin script, the index switches to a word-segmenting tokenizer (built on the browser- and Node-native `Intl.Segmenter`). Declaring your site's language is all it takes:
73
73
 
74
74
  ```ts blume.config.ts lineNumbers
75
75
  i18n: {
@@ -78,7 +78,7 @@ i18n: {
78
78
  }
79
79
  ```
80
80
 
81
- The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding. On a mixed-language site the whole index shares the default locale's tokenizer that's safe, because Latin words survive segmentation intact, so pages in English (or any spaced language) stay searchable alongside the default language.
81
+ The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding. The script is what decides, not the language name — `az-Cyrl` is segmented while `sr-Latn` is not — and it is the default locale that decides for the whole index: on a mixed-language site every page shares the default locale's tokenizer. With a non-Latin default that's safe, because Latin words survive segmentation intact, so pages in English stay searchable alongside the default language. The reverse doesn't hold: non-Latin translations on a Latin-default site aren't searchable. Latin-script languages that lean heavily on diacritics (Vietnamese, or Serbian in Latin script) also fare worse on the standard tokenizer, which folds only a few accented vowels and splits words on the rest.
82
82
 
83
83
  Japanese and Chinese go one step further. Segmenting alone indexes a compound term as its parts — 資金決済法 as 資金, 決済 and 法 — which lets a page mentioning each part somewhere outrank the page the term is actually about. Han, Hiragana and Katakana are therefore indexed as overlapping character pairs, and queries on those indexes prefer pages carrying a term's pairs together, loosening to any-pair matching when no page carries them all, so typing a whole sentence still returns its closest pages. Korean and Thai keep their segmented words.
84
84
 
@@ -86,7 +86,7 @@ Japanese and Chinese go one step further. Segmenting alone indexes a compound te
86
86
 
87
87
  A second keyless, client-side option. It reuses the same `/blume-search.json` index Orama ships and builds a [FlexSearch](https://github.com/nextapps-de/flexsearch) document index in the browser. Works in `blume dev` and `blume build`.
88
88
 
89
- FlexSearch has no equivalent segmentation hook, so for sites in Japanese, Chinese, Korean, or Thai prefer Orama (the default) or [Pagefind](#pagefind), whose `pagefind_extended` binary segments those languages natively.
89
+ FlexSearch has no equivalent segmentation hook, so for sites in a non-Latin script prefer Orama (the default) or [Pagefind](#pagefind), whose `pagefind_extended` binary indexes a broad set of languages and segments Chinese, Japanese and Korean natively.
90
90
 
91
91
  ```ts blume.config.ts lineNumbers
92
92
  search: {
@@ -123,7 +123,7 @@ Without a project `tsconfig.json`, only the generated runtime is checked.
123
123
 
124
124
  - **Internal page links** (`/guides/intro`, `./sibling`) must resolve to a real page — broken ones are reported as errors.
125
125
  - **Anchor links** (`#section`, `/guides/intro#setup`) must match a heading on the target page — misses are warnings.
126
- - **Asset links** (`/logo.png`) are checked against the `public/` directory.
126
+ - **Asset links** are checked where the file lives: an absolute path (`/logo.png`) against the `public/` directory, a relative image embed (`![](./diagram.png)`) against the page's own folder. A plain link to a relative path still resolves as a site route — only image embeds go through the image pipeline.
127
127
  - **External links** are only checked with `--external` (off by default since it requires the network); dead links (404/410/unreachable) are errors, while rate-limited or transient responses (403/429/5xx/timeout) are warnings.
128
128
 
129
129
  ## Auditing the built site
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -70,7 +70,7 @@ The single biggest shift for most sources — especially Mintlify — is that **
70
70
  - **`navigation`:** `tabs`, `selectors`, `featured` (links pinned above the sidebar on every route), `sidebar` (`{ display, items }` — `display` is the global render mode above; `items` is an explicit tree), `repo`. **Avoid an explicit `navigation.sidebar` unless you have to** — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for `sidebar.items` only for a shape files genuinely can't express (see "Config-declared nesting" above).
71
71
  - **`search`** (Orama default, Pagefind opt-in), **`ai`** (llms.txt, Ask AI, the MCP server), **`openapi`**, **`redirects`**, **`seo`**, **`markdown`**, **`analytics`**, **`deployment`**, **`i18n`**, **`toc`**, **`lastModified`**, **`github`**.
72
72
  - **Don't set `deployment.site`.** Blume auto-fills it: the dev server's `localhost` URL in dev, and the deployment URL (`VERCEL_PROJECT_PRODUCTION_URL`/`VERCEL_URL`) on Vercel. Hardcoding it in `blume.config.ts` overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a `url`/`site` field. (Sitemap still generates in production because the deploy URL is present there.)
73
- - **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` **collapses to one** pick a single file and report the loss.
73
+ - **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` maps to a filename pair: copy the light file to a conventional name (e.g. `public/icon.png`) and the dark file to its `-dark` sibling — same directory and extension, `-dark` before the extension (`public/icon-dark.png`). If the two files have different formats, convert one so the extensions match; only an exact sibling of the resolved icon is picked up.
74
74
 
75
75
  The schema is exported from `blume/schema`; the full field reference is in the `docs/configuration/` directory of the installed `blume` package (see "Full documentation" below for how to locate it).
76
76
 
@@ -19,7 +19,7 @@ Resolve `$ref` includes first (Mintlify splits config across files). Map only wh
19
19
  | `name` / `title` | `title` | |
20
20
  | `description` | `description` | |
21
21
  | `logo` (string or `{ light, dark, href }`) | `logo` (string, or `{ image: { light, dark, alt }, text, href }`) | Mintlify's `light`/`dark` nest under Blume's `image`; ensure files land in `public/`. **If the SVG is local and monochrome** (a solid black or white mark), rewrite its `fill`/`stroke` to `currentColor` and collapse `{ light, dark }` to the string shorthand `logo: "/logo.svg"` — one file that inherits the theme text color and flips with light/dark automatically. If the logo is a **wordmark** (brand name baked in), set `text: ""` so it doesn't render twice beside `title` |
22
- | `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy **one** file into `public/` under the conventional name (`favicon.svg`/`icon.png`) | Blume auto-detects by filename; there is **no** favicon config field, and **no** light/dark favicon a `{ light, dark }` source collapses to one; report the loss |
22
+ | `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy the file(s) into `public/` under conventional names (`favicon.svg`/`icon.png`) | Blume auto-detects by filename; there is **no** favicon config field. A `{ light, dark }` source maps to a filename pair: light file → `public/icon.png`, dark file → its `-dark` sibling `public/icon-dark.png` (same directory and extension — convert if the formats differ) |
23
23
  | `colors.primary` + `colors.light` | `theme.accent` (`{ light, dark }`) | accent is now **per-mode**: `accent: { light: <colors.primary>, dark: <colors.light> }`. If Mintlify sets only `primary`, collapse to the string shorthand `accent: "<colors.primary>"` (applies to both modes) |
24
24
  | `colors.dark` | `theme.action` | |
25
25
  | `appearance.default` | `theme.mode` (`light`/`dark`/`system`) | |
@@ -22,8 +22,8 @@ export interface AskPage {
22
22
  export interface AskData {
23
23
  /**
24
24
  * The site's `i18n.defaultLocale`, when i18n is configured. Selects a
25
- * word-segmenting Orama tokenizer for languages written without spaces, so
26
- * retrieval can match CJK/Thai content.
25
+ * word-segmenting Orama tokenizer for every non-Latin script, so retrieval
26
+ * can match CJK, Cyrillic, Greek, Hebrew, or Devanagari content.
27
27
  */
28
28
  defaultLocale?: string;
29
29
  documents: OramaDoc[];
@@ -36,6 +36,34 @@ const MAX_RESULTS = 6;
36
36
  const EXCERPT_CHARS = 2000;
37
37
  /** Overall cap on injected documentation characters. */
38
38
  const CONTEXT_BUDGET = 10_000;
39
+ /**
40
+ * Smallest excerpt worth injecting. A long page pushed under a tiny residual
41
+ * budget would get a full `## Title (/route)` heading over a fragment of a few
42
+ * dozen characters — a section the model is invited to cite but that grounds
43
+ * nothing. Short pages that fit whole are still injected below this floor.
44
+ */
45
+ const MIN_EXCERPT_CHARS = 200;
46
+
47
+ /**
48
+ * How much retrieved documentation a question carries (the `ai.ask.retrieval`
49
+ * config). Every field falls back to the built-in default, so a partial object
50
+ * only changes what it names. Injected characters dominate time-to-first-token
51
+ * on a self-hosted backend, and the three knobs aren't interchangeable: the
52
+ * budget caps the total, `excerptChars` decides how deep into one long page the
53
+ * excerpt reaches, and `maxResults` decides how many pages retrieval adds (the
54
+ * page the reader is viewing is injected on top of them).
55
+ */
56
+ export interface AskRetrievalOptions {
57
+ /** Overall cap on injected documentation characters. Defaults to `10000`. */
58
+ contextBudget?: number;
59
+ /** Characters kept per injected excerpt. Defaults to `2000`. */
60
+ excerptChars?: number;
61
+ /**
62
+ * Documents retrieved per question. Defaults to `6`. The current page is
63
+ * injected in addition when it isn't among the hits.
64
+ */
65
+ maxResults?: number;
66
+ }
39
67
  /** Chars of lead-in kept before the matched region, for heading/sentence context. */
40
68
  const EXCERPT_LEAD = 160;
41
69
 
@@ -176,13 +204,14 @@ export const relevantExcerpt = (
176
204
  return `${prefix}${slice}${suffix}`;
177
205
  };
178
206
 
179
- const lower = trimmed.toLowerCase();
207
+ // Case-insensitive matching via regex rather than `indexOf` on a lowercased
208
+ // copy: length-changing case mappings (Turkish İ → "i" + U+0307) would shift
209
+ // every index in the copy, sliding the excerpt window off the match. Terms
210
+ // come from TERM (letters, marks and digits only), so no regex escaping.
180
211
  const positions: number[] = [];
181
212
  for (const term of queryTerms(query)) {
182
- let idx = lower.indexOf(term);
183
- while (idx !== -1) {
184
- positions.push(idx);
185
- idx = lower.indexOf(term, idx + term.length);
213
+ for (const match of trimmed.matchAll(new RegExp(term, "giu"))) {
214
+ positions.push(match.index);
186
215
  }
187
216
  }
188
217
  // No query terms hit this doc — nothing to center on, so keep the head.
@@ -232,10 +261,13 @@ export const relevantExcerpt = (
232
261
  * the base instruction rather than replacing it: the base carries the
233
262
  * functional contract (answer only from the excerpts, cite pages as Markdown
234
263
  * links) that the panel's citation rendering depends on.
264
+ *
265
+ * `options.retrieval` (the `ai.ask.retrieval` config) sizes how much
266
+ * documentation each question carries; omitted fields keep today's defaults.
235
267
  */
236
268
  export const createAskContext = (
237
269
  data: AskData,
238
- options?: { instructions?: string }
270
+ options?: { instructions?: string; retrieval?: AskRetrievalOptions }
239
271
  ): ((
240
272
  messages: AskMessage[],
241
273
  page?: AskPage
@@ -250,6 +282,9 @@ export const createAskContext = (
250
282
  const instruction = options?.instructions
251
283
  ? `${BASE_INSTRUCTION}\n\n${options.instructions}`
252
284
  : BASE_INSTRUCTION;
285
+ const maxResults = options?.retrieval?.maxResults ?? MAX_RESULTS;
286
+ const excerptChars = options?.retrieval?.excerptChars ?? EXCERPT_CHARS;
287
+ const contextBudget = options?.retrieval?.contextBudget ?? CONTEXT_BUDGET;
253
288
 
254
289
  return async (messages, page) => {
255
290
  const list = Array.isArray(messages) ? messages : [];
@@ -263,22 +298,27 @@ export const createAskContext = (
263
298
  ? byRoute.get(normalizeRoute(page.path))
264
299
  : undefined;
265
300
  const db = await index();
266
- const hits = await queryOramaIndex(db, query, MAX_RESULTS, {
301
+ const hits = await queryOramaIndex(db, query, maxResults, {
267
302
  locale: current?.locale || undefined,
268
303
  });
269
304
 
270
305
  const seen = new Set<string>();
271
306
  const sections: string[] = [];
272
- let budget = CONTEXT_BUDGET;
307
+ let budget = contextBudget;
273
308
  const push = (doc: OramaDoc, label: string) => {
274
309
  if (seen.has(doc.route) || budget <= 0) {
275
310
  return;
276
311
  }
312
+ // Skip a page that would be cut to a junk fragment: its excerpt is only
313
+ // useful when it either fits whole or gets at least the minimum window.
314
+ if (budget < MIN_EXCERPT_CHARS && doc.content.trim().length > budget) {
315
+ return;
316
+ }
277
317
  seen.add(doc.route);
278
318
  const body = relevantExcerpt(
279
319
  doc.content,
280
320
  query,
281
- Math.min(EXCERPT_CHARS, budget)
321
+ Math.min(excerptChars, budget)
282
322
  );
283
323
  budget -= body.length;
284
324
  sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
@@ -37,8 +37,9 @@ export interface McpData {
37
37
  base: string;
38
38
  /**
39
39
  * The site's `i18n.defaultLocale`, when i18n is configured. Selects a
40
- * word-segmenting Orama tokenizer for languages written without spaces, so
41
- * `search_docs` can match CJK/Thai content.
40
+ * word-segmenting Orama tokenizer for every non-Latin script, so
41
+ * `search_docs` can match CJK, Cyrillic, Greek, Hebrew, or Devanagari
42
+ * content.
42
43
  */
43
44
  defaultLocale?: string;
44
45
  /**
@@ -311,8 +311,9 @@ export type OramaIndexProvider = () => Promise<
311
311
 
312
312
  /**
313
313
  * Memoize the search index so every server built from a snapshot shares it.
314
- * `locale` is the snapshot's `defaultLocale`, forwarded so unspaced scripts
315
- * (Japanese, Chinese, Korean, Thai) get a word-segmenting tokenizer.
314
+ * `locale` is the snapshot's `defaultLocale`, forwarded so non-Latin scripts
315
+ * (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
316
+ * get a word-segmenting tokenizer.
316
317
  */
317
318
  export const createIndexProvider = (
318
319
  documents: OramaDoc[],
Binary file
@@ -50,6 +50,7 @@ import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
50
50
  import { missingFontFiles, resolveOgFonts } from "../og/derive.ts";
51
51
  import type { DerivedOgFonts } from "../og/derive.ts";
52
52
  import { resolveOgLogo } from "../og/logo.ts";
53
+ import type { OpenApiData } from "../openapi/model.ts";
53
54
  import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
54
55
  import { buildReferenceFiles } from "../openapi/scalar.ts";
55
56
  import { isOpenApiSource } from "../openapi/source.ts";
@@ -94,6 +95,7 @@ import {
94
95
  mixedbreadSearchEndpointTemplate,
95
96
  notFoundPageTemplate,
96
97
  ogEndpointTemplate,
98
+ playgroundProxyTemplate,
97
99
  rawMarkdownEndpointTemplate,
98
100
  rssEndpointTemplate,
99
101
  runtimeDirWithin,
@@ -964,6 +966,13 @@ const FAVICON_CANDIDATES = [
964
966
  "icon.ico",
965
967
  ];
966
968
 
969
+ /**
970
+ * Derive the `-dark` sibling of an icon filename (`icon.svg` → `icon-dark.svg`),
971
+ * inserting the suffix before the extension.
972
+ */
973
+ const darkSibling = (name: string): string =>
974
+ name.replace(/\.(?=[^.]+$)/u, "-dark.");
975
+
967
976
  /** `<link type>` MIME for the favicon extensions we recognize. */
968
977
  const FAVICON_TYPES = new Map([
969
978
  ["ico", "image/x-icon"],
@@ -983,8 +992,19 @@ const faviconType = (name: string): string | undefined => {
983
992
  const inlineDataUri = (file: string, type: string): string =>
984
993
  `data:${type};base64,${readFileSync(file).toString("base64")}`;
985
994
 
986
- /** The bundled Blume favicon, inlined as a data URI so it needs no public file. */
995
+ /**
996
+ * The bundled Blume favicon, inlined as a data URI so it needs no public file.
997
+ * The mark is dark, so it ships with a light-on-transparent dark-scheme variant —
998
+ * otherwise it disappears against dark browser chrome.
999
+ */
987
1000
  const defaultFavicon = (): BlumeFavicon => ({
1001
+ dark: {
1002
+ href: inlineDataUri(
1003
+ join(BLUME_SRC, "assets", "icon-dark.png"),
1004
+ "image/png"
1005
+ ),
1006
+ type: "image/png",
1007
+ },
988
1008
  href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
989
1009
  type: "image/png",
990
1010
  });
@@ -1029,16 +1049,52 @@ const resolveIconFile = (
1029
1049
 
1030
1050
  /**
1031
1051
  * Resolve the site favicon by convention, falling back to the bundled Blume mark
1032
- * when the project ships no `icon.*`/`favicon.*` file.
1052
+ * when the project ships no `icon.*`/`favicon.*` file. The dark-scheme variant
1053
+ * is anchored to the resolved icon: its `-dark` sibling (e.g. `icon.svg` →
1054
+ * `icon-dark.svg`) in the same directory — never an unrelated `-dark` file, so a
1055
+ * stale or foreign `favicon-dark.*` can't silently pair with a different mark.
1056
+ * A `-dark` file with no light sibling is the site's only mark and is used for
1057
+ * both schemes, mirroring how `resolveLogo` treats a single-variant image.
1033
1058
  */
1034
- const resolveFavicon = (project: BlumeProject): BlumeFavicon =>
1035
- resolveIconFile(project, FAVICON_CANDIDATES) ?? defaultFavicon();
1059
+ const resolveFavicon = (project: BlumeProject): BlumeFavicon => {
1060
+ const { root } = project.context;
1061
+ for (const name of FAVICON_CANDIDATES) {
1062
+ if (existsSync(join(root, "public", name))) {
1063
+ const type = faviconType(name);
1064
+ const sibling = darkSibling(name);
1065
+ return existsSync(join(root, "public", sibling))
1066
+ ? { dark: { href: `/${sibling}`, type }, href: `/${name}`, type }
1067
+ : { href: `/${name}`, type };
1068
+ }
1069
+ }
1070
+ for (const name of FAVICON_CANDIDATES) {
1071
+ const file = join(root, name);
1072
+ if (existsSync(file)) {
1073
+ const type = faviconType(name);
1074
+ const mime = type ?? "image/x-icon";
1075
+ const siblingFile = join(root, darkSibling(name));
1076
+ return existsSync(siblingFile)
1077
+ ? {
1078
+ dark: { href: inlineDataUri(siblingFile, mime), type },
1079
+ href: inlineDataUri(file, mime),
1080
+ type,
1081
+ }
1082
+ : { href: inlineDataUri(file, mime), type };
1083
+ }
1084
+ }
1085
+ return (
1086
+ resolveIconFile(project, FAVICON_CANDIDATES.map(darkSibling)) ??
1087
+ defaultFavicon()
1088
+ );
1089
+ };
1036
1090
 
1037
1091
  /**
1038
1092
  * Resolve the Apple touch icon by convention, or null when the project ships
1039
1093
  * none (unlike the favicon, there's no bundled default). Note: iOS ignores
1040
1094
  * `data:`-URI apple-touch-icons, so a `public/` file (served by URL) is the
1041
1095
  * reliable path; a root-level file is still inlined for symmetry with favicons.
1096
+ * Deliberately no `-dark` sibling detection here: iOS ignores `media` on
1097
+ * `apple-touch-icon` links, so a dark variant could never be served.
1042
1098
  */
1043
1099
  const resolveAppleIcon = (project: BlumeProject): BlumeFavicon | null =>
1044
1100
  resolveIconFile(project, APPLE_ICON_CANDIDATES);
@@ -1231,6 +1287,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1231
1287
  palette: config.seo.og.palette,
1232
1288
  site: resolveOgSite(config),
1233
1289
  },
1290
+ openInChat: config.ai.openInChat,
1234
1291
  repoUrl,
1235
1292
  search: {
1236
1293
  enabled: config.search.provider !== "none",
@@ -1394,6 +1451,76 @@ const writeMcpFiles = async (
1394
1451
  ]);
1395
1452
  };
1396
1453
 
1454
+ /**
1455
+ * Decide whether to generate the playground's built-in CORS proxy endpoint.
1456
+ * Only the Blume renderer's playground with `proxy: true` needs it — a proxy
1457
+ * URL string points at an external service, and `false` sends requests
1458
+ * directly. Injected at `/_api-proxy` (rather than written under `pages/`)
1459
+ * because Astro treats `_`-prefixed page files as private; the endpoint's own
1460
+ * `prerender = false` export wins over the injection default.
1461
+ */
1462
+ const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
1463
+ enabled:
1464
+ config.openapi.enabled &&
1465
+ config.openapi.renderer === "blume" &&
1466
+ config.openapi.playground.enabled &&
1467
+ config.openapi.playground.proxy === true,
1468
+ entrypoint: join(srcDir, "blume-openapi", "api-proxy.ts"),
1469
+ pattern: "/_api-proxy",
1470
+ });
1471
+
1472
+ /**
1473
+ * The origins the built-in proxy is allowed to reach: one per absolute
1474
+ * `servers[].url` across the parsed specs. This is the endpoint's whole trust
1475
+ * boundary — the client sends the target as a query parameter, and a reader's
1476
+ * custom base URL is not a documented server — so it is derived here, at build
1477
+ * time, from the same documents the operation pages render.
1478
+ *
1479
+ * Relative (`/v1`) and templated (`{env}.api.example.com`) server URLs carry no
1480
+ * origin to allow and are skipped; AsyncAPI documents declare `servers` as a
1481
+ * map and contribute nothing (the proxy is OpenAPI-only).
1482
+ */
1483
+ const specOrigins = (data: OpenApiData): string[] => {
1484
+ const origins = new Set<string>();
1485
+ for (const spec of Object.values(data)) {
1486
+ // SAFETY: `document` is arbitrary parsed JSON; the assertion only names
1487
+ // the optional `servers` shape, and every access below re-checks it —
1488
+ // `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
1489
+ const { servers } = spec.document as { servers?: { url?: string }[] };
1490
+ for (const server of Array.isArray(servers) ? servers : []) {
1491
+ const url = server.url ?? "";
1492
+ // `new URL("https://{region}.api.example.com")` parses — the braces land
1493
+ // in the hostname — so templated URLs need an explicit check or their
1494
+ // junk literal becomes an allowlist entry no real request can match.
1495
+ if (url.includes("{")) {
1496
+ continue;
1497
+ }
1498
+ try {
1499
+ origins.add(new URL(url).origin);
1500
+ } catch {
1501
+ // Not an absolute URL: nothing to allow.
1502
+ }
1503
+ }
1504
+ }
1505
+ return [...origins].toSorted();
1506
+ };
1507
+
1508
+ /**
1509
+ * The build-time diagnostic for a proxy whose allowlist came out empty. The
1510
+ * allowlist comes solely from absolute `servers[].url` entries — relative and
1511
+ * templated ones carry no origin — and with none at all the endpoint would
1512
+ * 403 every playground send with nothing pointing the author at the spec.
1513
+ */
1514
+ const proxyAllowlistWarnings = (
1515
+ enabled: boolean,
1516
+ origins: string[]
1517
+ ): string[] =>
1518
+ enabled && origins.length === 0
1519
+ ? [
1520
+ "openapi.playground.proxy is enabled, but no spec declares an absolute servers[].url (relative and templated URLs carry no origin), so the proxy's allowlist is empty and it will refuse every request. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.",
1521
+ ]
1522
+ : [];
1523
+
1397
1524
  /**
1398
1525
  * Write the Ask AI endpoint and, unless the backend runs its own retrieval
1399
1526
  * (Inkeep), the grounding snapshot the endpoint queries at request time. A no-op
@@ -1417,7 +1544,10 @@ const writeAskFiles = async (
1417
1544
  }
1418
1545
  await write(
1419
1546
  join(srcDir, "pages", "api", "ask.ts"),
1420
- askEndpointTemplate(resolveAskBackend(ask), grounded, ask.instructions)
1547
+ askEndpointTemplate(resolveAskBackend(ask), grounded, {
1548
+ instructions: ask.instructions,
1549
+ retrieval: ask.retrieval,
1550
+ })
1421
1551
  );
1422
1552
  };
1423
1553
 
@@ -1635,6 +1765,25 @@ export const generateRuntime = async (
1635
1765
  const mcp = planMcp(project, srcDir, pages);
1636
1766
  pages.push(...mcp.discoveryPages);
1637
1767
 
1768
+ // The parsed OpenAPI specs behind the `blume:openapi` alias, also the source
1769
+ // of the proxy's origin allowlist below. The source parsed them during the
1770
+ // scan, so reading them here is free.
1771
+ const openApiSource = project.sources.find(isOpenApiSource);
1772
+ const openApiData = openApiSource ? openApiSource.openApiData() : {};
1773
+
1774
+ // The playground's built-in CORS proxy rides the same injection path as the
1775
+ // MCP discovery docs; the endpoint itself opts out of prerendering.
1776
+ const playgroundProxy = planPlaygroundProxy(config, srcDir);
1777
+ if (playgroundProxy.enabled) {
1778
+ pages.push({
1779
+ entrypoint: playgroundProxy.entrypoint,
1780
+ pattern: playgroundProxy.pattern,
1781
+ });
1782
+ }
1783
+ // Computed once: the endpoint template bakes it in below, and an empty list
1784
+ // is worth a diagnostic — the proxy would refuse every request it gets.
1785
+ const proxyOrigins = specOrigins(openApiData);
1786
+
1638
1787
  const hasStaged = staged.size > 0;
1639
1788
  // Only emit a project-scanning `docs` collection when a filesystem source
1640
1789
  // actually feeds it. An all-staged project (openapi/notion/…) has only staged
@@ -1780,6 +1929,9 @@ export const generateRuntime = async (
1780
1929
  ),
1781
1930
  writeAskFiles(project, srcDir, write),
1782
1931
  writeMcpFiles(project, mcp, write),
1932
+ playgroundProxy.enabled
1933
+ ? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
1934
+ : Promise.resolve(false),
1783
1935
  ]);
1784
1936
 
1785
1937
  if (config.seo.og.enabled) {
@@ -1895,6 +2047,7 @@ export const generateRuntime = async (
1895
2047
  // regenerated each run.
1896
2048
  const warnings: string[] = [
1897
2049
  ...(depsLinkWarning ? [depsLinkWarning] : []),
2050
+ ...proxyAllowlistWarnings(playgroundProxy.enabled, proxyOrigins),
1898
2051
  ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
1899
2052
  ...mcp.warnings,
1900
2053
  ...islandDiscovery.warnings,
@@ -1961,20 +2114,16 @@ export const generateRuntime = async (
1961
2114
  );
1962
2115
  }
1963
2116
 
1964
- // The parsed OpenAPI specs behind the `blume:openapi` alias. Always written
1965
- // (even as `{}`) so the alias resolves whether or not a reference is enabled;
1966
- // the source parsed the specs during the scan, so this is just serialization.
1967
- const openApiSource = project.sources.find(isOpenApiSource);
2117
+ // `openapi.json` (the `blume:openapi` alias) is always written — even as `{}`
2118
+ // so the alias resolves whether or not a reference is enabled; the specs
2119
+ // were parsed during the scan, so this is just serialization.
1968
2120
  // These write to distinct trees and never read one another, so they batch.
1969
2121
  // `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
1970
2122
  // they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
1971
2123
  // own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
1972
2124
  await Promise.all([
1973
2125
  write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
1974
- write(
1975
- openapiPath,
1976
- `${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
1977
- ),
2126
+ write(openapiPath, `${JSON.stringify(openApiData)}\n`),
1978
2127
  write(
1979
2128
  join(out, "blume.manifest.json"),
1980
2129
  `${JSON.stringify(project.manifest, null, 2)}\n`
@@ -3,6 +3,7 @@ import { pathToFileURL } from "node:url";
3
3
 
4
4
  import { dirname, isAbsolute, join, relative } from "pathe";
5
5
 
6
+ import type { AskRetrievalOptions } from "../ai/ask-context.ts";
6
7
  import { askBackendRuntimeDep } from "../ai/ask.ts";
7
8
  import type { AskBackend } from "../ai/ask.ts";
8
9
  import { buildHomeLinkHeader } from "../ai/link-headers.ts";
@@ -406,6 +407,11 @@ const resolveOptimizeDeps = (options: {
406
407
  const optimizeDepsInclude = [
407
408
  "blume > mermaid",
408
409
  "blume > epub-gen-memory/bundle",
410
+ // Astro's own client-router/prefetch virtual modules are deliberately NOT
411
+ // forced in here: they read Vite `define`-injected constants
412
+ // (__PREFETCH_PREFETCH_ALL__ and friends) that a pre-bundled copy loses,
413
+ // throwing ReferenceError on every page. Astro manages their optimization
414
+ // itself, without a mid-session reload.
409
415
  ...(options.needsReact && options.reactCompilerPath
410
416
  ? ["react/compiler-runtime"]
411
417
  : []),
@@ -694,10 +700,10 @@ ${userConfigSetup}export default defineConfig({
694
700
  },
695
701
  },
696
702
  devToolbar: { enabled: false },
697
- // Navigations are full document loads (no client router), so the next page's
698
- // HTML is fetched on hover/viewport to hide the request latency behind the
699
- // user's intent. Pairs with the cross-document view-transition rule in the
700
- // theme sheet, which smooths the swap itself.
703
+ // The layouts render Astro's <ClientRouter />, and its in-place swaps read
704
+ // from the prefetch cache fetching every link on hover/viewport hides the
705
+ // request latency behind the user's intent, so most navigations swap
706
+ // instantly.
701
707
  prefetch: { prefetchAll: true },
702
708
  vite: {
703
709
  plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
@@ -892,17 +898,30 @@ export const collections = { docs${options.staged ? ", staged" : ""} };
892
898
  const ASK_FALLBACK_PROMPT =
893
899
  "You are a helpful documentation assistant. Answer using the project's documentation.";
894
900
 
901
+ /** The `ai.ask` values the generated endpoint has to carry with it. */
902
+ export interface AskEndpointOptions {
903
+ /** `ai.ask.instructions` — extra system-prompt text. */
904
+ instructions?: string;
905
+ /** `ai.ask.retrieval` — how much documentation each question carries. */
906
+ retrieval?: AskRetrievalOptions;
907
+ }
908
+
895
909
  /**
896
910
  * Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
897
- * `instructions` (the `ai.ask.instructions` config) is appended to the
911
+ *
912
+ * `options.instructions` (the `ai.ask.instructions` config) is appended to the
898
913
  * built-in prompt on every path: the grounded prompt via `createAskContext`,
899
- * and the plain fallback here.
914
+ * and the plain fallback here. `options.retrieval` (the `ai.ask.retrieval`
915
+ * config) is forwarded to `createAskContext` on the grounded path, where it
916
+ * sizes retrieval. Both travel in one options object so a new call site can't
917
+ * silently drop one of them.
900
918
  */
901
919
  export const askEndpointTemplate = (
902
920
  backend: AskBackend,
903
921
  grounded: boolean,
904
- instructions?: string
922
+ options?: AskEndpointOptions
905
923
  ): string => {
924
+ const instructions = options?.instructions;
906
925
  const fallbackPrompt = instructions
907
926
  ? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
908
927
  : ASK_FALLBACK_PROMPT;
@@ -938,9 +957,15 @@ export const askEndpointTemplate = (
938
957
  'import { createAskContext } from "blume/ai/ask-context.ts";',
939
958
  'import askData from "../../generated/ask-data.json";'
940
959
  );
941
- const groundOptions = instructions
942
- ? `, { instructions: ${JSON.stringify(instructions)} }`
943
- : "";
960
+ const groundFields: string[] = [];
961
+ if (instructions) {
962
+ groundFields.push(`instructions: ${JSON.stringify(instructions)}`);
963
+ }
964
+ if (options?.retrieval) {
965
+ groundFields.push(`retrieval: ${JSON.stringify(options.retrieval)}`);
966
+ }
967
+ const groundOptions =
968
+ groundFields.length > 0 ? `, { ${groundFields.join(", ")} }` : "";
944
969
  setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
945
970
  }
946
971
  // Validate the client-supplied body and cap its size. The endpoint is
@@ -1104,7 +1129,7 @@ const SEARCH_BASE_IMPORT =
1104
1129
  /**
1105
1130
  * A client that loads a static `blume-search.json` index (Orama, FlexSearch).
1106
1131
  * `locale` (Orama only) is the site's `i18n.defaultLocale`, which selects a
1107
- * word-segmenting tokenizer for languages written without spaces.
1132
+ * word-segmenting tokenizer for every non-Latin script.
1108
1133
  */
1109
1134
  const staticSearchClient = (module: string, locale?: string): string =>
1110
1135
  `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
@@ -1415,6 +1440,31 @@ export const ALL: APIRoute = ({ request }) => handler(request);
1415
1440
  `;
1416
1441
  };
1417
1442
 
1443
+ /**
1444
+ * Generate the playground's CORS proxy endpoint
1445
+ * (`.blume/src/blume-openapi/api-proxy.ts`), behind
1446
+ * `openapi.playground.proxy: true`. A thin server-rendered wrapper around the
1447
+ * shipped `createPlaygroundProxyHandler`; injected at `/_api-proxy` rather
1448
+ * than written under `pages/` because Astro treats `_`-prefixed page files as
1449
+ * private.
1450
+ *
1451
+ * `origins` — the origins of the servers the documented specs declare — is
1452
+ * baked in as the handler's allowlist. It cannot come from the request or from
1453
+ * client-side data: that is the whole trust boundary keeping the endpoint from
1454
+ * being an open proxy onto the deployment's own network.
1455
+ */
1456
+ export const playgroundProxyTemplate = (origins: string[]): string =>
1457
+ `// Generated by Blume. Do not edit.
1458
+ import type { APIRoute } from "astro";
1459
+ import { createPlaygroundProxyHandler } from "blume/openapi/proxy.ts";
1460
+
1461
+ export const prerender = false;
1462
+
1463
+ const handler = createPlaygroundProxyHandler(${JSON.stringify(origins)});
1464
+
1465
+ export const ALL: APIRoute = ({ request }) => handler(request);
1466
+ `;
1467
+
1418
1468
  /** Generate a prerendered endpoint that serves a fixed JSON payload. */
1419
1469
  export const staticJsonEndpointTemplate = <Payload extends object>(
1420
1470
  payload: Payload
@@ -1956,6 +2006,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1956
2006
  feedback={data.config.feedback}
1957
2007
  exportPdf={${options.exportPdf}}
1958
2008
  exportEpub={${options.exportEpub}}
2009
+ openInChat={data.config.openInChat}
1959
2010
  feeds={data.feeds}
1960
2011
  discovery={data.config.discovery}
1961
2012
  siteUrl={data.config.site}
@@ -2188,6 +2239,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2188
2239
  canonical={canonical}
2189
2240
  exportPdf={${options.exportPdf}}
2190
2241
  exportEpub={${options.exportEpub}}
2242
+ openInChat={data.config.openInChat}
2191
2243
  feeds={data.feeds}
2192
2244
  discovery={data.config.discovery}
2193
2245
  siteUrl={data.config.site}
@@ -121,6 +121,10 @@ const accordionId = id ?? componentSlug(title);
121
121
  };
122
122
 
123
123
  window.addEventListener("hashchange", openHashAccordion);
124
+ // Client-router swaps replace the body with fresh server-rendered
125
+ // accordions (duplicate ids and all), so the dedupe + hash-open pass has
126
+ // to run again for each new page.
127
+ document.addEventListener("astro:after-swap", init);
124
128
  if (document.readyState === "loading") {
125
129
  document.addEventListener("DOMContentLoaded", init, { once: true });
126
130
  } else {