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
@@ -140,6 +140,18 @@ my-docs/
140
140
 
141
141
  SVG wins over PNG over ICO when several are present, and a file in `public/` is preferred over one at the root. If Blume finds no icon, it falls back to its own mark.
142
142
 
143
+ A dark mark disappears against dark browser chrome, so you can ship a second file for dark mode. Add a `-dark` sibling of your icon file — the same name and directory, with `-dark` before the extension (`icon.png` → `icon-dark.png`) — and Blume emits both icons behind a `prefers-color-scheme` media query, plus a plain light tag for browsers and crawlers that ignore media queries on icons:
144
+
145
+ ```
146
+ my-docs/
147
+ ├─ blume.config.ts
148
+ ├─ icon.png ← light mode
149
+ ├─ icon-dark.png ← dark mode
150
+ └─ docs/
151
+ ```
152
+
153
+ Only the sibling of the icon Blume picked is used — a `-dark` file with a different name stays ignored, so an unrelated file can't pair with your mark by accident. The dark file is optional; with only one icon, Blume emits a single tag as before. Blume's own fallback mark ships both variants.
154
+
143
155
  ### Apple touch icon
144
156
 
145
157
  The icon iOS uses when someone adds your site to their home screen is detected the same way. Drop an `apple-icon` file (`.png`, `.jpg`, or `.jpeg`) — or an `apple-touch-icon.png`, the name most favicon generators emit — in your project root or `public/` directory and Blume wires up `<link rel="apple-touch-icon">` for you. There's no default; if no file is found, no tag is emitted.
@@ -151,7 +163,7 @@ my-docs/
151
163
  └─ docs/
152
164
  ```
153
165
 
154
- Put the file in `public/` rather than the project root: iOS ignores the inlined data URI Blume uses for a root-level icon, so only a `public/` file (served at `/apple-icon.png`) reliably reaches the home screen.
166
+ Put the file in `public/` rather than the project root: iOS ignores the inlined data URI Blume uses for a root-level icon, so only a `public/` file (served at `/apple-icon.png`) reliably reaches the home screen. Unlike the favicon, there's no `-dark` sibling here — iOS ignores media queries on home-screen icons, so a dark variant could never be served.
155
167
 
156
168
  ### Banner
157
169
 
@@ -289,7 +301,7 @@ lastModified: true,
289
301
  | `{ type: "git" }` | Same as `true`, written explicitly. |
290
302
  | `{ type: "frontmatter" }` | Never run git — use only the `lastModified` frontmatter field. |
291
303
 
292
- The git source reads the most recent commit that touched each file, so it works in any git repository — including monorepos — and needs the repo's history at build time (avoid a shallow `--depth 1` checkout in CI). A page's own `lastModified` frontmatter always wins, which is handy for pinning a date or for files that aren't committed yet:
304
+ The git source reads the most recent commit that touched each file, so it works in any git repository — including monorepos — and needs the repo's history at build time. CI platforms usually check out a shallow clone, which silently drops most dates (the build warns with `BLUME_SHALLOW_GIT_HISTORY` when that happens): on Vercel, set the `VERCEL_DEEP_CLONE=true` environment variable; with `actions/checkout`, set `fetch-depth: 0`. A page's own `lastModified` frontmatter always wins, which is handy for pinning a date or for files that aren't committed yet:
293
305
 
294
306
  ```mdx page.mdx
295
307
  ---
@@ -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: {
@@ -15,7 +15,7 @@ theme: {
15
15
  radius: "md", // none | sm | md | lg
16
16
  mode: "system", // system | light | dark
17
17
  fonts: { // self-hosted Google Fonts
18
- display: "inter-tight",
18
+ display: "inter",
19
19
  body: "inter",
20
20
  mono: "ibm-plex-mono",
21
21
  },
@@ -57,12 +57,14 @@ A toggle in the header always lets readers switch, and their choice is remembere
57
57
  - **`body`** — body text, UI, and prose
58
58
  - **`mono`** — code blocks and inline code
59
59
 
60
+ Headings get display-grade letter-spacing (`-0.05em`) from the theme itself, so whatever you pick for `display` — including text families like the Inter default — reads correctly at heading sizes instead of depending on tracking built into the font.
61
+
60
62
  Each defaults to a curated Google Font, so Blume looks intentional out of the box:
61
63
 
62
64
  ```ts blume.config.ts lineNumbers
63
65
  theme: {
64
66
  fonts: {
65
- display: "inter-tight", // default
67
+ display: "inter", // default
66
68
  body: "inter", // default
67
69
  mono: "ibm-plex-mono", // default
68
70
  },
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: CLI
3
- description: Every Blume command and flag explained in one place — init, dev, build, preview, add, sync, and eject — along with the options each one accepts.
3
+ description: Every Blume command and flag explained in one place, along with the options each one accepts.
4
4
  ---
5
5
 
6
6
  ```bash
@@ -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.0",
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";
@@ -61,7 +62,7 @@ import {
61
62
  examplesEntryTemplate,
62
63
  tailwindEntryTemplate,
63
64
  } from "../theme/entry.ts";
64
- import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
65
+ import { buildFontsCss, configuredFonts } from "../theme/fonts.ts";
65
66
  import { buildThemeCss } from "../theme/palette.ts";
66
67
  import { twoslashCss } from "../theme/twoslash.ts";
67
68
  import { planComponentSlots } from "./component-slots.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",
@@ -1255,7 +1312,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1255
1312
  })),
