blume 0.6.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -19,7 +19,10 @@ interface Props {
19
19
  }
20
20
 
21
21
  const { askEnabled = false, navigation, strings, locale } = Astro.props;
22
- const s = strings ?? EN_UI.search;
22
+ // Merge over the English baseline per key (rather than `strings ?? …`) so a
23
+ // partial — or empty `{}` — strings object still resolves every label to a
24
+ // default, matching the pattern PageActions uses for its own dictionary.
25
+ const s = { ...EN_UI.search, ...strings };
23
26
 
24
27
  // Pages shown in the empty state, before the user has typed anything.
25
28
  const popular = navigation ? flattenPages(navigation.sidebar).slice(0, 6) : [];
@@ -30,8 +33,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
30
33
  <blume-search
31
34
  class="contents"
32
35
  data-ask={askEnabled ? "" : undefined}
36
+ data-i18n-ask={s.askAi}
37
+ data-i18n-ask-hint={s.askAiHint}
33
38
  data-i18n-dev={s.devOnly}
34
39
  data-i18n-empty={s.noResults}
40
+ data-i18n-error={s.error}
41
+ data-i18n-popular={s.popular}
42
+ data-i18n-results={s.results}
35
43
  data-locale={locale || undefined}
36
44
  >
37
45
  <button
@@ -59,7 +67,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
59
67
  <input
60
68
  aria-label={s.label}
61
69
  autocomplete="off"
62
- class="flex-1 border-0 bg-transparent text-foreground text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
70
+ class="flex-1 border-0 bg-transparent text-foreground text-sm pointer-coarse:text-base focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
63
71
  data-blume-search-input
64
72
  placeholder={s.placeholder}
65
73
  type="search"
@@ -116,15 +124,17 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
116
124
  <span class="flex items-center gap-1">
117
125
  <kbd class={`${kbd} text-[0.65rem]`}>↑</kbd>
118
126
  <kbd class={`${kbd} text-[0.65rem]`}>↓</kbd>
119
- navigate
127
+ {s.navigate}
120
128
  </span>
121
129
  <span class="flex items-center gap-1">
122
130
  <kbd class={`${kbd} text-[0.65rem]`}>↵</kbd>
123
- open
131
+ {s.open}
124
132
  </span>
125
133
  <span class="flex items-center gap-1 max-md:hidden">
126
- <kbd class={`${kbd} text-[0.65rem]`}>⌘J</kbd>
127
- preview
134
+ <kbd class={`${kbd} text-[0.65rem]`} data-blume-search-preview-kbd
135
+ >⌘J</kbd
136
+ >
137
+ {s.preview}
128
138
  </span>
129
139
  </div>
130
140
  </div>
@@ -139,6 +149,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
139
149
 
140
150
  <script>
141
151
  import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
152
+ import { prefixBase } from "../islands/base-path.ts";
142
153
  import {
143
154
  escapeHtml,
144
155
  highlight,
@@ -185,6 +196,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
185
196
  preview!: HTMLElement;
186
197
  searchFn: SearchFn | null = null;
187
198
  loaded = false;
199
+ loadFailed = false;
200
+ /** First-open client/index load still in flight. */
201
+ loading = false;
188
202
  askEnabled = false;
189
203
  popular: PopularPage[] = [];
190
204
  selectables: Selectable[] = [];
@@ -194,6 +208,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
194
208
  previewOn = true;
195
209
  devOnlyMsg = "Search is available in the production build.";
196
210
  noResultsMsg = "No results found.";
211
+ errorMsg = "Something went wrong. Please try again.";
212
+ askMsg = "Ask AI";
213
+ askHintMsg = "Get an instant answer from AI";
214
+ popularMsg = "Popular";
215
+ resultsMsg = "Results";
197
216
  // The active locale to filter to (null when i18n is off), and whether the
198
217
  // reader has opted to search across every language instead.
199
218
  locale: string | null = null;
@@ -204,6 +223,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
204
223
  this.getAttribute("data-i18n-dev") || this.devOnlyMsg;
205
224
  this.noResultsMsg =
206
225
  this.getAttribute("data-i18n-empty") || this.noResultsMsg;
226
+ this.errorMsg = this.getAttribute("data-i18n-error") || this.errorMsg;
227
+ this.askMsg = this.getAttribute("data-i18n-ask") || this.askMsg;
228
+ this.askHintMsg =
229
+ this.getAttribute("data-i18n-ask-hint") || this.askHintMsg;
230
+ this.popularMsg =
231
+ this.getAttribute("data-i18n-popular") || this.popularMsg;
232
+ this.resultsMsg =
233
+ this.getAttribute("data-i18n-results") || this.resultsMsg;
207
234
  this.locale = this.getAttribute("data-locale");
208
235
  this.dialog = this.querySelector("[data-blume-search-dialog]")!;
209
236
  this.input = this.querySelector("[data-blume-search-input]")!;
@@ -246,12 +273,18 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
246
273
  });
