blume 1.4.3 → 1.5.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 (194) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +1621 -576
  3. package/dist/cli/index.js.map +109 -104
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/config-input.d.ts +79 -27
  6. package/dist/types/core/config.d.ts +2 -1
  7. package/dist/types/core/data.d.ts +16 -1
  8. package/dist/types/core/diagnostics.d.ts +5 -1
  9. package/dist/types/core/i18n-ui.d.ts +12 -0
  10. package/dist/types/core/schema.d.ts +112 -15
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +7 -3
  13. package/dist/types/core/types.d.ts +43 -2
  14. package/dist/types/core/ui-packs/index.d.ts +9 -1
  15. package/dist/types/openapi/references.d.ts +6 -5
  16. package/dist/types/seo/x-handle.d.ts +3 -2
  17. package/docs/advanced/api-reference.mdx +8 -6
  18. package/docs/configuration/search.mdx +2 -0
  19. package/docs/configuration/seo.mdx +1 -1
  20. package/docs/content/i18n.mdx +1 -1
  21. package/docs/content/meta.mdx +2 -1
  22. package/docs/content/meta.ts +1 -0
  23. package/docs/content/navigation.mdx +35 -1
  24. package/docs/content/versioning.mdx +106 -0
  25. package/docs/reference/cli.mdx +1 -0
  26. package/docs/reference/frontmatter.mdx +3 -0
  27. package/package.json +3 -1
  28. package/skills/blume-migrate/SKILL.md +2 -2
  29. package/skills/blume-migrate/references/docusaurus.md +1 -1
  30. package/skills/blume-migrate/references/fumadocs.md +1 -1
  31. package/skills/blume-migrate/references/mintlify.md +1 -1
  32. package/src/ai/agent-readability.ts +37 -10
  33. package/src/ai/ask-context.ts +5 -1
  34. package/src/ai/ask.ts +10 -1
  35. package/src/ai/component-markdown.ts +80 -43
  36. package/src/ai/llms.ts +40 -16
  37. package/src/ai/mcp/data.ts +48 -12
  38. package/src/ai/mcp/discovery.ts +28 -11
  39. package/src/ai/mcp/server.ts +183 -38
  40. package/src/ai/mcp/tools.ts +3 -3
  41. package/src/ai/skills.ts +32 -9
  42. package/src/ai/visibility.ts +2 -2
  43. package/src/astro/component-slots.ts +2 -0
  44. package/src/astro/examples.ts +6 -2
  45. package/src/astro/generate.ts +54 -29
  46. package/src/astro/integration.ts +13 -2
  47. package/src/astro/islands.ts +16 -9
  48. package/src/astro/templates.ts +152 -33
  49. package/src/audit/agent.ts +2 -2
  50. package/src/audit/checks/content.ts +26 -11
  51. package/src/audit/checks/dns-aid.ts +3 -0
  52. package/src/audit/checks/indexability.ts +24 -6
  53. package/src/audit/checks/llms.ts +9 -4
  54. package/src/audit/checks/network.ts +2 -0
  55. package/src/audit/checks/social.ts +18 -10
  56. package/src/audit/crawl.ts +37 -9
  57. package/src/audit/report.ts +20 -19
  58. package/src/audit/run.ts +5 -2
  59. package/src/audit/snapshot.ts +2 -4
  60. package/src/audit/types.ts +25 -3
  61. package/src/blume-modules.d.ts +5 -1
  62. package/src/cli/commands/audit.ts +9 -4
  63. package/src/cli/commands/build.ts +15 -9
  64. package/src/cli/commands/dev.ts +2 -0
  65. package/src/cli/commands/doctor.ts +2 -0
  66. package/src/cli/commands/eval.ts +7 -3
  67. package/src/cli/commands/init.ts +9 -9
  68. package/src/cli/commands/mcp-stdio.ts +3 -0
  69. package/src/cli/commands/translate.ts +14 -3
  70. package/src/cli/commands/version.ts +85 -0
  71. package/src/cli/dev-lock.ts +31 -10
  72. package/src/cli/eject-scripts.ts +17 -2
  73. package/src/cli/index.ts +2 -0
  74. package/src/cli/init/questions.ts +1 -1
  75. package/src/cli/init/scaffold.ts +22 -15
  76. package/src/cli/internal-error.ts +1 -0
  77. package/src/components/content/auto-type-table.ts +3 -0
  78. package/src/components/content/diff.ts +9 -5
  79. package/src/components/content/github-info.ts +2 -0
  80. package/src/components/islands/ask-ai.tsx +33 -25
  81. package/src/components/islands/hooks.ts +5 -1
  82. package/src/components/islands/webmcp.ts +49 -12
  83. package/src/components/layout/Header.astro +25 -1
  84. package/src/components/layout/NavSelector.astro +11 -2
  85. package/src/components/layout/NavTree.astro +4 -2
  86. package/src/components/layout/RootLayout.astro +18 -0
  87. package/src/components/layout/Search.astro +77 -13
  88. package/src/components/layout/VersionBanner.astro +39 -0
  89. package/src/components/layout/analytics-client.ts +8 -5
  90. package/src/components/layout/hydration-hint.ts +1 -1
  91. package/src/components/layout/nav-utils.ts +1 -4
  92. package/src/components/layout/overrides.ts +25 -12
  93. package/src/components/layout/search/algolia.ts +18 -5
  94. package/src/components/layout/search/endpoint.ts +3 -0
  95. package/src/components/layout/search/flexsearch.ts +23 -7
  96. package/src/components/layout/search/orama-cloud.ts +1 -1
  97. package/src/components/layout/search/orama.ts +4 -1
  98. package/src/components/layout/search/pagefind.ts +2 -0
  99. package/src/components/layout/search/types.ts +13 -1
  100. package/src/components/layout/search/typesense.ts +19 -3
  101. package/src/components/openapi/ApiOverview.astro +32 -6
  102. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  103. package/src/components/openapi/Bindings.astro +89 -0
  104. package/src/components/openapi/MethodBadge.astro +3 -0
  105. package/src/components/openapi/Operation.astro +7 -2
  106. package/src/components/openapi/PanelTabs.astro +131 -0
  107. package/src/components/openapi/ParametersTable.astro +2 -0
  108. package/src/components/openapi/RequestPanel.astro +12 -119
  109. package/src/components/openapi/async-snippets.ts +174 -0
  110. package/src/components/openapi/async.ts +348 -0
  111. package/src/components/openapi/helpers.ts +52 -20
  112. package/src/components/openapi/security.ts +102 -29
  113. package/src/components/openapi/snippets.ts +11 -11
  114. package/src/core/component-overrides.ts +28 -23
  115. package/src/core/config-input.ts +88 -27
  116. package/src/core/config.ts +20 -7
  117. package/src/core/content.ts +3 -1
  118. package/src/core/data.ts +16 -1
  119. package/src/core/define-components.ts +5 -0
  120. package/src/core/diagnostics.ts +46 -38
  121. package/src/core/frontmatter.ts +33 -7
  122. package/src/core/graph.ts +137 -53
  123. package/src/core/i18n-ui.ts +15 -0
  124. package/src/core/i18n.ts +16 -8
  125. package/src/core/load-module.ts +1 -0
  126. package/src/core/manifest.ts +92 -3
  127. package/src/core/meta.ts +44 -14
  128. package/src/core/nav-diagnostics.ts +3 -3
  129. package/src/core/navigation.ts +247 -67
  130. package/src/core/project-graph.ts +15 -3
  131. package/src/core/schema.ts +213 -67
  132. package/src/core/sources/assets.ts +2 -0
  133. package/src/core/sources/cache.ts +6 -0
  134. package/src/core/sources/github-releases.ts +39 -31
  135. package/src/core/sources/mdx-remote.ts +4 -0
  136. package/src/core/sources/normalize.ts +67 -20
  137. package/src/core/sources/notion.ts +49 -17
  138. package/src/core/sources/portable-text.ts +32 -11
  139. package/src/core/sources/sanity.ts +68 -14
  140. package/src/core/sources/types.ts +4 -0
  141. package/src/core/sources/watch.ts +1 -1
  142. package/src/core/standard-schema.ts +9 -3
  143. package/src/core/text-width.ts +26 -0
  144. package/src/core/tsconfig-aliases.ts +9 -5
  145. package/src/core/types.ts +45 -2
  146. package/src/core/ui-packs/index.ts +9 -1
  147. package/src/core/version-cut.ts +301 -0
  148. package/src/core/version.ts +2 -0
  149. package/src/core/versions.ts +170 -0
  150. package/src/deploy/adapter-output.ts +5 -2
  151. package/src/deploy/cloudflare-negotiation.ts +25 -10
  152. package/src/deploy/sitemap.ts +33 -1
  153. package/src/deploy/vercel-negotiation.ts +11 -4
  154. package/src/eval/report.ts +4 -4
  155. package/src/eval/run.ts +2 -2
  156. package/src/eval/schema.ts +1 -1
  157. package/src/markdown/base-links.ts +6 -6
  158. package/src/markdown/directives.ts +7 -1
  159. package/src/markdown/heading-anchors.ts +17 -6
  160. package/src/markdown/index.ts +73 -24
  161. package/src/markdown/inline-code.ts +14 -2
  162. package/src/markdown/language-icon.ts +6 -2
  163. package/src/markdown/mdast.ts +18 -4
  164. package/src/markdown/package-commands.ts +6 -8
  165. package/src/markdown/table-wrap.ts +4 -1
  166. package/src/markdown/twoslash.ts +2 -0
  167. package/src/og/card.ts +30 -11
  168. package/src/og/derive.ts +43 -27
  169. package/src/openapi/asyncapi.ts +366 -0
  170. package/src/openapi/model.ts +126 -57
  171. package/src/openapi/parse.ts +97 -5
  172. package/src/openapi/references.ts +12 -10
  173. package/src/openapi/render-mdx.ts +73 -34
  174. package/src/openapi/scalar.ts +6 -8
  175. package/src/openapi/source.ts +98 -28
  176. package/src/registry/eject.ts +7 -2
  177. package/src/search/documents.ts +25 -5
  178. package/src/search/facets.ts +7 -5
  179. package/src/search/orama-index.ts +66 -20
  180. package/src/search/popular.ts +10 -5
  181. package/src/search/providers.ts +2 -2
  182. package/src/search/sync/index.ts +2 -0
  183. package/src/search/sync/typesense.ts +4 -2
  184. package/src/seo/jsonld.ts +24 -6
  185. package/src/seo/x-handle.ts +8 -3
  186. package/src/theme/chrome-icons.ts +7 -2
  187. package/src/theme/fonts.ts +8 -4
  188. package/src/theme/icons.ts +4 -2
  189. package/src/theme/palette.ts +22 -14
  190. package/src/translate/meta.ts +15 -6
  191. package/src/translate/report.ts +9 -5
  192. package/src/translate/run.ts +10 -4
  193. package/src/translate/validate.ts +52 -17
  194. package/src/translate/work-list.ts +0 -0
