blume 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli/index.js +380 -157
  2. package/dist/cli/index.js.map +23 -22
  3. package/dist/types/core/data.d.ts +4 -0
  4. package/dist/types/core/i18n-ui.d.ts +50 -0
  5. package/dist/types/core/schema.d.ts +328 -39
  6. package/dist/types/core/types.d.ts +8 -0
  7. package/docs/configuration/ai.mdx +56 -0
  8. package/docs/configuration/seo.mdx +59 -1
  9. package/docs/configuration/theming.mdx +14 -9
  10. package/docs/content/meta.mdx +3 -17
  11. package/docs/content/navigation.mdx +41 -4
  12. package/package.json +3 -1
  13. package/src/ai/agent-readability.ts +97 -0
  14. package/src/ai/ask-context.ts +131 -8
  15. package/src/ai/ask-data.ts +4 -1
  16. package/src/astro/generate.ts +4 -0
  17. package/src/astro/templates.ts +24 -5
  18. package/src/cli/commands/build.ts +15 -0
  19. package/src/cli/commands/dev.ts +31 -14
  20. package/src/cli/dev-lock.ts +94 -21
  21. package/src/components/content/GithubInfo.astro +11 -10
  22. package/src/components/content/TypeTable.astro +8 -3
  23. package/src/components/islands/AskAI.astro +66 -2
  24. package/src/components/islands/ask-ai.tsx +289 -53
  25. package/src/components/layout/Header.astro +1 -1
  26. package/src/components/layout/NavTree.astro +1 -1
  27. package/src/components/layout/PageActions.astro +73 -30
  28. package/src/components/layout/RootLayout.astro +48 -2
  29. package/src/core/data.ts +4 -0
  30. package/src/core/graph.ts +7 -2
  31. package/src/core/i18n-ui.ts +5 -0
  32. package/src/core/nav-diagnostics.ts +7 -0
  33. package/src/core/navigation.ts +38 -12
  34. package/src/core/schema.ts +124 -9
  35. package/src/core/sources/filesystem.ts +5 -1
  36. package/src/core/sources/watch.ts +43 -12
  37. package/src/core/types.ts +9 -0
  38. package/src/deploy/robots.ts +37 -4
  39. package/src/openapi/scalar.ts +1 -1
  40. package/src/search/documents.ts +9 -2
  41. package/src/theme/palette.ts +21 -14
@@ -62,6 +62,25 @@ ai: {
62
62
  }
63
63
  ```
64
64
 
65
+ ### Suggested questions
66
+
67
+ Seed the empty state with a few starter prompts. Each renders as a clickable suggestion — click one to send it — with an optional [Lucide icon](/docs/content/components#icon) beside the label:
68
+
69
+ ```ts blume.config.ts lineNumbers
70
+ ai: {
71
+ ask: {
72
+ enabled: true,
73
+ suggestions: [
74
+ { label: "What is Blume?", icon: "rocket" },
75
+ { label: "How do I write a docs page?", icon: "file-text" },
76
+ { label: "How do I configure the theme?", icon: "settings" },
77
+ ],
78
+ },
79
+ }
80
+ ```
81
+
82
+ `label` is the question that gets asked; `icon` is optional. Leave `suggestions` unset (or empty) and the panel opens to a plain input.
83
+
65
84
  ### Grounding
66
85
 
67
86
  Ask AI is **grounded in your docs**. For each question it retrieves the most relevant pages — using the same lexical [Orama](/docs/configuration/search) index that powers on-page search — and injects them into the model's system prompt, so answers come from your content instead of the model's own knowledge. The assistant is told to answer only from the retrieved pages, to say when something isn't covered, and to cite the pages it drew from.
@@ -175,6 +194,43 @@ A static build with `mcp.enabled` fails fast with a message telling you to set `
175
194
  claude mcp add --transport http my-docs https://docs.example.com/mcp