247
274
  }
248
275
 
249
- // The handler accepts ⌘K and Ctrl+K; show the right one per platform.
276
+ // The handlers accept both ⌘ and Ctrl chords; show the right modifier
277
+ // per platform on the button hint and the footer's preview hint.
278
+ const isApple = /mac|iphone|ipad|ipod/iu.test(navigator.platform);
250
279
  const hint = this.querySelector("[data-blume-search-kbd]");
251
280
  if (hint) {
252
- hint.textContent = /mac|iphone|ipad|ipod/iu.test(navigator.platform)
253
- ? "⌘K"
254
- : "Ctrl K";
281
+ hint.textContent = isApple ? "⌘K" : "Ctrl K";
282
+ }
283
+ const previewHint = this.querySelector(
284
+ "[data-blume-search-preview-kbd]"
285
+ );
286
+ if (previewHint) {
287
+ previewHint.textContent = isApple ? "⌘J" : "Ctrl J";
255
288
  }
256
289
 
257
290
  this.querySelector("[data-blume-search-open]")?.addEventListener(
@@ -297,6 +330,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
297
330
  this.input.focus();
298
331
  this.input.select();
299
332
  if (!this.loaded) {
333
+ this.loading = true;
300
334
  try {
301
335
  const { createSearch } = await import("blume:search-client");
302
336
  this.searchFn = await createSearch();
@@ -304,8 +338,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
304
338
  // fetching the index) must retry on the next open, not disable
305
339
  // search until a full page reload.
306
340
  this.loaded = true;
341
+ this.loadFailed = false;
307
342
  } catch {
308
343
  this.searchFn = null;
344
+ // In dev a client can be missing by design (Pagefind's bundle
345
+ // only exists in the production build) — that's the "dev only"
346
+ // hint. The same failure in production is a real error.
347
+ this.loadFailed = !import.meta.env.DEV;
348
+ } finally {
349
+ this.loading = false;
309
350
  }
310
351
  }
311
352
  this.render();
@@ -318,7 +359,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
318
359
  } else if (event.key === "ArrowUp") {
319
360
  event.preventDefault();
320
361
  this.move(-1);
321
- } else if (event.key === "Enter") {
362
+ } else if (event.key === "Enter" && !event.isComposing) {
363
+ // `isComposing` guards IME input: Enter confirming a CJK conversion
364
+ // must commit the text, not activate the selected result.
322
365
  const item = this.selectables[this.selectedIndex];
323
366
  if (item) {
324
367
  event.preventDefault();
@@ -357,16 +400,36 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
357
400
  if (!this.searchFn) {
358
401
  this.renderFilters([]);
359
402
  this.clearPreview();
360
- this.setMessage(this.devOnlyMsg);
403
+ if (this.loading) {
404
+ // Typing while the first-open load (client import + index fetch)
405
+ // is still in flight: the dev-only hint would be wrong in
406
+ // production and the error message premature. Show a neutral
407
+ // placeholder; `open()` re-renders once the load settles.
408
+ this.setMessage("…");
409
+ } else {
410
+ this.setMessage(this.loadFailed ? this.errorMsg : this.devOnlyMsg);
411
+ }
361
412
  return;
362
413
  }
363
414
 
364
415
  const localeFilter =
365
416
  this.locale && !this.allLocales ? this.locale : undefined;
366
- const result = await this.searchFn(query, {
367
- locale: localeFilter,
368
- section: this.activeSection ?? undefined,
369
- });
417
+ let result: Awaited<ReturnType<SearchFn>>;
418
+ try {
419
+ result = await this.searchFn(query, {
420
+ locale: localeFilter,
421
+ section: this.activeSection ?? undefined,
422
+ });
423
+ } catch {
424
+ // A hosted provider can reject (network error, outage); the results
425
+ // list is already cleared, so show a message instead of a blank pane.
426
+ if (generation === this.renderGeneration) {
427
+ this.renderFilters([]);
428
+ this.clearPreview();
429
+ this.setMessage(this.errorMsg);
430
+ }
431
+ return;
432
+ }
370
433
  // Any newer render — a keystroke, a section pill, a locale toggle —
371
434
  // supersedes this one mid-await, even for the same query text;
372
435
  // appending the stale hits would duplicate rows and desync selection.
@@ -374,17 +437,30 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
374
437
  return;
375
438
  }
376
439
 
440
+ // A section picked for an earlier query can be missing from the new
441
+ // pool — and when the pool has fewer than two sections the pills that
442
+ // would clear it are hidden too, so the stale filter would silently
443
+ // empty the results. Drop it and search again unfiltered.
444
+ if (
445
+ this.activeSection &&
446
+ !result.sections.some((s) => s.label === this.activeSection)
447
+ ) {
448
+ this.activeSection = null;
449
+ this.render();
450
+ return;
451
+ }
452
+
377
453
  this.renderFilters(result.sections);
378
454
 
379
455
  if (this.askEnabled) {
380
- const group = this.addGroup("Ask AI");
456
+ const group = this.addGroup(this.askMsg);
381
457
  const ask = this.createAskRow(query);
382
458
  group.appendChild(ask.el);
383
459
  this.selectables.push(ask);
384
460
  }
385
461
 
386
462
  if (result.hits.length > 0) {
387
- const group = this.addGroup("Results");
463
+ const group = this.addGroup(this.resultsMsg);
388
464
  for (const hit of result.hits) {
389
465
  const item = this.createHitRow(hit, query);
390
466
  group.appendChild(item.el);
@@ -399,13 +475,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
399
475
 
400
476
  renderEmpty() {
401
477
  if (this.askEnabled) {
402
- const group = this.addGroup("Ask AI");
478
+ const group = this.addGroup(this.askMsg);
403
479
  const ask = this.createAskRow("");
404
480
  group.appendChild(ask.el);
405
481
  this.selectables.push(ask);
406
482
  }
407
483
  if (this.popular.length > 0) {
408
- const group = this.addGroup("Popular");
484
+ const group = this.addGroup(this.popularMsg);
409
485
  for (const page of this.popular) {
410
486
  const item = this.createLinkRow(page.route, page.label);
411
487
  group.appendChild(item.el);
@@ -460,13 +536,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
460
536
  el.type = "button";
461
537
  el.className = `${ROW_CLASS} ${MARK}`;
462
538
  const title = query
463
- ? `Ask AI: <span class="text-muted-foreground">“${escapeHtml(query)}”</span>`
464
- : "Ask AI";
539
+ ? `${escapeHtml(this.askMsg)}: <span class="text-muted-foreground">“${escapeHtml(query)}”</span>`
540
+ : escapeHtml(this.askMsg);
465
541
  el.innerHTML = `
466
542
  <span class="mt-0.5 shrink-0 text-accent">${svg("sparkles")}</span>
467
543
  <span class="flex-1">
468
544
  <span class="block truncate font-normal text-foreground text-sm">${title}</span>
469
- <span class="block truncate text-muted-foreground text-sm">Get an instant answer from AI</span>
545
+ <span class="block truncate text-muted-foreground text-sm">${escapeHtml(this.askHintMsg)}</span>
470
546
  </span>`;
471
547
  const item: Selectable = { el, kind: "ask" };
472
548
  this.bindRow(item);
@@ -475,7 +551,10 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
475
551
 
476
552
  createHitRow(hit: SearchHit, query: string): Selectable {
477
553
  const el = document.createElement("a");
478
- el.href = hit.url;
554
+ // Index URLs are base-less logical routes; prefix the deployment base so
555
+ // clicking a result lands on the page's real served URL.
556
+ const href = prefixBase(import.meta.env.BASE_URL, hit.url);
557
+ el.href = href;
479
558
  el.className = `${ROW_CLASS} ${MARK}`;
480
559
  // `line-clamp-2` already sets `display`, so no `block` here (it would
481
560
  // override the clamp and let the excerpt run to full height).
@@ -488,21 +567,22 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
488
567
  <span class="block truncate font-normal text-foreground text-sm">${hit.title}</span>
489
568
  ${excerpt}
490
569
  </span>`;
491
- const item: Selectable = { el, hit, kind: "link", url: hit.url };
570
+ const item: Selectable = { el, hit, kind: "link", url: href };
492
571
  this.bindRow(item);
493
572
  return item;
494
573
  }
495
574
 
496
575
  createLinkRow(url: string, label: string): Selectable {
497
576
  const el = document.createElement("a");
498
- el.href = url;
577
+ const href = prefixBase(import.meta.env.BASE_URL, url);
578
+ el.href = href;
499
579
  el.className = ROW_CLASS;
500
580
  el.innerHTML = `
501
581
  <span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
502
582
  <span class="flex-1">
503
583
  <span class="block truncate font-normal text-foreground text-sm">${escapeHtml(label)}</span>
504
584
  </span>`;
505
- const item: Selectable = { el, kind: "link", url };
585
+ const item: Selectable = { el, kind: "link", url: href };
506
586
  this.bindRow(item);
507
587
  return item;
508
588
  }
@@ -119,10 +119,14 @@ const sectionChildren = (nodes: NavNode[], base: string): NavNode[] | null => {
119
119
  };
120
120
 
121
121
  /** Whether a group maps to a header tab (matched on its path or link route). */
122
- const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean =>
123
- node.kind === "group" &&
124
- ((node.path !== undefined && tabPaths.has(node.path)) ||
125
- (node.route !== undefined && tabPaths.has(node.route)));
122
+ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean => {
123
+ if (node.kind !== "group") {
124
+ return false;
125
+ }
126
+ const byPath = node.path !== undefined && tabPaths.has(node.path);
127
+ const byRoute = node.route !== undefined && tabPaths.has(node.route);
128
+ return byPath || byRoute;
129
+ };
126
130
 
127
131
  /**
128
132
  * Drop the groups that already own a header tab from the tree, at any depth —
@@ -132,9 +136,12 @@ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean =>
132
136
  * (`/`) spans everything, so it never removes anything.
133
137
  */
134
138
  const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
135
- const tabPaths = new Set(
136
- tabs.filter((tab) => tab.path !== "/").map((tab) => tab.path)
137
- );
139
+ const tabPaths = new Set<string>();
140
+ for (const tab of tabs) {
141
+ if (tab.path !== "/") {
142
+ tabPaths.add(tab.path);
143
+ }
144
+ }
138
145
  if (tabPaths.size === 0) {
139
146
  return nodes;
140
147
  }
@@ -145,11 +152,12 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
145
152
  continue;
146
153
  }
147
154
  if (item.kind === "group") {
155
+ // A container left empty by pruning is dropped, so no bare heading is
156
+ // stranded.
148
157
  const children = prune(item.children);
149
- if (children.length === 0) {
150
- continue;
158
+ if (children.length > 0) {
159
+ kept.push({ ...item, children });
151
160
  }
152
- kept.push({ ...item, children });
153
161
  } else {
154
162
  kept.push(item);
155
163
  }
@@ -20,10 +20,19 @@ export const createSearch = (opts: {
20
20
  searchApiKey: string;
21
21
  }): SearchFn => {
22
22
  const client = liteClient(opts.appId, opts.searchApiKey);
23
- return async (query) => {
23
+ return async (query, options) => {
24
24
  const { results } = await client.search<AlgoliaRecord>({
25
25
  requests: [
26
- { hitsPerPage: SEARCH_LIMIT, indexName: opts.indexName, query },
26
+ {
27
+ hitsPerPage: SEARCH_LIMIT,
28
+ indexName: opts.indexName,
29
+ query,
30
+ // The sync uploads `locale` on every record so an i18n site can
31
+ // scope hosted results to the active language.
32
+ ...(options?.locale && {
33
+ facetFilters: [`locale:${options.locale}`],
34
+ }),
35
+ },
27
36
  ],
28
37
  });
29
38
  const [first] = results;
@@ -1,10 +1,11 @@
1
- import { SEARCH_LIMIT } from "./types.ts";
1
+ import { highlight, SEARCH_LIMIT } from "./types.ts";
2
2
  import type { SearchFn, SearchHit } from "./types.ts";
3
3
 
4
4
  /**
5
5
  * Server-proxied search (Mixedbread): POST the query to a generated endpoint
6
- * that holds the secret key and talks to the service, then renders the
7
- * already-shaped hits it returns.
6
+ * that holds the secret key and talks to the service. The returned hits carry
7
+ * service-derived text and the dialog injects title/excerpt as HTML, so both
8
+ * are escaped (and query matches marked) here, like every other provider.
8
9
  */
9
10
  export const createSearch =
10
11
  (opts: { api: string }): SearchFn =>
@@ -17,6 +18,11 @@ export const createSearch =
17
18
  if (!response.ok) {
18
19
  return { hits: [], sections: [] };
19
20
  }
20
- const hits = (await response.json()) as SearchHit[];
21
- return { hits: hits.slice(0, SEARCH_LIMIT), sections: [] };
21
+ const records = (await response.json()) as SearchHit[];
22
+ const hits = records.slice(0, SEARCH_LIMIT).map((hit) => ({
23
+ ...hit,
24
+ excerpt: highlight(hit.excerpt, query),
25
+ title: highlight(hit.title, query),
26
+ }));
27
+ return { hits, sections: [] };
22
28
  };
@@ -22,8 +22,14 @@ export const createSearch = (opts: {
22
22
  api_key: opts.apiKey,
23
23
  endpoint: opts.endpoint,
24
24
  });
25
- return async (query) => {
26
- const results = await client.search({ limit: SEARCH_LIMIT, term: query });
25
+ return async (query, options) => {
26
+ const results = await client.search({
27
+ limit: SEARCH_LIMIT,
28
+ term: query,
29
+ // The sync carries `locale` on every record so an i18n site can scope
30
+ // hosted results to the active language.
31
+ ...(options?.locale && { where: { locale: options.locale } }),
32
+ });
27
33
  const hits = (results?.hits ?? []).map((hit) => {
28
34
  const doc = hit.document as unknown as OramaCloudRecord;
29
35
  return {
@@ -21,6 +21,9 @@ interface PagefindModule {
21
21
  export const createSearch = async (opts: {
22
22
  url: string;
23
23
  }): Promise<SearchFn> => {
24
+ // The pagefind bundle lives in the built site (not node_modules) and is
25
+ // resolved at runtime by URL — it can't be a static, code-splittable path.
26
+ // oxlint-disable-next-line react-doctor/no-dynamic-import-path
24
27
  const pagefind = (await import(
25
28
  /* @vite-ignore */
26
29
  opts.url
@@ -134,7 +134,11 @@ export const excerptFor = (
134
134
  if (query && matchIndex(content, query) >= 0) {
135
135
  return matchSnippet(content, query, 160);
136
136
  }
137
- return description || `${content.slice(0, 140)}…`;
137
+ if (description) {
138
+ return description;
139
+ }
140
+ const head = content.slice(0, 140);
141
+ return head.length < content.length ? `${head}…` : head;
138
142
  };
139
143
 
140
144
  /** Tally how many matches fall into each section, in first-seen order. */
@@ -31,7 +31,7 @@ export const createSearch = (opts: {
31
31
  },
32
32
  ],
33
33
  });
34
- return async (query) => {
34
+ return async (query, options) => {
35
35
  const response = await client
36
36
  .collections<TypesenseRecord>(opts.collection)
37
37
  .documents()
@@ -40,6 +40,9 @@ export const createSearch = (opts: {
40
40
  per_page: SEARCH_LIMIT,
41
41
  q: query,
42
42
  query_by: "title,description,content",
43
+ // The sync marks `locale` as a facet so an i18n site can scope
44
+ // hosted results to the active language.
45
+ ...(options?.locale && { filter_by: `locale:=${options.locale}` }),
43
46
  },
44
47
  {}
45
48
  );
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Client behaviour for the `<blume-toc>` custom element wrapping each "On this
2
+ * Client behavior for the `<blume-toc>` custom element wrapping each "On this
3
3
  * page" list. As the reader scrolls, it marks the link for the section currently
4
4
  * in view with `aria-current="location"` — styled via Tailwind `aria-[current]`
5
5
  * variants in RootLayout — giving the table of contents a live scrollspy.
@@ -52,7 +52,13 @@ class BlumeToc extends HTMLElement {
52
52
  for (const { heading } of this.#entries) {
53
53
  this.#observer.observe(heading);
54
54
  }
55
- window.addEventListener("scroll", this.#onScroll, { passive: true });
55
+ // The scroll listener is the intentional fallback for the one case the
56
+ // IntersectionObserver can't cover (a final section too short to push its
57
+ // heading past the trigger line), so replacing it with an observer would
58
+ // change behavior. `scroll` isn't cancelable, so it never preventDefaults
59
+ // and `{ passive: true }` would be a no-op here.
60
+ // oxlint-disable-next-line github/prefer-observers, react-doctor/client-passive-event-listeners
61
+ window.addEventListener("scroll", this.#onScroll);
56
62
  this.#update();
57
63
  }
58
64
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
+ import { withBase } from "../islands/base-path.ts";
3
4
  import MethodBadge from "./MethodBadge.astro";
4
5
 
5
6
  // The operation-link list for one tag section of an API overview page. The
@@ -25,7 +26,7 @@ const operations = Object.values(specs[source]?.operations ?? {}).filter(
25
26
  <li>
26
27
  <a
27
28
  class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
28
- href={operation.route}
29
+ href={withBase(operation.route)}
29
30
  >
30
31
  <MethodBadge method={operation.method} />
31
32
  <span class="font-medium text-foreground text-sm">
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
- import type { SchemaLike } from "./helpers.ts";
3
+ import {
4
+ mergeParameters,
5
+ type ParameterLike,
6
+ resolveComponentRef,
7
+ type SchemaLike,
8
+ } from "./helpers.ts";
4
9
  import { buildRequestSample, sampleLanguages } from "./snippets.ts";
5
10
  import MethodBadge from "./MethodBadge.astro";
6
11
  import ParametersTable from "./ParametersTable.astro";
@@ -13,36 +18,31 @@ interface Props {
13
18
  id: string;
14
19
  }
15
20
 
16
- interface ParamLike {
21
+ interface MediaTypeLike {
22
+ schema?: SchemaLike;
23
+ example?: unknown;
24
+ }
25
+
26
+ interface RequestBodyLike {
17
27
  $ref?: string;
18
- name?: string;
19
- in?: string;
20
28
  description?: string;
21
29
  required?: boolean;
22
- deprecated?: boolean;
23
- schema?: SchemaLike;
24
- example?: unknown;
30
+ content?: Record<string, MediaTypeLike>;
25
31
  }
26
32
 
27
- interface MediaTypeLike {
28
- schema?: SchemaLike;
29
- example?: unknown;
33
+ interface ResponseLike {
34
+ $ref?: string;
35
+ description?: string;
36
+ content?: Record<string, MediaTypeLike>;
30
37
  }
31
38
 
32
39
  interface FullOperation {
33
40
  summary?: string;
34
41
  description?: string;
35
42
  deprecated?: boolean;
36
- parameters?: ParamLike[];
37
- requestBody?: {
38
- description?: string;
39
- required?: boolean;
40
- content?: Record<string, MediaTypeLike>;
41
- };
42
- responses?: Record<
43
- string,
44
- { description?: string; content?: Record<string, MediaTypeLike> }
45
- >;
43
+ parameters?: ParameterLike[];
44
+ requestBody?: RequestBodyLike;
45
+ responses?: Record<string, ResponseLike>;
46
46
  }
47
47
 
48
48
  const { source, id } = Astro.props;
@@ -50,10 +50,15 @@ const spec = specs[source];
50
50
  const ref = spec?.operations[id];
51
51
 
52
52
  const doc = (spec?.document ?? {}) as {
53
- paths?: Record<string, Record<string, unknown> & { parameters?: ParamLike[] }>;
53
+ paths?: Record<
54
+ string,
55
+ Record<string, unknown> & { parameters?: ParameterLike[] }
56
+ >;
54
57
  components?: {
55
58
  schemas?: Record<string, SchemaLike>;
56
- parameters?: Record<string, ParamLike>;
59
+ parameters?: Record<string, ParameterLike>;
60
+ requestBodies?: Record<string, RequestBodyLike>;
61
+ responses?: Record<string, ResponseLike>;
57
62
  };
58
63
  servers?: { url?: string }[];
59
64
  };
@@ -63,24 +68,26 @@ const operation = (
63
68
  ) as FullOperation | undefined;
64
69
 
65
70
  const schemas = doc.components?.schemas ?? {};
66
- const componentParams = doc.components?.parameters ?? {};
67
- const PARAM_REF = /#\/components\/parameters\/(?<name>[^/]+)$/u;
68
- const resolveParam = (param: ParamLike): ParamLike => {
69
- if (typeof param.$ref === "string") {
70
- const name = PARAM_REF.exec(param.$ref)?.groups?.name;
71
- return (name && componentParams[name]) || param;
72
- }
73
- return param;
74
- };
75
- const params = [
76
- ...(pathItem?.parameters ?? []),
77
- ...(operation?.parameters ?? []),
78
- ].map(resolveParam);
71
+ const components = doc.components ?? {};
72
+ const params = mergeParameters(
73
+ pathItem?.parameters,
74
+ operation?.parameters,
75
+ components
76
+ );
77
+ const requestBody = operation?.requestBody
78
+ ? resolveComponentRef(operation.requestBody, components, "requestBodies")
79
+ : undefined;
80
+ const responses = Object.fromEntries(
81
+ Object.entries(operation?.responses ?? {}).map(([status, response]) => [
82
+ status,
83
+ resolveComponentRef(response, components, "responses"),
84
+ ])
85
+ );
79
86
 
80
87
  const sample =
81
88
  ref && operation
82
89
  ? buildRequestSample(
83
- { parameters: params, requestBody: operation.requestBody },
90
+ { parameters: params, requestBody },
84
91
  ref.method,
85
92
  ref.path,
86
93
  doc.servers ?? [],
@@ -109,17 +116,17 @@ const languages = sampleLanguages(spec?.codeSamples ?? []);
109
116
  <div class="grid grid-cols-1 items-start gap-x-10 gap-y-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,28rem)]">
110
117
  <div>
111
118
  <ParametersTable parameters={params} schemas={schemas} />
112
- {operation.requestBody && (
119
+ {requestBody && (
113
120
  <RequestBody
114
121
  expandAll={spec.expandSchemas}
115
- requestBody={operation.requestBody}
122
+ requestBody={requestBody}
116
123
  schemas={schemas}
117
124
  />
118
125
  )}
119
126
  {operation.responses && (
120
127
  <Responses
121
128
  expandAll={spec.expandSchemas}
122
- responses={operation.responses}
129
+ responses={responses}
123
130
  schemas={schemas}
124
131
  />
125
132
  )}
@@ -128,7 +135,7 @@ const languages = sampleLanguages(spec?.codeSamples ?? []);
128
135
  <div class="xl:sticky xl:top-24 xl:self-start">
129
136
  <RequestPanel
130
137
  languages={languages}
131
- responses={operation.responses ?? {}}
138
+ responses={responses}
132
139
  sample={sample}
133
140
  schemas={schemas}
134
141
  />