blume 2.2.2 → 2.2.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 (133) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/cli/{chunk-080mfch6.js → chunk-0wt5amrk.js} +15 -15
  3. package/dist/cli/{chunk-k45sq16q.js → chunk-0xyedh7k.js} +39 -23
  4. package/dist/cli/{chunk-k45sq16q.js.map → chunk-0xyedh7k.js.map} +3 -3
  5. package/dist/cli/{chunk-f260jdrn.js → chunk-0ynr0fr7.js} +5 -5
  6. package/dist/cli/{chunk-e1n41w9m.js → chunk-1ay3hzn0.js} +3 -3
  7. package/dist/cli/{chunk-b680sv30.js → chunk-2f6btenb.js} +10 -8
  8. package/dist/cli/chunk-2f6btenb.js.map +13 -0
  9. package/dist/cli/{chunk-gwy55cx3.js → chunk-426rytg6.js} +2 -2
  10. package/dist/cli/{chunk-9h8mh2tk.js → chunk-5f96yq2c.js} +1 -1
  11. package/dist/cli/{chunk-x60e15st.js → chunk-5xq12jz7.js} +17 -17
  12. package/dist/cli/{chunk-x7bh6d9j.js → chunk-6xag1c83.js} +4 -4
  13. package/dist/cli/{chunk-487b95qy.js → chunk-88q28zav.js} +41 -25
  14. package/dist/cli/{chunk-487b95qy.js.map → chunk-88q28zav.js.map} +3 -3
  15. package/dist/cli/{chunk-yg8ckc3g.js → chunk-88zkpg89.js} +2 -2
  16. package/dist/cli/{chunk-ppa6mg4d.js → chunk-8fm3b4vm.js} +6 -6
  17. package/dist/cli/{chunk-rtvgmg2j.js → chunk-8h2t2vyj.js} +2 -2
  18. package/dist/cli/{chunk-2ssh60qh.js → chunk-982qfeny.js} +6 -6
  19. package/dist/cli/{chunk-t2h8pwy1.js → chunk-9emfspcp.js} +4 -4
  20. package/dist/cli/{chunk-9jqmq5hv.js → chunk-akj2p7k1.js} +6 -6
  21. package/dist/cli/{chunk-beb0z983.js → chunk-b664x65a.js} +16 -16
  22. package/dist/cli/{chunk-kmswjxj9.js → chunk-b7n58hgj.js} +3 -3
  23. package/dist/cli/{chunk-fnxgtjeq.js → chunk-c0qddrxr.js} +1 -1
  24. package/dist/cli/{chunk-p834rkcn.js → chunk-g62vnvaw.js} +5 -5
  25. package/dist/cli/{chunk-zxd5jrgx.js → chunk-gc7knd2s.js} +76 -34
  26. package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-gc7knd2s.js.map} +4 -4
  27. package/dist/cli/{chunk-r5nm9w0c.js → chunk-hwjspffr.js} +105 -36
  28. package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-hwjspffr.js.map} +11 -10
  29. package/dist/cli/{chunk-40vk8kgf.js → chunk-p4fe7tax.js} +7 -7
  30. package/dist/cli/{chunk-34wap3ed.js → chunk-psg2za5z.js} +3 -3
  31. package/dist/cli/{chunk-hktq4j5r.js → chunk-pxp1y6yz.js} +16 -10
  32. package/dist/cli/{chunk-hktq4j5r.js.map → chunk-pxp1y6yz.js.map} +4 -4
  33. package/dist/cli/{chunk-qcyxkj44.js → chunk-qax1aekz.js} +43 -17
  34. package/dist/cli/chunk-qax1aekz.js.map +15 -0
  35. package/dist/cli/{chunk-h0qwdbem.js → chunk-r5ajrw1g.js} +2 -2
  36. package/dist/cli/{chunk-q1wwrxzw.js → chunk-rc2qk3qx.js} +3 -3
  37. package/dist/cli/chunk-rc2qk3qx.js.map +10 -0
  38. package/dist/cli/{chunk-69rjf786.js → chunk-ry2ymbxf.js} +5 -5
  39. package/dist/cli/{chunk-gxhcf2hs.js → chunk-t43g7v22.js} +2 -2
  40. package/dist/cli/{chunk-ydqzkhjn.js → chunk-x0jqyq1g.js} +6 -6
  41. package/dist/cli/{chunk-aafhwx74.js → chunk-x25s4gph.js} +1 -1
  42. package/dist/cli/{chunk-nqna8dzp.js → chunk-xkx216rd.js} +17 -17
  43. package/dist/cli/{chunk-etckvbrh.js → chunk-xv8nkt56.js} +14 -14
  44. package/dist/cli/{chunk-hn3g2b89.js → chunk-yrmw5n2b.js} +9 -9
  45. package/dist/cli/{chunk-4zxh25hc.js → chunk-zgca0vxs.js} +2 -2
  46. package/dist/cli/{chunk-c5bjxq8v.js → chunk-zj6bcx0n.js} +2 -2
  47. package/dist/cli/{chunk-1ch3ct3r.js → chunk-zjfwzrvd.js} +14 -14
  48. package/dist/cli/{chunk-j4nfgn2j.js → chunk-znwhf37a.js} +1 -1
  49. package/dist/cli/index.js +18 -18
  50. package/dist/types/ai/ask-context.d.ts +8 -0
  51. package/dist/types/ai/link-text.d.ts +5 -0
  52. package/dist/types/ai/relative-links.d.ts +26 -6
  53. package/dist/types/core/config-input.d.ts +35 -1
  54. package/dist/types/core/data.d.ts +14 -0
  55. package/dist/types/core/last-modified.d.ts +33 -18
  56. package/dist/types/core/links.d.ts +16 -0
  57. package/dist/types/core/schema.d.ts +16 -1
  58. package/dist/types/core/types.d.ts +10 -0
  59. package/dist/types/deploy/sitemap.d.ts +9 -0
  60. package/dist/types/search/documents.d.ts +3 -1
  61. package/dist/types/search/fold.d.ts +33 -0
  62. package/dist/types/search/orama-index.d.ts +4 -4
  63. package/dist/types/seo/jsonld.d.ts +33 -0
  64. package/docs/configuration/search.mdx +2 -2
  65. package/docs/content/frontmatter.mdx +1 -1
  66. package/docs/content/i18n.mdx +1 -1
  67. package/docs/content/sources/remote-mdx.mdx +1 -1
  68. package/docs/discoverability/open-graph.mdx +16 -1
  69. package/docs/discoverability/sitemap-and-robots.mdx +12 -0
  70. package/docs/discoverability/structured-data.mdx +44 -0
  71. package/package.json +1 -1
  72. package/src/ai/ask-context.ts +52 -14
  73. package/src/ai/ask-data.ts +4 -1
  74. package/src/ai/ask-tools.ts +17 -5
  75. package/src/ai/link-text.ts +6 -0
  76. package/src/ai/llms.ts +1 -7
  77. package/src/ai/markdown.ts +8 -4
  78. package/src/ai/relative-links.ts +155 -48
  79. package/src/ai/site-skill.ts +1 -4
  80. package/src/astro/generate.ts +7 -0
  81. package/src/astro/templates.ts +36 -20
  82. package/src/components/layout/PageLayout.astro +25 -12
  83. package/src/components/layout/RootLayout.astro +7 -0
  84. package/src/components/layout/locale-redirect.ts +38 -19
  85. package/src/components/layout/search/types.ts +12 -4
  86. package/src/core/config-input.ts +34 -1
  87. package/src/core/config.ts +5 -2
  88. package/src/core/data.ts +14 -0
  89. package/src/core/last-modified.ts +80 -34
  90. package/src/core/links.ts +43 -2
  91. package/src/core/manifest.ts +2 -0
  92. package/src/core/project-graph.ts +83 -43
  93. package/src/core/schema.ts +47 -9
  94. package/src/core/types.ts +10 -0
  95. package/src/deploy/sitemap.ts +127 -49
  96. package/src/search/documents.ts +34 -11
  97. package/src/search/fold.ts +175 -0
  98. package/src/search/orama-index.ts +40 -49
  99. package/src/seo/jsonld.ts +100 -23
  100. package/dist/cli/chunk-b680sv30.js.map +0 -13
  101. package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
  102. package/dist/cli/chunk-qcyxkj44.js.map +0 -14
  103. /package/dist/cli/{chunk-080mfch6.js.map → chunk-0wt5amrk.js.map} +0 -0
  104. /package/dist/cli/{chunk-f260jdrn.js.map → chunk-0ynr0fr7.js.map} +0 -0
  105. /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-1ay3hzn0.js.map} +0 -0
  106. /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-426rytg6.js.map} +0 -0
  107. /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-5f96yq2c.js.map} +0 -0
  108. /package/dist/cli/{chunk-x60e15st.js.map → chunk-5xq12jz7.js.map} +0 -0
  109. /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6xag1c83.js.map} +0 -0
  110. /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-88zkpg89.js.map} +0 -0
  111. /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-8fm3b4vm.js.map} +0 -0
  112. /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-8h2t2vyj.js.map} +0 -0
  113. /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-982qfeny.js.map} +0 -0
  114. /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-9emfspcp.js.map} +0 -0
  115. /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-akj2p7k1.js.map} +0 -0
  116. /package/dist/cli/{chunk-beb0z983.js.map → chunk-b664x65a.js.map} +0 -0
  117. /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-b7n58hgj.js.map} +0 -0
  118. /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-c0qddrxr.js.map} +0 -0
  119. /package/dist/cli/{chunk-p834rkcn.js.map → chunk-g62vnvaw.js.map} +0 -0
  120. /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-p4fe7tax.js.map} +0 -0
  121. /package/dist/cli/{chunk-34wap3ed.js.map → chunk-psg2za5z.js.map} +0 -0
  122. /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-r5ajrw1g.js.map} +0 -0
  123. /package/dist/cli/{chunk-69rjf786.js.map → chunk-ry2ymbxf.js.map} +0 -0
  124. /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-t43g7v22.js.map} +0 -0
  125. /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-x0jqyq1g.js.map} +0 -0
  126. /package/dist/cli/{chunk-aafhwx74.js.map → chunk-x25s4gph.js.map} +0 -0
  127. /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-xkx216rd.js.map} +0 -0
  128. /package/dist/cli/{chunk-etckvbrh.js.map → chunk-xv8nkt56.js.map} +0 -0
  129. /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-yrmw5n2b.js.map} +0 -0
  130. /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-zgca0vxs.js.map} +0 -0
  131. /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-zj6bcx0n.js.map} +0 -0
  132. /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-zjfwzrvd.js.map} +0 -0
  133. /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-znwhf37a.js.map} +0 -0
