blume 1.4.1 → 1.4.3

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 (93) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/cli/index.js +700 -579
  3. package/dist/cli/index.js.map +57 -56
  4. package/dist/types/core/base-path.d.ts +8 -0
  5. package/dist/types/core/config-input.d.ts +8 -0
  6. package/dist/types/core/data.d.ts +10 -0
  7. package/dist/types/core/schema.d.ts +4 -0
  8. package/dist/types/core/sources/types.d.ts +9 -1
  9. package/dist/types/openapi/references.d.ts +8 -2
  10. package/docs/configuration/ai.mdx +41 -9
  11. package/docs/content/sources.mdx +1 -1
  12. package/package.json +17 -7
  13. package/src/ai/agent-readability.ts +3 -2
  14. package/src/ai/api-catalog.ts +2 -2
  15. package/src/ai/ask-context.ts +45 -12
  16. package/src/ai/link-headers.ts +7 -2
  17. package/src/ai/llms.ts +2 -1
  18. package/src/ai/mcp/discovery.ts +25 -6
  19. package/src/ai/mcp/server.ts +108 -98
  20. package/src/ai/tar.ts +29 -70
  21. package/src/astro/examples.ts +7 -3
  22. package/src/astro/generate.ts +63 -34
  23. package/src/astro/islands.ts +7 -3
  24. package/src/astro/templates.ts +36 -9
  25. package/src/audit/agent.ts +14 -29
  26. package/src/audit/crawl.ts +41 -16
  27. package/src/audit/run.ts +10 -3
  28. package/src/audit/snapshot.ts +27 -2
  29. package/src/cli/commands/audit.ts +12 -17
  30. package/src/cli/commands/build.ts +15 -7
  31. package/src/cli/commands/dev.ts +13 -15
  32. package/src/cli/commands/eject.ts +4 -4
  33. package/src/cli/commands/eval.ts +17 -27
  34. package/src/cli/env.ts +13 -30
  35. package/src/cli/init/scaffold.ts +21 -0
  36. package/src/cli/report-format.ts +22 -0
  37. package/src/components/content/AccordionItem.astro +2 -9
  38. package/src/components/content/ColorItem.astro +5 -13
  39. package/src/components/content/Component.astro +12 -8
  40. package/src/components/content/Frame.astro +2 -12
  41. package/src/components/content/Prompt.astro +12 -31
  42. package/src/components/content/Tab.astro +2 -9
  43. package/src/components/content/Tooltip.astro +1 -9
  44. package/src/components/content/Update.astro +2 -9
  45. package/src/components/content/inline-markdown.ts +28 -0
  46. package/src/components/copy-feedback.ts +96 -0
  47. package/src/components/islands/ask-ai.tsx +78 -9
  48. package/src/components/layout/PageActions.astro +20 -32
  49. package/src/components/layout/PageLayout.astro +8 -28
  50. package/src/components/layout/RootLayout.astro +47 -48
  51. package/src/components/layout/Search.astro +56 -9
  52. package/src/components/layout/drawer-inert.ts +31 -0
  53. package/src/components/layout/search/pagefind.ts +6 -5
  54. package/src/components/layout/search/types.ts +32 -0
  55. package/src/components/openapi/panel.ts +11 -8
  56. package/src/components/raf-throttle.ts +21 -0
  57. package/src/components/slug.ts +14 -0
  58. package/src/core/base-path.ts +18 -1
  59. package/src/core/config-input.ts +8 -0
  60. package/src/core/data.ts +7 -0
  61. package/src/core/frontmatter.ts +45 -1
  62. package/src/core/probe.ts +7 -19
  63. package/src/core/project-graph.ts +12 -1
  64. package/src/core/schema.ts +6 -0
  65. package/src/core/site-url.ts +27 -0
  66. package/src/core/sources/cache.ts +10 -8
  67. package/src/core/sources/github-releases.ts +21 -1
  68. package/src/core/sources/normalize.ts +26 -2
  69. package/src/core/sources/notion.ts +27 -5
  70. package/src/core/sources/portable-text.ts +16 -1
  71. package/src/core/sources/resolve.ts +1 -0
  72. package/src/core/sources/types.ts +13 -1
  73. package/src/deploy/cloudflare-negotiation.ts +15 -1
  74. package/src/deploy/robots.ts +2 -1
  75. package/src/deploy/rss.ts +2 -1
  76. package/src/deploy/sitemap.ts +56 -7
  77. package/src/eval/agents.ts +13 -10
  78. package/src/eval/report.ts +1 -14
  79. package/src/markdown/package-commands.ts +61 -54
  80. package/src/og/card.ts +24 -26
  81. package/src/openapi/model.ts +9 -9
  82. package/src/openapi/parse.ts +69 -28
  83. package/src/openapi/references.ts +35 -12
  84. package/src/openapi/render-mdx.ts +64 -25
  85. package/src/openapi/scalar.ts +2 -2
  86. package/src/openapi/source.ts +28 -1
  87. package/src/search/documents.ts +78 -34
  88. package/src/search/orama-index.ts +51 -12
  89. package/src/theme/palette.ts +6 -2
  90. package/src/translate/ledger.ts +4 -2
  91. package/src/translate/report.ts +1 -14
  92. package/src/translate/run.ts +20 -35
  93. package/src/cli/coalesce.ts +0 -43