176
195
  ```
177
196
 
197
+ ## Agent readability
198
+
199
+ Blume writes an **`/agent-readability.json`** manifest at your site root that indexes the agent-facing surface described on this page — so an agent can discover it in a single fetch instead of guessing at conventions or scraping HTML. Unlike the features above, it's on by default:
200
+
201
+ ```ts blume.config.ts lineNumbers
202
+ seo: {
203
+ agentReadability: true,
204
+ }
205
+ ```
206
+
207
+ The manifest lists only what you've enabled — the [raw Markdown](#raw-markdown) mirror pattern, [`llms.txt`](#llmstxt) and `llms-full.txt`, the [MCP server](#mcp-server) and its discovery document, the [Ask AI](#ask-ai) endpoint, the [sitemap](/docs/configuration/seo#sitemap), and [RSS feeds](/docs/configuration/seo#rss-feeds) — alongside your site name, description, source repository, and the [content-signal](/docs/configuration/seo#content-signals) usage policy. URLs are absolute when [`deployment.site`](/docs/deployment) is set and root-relative otherwise:
208
+
209
+ ```json agent-readability.json
210
+ {
211
+ "artifacts": {
212
+ "markdown": {
213
+ "contentNegotiation": "text/markdown",
214
+ "pattern": "https://docs.example.com/{route}.md"
215
+ },
216
+ "llmsFullTxt": "https://docs.example.com/llms-full.txt",
217
+ "llmsTxt": "https://docs.example.com/llms.txt",
218
+ "mcp": {
219
+ "discovery": "https://docs.example.com/.well-known/mcp.json",
220
+ "url": "https://docs.example.com/mcp"
221
+ }
222
+ },
223
+ "description": "Docs for the Acme API.",
224
+ "generator": "blume@1.0.0",
225
+ "name": "Acme Docs",
226
+ "site": "https://docs.example.com",
227
+ "contentUsage": { "search": true, "ai-input": true, "ai-train": true },
228
+ "repository": "https://github.com/acme/docs"
229
+ }
230
+ ```
231
+
232
+ Set `seo.agentReadability` to `false` to skip it, or ship your own `public/agent-readability.json` to take over — Blume never overwrites a file you place in `public/`.
233
+
178
234
  ## Agent skill
179
235
 
180
236
  Building a Blume site with the help of a coding agent? Install the Blume [agent skill](https://docs.claude.com/en/docs/claude-code/skills) so it knows how Blume works without you explaining it:
@@ -154,7 +154,7 @@ Ship your own `public/sitemap.xml` to take over — Blume never overwrites a fil
154
154
 
155
155
  ## Robots
156
156
 
157
- Blume writes a `robots.txt` that allows all crawlers and adds a `Sitemap:` line pointing to the sitemap when one is available. On by default:
157
+ Blume writes a `robots.txt` that allows all crawlers, declares your [content signals](#content-signals), and adds a `Sitemap:` line pointing to the sitemap when one is available. On by default:
158
158
 
159
159
  ```ts blume.config.ts lineNumbers
