blume 1.4.3 → 1.5.1

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 (204) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +1784 -633
  4. package/dist/cli/index.js.map +111 -106
  5. package/dist/types/ai/component-markdown.d.ts +14 -4
  6. package/dist/types/core/config-input.d.ts +80 -28
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +19 -3
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +112 -15
  12. package/dist/types/core/sources/types.d.ts +3 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +6 -5
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/dist/types/theme/fonts.d.ts +11 -2
  19. package/docs/advanced/api-reference.mdx +8 -6
  20. package/docs/advanced/custom-pages.mdx +5 -1
  21. package/docs/configuration/index.mdx +1 -1
  22. package/docs/configuration/search.mdx +2 -0
  23. package/docs/configuration/seo.mdx +1 -1
  24. package/docs/configuration/theming.mdx +4 -2
  25. package/docs/content/i18n.mdx +1 -1
  26. package/docs/content/meta.mdx +2 -1
  27. package/docs/content/meta.ts +1 -0
  28. package/docs/content/navigation.mdx +35 -1
  29. package/docs/content/versioning.mdx +106 -0
  30. package/docs/reference/cli.mdx +2 -1
  31. package/docs/reference/frontmatter.mdx +3 -0
  32. package/package.json +3 -1
  33. package/skills/blume-migrate/SKILL.md +2 -2
  34. package/skills/blume-migrate/references/docusaurus.md +1 -1
  35. package/skills/blume-migrate/references/fumadocs.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +1 -1
  37. package/src/ai/agent-readability.ts +37 -10
  38. package/src/ai/ask-context.ts +5 -1
  39. package/src/ai/ask.ts +10 -1
  40. package/src/ai/component-markdown.ts +80 -43
  41. package/src/ai/llms.ts +40 -16
  42. package/src/ai/mcp/data.ts +48 -12
  43. package/src/ai/mcp/discovery.ts +28 -11
  44. package/src/ai/mcp/server.ts +183 -38
  45. package/src/ai/mcp/tools.ts +3 -3
  46. package/src/ai/skills.ts +32 -9
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +6 -2
  50. package/src/astro/generate.ts +64 -34
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +16 -9
  53. package/src/astro/templates.ts +181 -40
  54. package/src/audit/agent.ts +2 -2
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +37 -9
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +5 -2
  64. package/src/audit/snapshot.ts +2 -4
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +9 -4
  68. package/src/cli/commands/build.ts +15 -9
  69. package/src/cli/commands/dev.ts +2 -0
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eval.ts +7 -3
  72. package/src/cli/commands/init.ts +9 -9
  73. package/src/cli/commands/mcp-stdio.ts +3 -0
  74. package/src/cli/commands/translate.ts +14 -3
  75. package/src/cli/commands/version.ts +85 -0
  76. package/src/cli/dev-lock.ts +31 -10
  77. package/src/cli/eject-scripts.ts +17 -2
  78. package/src/cli/index.ts +2 -0
  79. package/src/cli/init/questions.ts +1 -1
  80. package/src/cli/init/scaffold.ts +22 -15
  81. package/src/cli/internal-error.ts +1 -0
  82. package/src/components/content/auto-type-table.ts +3 -0
  83. package/src/components/content/diff.ts +9 -5
  84. package/src/components/content/github-info.ts +2 -0
  85. package/src/components/islands/ask-ai.tsx +33 -25
  86. package/src/components/islands/hooks.ts +5 -1
  87. package/src/components/islands/webmcp.ts +49 -12
  88. package/src/components/layout/Fonts.astro +23 -3
  89. package/src/components/layout/Header.astro +25 -1
  90. package/src/components/layout/NavSelector.astro +11 -2
  91. package/src/components/layout/NavTree.astro +4 -2
  92. package/src/components/layout/PageLayout.astro +72 -3
  93. package/src/components/layout/ReferenceLayout.astro +2 -1
  94. package/src/components/layout/RootLayout.astro +20 -1
  95. package/src/components/layout/Search.astro +77 -13
  96. package/src/components/layout/VersionBanner.astro +39 -0
  97. package/src/components/layout/analytics-client.ts +8 -5
  98. package/src/components/layout/hydration-hint.ts +1 -1
  99. package/src/components/layout/nav-utils.ts +1 -4
  100. package/src/components/layout/overrides.ts +25 -12
  101. package/src/components/layout/search/algolia.ts +18 -5
  102. package/src/components/layout/search/endpoint.ts +3 -0
  103. package/src/components/layout/search/flexsearch.ts +23 -7
  104. package/src/components/layout/search/orama-cloud.ts +1 -1
  105. package/src/components/layout/search/orama.ts +4 -1
  106. package/src/components/layout/search/pagefind.ts +2 -0
  107. package/src/components/layout/search/types.ts +13 -1
  108. package/src/components/layout/search/typesense.ts +19 -3
  109. package/src/components/openapi/ApiOverview.astro +32 -6
  110. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  111. package/src/components/openapi/Bindings.astro +89 -0
  112. package/src/components/openapi/MethodBadge.astro +3 -0
  113. package/src/components/openapi/Operation.astro +7 -2
  114. package/src/components/openapi/PanelTabs.astro +131 -0
  115. package/src/components/openapi/ParametersTable.astro +2 -0
  116. package/src/components/openapi/RequestPanel.astro +12 -119
  117. package/src/components/openapi/async-snippets.ts +174 -0
  118. package/src/components/openapi/async.ts +348 -0
  119. package/src/components/openapi/helpers.ts +52 -20
  120. package/src/components/openapi/security.ts +102 -29
  121. package/src/components/openapi/snippets.ts +11 -11
  122. package/src/core/component-overrides.ts +28 -23
  123. package/src/core/config-input.ts +89 -28
  124. package/src/core/config.ts +20 -7
  125. package/src/core/content.ts +3 -1
  126. package/src/core/data.ts +19 -3
  127. package/src/core/define-components.ts +5 -0
  128. package/src/core/diagnostics.ts +46 -38
  129. package/src/core/frontmatter.ts +33 -7
  130. package/src/core/graph.ts +137 -53
  131. package/src/core/i18n-ui.ts +15 -0
  132. package/src/core/i18n.ts +16 -8
  133. package/src/core/last-modified.ts +49 -0
  134. package/src/core/load-module.ts +1 -0
  135. package/src/core/manifest.ts +92 -3
  136. package/src/core/meta.ts +44 -14
  137. package/src/core/nav-diagnostics.ts +3 -3
  138. package/src/core/navigation.ts +247 -67
  139. package/src/core/project-graph.ts +26 -3
  140. package/src/core/schema.ts +214 -68
  141. package/src/core/sources/assets.ts +2 -0
  142. package/src/core/sources/cache.ts +6 -0
  143. package/src/core/sources/github-releases.ts +39 -31
  144. package/src/core/sources/mdx-remote.ts +4 -0
  145. package/src/core/sources/normalize.ts +67 -20
  146. package/src/core/sources/notion.ts +49 -17
  147. package/src/core/sources/portable-text.ts +32 -11
  148. package/src/core/sources/sanity.ts +68 -14
  149. package/src/core/sources/types.ts +4 -0
  150. package/src/core/sources/watch.ts +1 -1
  151. package/src/core/standard-schema.ts +9 -3
  152. package/src/core/text-width.ts +26 -0
  153. package/src/core/tsconfig-aliases.ts +9 -5
  154. package/src/core/types.ts +45 -2
  155. package/src/core/ui-packs/index.ts +9 -1
  156. package/src/core/version-cut.ts +301 -0
  157. package/src/core/version.ts +2 -0
  158. package/src/core/versions.ts +170 -0
  159. package/src/deploy/adapter-output.ts +5 -2
  160. package/src/deploy/cloudflare-negotiation.ts +25 -10
  161. package/src/deploy/sitemap.ts +33 -1
  162. package/src/deploy/vercel-negotiation.ts +45 -18
  163. package/src/eval/report.ts +4 -4
  164. package/src/eval/run.ts +2 -2
  165. package/src/eval/schema.ts +1 -1
  166. package/src/markdown/base-links.ts +6 -6
  167. package/src/markdown/directives.ts +7 -1
  168. package/src/markdown/heading-anchors.ts +17 -6
  169. package/src/markdown/index.ts +73 -24
  170. package/src/markdown/inline-code.ts +14 -2
  171. package/src/markdown/language-icon.ts +6 -2
  172. package/src/markdown/mdast.ts +18 -4
  173. package/src/markdown/package-commands.ts +6 -8
  174. package/src/markdown/table-wrap.ts +4 -1
  175. package/src/markdown/twoslash.ts +2 -0
  176. package/src/og/card.ts +33 -12
  177. package/src/og/derive.ts +43 -27
  178. package/src/openapi/asyncapi.ts +366 -0
  179. package/src/openapi/model.ts +126 -57
  180. package/src/openapi/parse.ts +97 -5
  181. package/src/openapi/references.ts +12 -10
  182. package/src/openapi/render-mdx.ts +73 -34
  183. package/src/openapi/scalar.ts +6 -8
  184. package/src/openapi/source.ts +98 -28
  185. package/src/registry/eject.ts +7 -2
  186. package/src/search/documents.ts +25 -5
  187. package/src/search/facets.ts +7 -5
  188. package/src/search/orama-index.ts +66 -20
  189. package/src/search/popular.ts +10 -5
  190. package/src/search/providers.ts +2 -2
  191. package/src/search/sync/index.ts +2 -0
  192. package/src/search/sync/typesense.ts +4 -2
  193. package/src/seo/jsonld.ts +24 -6
  194. package/src/seo/x-handle.ts +8 -3
  195. package/src/theme/chrome-icons.ts +7 -2
  196. package/src/theme/entry.ts +24 -2
  197. package/src/theme/fonts.ts +83 -7
  198. package/src/theme/icons.ts +4 -2
  199. package/src/theme/palette.ts +22 -14
  200. package/src/translate/meta.ts +15 -6
  201. package/src/translate/report.ts +9 -5
  202. package/src/translate/run.ts +10 -4
  203. package/src/translate/validate.ts +52 -17
  204. package/src/translate/work-list.ts +0 -0