@@ -23,7 +23,7 @@ Frontmatter is YAML, so quote a value that holds a colon followed by a space, li
23
23
  authors: {
24
24
  type: "string | string[] | object[]",
25
25
  description:
26
- "Post author(s) for blog/changelog content — a name, or objects with a name plus optional avatar/url and any extra fields. Preserved as-is.",
26
+ "Post author(s) for blog/changelog content — a name, or objects with a name plus optional avatar/url and any extra fields. Credited as Person nodes in the page's JSON-LD; other fields are preserved as-is.",
27
27
  },
28
28
  slug: {
29
29
  type: "string",
@@ -186,7 +186,7 @@ i18n: {
186
186
  Localized SEO is handled for you — no per-page metadata to write:
187
187
 
188
188
  - `<html lang>` and `dir` are set from the active locale.
189
- - `hreflang` alternates link every real translation of a page, plus an `x-default` pointing at the default locale.
189
+ - `hreflang` alternates link every real translation of a page that search engines may index (a `noindex`, hidden, or canonical-elsewhere translation is left out), plus an `x-default` pointing at the default locale.
190
190
  - Canonical URLs are locale-correct, and JSON-LD carries `inLanguage`.
191
191
 
192
192
  Set [`deployment.site`](/docs/deployment) so these can be emitted as absolute URLs.
@@ -35,7 +35,7 @@ mdxRemote({
35
35
 
36
36
  `include` (default `["**/*.{md,mdx}"]`) filters the files by their path under `path`, the way a `filesystem()` source's `include` does: a pattern starting with `!` excludes what it matches (`["**/*.mdx", "!drafts/**"]`). With `github`, the source lists the repository's whole tree at `ref` and keeps the files under `path`. If nothing is under `path` at that ref, the source reads no pages and warns with `BLUME_SOURCE_PATH_MISSING`. GitHub caps the listing (100,000 entries or 7 MB for the whole repository, whatever `path` is), and past it the source warns with `BLUME_SOURCE_TRUNCATED`: list the files with `url` and `files` instead.
37
37
 
38
- A relative link between remote files (`[Errors](./02-errors.mdx)`) lands on the page the linked file publishes at, with its ordering prefix dropped from the route, and `blume validate` checks it the same way.
38
+ A relative link between remote files (`[Errors](./02-errors.mdx)`) lands on the page the linked file publishes at, with its ordering prefix dropped from the route. It does on the rendered page, in the page's [Markdown copy](/docs/discoverability/markdown#raw-markdown) and `llms-full.txt`, and in what the assistant reads, and `blume validate` checks it the same way.
39
39
 
40
40
  A private repo's token is read from the `GITHUB_TOKEN` environment variable. It is never inlined into your config or generated output, and it is only ever sent to GitHub's own hosts (`api.github.com`, `raw.githubusercontent.com`), never to a custom `url` base. A public repo works without it. With `github`, the adapter declares the variable, so `blume dev` and `blume build` warn `BLUME_MISSING_SECRET` when it's unset: the file listing goes through GitHub's API, which allows 60 unauthenticated requests an hour. A raw `url` reads files only, so nothing warns up front; when GitHub refuses a file with a 401, 403, or 404 while `GITHUB_TOKEN` is unset, the warning for the skipped file says the variable isn't set.
41
41
 
@@ -3,7 +3,7 @@ title: Open Graph images
3
3
  description: Generated 1200×630 social cards for every page — branding, card layers, fonts for non-Latin scripts, and per-page overrides.
4
4
  ---
5
5
 
6
- Blume can render a 1200×630 social card for every page at build time — no headless browser, thanks to [Takumi](https://takumi.kane.tw), so builds stay fast. On by default once [`deployment.site`](/docs/deployment) is set or auto-detected (the `og:image` URL has to be absolute to be useful to crawlers), and off otherwise. Set `enabled` to override that either way:
6
+ Blume can render a 1200×630 social card for every page at build time — no headless browser, thanks to [Takumi](https://takumi.kane.tw), so builds stay fast. On by default once [`deployment.site`](/docs/deployment) is set or auto-detected (the `og:image` URL has to be absolute to be useful to crawlers), and off otherwise or when a [default image](#default-image) replaces the cards. Set `enabled` to override that either way:
7
7
 
8
8
  ```ts blume.config.ts lineNumbers
9
9
  seo: {
@@ -11,6 +11,21 @@ seo: {
11
11
  }
12
12
  ```
13
13
 
14
+ ## Default image
15
+
16
+ Set `seo.og.image` to share one image across every page instead of a generated card, or give each locale its own:
17
+
18
+ ```ts blume.config.ts lineNumbers
19
+ seo: {
20
+ og: {
21
+ image: { en: "/og/default-en.png", fr: "/og/default-fr.png" },
22
+ // Or image: "https://example.com/og/default.png"
23
+ },
24
+ }
25
+ ```
26
+
27
+ A page's `seo.image`, or a custom page's `ogImage` prop, wins over the default. A locale missing from the map takes the default locale's image. The default covers custom pages rendered with `PageLayout` and the generated changelog index too, so Blume stops generating cards once it's set, unless `enabled` is `true`. Root-relative paths resolve under the deployment base. Blume declares image dimensions only for generated cards. Whichever image a page ends up with also goes into its [JSON-LD](/docs/discoverability/structured-data) as the page's `image`.
28
+
14
29
  ## Brand the generated card
15
30
 
16
31
  Set a local SVG and color palette to match the generated card to your brand. The logo can live in `public/` or at the project root. Omit any palette value to keep its default.
@@ -15,6 +15,18 @@ seo: {
15
15
  }
16
16
  ```
17
17
 
18
+ ### Locale alternates
19
+
20
+ Opt into alternate links on a multilingual site's sitemap:
21
+
22
+ ```ts blume.config.ts lineNumbers
23
+ seo: {
24
+ sitemap: { alternates: true },
25
+ }
26
+ ```
27
+
28
+ Each translated page lists its translations, itself included, as `xhtml:link` elements, plus an `x-default` link when the default locale has the page. These are the same `hreflang` links the page head carries: a `noindex`, hidden, or canonical-elsewhere translation is in neither. A page in fewer than two listed locales gets no alternate links. URLs include the deployment base. `sitemap: true` and `sitemap: {}` keep alternates off; `sitemap: false` turns the sitemap off.
29
+
18
30
  Ship your own `public/sitemap.xml` to take over — Blume never overwrites a file you place in `public/`.
19
31
 
20
32
  ## Robots
@@ -48,6 +48,50 @@ seo: {
48
48
 
49
49
  `software` adds a **SoftwareApplication** node to the homepage: the product's name and description (defaulting to the site's), `applicationCategory` (default `"DeveloperApplication"`), operating system, license, an `Offer` when `price` is set, and the registry or repository URLs in `sameAs`. Pass `software: true` to take every default. The organization, when configured, is cited as its `publisher`.
50
50
 
51
+ ## Shared entity identifiers
52
+
53
+ When docs live inside another site (`example.com/docs`), point at the entities that site already defines instead of defining them again:
54
+
55
+ ```ts blume.config.ts lineNumbers
56
+ seo: {
57
+ jsonLd: {
58
+ websiteId: "https://example.com/#website",
59
+ organizationId: "https://example.com/#organization",
60
+ },
61
+ }
62
+ ```
63
+
64
+ Every page names `websiteId` as its `isPartOf`, and Blume stops emitting its own WebSite node, whose name and URL would describe the docs instead of the site. Set `website: true` to emit it anyway under that id, or `website: false` to omit the docs' own WebSite node without sharing an id. `organizationId` becomes every page's publisher. With [`seo.organization`](#site-identity) also set, the Organization node uses that id and carries only the fields you configure: the docs' title and URL don't stand in for its name and URL. By default, Blume emits the WebSite node and uses the site URL plus `#website` and `#organization`. Identifiers must be absolute URLs, so they work without a site URL too.
65
+
66
+ ## Page author and publisher
67
+
68
+ Reference existing entities on every page:
69
+
70
+ ```ts blume.config.ts lineNumbers
71
+ seo: {
72
+ jsonLd: {
73
+ author: { "@id": "https://example.com/#author" },
74
+ publisher: { "@id": "https://example.com/#publisher" },
75
+ },
76
+ }
77
+ ```
78
+
79
+ These references apply to the WebPage or article node, and `publisher` replaces the organization as its publisher. Blume doesn't define the entities they point at. A page's own [`authors`](/docs/content/frontmatter) front matter wins over `author`: each one is credited as a `Person`.
80
+
81
+ ## Publication date from Git
82
+
83
+ Date pages by the commit that added their content file:
84
+
85
+ ```ts blume.config.ts lineNumbers
86
+ seo: {
87
+ datePublished: "git",
88
+ }
89
+ ```
90
+
91
+ Front matter `date` or `changelog.date` still wins. Git dates the rest, through the same `git log` as [`lastModified`](/docs/configuration#last-modified). Renames are followed only when the content is unchanged, so a new page never inherits the history of a similar one deleted in the same commit. Pages from non-filesystem sources and uncommitted files get no date from git. A shallow clone dates no page and warns instead, since its oldest commit is where the clone was cut. Fetch full history in CI (`VERCEL_DEEP_CLONE=true` on Vercel, `fetch-depth: 0` for `actions/checkout`).
92
+
93
+ The resolved [Open Graph image](/docs/discoverability/open-graph) is also included as the page node's `image`, whether it comes from frontmatter, a site default, or a generated card.
94
+
51
95
  ## Custom pages
52
96
 
53
97
  A custom [`.astro` page](/docs/advanced/custom-pages) rendered through `PageLayout` emits the same graph, so a marketing home page isn't the one URL without structured data. A custom page has no content file to date, so pass `lastModified` to give it a `dateModified`. Pass `structuredDataEnabled={config.structuredData}` to keep it in sync with this setting, or `structuredDataEnabled={false}` to turn it off for that page alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "The open-source docs framework for humans and agents.",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,8 +1,14 @@
1
1
  import { normalizeRoute } from "../core/base-path.ts";
2
2
  import { nextFenceState } from "../core/code-fences.ts";
3
3
  import type { FenceState } from "../core/code-fences.ts";
4
+ import {
5
+ accentInsensitive,
6
+ foldLatinTerm,
7
+ stripSoftHyphens,
8
+ } from "../search/fold.ts";
4
9
  import { buildOramaIndex, queryOramaIndex } from "../search/orama-index.ts";
5
10
  import type { OramaDoc } from "../search/orama-index.ts";
11
+ import { linkText } from "./link-text.ts";
6
12
 
7
13
  /** A chat message as posted by the assistant island (`{ role, content }`). */
8
14
  export interface AskMessage {
@@ -47,7 +53,7 @@ const EXCERPT_CHARS = 2000;
47
53
  const CONTEXT_BUDGET = 10_000;
48
54
  /**
49
55
  * Smallest excerpt worth injecting. A long page pushed under a tiny residual
50
- * budget would get a full `## Title (/route)` heading over a fragment of a few
56
+ * budget would get a full `## [Title](/route)` heading over a fragment of a few
51
57
  * dozen characters — a section the model is invited to cite but that grounds
52
58
  * nothing. Short pages that fit whole are still injected below this floor.
53
59
  */
@@ -196,7 +202,7 @@ const hasSegmenter = (
196
202
  ): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
197
203
 
198
204
  const segmentQuery = (query: string): string[] => {
199
- const lowered = query.normalize("NFC").toLowerCase();
205
+ const lowered = stripSoftHyphens(query).normalize("NFC").toLowerCase();
200
206
  if (!hasSegmenter(Intl.Segmenter)) {
201
207
  return lowered.match(TERM) ?? [];
202
208
  }
@@ -210,9 +216,15 @@ const segmentQuery = (query: string): string[] => {
210
216
  return pieces;
211
217
  };
212
218
 
213
- /** Lowercase word tokens of `text`, cut at the same boundaries as a query. */
219
+ /**
220
+ * Lowercase word tokens of `text`, cut at the same boundaries as a query.
221
+ * Latin words fold the way the search index folds them, so the section a page
222
+ * was retrieved for (Prüfung, for the question `prufung`) is the one excerpted.
223
+ */
214
224
  const tokenize = (text: string): string[] =>
215
- segmentQuery(text).flatMap((piece) => piece.match(TERM) ?? []);
225
+ segmentQuery(text).flatMap((piece) =>
226
+ (piece.match(TERM) ?? []).map((term) => foldLatinTerm(term))
227
+ );
216
228
 
217
229
  /** Distinct, meaningful lowercase terms from a query (drops stopwords). */
218
230
  const queryTerms = (query: string): string[] =>
@@ -222,11 +234,35 @@ const queryTerms = (query: string): string[] =>
222
234
 
223
235
  /**
224
236
  * The grounding preamble. The model is told to answer strictly from the injected
225
- * excerpts and to cite the pages it used as Markdown links (each excerpt is
226
- * headed by `## Title (/route)`), so citations render as real links in the panel.
237
+ * excerpts and to cite the pages it used as Markdown links, so citations render
238
+ * as real links in the panel. Each excerpt is headed by the very link to cite
239
+ * (`## [Title](/route)`), so a model that copies the heading copies a link.
227
240
  */
228
241
  const BASE_INSTRUCTION =
229
- "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).";
242
+ "You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. Each excerpt opens with a heading that links to its page, `## [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 with the link in the heading that opens their excerpt, written exactly as it is there: [Page Title](/route).";
243
+
244
+ /** `encodeURIComponent` leaves parentheses as they are, so they're mapped here. */
245
+ const PARENTHESES = new Map([
246
+ ["(", "%28"],
247
+ [")", "%29"],
248
+ ]);
249
+
250
+ /**
251
+ * An excerpt's heading: the Markdown link the model cites the page with. A
252
+ * bracket or backslash in the title, or whitespace or a parenthesis in the
253
+ * route, would break the link, and a `%` in the route would read back as an
254
+ * escape (`read_page` decodes them), so those are escaped; the rest of the
255
+ * route stays as the reader sees it (`/ja/ポイント`).
256
+ */
257
+ export const excerptHeading = (
258
+ doc: Pick<OramaDoc, "route" | "title">
259
+ ): string => {
260
+ const target = doc.route.replaceAll(
261
+ /[%\s()]/gu,
262
+ (char) => PARENTHESES.get(char) ?? encodeURIComponent(char)
263
+ );
264
+ return `## [${linkText(doc.title)}](${target})`;
265
+ };
230
266
 
231
267
  /**
232
268
  * Appended when the endpoint gives the model the docs tools (see
@@ -272,7 +308,7 @@ const isFollowUp = (message: string): boolean => {
272
308
  /**
273
309
  * The texts that retrieve for the latest question, in rank order.
274
310
  *
275
- * The question itself always leads, verbatim: Orama's own tokenizer and BM25
311
+ * The question itself always leads, verbatim: the index's tokenizer and BM25
276
312
  * weighting see the whole sentence (version numbers, single-character CJK
277
313
  * words, `--flags`, and the bigrams a ja/zh index depends on all survive), and
278
314
  * a short question that names its subject ("Does it support i18n?") is never
@@ -350,13 +386,15 @@ export const relevantExcerpt = (
350
386
  return `${prefix}${slice}${suffix}`;
351
387
  };
352
388
 
353
- // Case-insensitive matching via regex rather than `indexOf` on a lowercased
354
- // copy: length-changing case mappings (Turkish İ → "i" + U+0307) would shift
355
- // every index in the copy, sliding the excerpt window off the match. Terms
356
- // come from TERM (letters, marks and digits only), so no regex escaping.
389
+ // Case- and accent-insensitive matching via regex rather than `indexOf` on
390
+ // a folded copy: length-changing mappings (Turkish İ → "i" + U+0307, ß →
391
+ // "ss") would shift every index in the copy, sliding the excerpt window off
392
+ // the match.
357
393
  const positions: number[] = [];
358
394
  for (const term of queryTerms(query)) {
359
- for (const match of trimmed.matchAll(new RegExp(term, "giu"))) {
395
+ for (const match of trimmed.matchAll(
396
+ new RegExp(accentInsensitive(term), "giu")
397
+ )) {
360
398
  positions.push(match.index);
361
399
  }
362
400
  }
@@ -770,7 +808,7 @@ export const createAskContext = (
770
808
  Math.min(excerptChars, budget)
771
809
  );
772
810
  budget -= body.length;
773
- sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
811
+ sections.push(`${excerptHeading(doc)}${label}\n${body}`);
774
812
  };
775
813
 
776
814
  if (current) {
@@ -14,7 +14,10 @@ import type { AskData } from "./ask-context.ts";
14
14
  * from the docs instead of declining.
15
15
  * The reader is an AI agent, so `<Visibility>` resolves for the agents audience
16
16
  * (web-only content removed, agents-only unwrapped) and components downlevel to
17
- * Markdown, both matching llms-full.txt.
17
+ * Markdown, both matching llms-full.txt. Its page links point at the pages
18
+ * they mean, as there, but as the routes this snapshot keys those pages by:
19
+ * without the `deployment.base`, like the excerpt headings, since the chat
20
+ * panel mounts every link it renders under the base.
18
21
  */
19
22
  export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
20
23
  const documents = await buildSearchDocuments(project, {
@@ -3,7 +3,12 @@ import { jsonSchema, tool } from "ai";
3
3
  import { normalizeRoute } from "../core/base-path.ts";
4
4
  import { queryOramaIndex } from "../search/orama-index.ts";
5
5
  import type { OramaDoc } from "../search/orama-index.ts";
6
- import { askIndex, readerScope, sectionExcerpt } from "./ask-context.ts";
6
+ import {
7
+ askIndex,
8
+ excerptHeading,
9
+ readerScope,
10
+ sectionExcerpt,
11
+ } from "./ask-context.ts";
7
12
  import type { AskData, AskPage, ReaderScope } from "./ask-context.ts";
8
13
 
9
14
  /**
@@ -37,7 +42,7 @@ const PAGE_CHARS = 20_000;
37
42
 
38
43
  /** A result block, headed the way the injected excerpts are, for citing. */
39
44
  const block = (doc: OramaDoc, body: string): string =>
40
- `## ${doc.title} (${doc.route})\n${body}`;
45
+ `${excerptHeading(doc)}\n${body}`;
41
46
 
42
47
  /**
43
48
  * Search the docs for `query` within the reader's scope: the most relevant
@@ -66,7 +71,9 @@ export const searchDocs = async (
66
71
 
67
72
  /**
68
73
  * The route a model asked for, as the snapshot keys it: a full URL on the
69
- * site becomes its path, and a query string or fragment is dropped.
74
+ * site becomes its path, a query string or fragment is dropped, and
75
+ * percent-escapes are decoded (a cited route escapes whitespace,
76
+ * parentheses, and `%`; see `excerptHeading`).
70
77
  */
71
78
  const routeOf = (data: AskData, requested: string): string => {
72
79
  let path = requested.trim();
@@ -74,6 +81,11 @@ const routeOf = (data: AskData, requested: string): string => {
74
81
  path = path.slice(data.site.replace(/\/$/u, "").length) || "/";
75
82
  }
76
83
  path = path.replace(/[?#].*$/u, "");
84
+ try {
85
+ path = decodeURI(path);
86
+ } catch {
87
+ // Malformed percent sequence — look it up as written.
88
+ }
77
89
  return normalizeRoute(path.startsWith("/") ? path : `/${path}`);
78
90
  };
79
91
 
@@ -106,7 +118,7 @@ export const createAskTools = (data: AskData) => {
106
118
  return {
107
119
  read_page: tool({
108
120
  description:
109
- "Read one documentation page in full, by its route (the path in parentheses after a page title, such as /docs/getting-started).",
121
+ "Read one documentation page in full, by its route (the link target in the heading that opens a page's excerpt, such as /docs/getting-started).",
110
122
  execute: ({ route }) => Promise.resolve(readPage(data, byRoute, route)),
111
123
  inputSchema: jsonSchema<{ route: string }>({
112
124
  additionalProperties: false,
@@ -122,7 +134,7 @@ export const createAskTools = (data: AskData) => {
122
134
  }),
123
135
  search_docs: tool({
124
136
  description:
125
- "Search this project's documentation. Returns the most relevant excerpt of each matching page, headed by its title and route.",
137
+ "Search this project's documentation. Returns the most relevant excerpt of each matching page, headed by a Markdown link to the page.",
126
138
  execute: ({ query }) => searchDocs(data, scope, query),
127
139
  inputSchema: jsonSchema<{ query: string }>({
128
140
  additionalProperties: false,
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A title as Markdown link text. An unescaped bracket in it — `[Beta] Webhooks`
3
+ * — closes or nests the text early, and the line stops being a link.
4
+ */
5
+ export const linkText = (title: string): string =>
6
+ title.replaceAll(/[\\[\]]/gu, String.raw`\$&`);
package/src/ai/llms.ts CHANGED
@@ -13,6 +13,7 @@ import { AI_CATALOG_PATH, hasAiCatalog } from "./ai-catalog.ts";
13
13
  import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
14
14
  import { API_PAGES_PATH, OPENAPI_PATH } from "./api/paths.ts";
15
15
  import { downlevelComponents } from "./component-markdown.ts";
16
+ import { linkText } from "./link-text.ts";
16
17
  import { relativeLinkRewriter } from "./relative-links.ts";
17
18
  import { projectComponentSerializers } from "./serializers.ts";
18
19
  import {
@@ -33,13 +34,6 @@ const pageUrl = (route: string, site?: string, base = ""): string => {
33
34
  return encodeURI(site ? absoluteUrl(site, path) : path);
34
35
  };
35
36
 
36
- /**
37
- * A title as Markdown link text. An unescaped bracket in it — `[Beta] Webhooks`
38
- * — closes or nests the text early, and the line stops being a link.
39
- */
40
- const linkText = (title: string): string =>
41
- title.replaceAll(/[\\[\]]/gu, String.raw`\$&`);
42
-
43
37
  /** Inputs only the build has: the published skills, collected once per build. */
44
38
  export interface LlmsIndexOptions {
45
39
  /**
@@ -7,7 +7,7 @@ import { discoverPages, discoverPagesSync } from "../core/custom-pages.ts";
7
7
  import matter from "../core/frontmatter.ts";
8
8
  import type { BlumeProject } from "../core/project-graph.ts";
9
9
  import { readExpandedEntryText } from "../core/sources/read.ts";
10
- import type { RouteManifestEntry } from "../core/types.ts";
10
+ import type { PageRecord, RouteManifestEntry } from "../core/types.ts";
11
11
  import { advertisedConfig, servesMcp } from "./agent-surface.ts";
12
12
  import { buildChangelogIndexMarkdown } from "./changelog-markdown.ts";
13
13
  import { downlevelComponents } from "./component-markdown.ts";
@@ -64,8 +64,10 @@ export const buildRawMarkdown = async (
64
64
  const components = projectComponentSerializers(project);
65
65
  const rewriteLinks = relativeLinkRewriter(project);
66
66
 
67
- const readRoute = async (route: RouteManifestEntry): Promise<string> => {
68
- const page = pageById.get(route.id);
67
+ const readRoute = async (
68
+ route: RouteManifestEntry,
69
+ page: PageRecord | undefined
70
+ ): Promise<string> => {
69
71
  if (page) {
70
72
  return await readExpandedEntryText(project, page);
71
73
  }
@@ -74,7 +76,8 @@ export const buildRawMarkdown = async (
74
76
 
75
77
  const entries = await Promise.all(
76
78
  project.manifest.routes.map(async (route) => {
77
- let text = await readRoute(route);
79
+ const page = pageById.get(route.id);
80
+ let text = await readRoute(route, page);
78
81
  if (route.sourcePath) {
79
82
  text = rewriteRelativeAssets({
80
83
  deployBase: project.config.deployment.options.base,
@@ -87,6 +90,7 @@ export const buildRawMarkdown = async (
87
90
  // agent fetched resolves it to, and `/install` gains the base and the
88
91
  // page's locale the rendered link has — remote content included.
89
92
  text = rewriteLinks(text, {
93
+ entryId: page?.entryId,
90
94
  route: route.path,
91
95
  sourcePath: route.sourcePath,
92
96
  });