@@ -11,9 +11,16 @@ import { isUnderPath } from "./nav-utils.ts";
11
11
  interface Props {
12
12
  selector: NavSelector;
13
13
  route: string;
14
+ /**
15
+ * Which edge the dropdown panel anchors to. `start` (the default) suits the
16
+ * header's leading selector group; the version selector sits at the trailing
17
+ * edge next to the language switcher, where a start-anchored panel would
18
+ * overflow the viewport — pass `end` there.
19
+ */
20
+ align?: "start" | "end";
14
21
  }
15
22
 
16
- const { selector, route } = Astro.props;
23
+ const { selector, route, align = "start" } = Astro.props;
17
24
 
18
25
  // The active item is the deepest path the current route sits under (on a path
19
26
  // boundary, so `/api` never claims `/api-reference` routes), falling back to
@@ -46,7 +53,9 @@ const menuRowClass =
46
53
  size={14}
47
54
  />
48
55
  </summary>
49
- <div class="absolute start-0 z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl">
56
+ <div
57
+ class={`absolute z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl ${align === "end" ? "end-0" : "start-0"}`}
58
+ >
50
59
  {selector.items.map((item) => (
51
60
  <a
52
61
  aria-current={item.path === active?.path ? "true" : undefined}
@@ -45,8 +45,8 @@ const n = { ...EN_UI.nav, ...strings };
45
45
  const badgeBase =
46
46
  "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
47
47
  const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
48
- // HTTP-method badges (from an OpenAPI reference's sidebar) are color-coded;
49
- // every other badge keeps the neutral style.
48
+ // HTTP-method and AsyncAPI-action badges (from a reference's sidebar) are
49
+ // color-coded to match MethodBadge; every other badge keeps the neutral style.
50
50
  const METHOD_BADGE: Record<string, string> = {
51
51
  DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
52
52
  GET: "bg-green-500/15 text-green-700 dark:text-green-300",
@@ -55,6 +55,8 @@ const METHOD_BADGE: Record<string, string> = {
55
55
  PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
56
56
  POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
57
57
  PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
58
+ RECEIVE: "bg-teal-500/15 text-teal-700 dark:text-teal-300",
59
+ SEND: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
58
60
  };
59
61
  const badgeClassFor = (badge: string): string => {
60
62
  const method = METHOD_BADGE[badge.toUpperCase()];
@@ -8,6 +8,7 @@ import type {
8
8
  Heading,
9
9
  LocaleSwitchOption,
10
10
  Navigation,
11
+ NavSelector as NavSelectorType,
11
12
  } from "../../core/types.ts";
12
13
  import "blume:theme";
13
14
  import type { ComponentOverride } from "../../core/define-components.ts";
@@ -46,6 +47,7 @@ import PageActions from "./PageActions.astro";
46
47
  import PageFeedback from "./PageFeedback.astro";
47
48
  import Pagination from "./Pagination.astro";
48
49
  import TableOfContents from "./TableOfContents.astro";
50
+ import VersionBanner from "./VersionBanner.astro";
49
51
 
50
52
  interface Props {
51
53
  site: { title: string; description?: string };
@@ -149,6 +151,16 @@ interface Props {
149
151
  xDefault?: string | null;
150
152
  /** Language-switcher entries for the current page. */
151
153
  localeSwitch?: LocaleSwitchOption[];
154
+ /** Auto-populated version switcher (`null` when versioning is off or a user selector owns it). */
155
+ versionSelector?: NavSelectorType | null;
156
+ /** Old-version notice for archived pages (`null` on current-docs pages). */
157
+ versionNotice?: {
158
+ message: string;
159
+ latestHref: string;
160
+ latestLabel: string;
161
+ } | null;
162
+ /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
163
+ searchVersion?: string | null;
152
164
  /**
153
165
  * User layout-slot overrides from `components.ts` (`defineComponents`). Each
154
166
  * key replaces the matching built-in; unknown keys are ignored. Wired slots:
@@ -220,6 +232,9 @@ const {
220
232
  localeAlternates,
221
233
  xDefault,
222
234
  localeSwitch,
235
+ versionSelector,
236
+ versionNotice,
237
+ searchVersion = null,
223
238
  layout = {},
224
239
  clientData,
225
240
  toc = { enabled: true, maxLevel: 3, minLevel: 2 },
@@ -499,16 +514,19 @@ const bannerKey = banner?.dismissible ? banner.key : null;
499
514
  href="#blume-content">{strings.page.skipToContent}</a
500
515
  >
501
516
  <Banner banner={banner} strings={strings.banner} />
517
+ <VersionBanner notice={versionNotice ?? null} />
502
518
  <HeaderSlot
503
519
  askEnabled={askEnabled}
504
520
  askStrings={strings.ask}
505
521
  layout={layout}
506
522
  localeSwitch={localeSwitch}
523
+ versionSelector={versionSelector}
507
524
  logo={logo}
508
525
  navigation={navigation}
509
526
  route={page.route}
510
527
  searchEnabled={searchEnabled}
511
528
  searchLocale={searchLocale}
529
+ searchVersion={searchVersion}
512
530
  navStrings={navStrings}
513
531
  searchStrings={strings.search}
514
532
  site={site}
@@ -19,10 +19,21 @@ interface Props {
19
19
  strings?: UIStrings["search"];
20
20
  /** Active locale to filter results to; omitted disables locale filtering. */
21
21
  locale?: string;
22
+ /**
23
+ * Docs version to filter results to (`""` = the current docs — a meaningful
24
+ * value, so `null`/omitted is what disables version filtering).
25
+ */
26
+ version?: string | null;
22
27
  }
23
28
 
24
- const { askEnabled = false, navigation, popularPages, strings, locale } =
25
- Astro.props;
29
+ const {
30
+ askEnabled = false,
31
+ navigation,
32
+ popularPages,
33
+ strings,
34
+ locale,
35
+ version = null,
36
+ } = Astro.props;
26
37
  // Merge over the English baseline per key (rather than `strings ?? …`) so a
27
38
  // partial — or empty `{}` — strings object still resolves every label to a
28
39
  // default, matching the pattern PageActions uses for its own dictionary.
@@ -59,13 +70,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
59
70
  data-i18n-popular={s.popular}
60
71
  data-i18n-results={s.results}
61
72
  data-locale={locale || undefined}
73
+ data-version={version ?? undefined}
74
+ data-versioned={version === null ? undefined : ""}
62
75
  >
63
76
  {/* The label and shortcut hint wait until `lg`: below it the hamburger and
64
77
  inline tab bar share the header row, and a full-width search field would
65
78
  press into the language switcher. */}
66
79
  <button
67
80
  aria-label={s.button}
68
- class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-48"
81
+ class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-40"
69
82
  data-blume-search-open
70
83
  type="button"
71
84
  >
@@ -135,15 +148,29 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
135
148
  class="flex items-center justify-between gap-3 border-border border-t px-3 py-2 text-muted-foreground text-xs"
136
149
  >
137
150
  {
138
- locale ? (
139
- <label class="flex cursor-pointer select-none items-center gap-1.5">
140
- <input
141
- class="size-3.5 accent-accent"
142
- data-blume-search-all-locales
143
- type="checkbox"
144
- />
145
- {s.allLanguages}
146
- </label>
151
+ locale || version !== null ? (
152
+ <span class="flex items-center gap-3">
153
+ {locale && (
154
+ <label class="flex cursor-pointer select-none items-center gap-1.5">
155
+ <input
156
+ class="size-3.5 accent-accent"
157
+ data-blume-search-all-locales
158
+ type="checkbox"
159
+ />
160
+ {s.allLanguages}
161
+ </label>
162
+ )}
163
+ {version !== null && (
164
+ <label class="flex cursor-pointer select-none items-center gap-1.5">
165
+ <input
166
+ class="size-3.5 accent-accent"
167
+ data-blume-search-all-versions
168
+ type="checkbox"
169
+ />
170
+ {s.allVersions}
171
+ </label>
172
+ )}
173
+ </span>
147
174
  ) : (
148
175
  <span />
149
176
  )
@@ -268,6 +295,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
268
295
  // reader has opted to search across every language instead.
269
296
  locale: string | null = null;
270
297
  allLocales = false;
298
+ // The viewed docs version ("" = current; null when versioning is off),
299
+ // and the opt-in to search across every version.
300
+ versioned = false;
301
+ version = "";
302
+ allVersions = false;
271
303
 
272
304
  connectedCallback() {
273
305
  this.devOnlyMsg =
@@ -284,6 +316,10 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
284
316
  this.resultsMsg =
285
317
  this.getAttribute("data-i18n-results") || this.resultsMsg;
286
318
  this.locale = this.getAttribute("data-locale");
319
+ // "" (the current docs) is a real version value, so a presence flag —
320
+ // not the attribute's truthiness — decides whether filtering is on.
321
+ this.versioned = this.hasAttribute("data-versioned");
322
+ this.version = this.getAttribute("data-version") ?? "";
287
323
  this.dialog = this.querySelector("[data-blume-search-dialog]")!;
288
324
  this.input = this.querySelector("[data-blume-search-input]")!;
289
325
  this.grid = this.querySelector("[data-blume-search-grid]")!;
@@ -323,6 +359,25 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
323
359
  });
324
360
  }
325
361
 
362
+ // Per-version filtering mirrors the locale toggle: default to the
363
+ // viewed version, with a remembered opt-in to search every version.
364
+ const allVersionsToggle = this.querySelector<HTMLInputElement>(
365
+ "[data-blume-search-all-versions]"
366
+ );
367
+ if (allVersionsToggle) {
368
+ this.allVersions =
369
+ readStorage("blume-search-all-versions") === "1";
370
+ allVersionsToggle.checked = this.allVersions;
371
+ allVersionsToggle.addEventListener("change", () => {
372
+ this.allVersions = allVersionsToggle.checked;
373
+ writeStorage(
374
+ "blume-search-all-versions",
375
+ this.allVersions ? "1" : "0"
376
+ );
377
+ this.render();
378
+ });
379
+ }
380
+
326
381
  // The handlers accept both ⌘ and Ctrl chords; show the right modifier
327
382
  // per platform on the button hint and the footer's preview hint.
328
383
  const isApple = /mac|iphone|ipad|ipod/iu.test(navigator.platform);
@@ -483,11 +538,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
483
538
 
484
539
  const localeFilter =
485
540
  this.locale && !this.allLocales ? this.locale : undefined;
541
+ const versionFilter =
542
+ this.versioned && !this.allVersions ? this.version : undefined;
486
543
  let result: Awaited<ReturnType<SearchFn>>;
487
544
  try {
488
545
  result = await this.searchFn(query, {
489
546
  locale: localeFilter,
490
547
  section: this.activeSection ?? undefined,
548
+ version: versionFilter,
491
549
  });
492
550
  } catch {
493
551
  // A hosted provider can reject (network error, outage); the results
@@ -636,10 +694,16 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
636
694
  const excerpt = hit.excerpt
637
695
  ? `<span class="mt-0.5 line-clamp-2 text-muted-foreground text-xs">${hit.excerpt}</span>`
638
696
  : "";
697
+ // A cross-version hit (all-versions search) names its version so the
698
+ // reader knows they're about to leave the docs they're viewing.
699
+ const versionTag =
700
+ this.versioned && hit.version !== undefined && hit.version !== this.version
701
+ ? `<span class="ms-2 inline-block rounded-full bg-muted px-1.5 py-0.5 align-middle text-[0.65rem] text-muted-foreground">${escapeHtml(hit.version || "latest")}</span>`
702
+ : "";
639
703
  el.innerHTML = `
640
704
  <span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
641
705
  <span class="flex-1">
642
- <span class="block truncate font-normal text-foreground text-sm">${hit.title}</span>
706
+ <span class="block truncate font-normal text-foreground text-sm">${hit.title}${versionTag}</span>
643
707
  ${excerpt}
644
708
  </span>`;
645
709
  const item: Selectable = { el, hit, kind: "link", url: href };
@@ -0,0 +1,39 @@
1
+ ---
2
+ import { withBase } from "../islands/base-path.ts";
3
+ import Icon from "../Icon.astro";
4
+
5
+ // The "you're viewing an old version" notice, shared by RootLayout and
6
+ // ReferenceLayout so every shell shows the same bar on archived pages. Unlike
7
+ // the announcement Banner it is never dismissible — the reader should know
8
+ // they're on frozen docs for as long as they are. The message and link label
9
+ // arrive fully resolved (localized, `{version}` substituted) from the
10
+ // catch-all, so this component carries no string fallbacks of its own.
11
+ interface Props {
12
+ notice: {
13
+ message: string;
14
+ /** The same page in the latest docs when it exists, else the docs root. */
15
+ latestHref: string;
16
+ latestLabel: string;
17
+ } | null;
18
+ }
19
+
20
+ const { notice } = Astro.props;
21
+ ---
22
+
23
+ {
24
+ notice && (
25
+ <div
26
+ class="flex items-center justify-center gap-x-2 gap-y-0.5 border-amber-300 border-b bg-amber-100 px-10 py-2.5 text-center text-amber-900 text-sm max-sm:flex-wrap dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200"
27
+ data-blume-version-banner
28
+ >
29
+ <span>{notice.message}</span>
30
+ <a
31
+ class="inline-flex items-center gap-1 font-medium underline underline-offset-2"
32
+ href={withBase(notice.latestHref)}
33
+ >
34
+ {notice.latestLabel}
35
+ <Icon name="arrow-right" size={14} />
36
+ </a>
37
+ </div>
38
+ )
39
+ }
@@ -19,10 +19,15 @@ interface AnalyticsWindow {
19
19
  }
20
20
 
21
21
  export const track = (event: string, props: TrackProps): void => {
22
- if (typeof window === "undefined") {
22
+ // Read through `globalThis` so an SSR/import-time call sees `undefined`
23
+ // instead of a bare-identifier ReferenceError.
24
+ const browserWindow = globalThis.window;
25
+ if (browserWindow === undefined) {
23
26
  return;
24
27
  }
25
- const w = window as typeof window & AnalyticsWindow;
28
+ // SAFETY: AnalyticsWindow only adds optional provider globals, so any window
29
+ // satisfies the intersection; each provider is feature-checked before use.
30
+ const w = browserWindow as typeof browserWindow & AnalyticsWindow;
26
31
 
27
32
  // Vercel Web Analytics — self-gates to a no-op until `window.va` is set up.
28
33
  vercelTrack(event, props);
@@ -32,7 +37,5 @@ export const track = (event: string, props: TrackProps): void => {
32
37
  w.gtag?.("event", event, props);
33
38
  w.plausible?.(event, { props });
34
39
  // Universal hook for any other integration.
35
- window.dispatchEvent(
36
- new CustomEvent("blume:track", { detail: { event, props } })
37
- );
40
+ w.dispatchEvent(new CustomEvent("blume:track", { detail: { event, props } }));
38
41
  };
@@ -13,7 +13,7 @@ const PATTERNS = [
13
13
  /server rendered html/iu,
14
14
  ];
15
15
 
16
- if (import.meta.env.DEV && typeof window !== "undefined") {
16
+ if (import.meta.env.DEV && "window" in globalThis) {
17
17
  const original = console.error.bind(console);
18
18
  let shown = false;
19
19
  console.error = (...args: unknown[]) => {
@@ -209,10 +209,7 @@ export const sidebarForRoute = (
209
209
  };
210
210
 
211
211
  /** Resolve previous/next pages around the current route. */
212
- export const getPagination = (
213
- flat: FlatPage[],
214
- route: string
215
- ): { prev: FlatPage | null; next: FlatPage | null } => {
212
+ export const getPagination = (flat: FlatPage[], route: string) => {
216
213
  const index = flat.findIndex((page) => page.route === route);
217
214
  if (index === -1) {
218
215
  return { next: null, prev: null };
@@ -1,4 +1,21 @@
1
- import type { ComponentOverride } from "../../core/define-components.ts";
1
+ import type {
2
+ ComponentOverride,
3
+ IslandDescriptor,
4
+ } from "../../core/define-components.ts";
5
+
6
+ /** A leftover path string an override resolved to (see `resolveSlot`). */
7
+ const isPathString = (override: ComponentOverride): override is string =>
8
+ typeof override === "string";
9
+
10
+ /** An `IslandDescriptor` whose `component` is actually present. */
11
+ const isResolvedIsland = (
12
+ override: ComponentOverride
13
+ ): override is IslandDescriptor =>
14
+ typeof override === "object" &&
15
+ override !== null &&
16
+ "component" in override &&
17
+ override.component !== undefined &&
18
+ override.component !== null;
2
19
 
3
20
  /**
4
21
  * Resolve a layout-slot override to the component Astro should render, falling
@@ -15,20 +32,16 @@ export const resolveSlot = <T>(
15
32
  override: ComponentOverride | undefined,
16
33
  fallback: T
17
34
  ): T => {
18
- if (
19
- override === undefined ||
20
- override === null ||
21
- typeof override === "string"
22
- ) {
35
+ if (override === undefined || override === null || isPathString(override)) {
23
36
  return fallback;
24
37
  }
25
- if (
26
- typeof override === "object" &&
27
- "component" in override &&
28
- override.component !== undefined &&
29
- override.component !== null
30
- ) {
38
+ if (isResolvedIsland(override)) {
39
+ // SAFETY: `ComponentReference` is untyped (`unknown`); the generated
40
+ // components map stores real components for this slot, so the descriptor's
41
+ // component is renderable as the slot's component type.
31
42
  return override.component as T;
32
43
  }
44
+ // SAFETY: same untyped `ComponentReference` — a bare value here is the
45
+ // imported component the config referenced for this slot.
33
46
  return override as T;
34
47
  };
@@ -8,6 +8,7 @@ interface AlgoliaRecord {
8
8
  title: string;
9
9
  description?: string;
10
10
  content?: string;
11
+ version?: string;
11
12
  }
12
13
 
13
14
  /**
@@ -27,15 +28,24 @@ export const createSearch = (opts: {
27
28
  hitsPerPage: SEARCH_LIMIT,
28
29
  indexName: opts.indexName,
29
30
  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
- }),
31
+ // The sync uploads `locale` and `version` on every record so a
32
+ // site can scope hosted results to the active language and the
33
+ // viewed docs version (the current docs upload as "current").
34
+ ...(() => {
35
+ const facetFilters = [
36
+ ...(options?.locale ? [`locale:${options.locale}`] : []),
37
+ ...(options?.version === undefined
38
+ ? []
39
+ : [`version:${options.version || "current"}`]),
40
+ ];
41
+ return facetFilters.length > 0 ? { facetFilters } : {};
42
+ })(),
35
43
  },
36
44
  ],
37
45
  });
38
46
  const [first] = results;
47
+ // SAFETY: the build-time sync uploads every record in the AlgoliaRecord
48
+ // shape, so hits returned by that index carry those fields.
39
49
  const records =
40
50
  first && "hits" in first ? (first.hits as AlgoliaRecord[]) : [];
41
51
  const hits = records.map((record) => ({
@@ -46,6 +56,9 @@ export const createSearch = (opts: {
46
56
  ),
47
57
  title: highlight(record.title, query),
48
58
  url: record.url,
59
+ // Records store the current docs' version as "current" (hosted backends
60
+ // treat empty facet values unreliably); the hit contract uses "".
61
+ version: record.version === "current" ? "" : record.version,
49
62
  }));
50
63
  return { hits, sections: [] };
51
64
  };
@@ -18,6 +18,9 @@ export const createSearch =
18
18
  if (!response.ok) {
19
19
  return { hits: [], sections: [] };
20
20
  }
21
+ // SAFETY: the endpoint is Blume-generated (`search-endpoint` template) and
22
+ // responds with the SearchHit list it built; title/excerpt are still
23
+ // escaped below before the dialog injects them as HTML.
21
24
  const records = (await response.json()) as SearchHit[];
22
25
  const hits = records.slice(0, SEARCH_LIMIT).map((hit) => ({
23
26
  ...hit,
@@ -3,6 +3,16 @@ import { Document } from "flexsearch";
3
3
  import { buildResult, RESULT_POOL } from "./types.ts";
4
4
  import type { IndexedDocument, SearchFn } from "./types.ts";
5
5
 
6
+ /**
7
+ * The fields FlexSearch indexes. An anonymous alias of the indexed fields:
8
+ * unlike the `IndexedDocument` interface it satisfies FlexSearch's
9
+ * index-signature `DocumentData` constraint structurally.
10
+ */
11
+ type SearchDocument = Pick<
12
+ IndexedDocument,
13
+ "content" | "description" | "route" | "title"
14
+ >;
15
+
6
16
  /**
7
17
  * FlexSearch: reuse the same static `blume-search.json` Orama ships, but build
8
18
  * a FlexSearch document index in the browser. Keyless; works in dev and build.
@@ -13,17 +23,17 @@ export const createSearch = async (opts: {
13
23
  indexUrl: string;
14
24
  }): Promise<SearchFn> => {
15
25
  const response = await fetch(opts.indexUrl);
26
+ // SAFETY: `blume-search.json` is generated by our own search indexer, which
27
+ // writes exactly this document shape.
16
28
  const documents = (await response.json()) as IndexedDocument[];
17
29
  const byRoute = new Map(documents.map((doc) => [doc.route, doc]));
18
30
 
19
- const index = new Document({
31
+ const index = new Document<SearchDocument>({
20
32
  document: { id: "route", index: ["title", "description", "content"] },
21
33
  tokenize: "forward",
22
34
  });
23
35
  for (const doc of documents) {
24
- // FlexSearch's `DocumentData` is an index-signature type; our concrete
25
- // record satisfies it structurally but TS needs the cast.
26
- index.add(doc as unknown as Record<string, string>);
36
+ index.add(doc);
27
37
  }
28
38
 
29
39
  // FlexSearch's in-memory search is synchronous, so the SearchFn resolves
@@ -40,9 +50,15 @@ export const createSearch = async (opts: {
40
50
  }
41
51
  seen.add(route);
42
52
  const doc = byRoute.get(route);
43
- // Filter to the active locale (when one is requested) before shaping,
44
- // so section counts and results stay within the language.
45
- if (doc && (!options?.locale || doc.locale === options.locale)) {
53
+ // Filter to the active locale and version (when requested) before
54
+ // shaping, so section counts and results stay within scope. `""` is
55
+ // the current version, so version presence is tested explicitly.
56
+ if (
57
+ doc &&
58
+ (!options?.locale || doc.locale === options.locale) &&
59
+ (options?.version === undefined ||
60
+ (doc.version ?? "") === options.version)
61
+ ) {
46
62
  matched.push(doc);
47
63
  }
48
64
  }
@@ -31,7 +31,7 @@ export const createSearch = (opts: {
31
31
  ...(options?.locale && { where: { locale: options.locale } }),
32
32
  });
33
33
  const hits = (results?.hits ?? []).map((hit) => {
34
- const doc = hit.document as unknown as OramaCloudRecord;
34
+ const doc: OramaCloudRecord = hit.document;
35
35
  return {
36
36
  content: doc.content ?? "",
37
37
  excerpt: highlight(
@@ -19,13 +19,16 @@ export const createSearch = async (opts: {
19
19
  locale?: string;
20
20
  }): Promise<SearchFn> => {
21
21
  const response = await fetch(opts.indexUrl);
22
+ // SAFETY: `blume-search.json` is generated by our own indexer
23
+ // (`buildSearchDocuments`), which writes exactly this document shape.
22
24
  const documents = (await response.json()) as IndexedDocument[];
23
25
  const db = await buildOramaIndex(documents, opts.locale);
24
26
 
25
27
  return async (query, options) => {
26
28
  const docs = await queryOramaIndex(db, query, RESULT_POOL, {
27
29
  locale: options?.locale,
30
+ version: options?.version,
28
31
  });
29
- return buildResult(docs as IndexedDocument[], query, options?.section);
32
+ return buildResult(docs, query, options?.section);
30
33
  };
31
34
  };
@@ -23,6 +23,8 @@ export const createSearch = async (opts: {
23
23
  }): Promise<SearchFn> => {
24
24
  // The pagefind bundle lives in the built site (not node_modules) and is
25
25
  // resolved at runtime by URL — it can't be a static, code-splittable path.
26
+ // SAFETY: the URL points at the `pagefind.js` module our own build emitted,
27
+ // whose export contract (`search()`) is fixed by pagefind.
26
28
  // oxlint-disable-next-line react-doctor/no-dynamic-import-path
27
29
  const pagefind = (await import(
28
30
  /* @vite-ignore */
@@ -13,6 +13,11 @@ export interface SearchHit {
13
13
  section?: string;
14
14
  /** Plain-text page content, used to render the preview pane. */
15
15
  content?: string;
16
+ /**
17
+ * Docs version the hit belongs to (`""` = current). Local indexes and the
18
+ * hosted Algolia/Typesense adapters set it; other providers leave it unset.
19
+ */
20
+ version?: string;
16
21
  }
17
22
 
18
23
  /** A category pill with its result count. */
@@ -30,7 +35,12 @@ export interface SearchResult {
30
35
  /** A configured query function — the common contract every provider returns. */
31
36
  export type SearchFn = (
32
37
  query: string,
33
- options?: { section?: string; locale?: string }
38
+ options?: {
39
+ section?: string;
40
+ locale?: string;
41
+ /** Docs version to scope to (`""` = current); omitted disables it. */
42
+ version?: string;
43
+ }
34
44
  ) => Promise<SearchResult>;
35
45
 
36
46
  /** A document in the client-loaded `blume-search.json` index. */
@@ -42,6 +52,7 @@ export interface IndexedDocument {
42
52
  breadcrumb?: string[];
43
53
  section?: string;
44
54
  locale?: string;
55
+ version?: string;
45
56
  }
46
57
 
47
58
  /** Max results surfaced in the dialog. */
@@ -193,6 +204,7 @@ export const buildResult = (
193
204
  section: doc.section ?? "",
194
205
  title: highlight(doc.title, query),
195
206
  url: doc.route,
207
+ version: doc.version,
196
208
  }));
197
209
  return { hits, sections };
198
210
  };
@@ -8,6 +8,7 @@ interface TypesenseRecord extends Record<string, unknown> {
8
8
  title: string;
9
9
  description?: string;
10
10
  content?: string;
11
+ version?: string;
11
12
  }
12
13
 
13
14
  /**
@@ -40,9 +41,20 @@ export const createSearch = (opts: {
40
41
  per_page: SEARCH_LIMIT,
41
42
  q: query,
42
43
  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}` }),
44
+ // The sync marks `locale` and `version` as facets so hosted results
45
+ // scope to the active language and the viewed docs version (the
46
+ // current docs upload as "current").
47
+ ...(() => {
48
+ const clauses = [
49
+ ...(options?.locale ? [`locale:=${options.locale}`] : []),
50
+ ...(options?.version === undefined
51
+ ? []
52
+ : [`version:=${options.version || "current"}`]),
53
+ ];
54
+ return clauses.length > 0
55
+ ? { filter_by: clauses.join(" && ") }
56
+ : {};
57
+ })(),
46
58
  },
47
59
  {}
48
60
  );
@@ -56,6 +68,10 @@ export const createSearch = (opts: {
56
68
  ),
57
69
  title: highlight(doc.title, query),
58
70
  url: doc.url,
71
+ // Records store the current docs' version as "current" (hosted
72
+ // backends treat empty facet values unreliably); the hit contract
73
+ // uses "".
74
+ version: doc.version === "current" ? "" : doc.version,
59
75
  };
60
76
  });
61
77
  return { hits, sections: [] };