@@ -5,7 +5,11 @@ import { withBase } from "../islands/base-path.ts";
5
5
  import type { ComponentOverride } from "../../core/define-components.ts";
6
6
  import { EN_UI } from "../../core/i18n-ui.ts";
7
7
  import type { UIStrings } from "../../core/i18n-ui.ts";
8
- import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
8
+ import type {
9
+ LocaleSwitchOption,
10
+ Navigation,
11
+ NavSelector as NavSelectorConfig,
12
+ } from "../../core/types.ts";
9
13
  import { GITHUB_MARK } from "../github-mark.ts";
10
14
  import Icon from "../Icon.astro";
11
15
  import LanguageSwitcher from "./LanguageSwitcher.astro";
@@ -50,8 +54,16 @@ interface Props {
50
54
  /** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
51
55
  navStrings?: UIStrings["nav"];
52
56
  localeSwitch?: LocaleSwitchOption[];
57
+ /**
58
+ * Auto-populated version switcher, rendered ahead of the configured
59
+ * selectors. `null`/absent when versioning is off — or when the user
60
+ * declares their own `kind: "version"` selector, which then owns the UI.
61
+ */
62
+ versionSelector?: NavSelectorConfig | null;
53
63
  /** Active locale for per-language search filtering. */
54
64
  searchLocale?: string;
65
+ /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
66
+ searchVersion?: string | null;
55
67
  /**
56
68
  * Layout-slot overrides forwarded from the root layout. The header honors
57
69
  * `Logo` and `Search` here so those pieces can be replaced without swapping
@@ -77,7 +89,9 @@ const {
77
89
  switcherStrings,
78
90
  navStrings,
79
91
  localeSwitch,
92
+ versionSelector,
80
93
  searchLocale,
94
+ searchVersion = null,
81
95
  layout = {},
82
96
  } = Astro.props;
83
97
 
@@ -167,6 +181,15 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
167
181
  )
168
182
  }
169
183
  <div class="flex-1"></div>
184
+ {
185
+ /* Right-aligned next to the language switcher: the spacer absorbs its
186
+ width, so pages without a version selector (blog, generated references)
187
+ keep the logo, tabs, and the rest of this cluster in place — no layout
188
+ shift when crossing into the docs. */
189
+ versionSelector && (
190
+ <NavSelector align="end" route={route} selector={versionSelector} />
191
+ )
192
+ }
170
193
  {
171
194
  localeSwitch && localeSwitch.length > 1 && (
172
195
  <LanguageSwitcher
@@ -184,6 +207,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
184
207
  navigation={navigation}
185
208
  popularPages={data.config.search.popular}
186
209
  strings={searchStrings}
210
+ version={searchVersion}
187
211
  />
188
212
  )
189
213
  }