1256
1313
  // CSS variables for Astro's <Font> component; matches the astro.config
1257
1314
  // `fonts:` entries derived from the same theme.fonts config.
1258
- fontCssVars: configuredCssVars(config.theme.fonts),
1315
+ fontCssVars: configuredFonts(config.theme.fonts),
1259
1316
  navigation: withRepoUrl(graph.navigation),
1260
1317
  // Per-locale navigation; the catch-all selects the active locale's tree.
1261
1318
  navigationByLocale,
@@ -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
 
@@ -1624,12 +1754,36 @@ export const generateRuntime = async (
1624
1754
  // private and filtered out anyway, but the intent is the user's pages.
1625
1755
  const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
1626
1756
 
1757
+ // Whether the generated `/changelog` index exists — shared by the OG endpoint
1758
+ // (which adds the index's own card) and the page write below. Computed here,
1759
+ // before the MCP discovery pages are appended, on the user's own pages.
1760
+ const changelogIndex = hasGeneratedChangelog(project, pages);
1761
+
1627
1762
  // The hosted MCP server. The `.well-known` discovery docs are injected as
1628
1763
  // prerendered routes alongside user pages; the server endpoint itself is a
1629
1764
  // normal (server-rendered) page written by `writeMcpFiles`.
1630
1765
  const mcp = planMcp(project, srcDir, pages);
1631
1766
  pages.push(...mcp.discoveryPages);
1632
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
+
1633
1787
  const hasStaged = staged.size > 0;
1634
1788
  // Only emit a project-scanning `docs` collection when a filesystem source
1635
1789
  // actually feeds it. An all-staged project (openapi/notion/…) has only staged
@@ -1775,17 +1929,20 @@ export const generateRuntime = async (
1775
1929
  ),
1776
1930
  writeAskFiles(project, srcDir, write),
1777
1931
  writeMcpFiles(project, mcp, write),
1932
+ playgroundProxy.enabled
1933
+ ? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
1934
+ : Promise.resolve(false),
1778
1935
  ]);
1779
1936
 
1780
1937
  if (config.seo.og.enabled) {
1781
1938
  await write(
1782
1939
  join(srcDir, "pages", "og", "[...slug].png.ts"),
1783
- ogEndpointTemplate(ogRoutes, projectOgFonts(project))
1940
+ ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex)
1784
1941
  );
1785
1942
  }
1786
1943
 
1787
1944
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1788
- if (hasGeneratedChangelog(project, pages)) {
1945
+ if (changelogIndex) {
1789
1946
  await write(
1790
1947
  join(srcDir, "pages", "changelog.astro"),
1791
1948
  changelogIndexTemplate({
@@ -1890,6 +2047,7 @@ export const generateRuntime = async (
1890
2047
  // regenerated each run.
1891
2048
  const warnings: string[] = [
1892
2049
  ...(depsLinkWarning ? [depsLinkWarning] : []),
2050
+ ...proxyAllowlistWarnings(playgroundProxy.enabled, proxyOrigins),
1893
2051
  ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
1894
2052
  ...mcp.warnings,
1895
2053
  ...islandDiscovery.warnings,
@@ -1906,7 +2064,7 @@ export const generateRuntime = async (
1906
2064
  ...pages.map((page) => page.pattern),
1907
2065
  ...referenceRoutes(config),
1908
2066
  ]);
1909
- if (hasGeneratedChangelog(project, pages)) {
2067
+ if (changelogIndex) {
1910
2068
  navTargetRoutes.add("/changelog");
1911
2069
  }
1912
2070
  // Curated `search.popular` icons live outside the navigation model, so they
@@ -1956,20 +2114,16 @@ export const generateRuntime = async (
1956
2114
  );
1957
2115
  }
1958
2116
 
1959
- // The parsed OpenAPI specs behind the `blume:openapi` alias. Always written
1960
- // (even as `{}`) so the alias resolves whether or not a reference is enabled;
1961
- // the source parsed the specs during the scan, so this is just serialization.
1962
- 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.
1963
2120
  // These write to distinct trees and never read one another, so they batch.
1964
2121
  // `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
1965
2122
  // they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
1966
2123
  // own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
1967
2124
  await Promise.all([
1968
2125
  write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
1969
- write(
1970
- openapiPath,
1971
- `${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
1972
- ),
2126
+ write(openapiPath, `${JSON.stringify(openApiData)}\n`),
1973
2127
  write(
1974
2128
  join(out, "blume.manifest.json"),
1975
2129
  `${JSON.stringify(project.manifest, null, 2)}\n`