@@ -22,6 +22,14 @@ export declare const normalizeBasePath: (input?: string) => string;
22
22
  * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
23
23
  */
24
24
  export declare const normalizePath: (path: string) => string;
25
+ /**
26
+ * Canonicalize a route-ish string (a configured route, a page path, an agent-
27
+ * supplied route) to `/` or `/seg[/seg…]`: trimmed, exactly one leading slash,
28
+ * no trailing slash. The shared spelling of what openapi/references,
29
+ * ai/ask-context, and ai/mcp/server each hand-rolled with slightly different
30
+ * regexes.
31
+ */
32
+ export declare const normalizeRoute: (input: string) => string;
25
33
  /**
26
34
  * Whether a link target is a root-relative internal path (`/x`) — the only
27
35
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
@@ -171,6 +171,8 @@ export interface SanitySource {
171
171
  /** A Notion database; pages become entries, blocks become MDX. */
172
172
  export interface NotionSource {
173
173
  type: "notion";
174
+ /** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */
175
+ concurrency?: number;
174
176
  /** Notion database id. */
175
177
  database: string;
176
178
  /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
@@ -565,6 +567,12 @@ export interface AskConfig {
565
567
  * limiting, and streaming. Accepts an absolute URL or root-relative path.
566
568
  */
567
569
  endpoint?: string;
570
+ /**
571
+ * Extra system-prompt text appended to the built-in instructions — use it
572
+ * for identity, language, or tone. The built-in grounding behavior (answer
573
+ * from the retrieved excerpts, cite pages as Markdown links) is preserved.
574
+ */
575
+ instructions?: string;
568
576
  /** Model id to use. Defaults to `openai/gpt-5.5`. */
569
577
  model?: string;
570
578
  /** Which backend routes the request. Defaults to `gateway`. */
@@ -107,6 +107,16 @@ export interface BlumeDataConfig {
107
107
  /** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
108
108
  dateFormat: ResolvedConfig["dateFormat"];
109
109
  description: string | undefined;
110
+ /**
111
+ * Which agent-discovery resources exist for the layout to advertise in every
112
+ * page's `<head>` (`seo.agentReadability`, `ai.llmsTxt.enabled`) — the HTML
113
+ * counterpart of the homepage-only HTTP `Link` header, for agents that enter
114
+ * on a deep page (see `ai/link-headers.ts`).
115
+ */
116
+ discovery: {
117
+ agentReadability: boolean;
118
+ llmsTxt: boolean;
119
+ };
110
120
  favicon: BlumeFavicon;
111
121
  feedback: boolean;
112
122
  i18n: BlumeDataI18n | null;
@@ -189,6 +189,7 @@ declare const contentSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
189
189
  query: z.ZodString;
190
190
  type: z.ZodLiteral<"sanity">;
191
191
  }, z.core.$strip>, z.ZodObject<{
192
+ concurrency: z.ZodOptional<z.ZodNumber>;
192
193
  database: z.ZodString;
193
194
  pollInterval: z.ZodOptional<z.ZodNumber>;
194
195
  prefix: z.ZodOptional<z.ZodString>;
@@ -244,6 +245,7 @@ declare const aiConfigSchema: z.ZodObject<{
244
245
  baseUrl: z.ZodOptional<z.ZodURL>;
245
246
  enabled: z.ZodDefault<z.ZodBoolean>;
246
247
  endpoint: z.ZodOptional<z.ZodString>;
248
+ instructions: z.ZodOptional<z.ZodString>;
247
249
  model: z.ZodDefault<z.ZodString>;
248
250
  provider: z.ZodDefault<z.ZodEnum<{
249
251
  gateway: "gateway";
@@ -400,6 +402,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
400
402
  baseUrl: z.ZodOptional<z.ZodURL>;
401
403
  enabled: z.ZodDefault<z.ZodBoolean>;
402
404
  endpoint: z.ZodOptional<z.ZodString>;
405
+ instructions: z.ZodOptional<z.ZodString>;
403
406
  model: z.ZodDefault<z.ZodString>;
404
407
  provider: z.ZodDefault<z.ZodEnum<{
405
408
  gateway: "gateway";
@@ -527,6 +530,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
527
530
  query: z.ZodString;
528
531
  type: z.ZodLiteral<"sanity">;
529
532
  }, z.core.$strip>, z.ZodObject<{
533
+ concurrency: z.ZodOptional<z.ZodNumber>;
530
534
  database: z.ZodString;
531
535
  pollInterval: z.ZodOptional<z.ZodNumber>;
532
536
  prefix: z.ZodOptional<z.ZodString>;
@@ -1,4 +1,4 @@
1
- import type { FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
1
+ import type { FolderMeta, FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
2
2
  import type { Diagnostic } from "../types.ts";
3
3
  /**
4
4
  * A single content item, normalized by a source adapter. Adapters lower their
@@ -41,6 +41,14 @@ export interface SourceLoadResult {
41
41
  entries: SourceEntry[];
42
42
  /** Source-level diagnostics (e.g. an offline cache fallback warning). */
43
43
  diagnostics: Diagnostic[];
44
+ /**
45
+ * Folder meta the source derives for the sidebar groups its entries create,
46
+ * keyed by locale-stripped group path (the `meta.ts` key space). The OpenAPI
47
+ * source labels each tag directory with the spec's own tag name, so the
48
+ * sidebar shows `OAuth2`/`Größe` instead of a re-humanized slug. Merged
49
+ * beneath user-authored meta files, which always win.
50
+ */
51
+ folderMeta?: Record<string, FolderMeta>;
44
52
  }
45
53
  /**
46
54
  * Per-source runtime context, handed to an adapter factory at construction so
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../core/schema.ts";
2
+ export { normalizeRoute } from "../core/base-path.ts";
2
3
  /**
3
4
  * Pure resolution of the configured API reference blocks into concrete routes,
4
5
  * labels, and a renderer choice — no file IO, so the content source, the
@@ -54,9 +55,14 @@ export interface ReferenceSource {
54
55
  */
55
56
  collisions?: string[];
56
57
  }
58
+ /**
59
+ * Lowercase, hyphen-separated slug: `Add a Pet!` -> `add-a-pet`. Unicode
60
+ * letters are kept (`Größe` -> `größe`), NFC-normalized so canonically
61
+ * equivalent spellings (NFD input from macOS tooling) land on one slug.
62
+ * Non-ASCII slugs rely on the emitter percent-encoding the URL where a raw
63
+ * URI is required (sitemap, canonical).
64
+ */
57
65
  export declare const slugify: (text: string) => string;
58
- /** Normalize a configured route to a single leading slash, no trailing slash. */
59
- export declare const normalizeRoute: (route: string) => string;
60
66
  /**
61
67
  * Resolve every enabled reference. OpenAPI honors its `renderer` (Blume's own UI
62
68
  * by default); AsyncAPI is always rendered by Scalar for now.
@@ -136,6 +136,22 @@ ai: {
136
136
 
137
137
  `label` is the question that gets asked; `icon` is optional. Leave `suggestions` unset (or empty) and the panel opens to a plain input.
138
138
 
139
+ ### Custom instructions
140
+
141
+ Add your own system-prompt text with `instructions` — identity, language, tone, or anything else the assistant should keep in mind:
142
+
143
+ ```ts blume.config.ts lineNumbers
144
+ ai: {
145
+ ask: {
146
+ enabled: true,
147
+ instructions:
148
+ "You are Bloomy, the Acme docs assistant. Answer in the language the question was asked in, and keep answers under three paragraphs.",
149
+ },
150
+ }
151
+ ```
152
+
153
+ Your text is **appended to** the built-in instructions rather than replacing them: the built-in part carries the [grounding](#grounding) contract — answer only from the retrieved pages, cite them as Markdown links — that the chat panel's citations depend on, so it stays intact whatever you add.
154
+
139
155
  ### Grounding
140
156
 
141
157
  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.
@@ -185,15 +201,17 @@ A static build with Ask AI enabled and no external `endpoint` fails fast with a
185
201
 
186
202
  By default Ask AI routes through the **Vercel AI Gateway**: `model` is a `provider/model` string, so you switch models by changing it (`openai/gpt-5.5`, `anthropic/claude-sonnet-4-5`, and so on) with no provider SDK to install. The gateway reads `AI_GATEWAY_API_KEY` from your environment and is wired up automatically when you deploy on Vercel.
187
203
 
188
- Set `provider` to point Ask AI somewhere else. Each backend reads its API key from an environment variable, and the matching provider SDK is added to your project's runtime automatically when you build — only the one you use:
204
+ Set `provider` to point Ask AI somewhere else. Each backend reads its API key from an environment variable and streams through a provider SDK you install in your project — only the one you use:
205
+
206
+ | `provider` | `model` | API key env var | SDK to install |
207
+ | --- | --- | --- | --- |
208
+ | `gateway` (default) | a `provider/model` string via the AI Gateway | `AI_GATEWAY_API_KEY` | none — ships with Blume |
209
+ | `openrouter` | any [OpenRouter](https://openrouter.ai) model | `OPENROUTER_API_KEY` | `@openrouter/ai-sdk-provider` |
210
+ | `llmgateway` | any [LLMGateway](https://llmgateway.io) model | `LLMGATEWAY_API_KEY` | `@ai-sdk/openai-compatible` |
211
+ | `inkeep` | an [Inkeep](https://inkeep.com) QA model | `INKEEP_API_KEY` | `@ai-sdk/openai-compatible` |
212
+ | `openai-compatible` | whatever your endpoint serves | set with `apiKeyEnv` | `@ai-sdk/openai-compatible` |
189
213
 
190
- | `provider` | `model` | API key env var |
191
- | --- | --- | --- |
192
- | `gateway` (default) | a `provider/model` string via the AI Gateway | `AI_GATEWAY_API_KEY` |
193
- | `openrouter` | any [OpenRouter](https://openrouter.ai) model | `OPENROUTER_API_KEY` |
194
- | `llmgateway` | any [LLMGateway](https://llmgateway.io) model | `LLMGATEWAY_API_KEY` |
195
- | `inkeep` | an [Inkeep](https://inkeep.com) QA model | `INKEEP_API_KEY` |
196
- | `openai-compatible` | whatever your endpoint serves | set with `apiKeyEnv` |
214
+ The SDKs are optional peer dependencies, so add the one your backend needs to your project (e.g. `npm install @openrouter/ai-sdk-provider`). If it's missing, the build warns with the exact package name before Vite would fail to resolve the import.
197
215
 
198
216
  For example, to use OpenRouter:
199
217
 
@@ -338,7 +356,21 @@ Link: </agent-readability.json>; rel="describedby"; type="application/json",
338
356
  </index.md>; rel="alternate"; type="text/markdown"
339
357
  ```
340
358
 
341
- Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror — the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules. Hosts that ignore `_headers` on static output (GitHub Pages, S3) can't send custom response headers at all — there, agents still find everything through `llms.txt` and `agent-readability.json` at the site root.
359
+ Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror — the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules.
360
+
361
+ Not every agent enters through the root, though — one following a search result or a shared link lands on a deep page and never sees the homepage header. So every rendered page also carries the same discovery links in its HTML `<head>`, using the same IANA-registered relations:
362
+
363
+ ```html
364
+ <link
365
+ rel="describedby"
366
+ href="/agent-readability.json"
367
+ type="application/json"
368
+ />
369
+ <link rel="describedby" href="/llms.txt" type="text/plain" />
370
+ <link rel="alternate" href="/docs/example.md" type="text/markdown" />
371
+ ```
372
+
373
+ Here the `alternate` link points at _that page's own_ [raw-Markdown mirror](#raw-markdown), so an agent can jump straight from the HTML it landed on to the token-efficient version. Because the head links travel with the prerendered HTML, they also work on hosts that ignore `_headers` and can't send custom response headers at all (GitHub Pages, S3) — no matter which page the agent enters on.
342
374
 
343
375
  ### API catalog
344
376
 
@@ -146,7 +146,7 @@ export default defineConfig({
146
146
  });
147
147
  ```
148
148
 
149
- The integration token comes from the `NOTION_TOKEN` environment variable (share the database with your integration). By default every page is imported; set `publishedValue` to make the `Status` property a publish gate — any other value then maps to `draft: true`, which production builds drop. **Notion image URLs are signed and expire**, so the adapter downloads them at build time into the site's assets and rewrites the references — a CMS image never rots a static build.
149
+ The integration token comes from the `NOTION_TOKEN` environment variable (share the database with your integration). By default every page is imported; set `publishedValue` to make the `Status` property a publish gate — any other value then maps to `draft: true`, which production builds drop. **Notion image URLs are signed and expire**, so the adapter downloads them at build time into the site's assets and rewrites the references — a CMS image never rots a static build. API calls are paced through a small request pool (3 at a time, matching Notion's per-integration rate limit) so databases with hundreds of pages import without tripping `429` responses; set `concurrency` on the source to tune it.
150
150
 
151
151
  ## Preview and sync
152
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -71,7 +71,7 @@
71
71
  "@astrojs/mdx": "^7.0.0",
72
72
  "@astrojs/node": "^11.0.0",
73
73
  "@astrojs/react": "^6.0.0",
74
- "@astrojs/vercel": "^11.0.0",
74
+ "@astrojs/vercel": "^11.0.3",
75
75
  "@clack/prompts": "^1.7.0",
76
76
  "@iconify-json/lucide": "^1.2.115",
77
77
  "@iconify/types": "^2.0.0",
@@ -86,14 +86,16 @@
86
86
  "@shikijs/twoslash": "^4.2.0",
87
87
  "@tailwindcss/typography": "^0.5.20",
88
88
  "@tailwindcss/vite": "^4",
89
+ "@types/mdast": "^4.0.4",
89
90
  "@vercel/analytics": "^2.0.1",
90
91
  "ai": "^7.0.42",
91
- "astro": "^7.0.2",
92
+ "astro": "^7.1.0",
92
93
  "babel-plugin-react-compiler": "^1.0.0",
93
94
  "chokidar": "^5.0.0",
94
95
  "citty": "^0.1.6",
95
96
  "consola": "^3.4.0",
96
- "dompurify": "^3.4.11",
97
+ "cross-spawn": "^7.0.6",
98
+ "dompurify": "^3.4.13",
97
99
  "dotenv": "^17.4.2",
98
100
  "epub-gen-memory": "^1.1.2",
99
101
  "fast-xml-parser": "^5.10.1",
@@ -103,7 +105,7 @@
103
105
  "html-escaper": "^3.0.3",
104
106
  "image-size": "^2.0.2",
105
107
  "jiti": "^2.4.0",
106
- "js-yaml": "^4.1.0",
108
+ "js-yaml": "^4.3.1",
107
109
  "katex": "^0.18.1",
108
110
  "markdown-table": "^3.0.4",
109
111
  "marked": "^18.0.5",
@@ -111,12 +113,18 @@
111
113
  "mdast-util-gfm": "^3.1.0",
112
114
  "mdast-util-to-string": "^4.0.0",
113
115
  "medium-zoom": "^1.1.0",
114
- "mermaid": "^11.15.0",
116
+ "mermaid": "^11.16.1",
115
117
  "micromark-extension-gfm": "^3.0.0",
118
+ "nanotar": "^0.3.0",
116
119
  "node-html-parser": "^9.0.0",
117
120
  "openapi-sampler": "^1.7.4",
121
+ "p-limit": "^7.3.1",
122
+ "p-map": "^7.0.6",
123
+ "p-retry": "^8.0.0",
124
+ "package-manager-detector": "^1.8.0",
118
125
  "pagefind": "^1.3.0",
119
126
  "pathe": "^2.0.0",
127
+ "perfect-debounce": "^2.1.0",
120
128
  "picomatch": "^4.0.5",
121
129
  "react": "^19.0.0",
122
130
  "react-dom": "^19.0.0",
@@ -131,11 +139,13 @@
131
139
  "tinyglobby": "^0.2.10",
132
140
  "twoslash": "^0.3.9",
133
141
  "typescript": "^6.0.3",
134
- "undici": "^8.6.0",
142
+ "ufo": "^1.6.4",
143
+ "undici": "^8.9.0",
135
144
  "write-file-atomic": "^8.0.0",
136
145
  "zod": "^4.3.6"
137
146
  },
138
147
  "devDependencies": {
148
+ "@types/cross-spawn": "^6.0.6",
139
149
  "@types/html-escaper": "^3.0.4",
140
150
  "@types/js-yaml": "^4.0.9",
141
151
  "@types/node": "^22.10.0",
@@ -1,6 +1,7 @@
1
1
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import type { BlumeProject } from "../core/project-graph.ts";
3
3
  import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
4
+ import { absoluteUrl } from "../core/site-url.ts";
4
5
  import { buildRssFeeds } from "../deploy/rss.ts";
5
6
  import { hasApiCatalog } from "./api-catalog.ts";
6
7
 
@@ -42,7 +43,7 @@ const askApiUrl = (
42
43
  return abs("/api/ask");
43
44
  }
44
45
  return site && endpoint.startsWith("/")
45
- ? `${site.replace(/\/+$/u, "")}${endpoint}`
46
+ ? absoluteUrl(site, endpoint)
46
47
  : endpoint;
47
48
  };
48
49
 
@@ -88,7 +89,7 @@ export const buildAgentReadability = (
88
89
  const deployBase = normalizeBasePath(config.deployment.base);
89
90
  const abs = (path: string): string => {
90
91
  const based = withBasePath(deployBase, path);
91
- return site ? `${site.replace(/\/+$/u, "")}${based}` : based;
92
+ return site ? absoluteUrl(site, based) : based;
92
93
  };
93
94
 
94
95
  // Advertise `Accept: text/markdown` negotiation only where the deployed site
@@ -1,6 +1,6 @@
1
1
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
- import { trimEnd } from "../core/trim.ts";
3
+ import { absoluteUrl } from "../core/site-url.ts";
4
4
  import { resolveReferences } from "../openapi/references.ts";
5
5
 
6
6
  /**
@@ -32,7 +32,7 @@ const linksetEntries = (config: ResolvedConfig): LinksetEntry[] => {
32
32
  const deployBase = normalizeBasePath(config.deployment.base);
33
33
  const abs = (path: string): string => {
34
34
  const based = withBasePath(deployBase, path);
35
- return site ? `${trimEnd(site, "/")}${based}` : based;
35
+ return site ? absoluteUrl(site, based) : based;
36
36
  };
37
37
 
38
38
  const entries: LinksetEntry[] = [];
@@ -1,3 +1,4 @@
1
+ import { normalizeRoute } from "../core/base-path.ts";
1
2
  import { buildOramaIndex, queryOramaIndex } from "../search/orama-index.ts";
2
3
  import type { OramaDoc } from "../search/orama-index.ts";
3
4
 
@@ -90,11 +91,39 @@ const STOPWORDS = new Set([
90
91
  "your",
91
92
  ]);
92
93
 
94
+ /** A run of letters, combining marks and digits inside a word-like segment. */
95
+ const TERM = /[\p{L}\p{M}\p{N}]+/gu;
96
+
97
+ /**
98
+ * Word-shaped pieces of a query, NFC-normalized and lowercased. Languages
99
+ * written without spaces (the CJK/Thai sites the Orama tokenizer goes out of
100
+ * its way to support) have no delimiter for a regex to split on, so the query
101
+ * is cut with `Intl.Segmenter` where available — otherwise every excerpt
102
+ * window silently degrades to the head of the page. The regex fallback covers
103
+ * runtimes without the segmenter and still handles spaced scripts correctly.
104
+ */
105
+ const segmentQuery = (query: string): string[] => {
106
+ const lowered = query.normalize("NFC").toLowerCase();
107
+ if (typeof Intl.Segmenter !== "function") {
108
+ return lowered.match(TERM) ?? [];
109
+ }
110
+ const pieces: string[] = [];
111
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
112
+ for (const segment of segmenter.segment(lowered)) {
113
+ if (segment.isWordLike) {
114
+ pieces.push(segment.segment);
115
+ }
116
+ }
117
+ return pieces;
118
+ };
119
+
93
120
  /** Distinct, meaningful lowercase terms from a query (drops stopwords). */
94
- const queryTerms = (query: string): string[] =>
95
- [...new Set(query.toLowerCase().match(/[a-z0-9]+/gu))].filter(
121
+ const queryTerms = (query: string): string[] => {
122
+ const terms = segmentQuery(query).flatMap((piece) => piece.match(TERM) ?? []);
123
+ return [...new Set(terms)].filter(
96
124
  (term) => term.length >= 2 && !STOPWORDS.has(term)
97
125
  );
126
+ };
98
127
 
99
128
  /**
100
129
  * The grounding preamble. The model is told to answer strictly from the injected
@@ -104,13 +133,6 @@ const queryTerms = (query: string): string[] =>
104
133
  const BASE_INSTRUCTION =
105
134
  "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).";
106
135
 
107
- /** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
108
- const normalizeRoute = (input: string): string => {
109
- const noTrailing = input.trim().replace(/\/+$/u, "");
110
- const withSlash = noTrailing.startsWith("/") ? noTrailing : `/${noTrailing}`;
111
- return withSlash === "" ? "/" : withSlash;
112
- };
113
-
114
136
  /** The most recent non-empty user message, used as the retrieval query. */
115
137
  const lastUserMessage = (messages: AskMessage[]): string => {
116
138
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -137,7 +159,9 @@ export const relevantExcerpt = (
137
159
  query: string,
138
160
  max: number
139
161
  ): string => {
140
- const trimmed = content.trim();
162
+ // NFC to match the normalized query terms; positions are computed on (and
163
+ // sliced from) this same string, so offsets stay aligned.
164
+ const trimmed = content.normalize("NFC").trim();
141
165
  if (trimmed.length <= max) {
142
166
  return trimmed;
143
167
  }
@@ -199,9 +223,15 @@ export const relevantExcerpt = (
199
223
  * grounded system prompt — the retrieved excerpts plus the page the user is
200
224
  * viewing — or `undefined` when there is nothing to ground on, so the endpoint
201
225
  * can fall back to its plain prompt.
226
+ *
227
+ * `options.instructions` (the `ai.ask.instructions` config) is appended after
228
+ * the base instruction rather than replacing it: the base carries the
229
+ * functional contract (answer only from the excerpts, cite pages as Markdown
230
+ * links) that the panel's citation rendering depends on.
202
231
  */
203
232
  export const createAskContext = (
204
- data: AskData
233
+ data: AskData,
234
+ options?: { instructions?: string }
205
235
  ): ((
206
236
  messages: AskMessage[],
207
237
  page?: AskPage
@@ -213,6 +243,9 @@ export const createAskContext = (
213
243
  return dbPromise;
214
244
  };
215
245
  const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
246
+ const instruction = options?.instructions
247
+ ? `${BASE_INSTRUCTION}\n\n${options.instructions}`
248
+ : BASE_INSTRUCTION;
216
249
 
217
250
  return async (messages, page) => {
218
251
  const list = Array.isArray(messages) ? messages : [];
@@ -257,6 +290,6 @@ export const createAskContext = (
257
290
  if (sections.length === 0) {
258
291
  return;
259
292
  }
260
- return `${BASE_INSTRUCTION}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
293
+ return `${instruction}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
261
294
  };
262
295
  };
@@ -14,8 +14,13 @@ import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
14
14
  * The header is homepage-only by design: the root response is what agents
15
15
  * probe, and `agent-readability.json` indexes the rest of the surface (the
16
16
  * per-route Markdown pattern, MCP, feeds) far better than per-page headers
17
- * could. Targets are root-relative under `deployment.base` RFC 8288 resolves
18
- * them against the request URL. Returns null when nothing is advertisable.
17
+ * could. An agent that enters on a deep page (a search result, a shared link)
18
+ * never sees this header at all that path is covered in the HTML instead:
19
+ * every page's `<head>` carries the same `describedby` links plus its own
20
+ * Markdown mirror as an `alternate` (see `RootLayout.astro`), which also
21
+ * reaches hosts where Blume can't set response headers. Targets are
22
+ * root-relative under `deployment.base` — RFC 8288 resolves them against the
23
+ * request URL. Returns null when nothing is advertisable.
19
24
  */
20
25
  export const buildHomeLinkHeader = (
21
26
  config: ResolvedConfig,
package/src/ai/llms.ts CHANGED
@@ -2,6 +2,7 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import { rewriteRelativeImages } from "../core/content-assets.ts";
3
3
  import matter from "../core/frontmatter.ts";
4
4
  import type { BlumeProject } from "../core/project-graph.ts";
5
+ import { absoluteUrl } from "../core/site-url.ts";
5
6
  import { readEntryText } from "../core/sources/read.ts";
6
7
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
7
8
  import { buildRssFeeds } from "../deploy/rss.ts";
@@ -17,7 +18,7 @@ import { applyAgentVisibility } from "./visibility.ts";
17
18
  // spaces or non-ASCII must still yield a valid Markdown link.
18
19
  const pageUrl = (route: string, site?: string, base = ""): string => {
19
20
  const path = withBasePath(base, route);
20
- return encodeURI(site ? `${site.replace(/\/$/u, "")}${path}` : path);
21
+ return encodeURI(site ? absoluteUrl(site, path) : path);
21
22
  };
22
23
 
23
24
  // Drafts, hidden, and ordinary `noindex` pages are excluded. Generated API
@@ -1,6 +1,6 @@
1
1
  import { withBasePath } from "../../core/base-path.ts";
2
- import { trimEnd } from "../../core/trim.ts";
3
- import { slugify } from "../../openapi/references.ts";
2
+ import { absoluteUrl, siteRoot } from "../../core/site-url.ts";
3
+ import { trimChar } from "../../core/trim.ts";
4
4
  import { MCP_TOOLS } from "./tools.ts";
5
5
 
6
6
  /** Inputs needed to describe the MCP server in discovery documents. */
@@ -20,7 +20,7 @@ const serverUrl = (input: McpDiscoveryInput): string => {
20
20
  const path = withBasePath(input.base, input.route);
21
21
  // Concatenate rather than `new URL(path, site)` — a root-absolute path
22
22
  // would drop the base path of a subpath deployment (`acme.com/docs`).
23
- return input.site ? `${trimEnd(input.site, "/")}${path}` : path;
23
+ return input.site ? absoluteUrl(input.site, path) : path;
24
24
  };
25
25
 
26
26
  /**
@@ -49,10 +49,29 @@ const CARD_TEXT_MAX = 100;
49
49
  const truncate = (text: string): string =>
50
50
  text.length > CARD_TEXT_MAX ? `${text.slice(0, CARD_TEXT_MAX - 1)}…` : text;
51
51
 
52
+ // The server-card schema constrains `name` to `namespace/server` in ASCII
53
+ // (`^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$`), so the display name cannot reuse the
54
+ // route slugifier, which keeps Unicode letters. Decompose (NFKD) and drop
55
+ // combining marks first so an accented name transliterates (`Café` → `cafe`)
56
+ // instead of losing the letter.
57
+ const NON_ASCII_SLUG = /[^a-z0-9]+/gu;
58
+ const COMBINING_MARKS = /\p{M}+/gu;
59
+
60
+ const asciiSlugify = (text: string): string =>
61
+ trimChar(
62
+ text
63
+ .normalize("NFKD")
64
+ .replace(COMBINING_MARKS, "")
65
+ .toLowerCase()
66
+ .replace(NON_ASCII_SLUG, "-"),
67
+ "-"
68
+ );
69
+
52
70
  /**
53
71
  * The card's identity in the schema's reverse-DNS `namespace/server` form:
54
72
  * the site hostname reversed (`useblume.dev` → `dev.useblume`), or
55
- * `localhost` when no site is configured, plus the slugged display name.
73
+ * `localhost` when no site is configured, plus the slugged display name (an
74
+ * entirely non-ASCII name falls back to `docs`).
56
75
  */
57
76
  const reverseDnsName = (input: McpDiscoveryInput): string => {
58
77
  let namespace = "localhost";
@@ -66,7 +85,7 @@ const reverseDnsName = (input: McpDiscoveryInput): string => {
66
85
  // Not a parsable URL; the local namespace is honest enough.
67
86
  }
68
87
  }
69
- return `${namespace}/${slugify(input.name) || "docs"}`;
88
+ return `${namespace}/${asciiSlugify(input.name) || "docs"}`;
70
89
  };
71
90
 
72
91
  const HTTP_URL = /^https?:\/\//u;
@@ -109,6 +128,6 @@ export const buildMcpServerCard = (
109
128
  transports: [{ endpoint: url, type: "streamable-http" }],
110
129
  url,
111
130
  version: input.version,
112
- ...(input.site ? { websiteUrl: trimEnd(input.site, "/") } : {}),
131
+ ...(input.site ? { websiteUrl: siteRoot(input.site) } : {}),
113
132
  };
114
133
  };