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.
- package/dist/cli/index.js +380 -157
- package/dist/cli/index.js.map +23 -22
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +50 -0
- package/dist/types/core/schema.d.ts +328 -39
- package/dist/types/core/types.d.ts +8 -0
- package/docs/configuration/ai.mdx +56 -0
- package/docs/configuration/seo.mdx +59 -1
- package/docs/configuration/theming.mdx +14 -9
- package/docs/content/meta.mdx +3 -17
- package/docs/content/navigation.mdx +41 -4
- package/package.json +3 -1
- package/src/ai/agent-readability.ts +97 -0
- package/src/ai/ask-context.ts +131 -8
- package/src/ai/ask-data.ts +4 -1
- package/src/astro/generate.ts +4 -0
- package/src/astro/templates.ts +24 -5
- package/src/cli/commands/build.ts +15 -0
- package/src/cli/commands/dev.ts +31 -14
- package/src/cli/dev-lock.ts +94 -21
- package/src/components/content/GithubInfo.astro +11 -10
- package/src/components/content/TypeTable.astro +8 -3
- package/src/components/islands/AskAI.astro +66 -2
- package/src/components/islands/ask-ai.tsx +289 -53
- package/src/components/layout/Header.astro +1 -1
- package/src/components/layout/NavTree.astro +1 -1
- package/src/components/layout/PageActions.astro +73 -30
- package/src/components/layout/RootLayout.astro +48 -2
- package/src/core/data.ts +4 -0
- package/src/core/graph.ts +7 -2
- package/src/core/i18n-ui.ts +5 -0
- package/src/core/nav-diagnostics.ts +7 -0
- package/src/core/navigation.ts +38 -12
- package/src/core/schema.ts +124 -9
- package/src/core/sources/filesystem.ts +5 -1
- package/src/core/sources/watch.ts +43 -12
- package/src/core/types.ts +9 -0
- package/src/deploy/robots.ts +37 -4
- package/src/openapi/scalar.ts +1 -1
- package/src/search/documents.ts +9 -2
- 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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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`
|
|
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:
|
|
122
|
-
|
|
124
|
+
backgroundImage: {
|
|
125
|
+
light: "/bg-light.svg",
|
|
126
|
+
dark: "/bg-dark.svg",
|
|
127
|
+
},
|
|
123
128
|
}
|
|
124
129
|
```
|
|
125
130
|
|
package/docs/content/meta.mdx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Folder meta
|
|
3
|
-
description: Configure a sidebar group — its title, icon, order,
|
|
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
|
-
| `
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
+
};
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -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 =
|
|
29
|
+
const EXCERPT_CHARS = 2000;
|
|
30
30
|
/** Overall cap on injected documentation characters. */
|
|
31
|
-
const CONTEXT_BUDGET =
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
59
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
};
|
package/src/ai/ask-data.ts
CHANGED
|
@@ -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 {
|
package/src/astro/generate.ts
CHANGED
|
@@ -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,
|