160
160
  seo: {
@@ -164,9 +164,67 @@ seo: {
164
164
 
165
165
  ```txt robots.txt
166
166
  User-agent: *
167
+ Content-Signal: search=yes, ai-input=yes, ai-train=yes
167
168
  Allow: /
168
169
 
169
170
  Sitemap: https://docs.example.com/sitemap.xml
170
171
  ```
171
172
 
173
+ ### Content signals
174
+
175
+ The `Content-Signal` line — the emerging content-usage convention — declares how AI crawlers may reuse your docs. Blume emits it **on by default with every signal set to `yes`**, matching its stance that docs are open to humans and agents alike:
176
+
177
+ - `search` — traditional and AI search indexing
178
+ - `aiInput` — grounding / RAG at answer time
179
+ - `aiTrain` — model training
180
+
181
+ Restrict any signal by setting it to `false`; the ones you leave out stay `yes`:
182
+
183
+ ```ts blume.config.ts lineNumbers
184
+ seo: {
185
+ contentSignals: {
186
+ aiTrain: false, // opt out of training, keep search + grounding
187
+ },
188
+ }
189
+ ```
190
+
191
+ ```txt robots.txt
192
+ User-agent: *
193
+ Content-Signal: search=yes, ai-input=yes, ai-train=no
194
+ Allow: /
195
+ ```
196
+
197
+ Set `contentSignals: false` to drop the declaration entirely:
198
+
199
+ ```ts blume.config.ts lineNumbers
200
+ seo: {
201
+ contentSignals: false,
202
+ }
203
+ ```
204
+
205
+ <TypeTable
206
+ type={{
207
+ "seo.contentSignals": {
208
+ type: "boolean | object",
209
+ description:
210
+ "Content-Signal declaration. true or omitted emits all signals as yes; false drops the line; an object sets signals individually.",
211
+ },
212
+ "contentSignals.search": {
213
+ type: "boolean",
214
+ description: "Allow use for search indexing (search). Default true.",
215
+ },
216
+ "contentSignals.aiInput": {
217
+ type: "boolean",
218
+ description:
219
+ "Allow use for AI grounding / RAG at answer time (ai-input). Default true.",
220
+ },
221
+ "contentSignals.aiTrain": {
222
+ type: "boolean",
223
+ description: "Allow use for AI model training (ai-train). Default true.",
224
+ },
225
+ }}
226
+ />
227
+
228
+ Content signals express a preference, not access control: they tell well-behaved crawlers how you'd like your content used, and it's on the crawler to honour them.
229
+
172
230
  Ship your own `public/robots.txt` to take over.
@@ -34,6 +34,8 @@ theme: {
34
34
 
35
35
  Named presets: `blue` (default), `green`, `orange`, `pink`, `purple`, `red`, and `teal`.
36
36
 
37
+ A string applies to both color modes; pass an object for [a different accent per mode](#dark-mode-colors).
38
+
37
39
  ### Radius
38
40
 
39
41
  `radius` sets the corner rounding shared by cards, code blocks, callouts, and inputs — `none`, `sm`, `md` (default), or `lg`.
@@ -89,18 +91,19 @@ Need a font that isn't listed, or want to drop back to the system stack? Overrid
89
91
 
90
92
  ### Dark-mode colors
91
93
 
92
- By default light and dark share one `accent`. Set `accentDark` for a different accent in dark mode, and `background` / `backgroundDark` to override the page background per mode:
94
+ `accent` and `background` follow one rule: a string applies to both color modes, and a `{ light, dark }` object sets each mode individually:
93
95
 
94
96
  ```ts blume.config.ts lineNumbers
95
97
  theme: {
96
- accent: "blue",
97
- accentDark: "teal", // a different accent in dark mode
98
- background: "#ffffff", // light-mode page background
99
- backgroundDark: "#0a0a0a", // dark-mode page background
98
+ accent: { light: "blue", dark: "teal" },
99
+ background: {
100
+ light: "#ffffff",
101
+ dark: "#0a0a0a",
102
+ },
100
103
  }
101
104
  ```
102
105
 
103
- Each takes a named preset or any CSS color, exactly like `accent`.
106
+ Each color takes a named preset or any CSS color. For `background` (and `backgroundImage`) either key can be omitted to override a single mode — `background: { dark: "#0a0a0a" }` keeps the default light background.
104
107
 
105
108
  ### Action color
106
109
 
@@ -114,12 +117,14 @@ theme: {
114
117
 
115
118
  ### Background image
116
119
 
117
- Set a background image behind your content with `backgroundImage` (and `backgroundImageDark` for a dark variant) a URL or a path under `public/`:
120
+ Set a background image behind your content with `backgroundImage` a URL or a path under `public/`. Like the colors, a string applies to both modes and a `{ light, dark }` object sets each mode's image:
118
121
 
119
122
  ```ts blume.config.ts lineNumbers
120
123
  theme: {
121
- backgroundImage: "/bg-light.svg",
122
- backgroundImageDark: "/bg-dark.svg",
124
+ backgroundImage: {
125
+ light: "/bg-light.svg",
126
+ dark: "/bg-dark.svg",
127
+ },
123
128
  }
124
129
  ```
125
130
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Folder meta
3
- description: Configure a sidebar group — its title, icon, order, display mode, and page order — with a meta.ts file.
3
+ description: Configure a sidebar group — its title, icon, order, and page order — with a meta.ts file.
4
4
  ---
5
5
 
6
6
  Every folder in your content tree becomes a sidebar group. Drop a `meta.ts` beside its pages to control how that group looks and how its children are ordered. It's entirely optional: without one, the group's label is the humanized folder name and its pages sort by [index, numeric prefix, then alphabetically](/docs/content/navigation#ordering).
@@ -16,7 +16,6 @@ export default defineMeta({
16
16
  title: "Guides",
17
17
  icon: "book-open",
18
18
  order: 2,
19
- display: "flat",
20
19
  collapsed: false,
21
20
  pages: ["configuration", "theming", "deployment"],
22
21
  });
@@ -31,25 +30,12 @@ Every field is optional — set only what you want to override.
31
30
  | `title` | `string` | The group's label. Defaults to the humanized folder name. |
32
31
  | `icon` | `string` | Icon shown next to the label. |
33
32
  | `order` | `number` | Position among sibling groups and pages. Lower numbers sort first. |
34
- | `display` | `"flat" \| "group" \| "page"` | How the group renders in the sidebar. Defaults to `flat`. See [below](#display-modes). |
35
- | `collapsed` | `boolean` | For `display: "group"`, whether the group starts collapsed. |
33
+ | `collapsed` | `boolean` | Under the [`group` display mode](/docs/content/navigation#display-modes), whether the group starts collapsed. |
36
34
  | `pages` | `string[]` | Explicit order for the group's children, by slug. |
37
35
 
38
36
  The `pages` array lists children by slug — the folder or file name with its numeric prefix and any parentheses stripped (so `01-quickstart.mdx` is `"quickstart"`). Children you leave out still appear, after the listed ones.
39
37
 
40
- ## Display modes
41
-
42
- `display` controls how a group and its items appear in the sidebar:
43
-
44
- - **`flat`** (default) — a non-collapsible header with its pages listed beneath.
45
- - **`group`** — a collapsible `<details>` disclosure. Pair it with `collapsed` to set the initial state; a group containing the current page always starts open.
46
- - **`page`** — a single row that, when clicked, slides the sidebar into a sub-panel showing only that group's items, with a back arrow at the top. The panel is route-aware, so landing directly on a page inside the group opens straight to it.
47
-
48
- :::tip
49
- `page` mode keeps deep sections tidy — reach for it when a group has many children and you'd rather drill into it than scroll past it.
50
- :::
51
-
52
- `display` works the same on a group in an [explicit sidebar](/docs/content/navigation#explicit-sidebar) config.
38
+ How groups render — flat headers, collapsible disclosures, or drill-in panels — is a sidebar-wide setting, not per folder: see [display modes](/docs/content/navigation#display-modes).
53
39
 
54
40
  ## Computed meta
55
41
 
@@ -31,7 +31,7 @@ See [Frontmatter](/docs/reference/frontmatter) for the full page schema.
31
31
 
32
32
  ## Folder groups
33
33
 
34
- Each folder becomes a sidebar group. Drop a [`meta.ts`](/docs/content/meta) beside its pages to set the group's title, icon, order, [display mode](/docs/content/meta#display-modes), and the order of its children:
34
+ Each folder becomes a sidebar group. Drop a [`meta.ts`](/docs/content/meta) beside its pages to set the group's title, icon, order, and the order of its children:
35
35
 
36
36
  ```ts meta.ts
37
37
  import { defineMeta } from "blume";
@@ -43,10 +43,32 @@ export default defineMeta({
43
43
  });
44
44
  ```
45
45
 
46
- See [Folder meta](/docs/content/meta) for every field, the three display modes, and computing meta at scan time.
46
+ See [Folder meta](/docs/content/meta) for every field and computing meta at scan time.
47
47
 
48
48
  To group pages _without_ adding a URL segment, use a parenthesized folder name — see [Pages](/docs/content#group-folders).
49
49
 
50
+ ## Display modes
51
+
52
+ `navigation.sidebar.display` sets how every sidebar group renders:
53
+
54
+ ```ts blume.config.ts lineNumbers
55
+ navigation: {
56
+ sidebar: {
57
+ display: "flat", // "flat" | "group" | "page"
58
+ },
59
+ }
60
+ ```
61
+
62
+ - **`flat`** (default) — a non-collapsible header with its pages listed beneath. Pages that aren't in any group always list first, above the group sections, so they can't be mistaken for a group's children.
63
+ - **`group`** — a collapsible `<details>` disclosure per group. Pair it with `collapsed` in [folder meta](/docs/content/meta) to set the initial state; a group containing the current page always starts open.
64
+ - **`page`** — each group is a single row that, when clicked, slides the sidebar into a sub-panel showing only that group's items, with a back arrow at the top. The panel is route-aware, so landing directly on a page inside the group opens straight to it.
65
+
66
+ :::tip
67
+ `page` mode keeps deep sections tidy — reach for it when groups have many children and you'd rather drill into them than scroll past them.
68
+ :::
69
+
70
+ A group in an [explicit sidebar](#explicit-sidebar) can override the global mode with its own `display`.
71
+
50
72
  ## Ordering
51
73
 
52
74
  When the sidebar is generated, order is resolved highest priority first:
@@ -112,9 +134,24 @@ navigation: {
112
134
 
113
135
  Each item takes a `label`, a `path`, and optional `icon`, `description`, and `tag`. `kind` (`dropdown`, `product`, `version`, or `language`) is a hint for how the selector is used; all render the same dropdown.
114
136
 
137
+ ## Featured links
138
+
139
+ Pin links to the top of the sidebar, above every section — a blog, a changelog, a contact or support page that should always be one click away. Unlike the generated tree, featured links are **not scoped by tab**: they show on every route, on every breakpoint.
140
+
141
+ ```ts blume.config.ts lineNumbers
142
+ navigation: {
143
+ featured: [
144
+ { label: "Blog", href: "https://example.com/blog", icon: "newspaper" },
145
+ { label: "Contact", href: "/contact", icon: "headphones" },
146
+ ],
147
+ }
148
+ ```
149
+
150
+ Each link takes a `label`, an `href`, and an optional `icon` (a [built-in icon](/docs/configuration/customization) name, image path/URL, or inline SVG — the same as anywhere else). An `href` may point anywhere: an external URL opens in a new tab, while an internal route (`/contact`) is validated against your pages at build time, warning you if nothing matches.
151
+
115
152
  ## Explicit sidebar
116
153
 
117
- For full control, define `navigation.sidebar` in config. When it's set, Blume uses it verbatim and skips file-system generation:
154
+ For full control, list explicit items in `navigation.sidebar` a bare array is shorthand for `sidebar.items`, and the object form combines them with a global [`display`](#display-modes). When items are set, Blume uses them verbatim and skips file-system generation:
118
155
 
119
156
  ```ts blume.config.ts lineNumbers
120
157
  navigation: {
@@ -130,7 +167,7 @@ navigation: {
130
167
  }
131
168
  ```
132
169
 
133
- Each item is a page route (a string), a group (`label` + `items`), or a link (`label` + `href`). Groups can nest, take a [`display` mode](/docs/content/meta#display-modes), and start `collapsed`.
170
+ Each item is a page route (a string), a group (`label` + `items`), or a link (`label` + `href`). Groups can nest, override the global [`display` mode](#display-modes), and start `collapsed`.
134
171
 
135
172
  ## Repository link
136
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -92,12 +92,14 @@
92
92
  "citty": "^0.1.6",
93
93
  "consola": "^3.4.0",
94
94
  "deepmerge": "^4.3.1",
95
+ "dompurify": "^3.4.11",
95
96
  "epub-gen-memory": "^1.1.2",
96
97
  "github-slugger": "^2.0.0",
97
98
  "gray-matter": "^4.0.3",
98
99
  "jiti": "^2.4.0",
99
100
  "js-yaml": "^4.1.0",
100
101
  "katex": "^0.17.0",
102
+ "marked": "^18.0.5",
101
103
  "mermaid": "^11.15.0",
102
104
  "pagefind": "^1.3.0",
103
105
  "pathe": "^2.0.0",
@@ -0,0 +1,97 @@
1
+ import type { BlumeProject } from "../core/project-graph.ts";
2
+ import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
3
+ import { buildRssFeeds } from "../deploy/rss.ts";
4
+
5
+ /** Token map for the machine-readable content-usage echo. */
6
+ const USAGE_TOKENS: [keyof ContentSignalPolicy, string][] = [
7
+ ["search", "search"],
8
+ ["aiInput", "ai-input"],
9
+ ["aiTrain", "ai-train"],
10
+ ];
11
+
12
+ /**
13
+ * The configured usage preferences as a `{ token: allowed }` object, or null
14
+ * when the declaration is disabled (`contentSignals: false`). Mirrors the
15
+ * robots.txt `Content-Signal` line so an agent that reads the manifest instead
16
+ * of robots.txt sees the same policy.
17
+ */
18
+ const usagePolicy = (
19
+ signals: ContentSignals
20
+ ): Record<string, boolean> | null => {
21
+ if (!signals) {
22
+ return null;
23
+ }
24
+ return Object.fromEntries(
25
+ USAGE_TOKENS.map(([key, token]) => [token, signals[key]] as const)
26
+ );
27
+ };
28
+
29
+ /**
30
+ * Build `agent-readability.json`: a root manifest that indexes the project's
31
+ * agent-facing surface — llms.txt, the raw-Markdown mirrors, the MCP server,
32
+ * Ask AI, sitemap, and feeds — so agents can discover and cite the docs without
33
+ * scraping HTML. URLs are absolute when a `site` is configured and root-relative
34
+ * otherwise. Returns null when the manifest is disabled.
35
+ */
36
+ export const buildAgentReadability = (
37
+ project: BlumeProject
38
+ ): Record<string, unknown> | null => {
39
+ const { config } = project;
40
+ if (!config.seo.agentReadability) {
41
+ return null;
42
+ }
43
+
44
+ const site = config.deployment.site ?? null;
45
+ // Concatenate rather than `new URL()` so a subpath deployment's base is kept.
46
+ const abs = (path: string): string =>
47
+ site ? `${site.replace(/\/+$/u, "")}${path}` : path;
48
+
49
+ const artifacts: Record<string, unknown> = {
50
+ markdown: {
51
+ contentNegotiation: "text/markdown",
52
+ pattern: abs("/{route}.md"),
53
+ },
54
+ };
55
+ if (config.ai.llmsTxt) {
56
+ artifacts.llmsFullTxt = abs("/llms-full.txt");
57
+ artifacts.llmsTxt = abs("/llms.txt");
58
+ }
59
+ if (config.mcp.enabled) {
60
+ artifacts.mcp = {
61
+ discovery: abs("/.well-known/mcp.json"),
62
+ url: abs(config.mcp.route),
63
+ };
64
+ }
65
+ if (config.ai.ask?.enabled) {
66
+ artifacts.askApi = abs("/api/ask");
67
+ }
68
+ if (site && config.seo.sitemap) {
69
+ artifacts.sitemap = abs("/sitemap.xml");
70
+ }
71
+ const feeds =
72
+ site && config.seo.rss.enabled
73
+ ? buildRssFeeds(project).map((feed) => abs(feed.path))
74
+ : [];
75
+ if (feeds.length > 0) {
76
+ artifacts.feeds = feeds;
77
+ }
78
+
79
+ const version = project.manifest?.blumeVersion;
80
+ const manifest: Record<string, unknown> = {
81
+ artifacts,
82
+ description: config.description,
83
+ generator: version ? `blume@${version}` : undefined,
84
+ name: config.mcp.name ?? config.title,
85
+ site,
86
+ };
87
+
88
+ const usage = usagePolicy(config.seo.contentSignals);
89
+ if (usage) {
90
+ manifest.contentUsage = usage;
91
+ }
92
+ if (config.github) {
93
+ manifest.repository = `https://github.com/${config.github.owner}/${config.github.repo}`;
94
+ }
95
+
96
+ return manifest;
97
+ };
@@ -26,16 +26,77 @@ export interface AskData {
26
26
  /** Documents retrieved per question and injected into the system prompt. */
27
27
  const MAX_RESULTS = 6;
28
28
  /** Characters kept per injected excerpt. */
29
- const EXCERPT_CHARS = 1500;
29
+ const EXCERPT_CHARS = 2000;
30
30
  /** Overall cap on injected documentation characters. */
31
- const CONTEXT_BUDGET = 8000;
31
+ const CONTEXT_BUDGET = 10_000;
32
+ /** Chars of lead-in kept before the matched region, for heading/sentence context. */
33
+ const EXCERPT_LEAD = 160;
34
+
35
+ /**
36
+ * Common words dropped from the retrieval query before locating the relevant
37
+ * excerpt region, so short filler ("how does…", "what is…") doesn't drag the
38
+ * window toward incidental matches instead of the meaningful terms.
39
+ */
40
+ const STOPWORDS = new Set([
41
+ "about",
42
+ "and",
43
+ "are",
44
+ "as",
45
+ "at",
46
+ "be",
47
+ "but",
48
+ "by",
49
+ "can",
50
+ "do",
51
+ "does",
52
+ "for",
53
+ "from",
54
+ "how",
55
+ "in",
56
+ "into",
57
+ "is",
58
+ "it",
59
+ "its",
60
+ "my",
61
+ "of",
62
+ "on",
63
+ "or",
64
+ "our",
65
+ "that",
66
+ "the",
67
+ "these",
68
+ "this",
69
+ "those",
70
+ "to",
71
+ "use",
72
+ "used",
73
+ "using",
74
+ "was",
75
+ "were",
76
+ "what",
77
+ "when",
78
+ "where",
79
+ "which",
80
+ "who",
81
+ "why",
82
+ "with",
83
+ "you",
84
+ "your",
85
+ ]);
86
+
87
+ /** Distinct, meaningful lowercase terms from a query (drops stopwords). */
88
+ const queryTerms = (query: string): string[] =>
89
+ [...new Set(query.toLowerCase().match(/[a-z0-9]+/gu))].filter(
90
+ (term) => term.length >= 2 && !STOPWORDS.has(term)
91
+ );
32
92
 
33
93
  /**
34
94
  * The grounding preamble. The model is told to answer strictly from the injected
35
- * excerpts and to cite the pages it used, so answers stay tied to the docs.
95
+ * excerpts and to cite the pages it used as Markdown links (each excerpt is
96
+ * headed by `## Title (/route)`), so citations render as real links in the panel.
36
97
  */
37
98
  const BASE_INSTRUCTION =
38
- "You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. If the answer is not covered by them, say you don't know and suggest where in the docs to look — do not invent details. Cite the page titles you drew from.";
99
+ "You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
39
100
 
40
101
  /** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
41
102
  const normalizeRoute = (input: string): string => {
@@ -55,10 +116,68 @@ const lastUserMessage = (messages: AskMessage[]): string => {
55
116
  return "";
56
117
  };
57
118
 
58
- /** Trim a document body to `max` characters, marking truncation with an ellipsis. */
59
- const excerpt = (content: string, max: number): string => {
119
+ /**
120
+ * Excerpt the region of `content` most relevant to `query`, not just its head.
121
+ *
122
+ * Pages are indexed whole (one document each), so a naive head slice of a long
123
+ * page returns its intro and misses sections below the fold — the exact failure
124
+ * where "How does Ask AI work?" retrieves the right page but only sees its
125
+ * opening paragraph. This centers the window on the densest cluster of query
126
+ * terms so the injected text is the part that actually answers the question.
127
+ */
128
+ const relevantExcerpt = (
129
+ content: string,
130
+ query: string,
131
+ max: number
132
+ ): string => {
60
133
  const trimmed = content.trim();
61
- return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
134
+ if (trimmed.length <= max) {
135
+ return trimmed;
136
+ }
137
+ const withEllipsis = (start: number): string => {
138
+ const slice = trimmed.slice(start, start + max).trim();
139
+ const prefix = start > 0 ? "…" : "";
140
+ const suffix = start + max < trimmed.length ? "…" : "";
141
+ return `${prefix}${slice}${suffix}`;
142
+ };
143
+
144
+ const lower = trimmed.toLowerCase();
145
+ const positions: number[] = [];
146
+ for (const term of queryTerms(query)) {
147
+ let idx = lower.indexOf(term);
148
+ while (idx !== -1) {
149
+ positions.push(idx);
150
+ idx = lower.indexOf(term, idx + term.length);
151
+ }
152
+ }
153
+ // No query terms hit this doc — nothing to center on, so keep the head.
154
+ if (positions.length === 0) {
155
+ return withEllipsis(0);
156
+ }
157
+
158
+ // Pick the term hit whose following `max`-char window covers the most hits.
159
+ // `positions` is non-empty here, so the first window (count ≥ 1) always wins
160
+ // over the initial 0 and assigns a real offset to `best`.
161
+ positions.sort((a, b) => a - b);
162
+ let best = 0;
163
+ let bestCount = 0;
164
+ for (const start of positions) {
165
+ const end = start + max;
166
+ let count = 0;
167
+ for (const pos of positions) {
168
+ if (pos >= end) {
169
+ break;
170
+ }
171
+ if (pos >= start) {
172
+ count += 1;
173
+ }
174
+ }
175
+ if (count > bestCount) {
176
+ bestCount = count;
177
+ best = start;
178
+ }
179
+ }
180
+ return withEllipsis(Math.max(0, best - EXCERPT_LEAD));
62
181
  };
63
182
 
64
183
  /**
@@ -111,7 +230,11 @@ export const createAskContext = (
111
230
  return;
112
231
  }
113
232
  seen.add(doc.route);
114
- const body = excerpt(doc.content, Math.min(EXCERPT_CHARS, budget));
233
+ const body = relevantExcerpt(
234
+ doc.content,
235
+ query,
236
+ Math.min(EXCERPT_CHARS, budget)
237
+ );
115
238
  budget -= body.length;
116
239
  sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
117
240
  };
@@ -6,10 +6,13 @@ import type { AskData } from "./ask-context.ts";
6
6
  * Build the grounding snapshot the Ask AI endpoint serves. Like the MCP server,
7
7
  * Ask AI is independent of on-page search, so documents are indexed even when the
8
8
  * search provider is `none` (`includeWhenDisabled`). `locale` is kept (unlike the
9
- * MCP snapshot) so retrieval can be filtered to the current page's language.
9
+ * MCP snapshot) so retrieval can be filtered to the current page's language, and
10
+ * content is kept as Markdown so grounding sees fenced code examples — the model
11
+ * answers "what does the config look like?" from the docs instead of declining.
10
12
  */
11
13
  export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
12
14
  const documents = await buildSearchDocuments(project, {
15
+ content: "markdown",
13
16
  includeWhenDisabled: true,
14
17
  });
15
18
  return {
@@ -620,6 +620,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
620
620
  code,
621
621
  withReferenceTabs(
622
622
  graph.navigationByLocale[code] ?? {
623
+ featured: [],
623
624
  selectors: [],
624
625
  sidebar: [],
625
626
  tabs: [],
@@ -633,6 +634,9 @@ export const buildRuntimeData = (project: BlumeProject): string => {
633
634
  config: {
634
635
  analytics: config.analytics ?? null,
635
636
  appleIcon: resolveAppleIcon(project),
637
+ ask: config.ai.ask?.enabled
638
+ ? { suggestions: config.ai.ask.suggestions }
639
+ : null,
636
640
  banner: resolveBanner(config),
637
641
  codeWrap: config.markdown.code.wrap,
638
642
  description: config.description,