blume 1.4.2 → 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 (90) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli/index.js +694 -579
  3. package/dist/cli/index.js.map +56 -55
  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/schema.d.ts +4 -0
  7. package/dist/types/core/sources/types.d.ts +9 -1
  8. package/dist/types/openapi/references.d.ts +8 -2
  9. package/docs/configuration/ai.mdx +26 -8
  10. package/docs/content/sources.mdx +1 -1
  11. package/package.json +11 -1
  12. package/src/ai/agent-readability.ts +3 -2
  13. package/src/ai/api-catalog.ts +2 -2
  14. package/src/ai/ask-context.ts +45 -12
  15. package/src/ai/llms.ts +2 -1
  16. package/src/ai/mcp/discovery.ts +25 -6
  17. package/src/ai/mcp/server.ts +108 -98
  18. package/src/ai/tar.ts +29 -70
  19. package/src/astro/examples.ts +7 -3
  20. package/src/astro/generate.ts +59 -34
  21. package/src/astro/islands.ts +7 -3
  22. package/src/astro/templates.ts +34 -9
  23. package/src/audit/agent.ts +14 -29
  24. package/src/audit/crawl.ts +41 -16
  25. package/src/audit/run.ts +10 -3
  26. package/src/audit/snapshot.ts +27 -2
  27. package/src/cli/commands/audit.ts +12 -17
  28. package/src/cli/commands/build.ts +15 -7
  29. package/src/cli/commands/dev.ts +13 -15
  30. package/src/cli/commands/eject.ts +4 -4
  31. package/src/cli/commands/eval.ts +17 -27
  32. package/src/cli/env.ts +13 -30
  33. package/src/cli/init/scaffold.ts +21 -0
  34. package/src/cli/report-format.ts +22 -0
  35. package/src/components/content/AccordionItem.astro +2 -9
  36. package/src/components/content/ColorItem.astro +5 -13
  37. package/src/components/content/Component.astro +12 -8
  38. package/src/components/content/Frame.astro +2 -12
  39. package/src/components/content/Prompt.astro +12 -31
  40. package/src/components/content/Tab.astro +2 -9
  41. package/src/components/content/Tooltip.astro +1 -9
  42. package/src/components/content/Update.astro +2 -9
  43. package/src/components/content/inline-markdown.ts +28 -0
  44. package/src/components/copy-feedback.ts +96 -0
  45. package/src/components/islands/ask-ai.tsx +78 -9
  46. package/src/components/layout/PageActions.astro +20 -32
  47. package/src/components/layout/PageLayout.astro +8 -28
  48. package/src/components/layout/RootLayout.astro +6 -48
  49. package/src/components/layout/Search.astro +56 -9
  50. package/src/components/layout/drawer-inert.ts +31 -0
  51. package/src/components/layout/search/pagefind.ts +6 -5
  52. package/src/components/layout/search/types.ts +32 -0
  53. package/src/components/openapi/panel.ts +11 -8
  54. package/src/components/raf-throttle.ts +21 -0
  55. package/src/components/slug.ts +14 -0
  56. package/src/core/base-path.ts +18 -1
  57. package/src/core/config-input.ts +8 -0
  58. package/src/core/frontmatter.ts +45 -1
  59. package/src/core/probe.ts +7 -19
  60. package/src/core/project-graph.ts +12 -1
  61. package/src/core/schema.ts +6 -0
  62. package/src/core/site-url.ts +27 -0
  63. package/src/core/sources/cache.ts +10 -8
  64. package/src/core/sources/github-releases.ts +21 -1
  65. package/src/core/sources/normalize.ts +26 -2
  66. package/src/core/sources/notion.ts +27 -5
  67. package/src/core/sources/portable-text.ts +16 -1
  68. package/src/core/sources/resolve.ts +1 -0
  69. package/src/core/sources/types.ts +13 -1
  70. package/src/deploy/cloudflare-negotiation.ts +15 -1
  71. package/src/deploy/robots.ts +2 -1
  72. package/src/deploy/rss.ts +2 -1
  73. package/src/deploy/sitemap.ts +56 -7
  74. package/src/eval/agents.ts +13 -10
  75. package/src/eval/report.ts +1 -14
  76. package/src/markdown/package-commands.ts +61 -54
  77. package/src/og/card.ts +24 -26
  78. package/src/openapi/model.ts +9 -9
  79. package/src/openapi/parse.ts +69 -28
  80. package/src/openapi/references.ts +35 -12
  81. package/src/openapi/render-mdx.ts +64 -25
  82. package/src/openapi/scalar.ts +2 -2
  83. package/src/openapi/source.ts +28 -1
  84. package/src/search/documents.ts +78 -34
  85. package/src/search/orama-index.ts +51 -12
  86. package/src/theme/palette.ts +6 -2
  87. package/src/translate/ledger.ts +4 -2
  88. package/src/translate/report.ts +1 -14
  89. package/src/translate/run.ts +20 -35
  90. 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`. */
@@ -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
 
@@ -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.2",
3
+ "version": "1.4.3",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -86,6 +86,7 @@
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
92
  "astro": "^7.1.0",
@@ -93,6 +94,7 @@
93
94
  "chokidar": "^5.0.0",
94
95
  "citty": "^0.1.6",
95
96
  "consola": "^3.4.0",
97
+ "cross-spawn": "^7.0.6",
96
98
  "dompurify": "^3.4.13",
97
99
  "dotenv": "^17.4.2",
98
100
  "epub-gen-memory": "^1.1.2",
@@ -113,10 +115,16 @@
113
115
  "medium-zoom": "^1.1.0",
114
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",
142
+ "ufo": "^1.6.4",
134
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
  };
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
  };