@@ -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()];
@@ -24,12 +24,14 @@ import type {
24
24
  } from "../../core/data.ts";
25
25
  import { EN_UI } from "../../core/i18n-ui.ts";
26
26
  import type { UIStrings } from "../../core/i18n-ui.ts";
27
+ import type { FontHead } from "../../theme/fonts.ts";
27
28
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
28
29
  import {
29
30
  OG_IMAGE_HEIGHT,
30
31
  OG_IMAGE_TYPE,
31
32
  OG_IMAGE_WIDTH,
32
33
  } from "../../og/dimensions.ts";
34
+ import { buildStructuredData } from "../../seo/jsonld.ts";
33
35
  import { normalizeXHandle } from "../../seo/x-handle.ts";
34
36
  import { withBase } from "../islands/base-path.ts";
35
37
  import "blume:theme";
@@ -57,7 +59,7 @@ interface Props {
57
59
  */
58
60
  page?: { title?: string; description?: string; route?: string };
59
61
  themeMode: "system" | "light" | "dark";
60
- fontCssVars?: string[];
62
+ fontCssVars?: (string | FontHead)[];
61
63
  searchEnabled: boolean;
62
64
  /**
63
65
  * Opt this page out of the header's Ask AI trigger. Defaults to whether Ask
@@ -75,7 +77,24 @@ interface Props {
75
77
  ogEnabled?: boolean;
76
78
  /** SEO overrides; a marketing page often sets its own canonical/og image. */
77
79
  ogImage?: string | null;
80
+ /**
81
+ * Alt text for a user-supplied `ogImage`, emitted as `og:image:alt` /
82
+ * `twitter:image:alt`. The generated card derives its own from the title.
83
+ */
84
+ ogImageAlt?: string;
85
+ /**
86
+ * Pixel size of a user-supplied `ogImage`, emitted as `og:image:width` /
87
+ * `og:image:height` so crawlers can lay the card out before fetching it.
88
+ * The generated card declares its known size automatically.
89
+ */
90
+ ogImageSize?: { height: number; width: number };
78
91
  canonical?: string | null;
92
+ /**
93
+ * Emit schema.org JSON-LD for this page (`data.config.structuredData`) — a
94
+ * WebSite node, plus an article node on non-root routes. Defaults to on,
95
+ * matching RootLayout.
96
+ */
97
+ structuredDataEnabled?: boolean;
79
98
  /**
80
99
  * X (Twitter) attribution (`data.config.x`): the site's account and an author
81
100
  * handle, emitted as `twitter:site`/`twitter:creator`.
@@ -113,7 +132,10 @@ const {
113
132
  siteUrl,
114
133
  ogEnabled,
115
134
  ogImage,
135
+ ogImageAlt,
136
+ ogImageSize,
116
137
  canonical,
138
+ structuredDataEnabled,
117
139
  x,
118
140
  noindex,
119
141
  locale = "en",
@@ -153,11 +175,12 @@ const basedRoute = withBase(route);
153
175
  // don't come out double-slashed — the catch-all strips it the same way.
154
176
  const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
155
177
  // The route-derived part is percent-encoded (the sitemap convention) so a
156
- // Unicode route slug yields a legal URI that byte-matches the sitemap <loc>.
178
+ // Unicode route slug yields a legal URI that byte-matches the sitemap <loc> —
179
+ // including the root, whose <loc> is `https://site/` with the slash.
157
180
  const resolvedCanonical =
158
181
  canonical ??
159
182
  (siteBase
160
- ? `${siteBase}${basedRoute === "/" ? "" : encodeURI(basedRoute)}`
183
+ ? `${siteBase}${basedRoute === "/" ? "/" : encodeURI(basedRoute)}`
161
184
  : null);
162
185
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
163
186
  // `public/`) is resolved against the site URL so crawlers get an absolute
@@ -180,6 +203,25 @@ const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
180
203
  const xSite = normalizeXHandle(x?.handle);
181
204
  const xCreator = normalizeXHandle(x?.creator);
182
205
 
206
+ // JSON-LD, mirroring RootLayout: skipped when disabled or noindexed. A custom
207
+ // page has no breadcrumb trail; the home route yields the WebSite node alone.
208
+ const structuredData =
209
+ structuredDataEnabled === false || noindex
210
+ ? null
211
+ : buildStructuredData({
212
+ base: import.meta.env.BASE_URL,
213
+ breadcrumbs: [],
214
+ description,
215
+ locale,
216
+ route,
217
+ siteName: site.title,
218
+ siteUrl: siteUrl ?? null,
219
+ title: pageTitle,
220
+ });
221
+ const structuredDataJson = structuredData
222
+ ? JSON.stringify(structuredData).replaceAll("<", "\\u003c")
223
+ : null;
224
+
183
225
  const bannerKey = banner?.dismissible ? banner.key : null;
184
226
  ---
185
227
 
@@ -218,8 +260,26 @@ const bannerKey = banner?.dismissible ? banner.key : null;
218
260
  <meta content={pageTitle} property="og:image:alt" />
219
261
  </>
220
262
  )}
263
+ {!ogGenerated && ogImageSize && (
264
+ <>
265
+ <meta
266
+ content={String(ogImageSize.width)}
267
+ property="og:image:width"
268
+ />
269
+ <meta
270
+ content={String(ogImageSize.height)}
271
+ property="og:image:height"
272
+ />
273
+ </>
274
+ )}
275
+ {!ogGenerated && ogImageAlt && (
276
+ <meta content={ogImageAlt} property="og:image:alt" />
277
+ )}
221
278
  <meta content={resolvedOgImage} name="twitter:image" />
222
279
  {ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
280
+ {!ogGenerated && ogImageAlt && (
281
+ <meta content={ogImageAlt} name="twitter:image:alt" />
282
+ )}
223
283
  </>
224
284
  )
225
285
  }
@@ -228,6 +288,15 @@ const bannerKey = banner?.dismissible ? banner.key : null;
228
288
  {description && <meta content={description} name="twitter:description" />}
229
289
  {xSite && <meta content={xSite} name="twitter:site" />}
230
290
  {xCreator && <meta content={xCreator} name="twitter:creator" />}
291
+ {
292
+ structuredDataJson && (
293
+ <script
294
+ is:inline
295
+ set:html={structuredDataJson}
296
+ type="application/ld+json"
297
+ />
298
+ )
299
+ }
231
300
  {
232
301
  bannerKey && (
233
302
  <script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
@@ -2,6 +2,7 @@
2
2
  import "blume:theme";
3
3
  import { EN_UI } from "../../core/i18n-ui.ts";
4
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
5
+ import type { FontHead } from "../../theme/fonts.ts";
5
6
  import type { Navigation } from "../../core/types.ts";
6
7
  import Analytics from "./Analytics.astro";
7
8
  import WebMcp from "./WebMcp.astro";
@@ -51,7 +52,7 @@ interface Props {
51
52
  navigation: Navigation;
52
53
  route: string;
53
54
  themeMode: "system" | "light" | "dark";
54
- fontCssVars?: string[];
55
+ fontCssVars?: (string | FontHead)[];
55
56
  searchEnabled: boolean;
56
57
  pageTitle: string;
57
58
  /** Keep the reference route out of crawler indexes. */
@@ -8,8 +8,10 @@ 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";
14
+ import type { FontHead } from "../../theme/fonts.ts";
13
15
  import type { ComponentOverride } from "../../core/define-components.ts";
14
16
  import {
15
17
  OG_IMAGE_HEIGHT,
@@ -46,6 +48,7 @@ import PageActions from "./PageActions.astro";
46
48
  import PageFeedback from "./PageFeedback.astro";
47
49
  import Pagination from "./Pagination.astro";
48
50
  import TableOfContents from "./TableOfContents.astro";
51
+ import VersionBanner from "./VersionBanner.astro";
49
52
 
50
53
  interface Props {
51
54
  site: { title: string; description?: string };
@@ -88,7 +91,7 @@ interface Props {
88
91
  imageZoom?: boolean;
89
92
  codeWrap?: boolean;
90
93
  themeMode: "system" | "light" | "dark";
91
- fontCssVars?: string[];
94
+ fontCssVars?: (string | FontHead)[];
92
95
  searchEnabled: boolean;
93
96
  indexable: boolean;
94
97
  ogImage?: string | null;
@@ -149,6 +152,16 @@ interface Props {
149
152
  xDefault?: string | null;
150
153
  /** Language-switcher entries for the current page. */
151
154
  localeSwitch?: LocaleSwitchOption[];
155
+ /** Auto-populated version switcher (`null` when versioning is off or a user selector owns it). */
156
+ versionSelector?: NavSelectorType | null;
157
+ /** Old-version notice for archived pages (`null` on current-docs pages). */
158
+ versionNotice?: {
159
+ message: string;
160
+ latestHref: string;
161
+ latestLabel: string;
162
+ } | null;
163
+ /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
164
+ searchVersion?: string | null;
152
165
  /**
153
166
  * User layout-slot overrides from `components.ts` (`defineComponents`). Each
154
167
  * key replaces the matching built-in; unknown keys are ignored. Wired slots:
@@ -220,6 +233,9 @@ const {
220
233
  localeAlternates,
221
234
  xDefault,
222
235
  localeSwitch,
236
+ versionSelector,
237
+ versionNotice,
238
+ searchVersion = null,
223
239
  layout = {},
224
240
  clientData,
225
241
  toc = { enabled: true, maxLevel: 3, minLevel: 2 },
@@ -499,16 +515,19 @@ const bannerKey = banner?.dismissible ? banner.key : null;
499
515
  href="#blume-content">{strings.page.skipToContent}</a
500
516
  >
501
517
  <Banner banner={banner} strings={strings.banner} />
518
+ <VersionBanner notice={versionNotice ?? null} />
502
519
  <HeaderSlot
503
520
  askEnabled={askEnabled}
504
521
  askStrings={strings.ask}
505
522
  layout={layout}
506
523
  localeSwitch={localeSwitch}
524
+ versionSelector={versionSelector}
507
525
  logo={logo}
508
526
  navigation={navigation}
509
527
  route={page.route}
510
528
  searchEnabled={searchEnabled}
511
529
  searchLocale={searchLocale}
530
+ searchVersion={searchVersion}
512
531
  navStrings={navStrings}
513
532
  searchStrings={strings.search}
514
533
  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
  };