blume 0.7.0 → 1.0.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 (185) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1852 -380
  5. package/dist/cli/index.js.map +98 -91
  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 +9 -0
  9. package/dist/types/core/config-input.d.ts +47 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +7 -0
  12. package/dist/types/core/i18n-ui.d.ts +526 -132
  13. package/dist/types/core/schema.d.ts +293 -146
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/dist/types/seo/x-handle.d.ts +12 -0
  17. package/docs/01-quickstart.mdx +5 -2
  18. package/docs/02-deployment.mdx +8 -8
  19. package/docs/03-faq.mdx +46 -16
  20. package/docs/advanced/api-reference.mdx +1 -1
  21. package/docs/advanced/changelog.mdx +1 -1
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/skills.mdx +1 -1
  24. package/docs/configuration/ai.mdx +49 -10
  25. package/docs/configuration/customization.mdx +11 -0
  26. package/docs/configuration/export.mdx +1 -1
  27. package/docs/configuration/index.mdx +27 -3
  28. package/docs/configuration/seo.mdx +35 -5
  29. package/docs/content/components.mdx +2 -2
  30. package/docs/content/i18n.mdx +1 -1
  31. package/docs/content/navigation.mdx +3 -3
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +6 -4
  34. package/docs/index.mdx +2 -2
  35. package/docs/reference/cli.mdx +9 -7
  36. package/docs/reference/frontmatter.mdx +1 -1
  37. package/package.json +22 -4
  38. package/skills/blume/SKILL.md +5 -3
  39. package/skills/blume-update-docs/SKILL.md +3 -2
  40. package/src/ai/agent-readability.ts +9 -8
  41. package/src/ai/ask-context.ts +7 -2
  42. package/src/ai/ask-data.ts +3 -0
  43. package/src/ai/component-markdown.ts +461 -0
  44. package/src/ai/llms.ts +135 -26
  45. package/src/ai/markdown.ts +35 -6
  46. package/src/ai/mcp/data.ts +25 -4
  47. package/src/ai/mcp/discovery.ts +10 -3
  48. package/src/ai/mcp/server.ts +21 -7
  49. package/src/ai/mcp/tools.ts +1 -1
  50. package/src/ai/visibility.ts +74 -0
  51. package/src/astro/component-slots.ts +11 -1
  52. package/src/astro/generate.ts +77 -45
  53. package/src/astro/integration.ts +1 -1
  54. package/src/astro/markdown-negotiation.ts +1 -1
  55. package/src/astro/pages.ts +81 -19
  56. package/src/astro/templates.ts +150 -19
  57. package/src/blume-modules.d.ts +8 -0
  58. package/src/cli/commands/build.ts +120 -23
  59. package/src/cli/commands/check.ts +1 -1
  60. package/src/cli/commands/dev.ts +26 -5
  61. package/src/cli/commands/eject.ts +47 -19
  62. package/src/cli/commands/init.ts +120 -180
  63. package/src/cli/commands/preview.ts +4 -1
  64. package/src/cli/commands/validate.ts +43 -2
  65. package/src/cli/dev-lock.ts +8 -4
  66. package/src/cli/eject-scripts.ts +72 -0
  67. package/src/cli/env.ts +15 -5
  68. package/src/cli/init/questions.ts +158 -0
  69. package/src/cli/init/scaffold.ts +380 -0
  70. package/src/cli/internal-error.ts +9 -4
  71. package/src/cli/prepare.ts +3 -2
  72. package/src/components/Icon.astro +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/Step.astro +10 -1
  77. package/src/components/content/Tabs.astro +15 -3
  78. package/src/components/content/Tile.astro +2 -1
  79. package/src/components/content/Tooltip.astro +3 -1
  80. package/src/components/content/Update.astro +9 -2
  81. package/src/components/content/auto-type-table.ts +7 -1
  82. package/src/components/content/base-href.ts +33 -0
  83. package/src/components/content/changelog-element.ts +9 -2
  84. package/src/components/content/mermaid-element.ts +7 -2
  85. package/src/components/islands/AskAI.astro +5 -2
  86. package/src/components/islands/ask-ai.tsx +86 -11
  87. package/src/components/islands/hooks.ts +28 -8
  88. package/src/components/layout/Banner.astro +10 -2
  89. package/src/components/layout/Breadcrumbs.astro +11 -2
  90. package/src/components/layout/Header.astro +13 -4
  91. package/src/components/layout/Logo.astro +11 -3
  92. package/src/components/layout/NavTree.astro +19 -5
  93. package/src/components/layout/PageActions.astro +25 -10
  94. package/src/components/layout/PageLayout.astro +85 -9
  95. package/src/components/layout/Pagination.astro +10 -4
  96. package/src/components/layout/ReferenceLayout.astro +20 -2
  97. package/src/components/layout/RootLayout.astro +142 -12
  98. package/src/components/layout/Search.astro +117 -27
  99. package/src/components/layout/search/algolia.ts +11 -2
  100. package/src/components/layout/search/endpoint.ts +11 -5
  101. package/src/components/layout/search/orama-cloud.ts +8 -2
  102. package/src/components/layout/search/types.ts +5 -1
  103. package/src/components/layout/search/typesense.ts +4 -1
  104. package/src/components/layout/toc-element.ts +1 -1
  105. package/src/components/openapi/ApiTagOperations.astro +2 -1
  106. package/src/components/openapi/Operation.astro +47 -40
  107. package/src/components/openapi/RequestPanel.astro +1 -1
  108. package/src/components/openapi/helpers.ts +71 -3
  109. package/src/components/openapi/panel.ts +1 -1
  110. package/src/core/base-path.ts +24 -0
  111. package/src/core/builtin-tags.ts +2 -0
  112. package/src/core/config-input.ts +48 -2
  113. package/src/core/config.ts +3 -2
  114. package/src/core/data.ts +4 -0
  115. package/src/core/frontmatter.ts +7 -0
  116. package/src/core/graph.ts +15 -5
  117. package/src/core/i18n-ui.ts +54 -0
  118. package/src/core/i18n.ts +16 -8
  119. package/src/core/last-modified.ts +13 -6
  120. package/src/core/links.ts +32 -8
  121. package/src/core/navigation.ts +29 -4
  122. package/src/core/package-json.ts +17 -2
  123. package/src/core/project-graph.ts +15 -6
  124. package/src/core/schema.ts +71 -2
  125. package/src/core/sources/assets.ts +6 -1
  126. package/src/core/sources/filesystem.ts +4 -0
  127. package/src/core/sources/mdx-remote.ts +23 -14
  128. package/src/core/sources/normalize.ts +152 -50
  129. package/src/core/sources/notion.ts +8 -8
  130. package/src/core/ui-packs/ar.ts +8 -0
  131. package/src/core/ui-packs/bg.ts +8 -0
  132. package/src/core/ui-packs/bn.ts +8 -0
  133. package/src/core/ui-packs/ca.ts +8 -0
  134. package/src/core/ui-packs/cs.ts +8 -0
  135. package/src/core/ui-packs/da.ts +8 -0
  136. package/src/core/ui-packs/de.ts +8 -0
  137. package/src/core/ui-packs/el.ts +8 -0
  138. package/src/core/ui-packs/es.ts +8 -0
  139. package/src/core/ui-packs/fa.ts +8 -0
  140. package/src/core/ui-packs/fi.ts +8 -0
  141. package/src/core/ui-packs/fr.ts +9 -1
  142. package/src/core/ui-packs/he.ts +8 -0
  143. package/src/core/ui-packs/hi.ts +8 -0
  144. package/src/core/ui-packs/hr.ts +8 -0
  145. package/src/core/ui-packs/hu.ts +8 -0
  146. package/src/core/ui-packs/id.ts +8 -0
  147. package/src/core/ui-packs/it.ts +8 -0
  148. package/src/core/ui-packs/ja.ts +8 -0
  149. package/src/core/ui-packs/ko.ts +8 -0
  150. package/src/core/ui-packs/nl.ts +8 -0
  151. package/src/core/ui-packs/no.ts +8 -0
  152. package/src/core/ui-packs/pl.ts +8 -0
  153. package/src/core/ui-packs/pt-br.ts +8 -0
  154. package/src/core/ui-packs/pt.ts +8 -0
  155. package/src/core/ui-packs/ro.ts +8 -0
  156. package/src/core/ui-packs/ru.ts +8 -0
  157. package/src/core/ui-packs/sk.ts +8 -0
  158. package/src/core/ui-packs/sr.ts +8 -0
  159. package/src/core/ui-packs/sv.ts +8 -0
  160. package/src/core/ui-packs/th.ts +8 -0
  161. package/src/core/ui-packs/tr.ts +8 -0
  162. package/src/core/ui-packs/uk.ts +8 -0
  163. package/src/core/ui-packs/vi.ts +8 -0
  164. package/src/core/ui-packs/zh-tw.ts +8 -0
  165. package/src/core/ui-packs/zh.ts +8 -0
  166. package/src/deploy/adapter-output.ts +18 -8
  167. package/src/deploy/redirects.ts +7 -2
  168. package/src/deploy/sitemap.ts +53 -11
  169. package/src/index.ts +5 -0
  170. package/src/markdown/base-links.ts +10 -8
  171. package/src/markdown/index.ts +15 -3
  172. package/src/markdown/inline-code.ts +7 -2
  173. package/src/markdown/package-commands.ts +10 -4
  174. package/src/og/card.ts +4 -2
  175. package/src/og/dimensions.ts +12 -0
  176. package/src/openapi/model.ts +12 -4
  177. package/src/openapi/parse.ts +21 -0
  178. package/src/openapi/references.ts +38 -8
  179. package/src/openapi/render-mdx.ts +62 -1
  180. package/src/openapi/source.ts +59 -10
  181. package/src/registry/eject.ts +184 -12
  182. package/src/registry/registry.ts +0 -3
  183. package/src/search/documents.ts +34 -2
  184. package/src/seo/jsonld.ts +20 -13
  185. package/src/seo/x-handle.ts +18 -0
@@ -9,7 +9,13 @@ import type {
9
9
  } from "../../core/types.ts";
10
10
  import "blume:theme";
11
11
  import type { ComponentOverride } from "../../core/define-components.ts";
12
- import { buildStructuredData } from "../../seo/jsonld.ts";
12
+ import {
13
+ OG_IMAGE_HEIGHT,
14
+ OG_IMAGE_TYPE,
15
+ OG_IMAGE_WIDTH,
16
+ } from "../../og/dimensions.ts";
17
+ import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
18
+ import { normalizeXHandle } from "../../seo/x-handle.ts";
13
19
  import { withBase } from "../islands/base-path.ts";
14
20
  import Analytics from "./Analytics.astro";
15
21
  import Banner from "./Banner.astro";
@@ -79,6 +85,19 @@ interface Props {
79
85
  searchEnabled: boolean;
80
86
  indexable: boolean;
81
87
  ogImage?: string | null;
88
+ /**
89
+ * Whether `ogImage` is Blume's generated card (rather than a user `seo.image`).
90
+ * Only the generated card has known dimensions and format, so `og:image:width`
91
+ * / `og:image:height` / `og:image:type` are declared for it alone — asserting
92
+ * 1200x630 PNG over someone's arbitrary JPEG would be a lie crawlers act on.
93
+ */
94
+ ogGenerated?: boolean;
95
+ /**
96
+ * X (Twitter) attribution: `handle` is the site's account (`twitter:site`),
97
+ * `creator` the author's (`twitter:creator`). The rest of the X card is read
98
+ * from `og:*`, so these are the only values X can't infer.
99
+ */
100
+ x?: { creator?: string; handle?: string };
82
101
  canonical?: string | null;
83
102
  editUrl?: string | null;
84
103
  askEnabled?: boolean;
@@ -154,6 +173,8 @@ const {
154
173
  searchEnabled,
155
174
  indexable,
156
175
  ogImage,
176
+ ogGenerated = false,
177
+ x,
157
178
  canonical,
158
179
  editUrl,
159
180
  askEnabled,
@@ -206,6 +227,12 @@ const PageFooterSlot = resolveSlot(layout.PageFooter, Empty);
206
227
  const FeedbackSlot = resolveSlot(layout.Feedback, PageFeedback);
207
228
 
208
229
  const strings = ui ?? EN_UI;
230
+ // Merge the groups this layout reads directly over the English defaults so a
231
+ // key missing from a translation (or from a not-yet-regenerated snapshot)
232
+ // still renders instead of coming out blank — the PageActions pattern.
233
+ const navStrings = { ...EN_UI.nav, ...strings.nav };
234
+ const actionStrings = { ...EN_UI.actions, ...strings.actions };
235
+ const contentStrings = { ...EN_UI.content, ...strings.content };
209
236
  // Filter search to the active language only when the site is multi-locale.
210
237
  const searchLocale =
211
238
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
@@ -238,6 +265,24 @@ if (isBare) {
238
265
  }
239
266
  const pageTitle = page.title ? `${page.title} - ${site.title}` : site.title;
240
267
  const description = page.description ?? site.description;
268
+ // Blog posts and changelog entries are dated writing, so they take `og:type:
269
+ // article` — the same split JSON-LD makes when it picks an article @type. Docs
270
+ // pages (and the changelog index, which has no `type`) stay `website`.
271
+ const ogType =
272
+ pageType === "blog" || pageType === "changelog" ? "article" : "website";
273
+ // `article:*` timestamps only belong on an article; a docs page carrying them
274
+ // would be declaring properties its `og:type` doesn't define.
275
+ const articlePublished = ogType === "article" ? toIso(published) : undefined;
276
+ const articleModified = ogType === "article" ? toIso(lastModified) : undefined;
277
+ // X sizes the card from `twitter:card`: a large image needs the wide variant,
278
+ // and a page with no image still gets a card (the compact `summary`) rather
279
+ // than rendering as a bare link.
280
+ const twitterCard = ogImage ? "summary_large_image" : "summary";
281
+ // Normalized here, not just in the config schema: a page's `seo.x.creator`
282
+ // arrives as raw frontmatter (the collections carry no schema), so the schema's
283
+ // transform never touches it — `creator: guestauthor` still needs its `@`.
284
+ const xSite = normalizeXHandle(x?.handle);
285
+ const xCreator = normalizeXHandle(x?.creator);
241
286
 
242
287
  // "Last updated on <date>" — formatted in UTC to match the changelog timeline.
243
288
  const lastModifiedDate = lastModified ? new Date(lastModified) : null;
@@ -307,17 +352,46 @@ const bannerScript = banner?.dismissible
307
352
  {xDefault && <link href={xDefault} hreflang="x-default" rel="alternate" />}
308
353
  {noindex && <meta name="robots" content="noindex" />}
309
354
  {description && <meta name="description" content={description} />}
355
+ <meta property="og:type" content={ogType} />
356
+ <meta property="og:site_name" content={site.title} />
310
357
  <meta property="og:title" content={pageTitle} />
311
358
  {description && <meta property="og:description" content={description} />}
359
+ {canonical && <meta property="og:url" content={canonical} />}
360
+ {
361
+ articlePublished && (
362
+ <meta property="article:published_time" content={articlePublished} />
363
+ )
364
+ }
365
+ {
366
+ articleModified && (
367
+ <meta property="article:modified_time" content={articleModified} />
368
+ )
369
+ }
312
370
  {
313
371
  ogImage && (
314
372
  <>
315
373
  <meta property="og:image" content={ogImage} />
374
+ {ogGenerated && (
375
+ <>
376
+ <meta property="og:image:type" content={OG_IMAGE_TYPE} />
377
+ <meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
378
+ <meta
379
+ property="og:image:height"
380
+ content={String(OG_IMAGE_HEIGHT)}
381
+ />
382
+ <meta property="og:image:alt" content={pageTitle} />
383
+ </>
384
+ )}
316
385
  <meta name="twitter:image" content={ogImage} />
317
- <meta name="twitter:card" content="summary_large_image" />
386
+ {ogGenerated && <meta name="twitter:image:alt" content={pageTitle} />}
318
387
  </>
319
388
  )
320
389
  }
390
+ <meta name="twitter:card" content={twitterCard} />
391
+ <meta name="twitter:title" content={pageTitle} />
392
+ {description && <meta name="twitter:description" content={description} />}
393
+ {xSite && <meta name="twitter:site" content={xSite} />}
394
+ {xCreator && <meta name="twitter:creator" content={xCreator} />}
321
395
  {
322
396
  feeds?.map((feed) => (
323
397
  <link
@@ -345,12 +419,14 @@ const bannerScript = banner?.dismissible
345
419
  class="bg-background font-sans text-foreground antialiased"
346
420
  data-blume-code-wrap={codeWrap ? "" : undefined}
347
421
  data-blume-image-zoom={imageZoom ? "" : undefined}
422
+ data-i18n-copy-code={actionStrings.copyCode}
423
+ data-i18n-diagram-error={contentStrings.diagramError}
348
424
  >
349
425
  <a
350
426
  class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
351
427
  href="#blume-content">{strings.page.skipToContent}</a
352
428
  >
353
- <Banner banner={banner} />
429
+ <Banner banner={banner} strings={strings.banner} />
354
430
  <HeaderSlot
355
431
  askEnabled={askEnabled}
356
432
  layout={layout}
@@ -360,6 +436,7 @@ const bannerScript = banner?.dismissible
360
436
  route={page.route}
361
437
  searchEnabled={searchEnabled}
362
438
  searchLocale={searchLocale}
439
+ navStrings={navStrings}
363
440
  searchStrings={strings.search}
364
441
  site={site}
365
442
  switcherStrings={strings.languageSwitcher}
@@ -371,7 +448,8 @@ const bannerScript = banner?.dismissible
371
448
  data-blume-doc-grid
372
449
  >
373
450
  <aside
374
- aria-label="Primary"
451
+ aria-label={navStrings.primary}
452
+ data-blume-nav-drawer
375
453
  class:list={[
376
454
  "fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4",
377
455
  // A "bare" landing (the changelog index) has no sidebar column on
@@ -385,7 +463,10 @@ const bannerScript = banner?.dismissible
385
463
  // all breakpoints, outside the tab-scoped sidebar so they never change
386
464
  // with the active tab. External hrefs open in a new tab.
387
465
  navigation.featured.length > 0 && (
388
- <nav aria-label="Featured" class="mb-4 border-border border-b pb-4">
466
+ <nav
467
+ aria-label={navStrings.featured}
468
+ class="mb-4 border-border border-b pb-4"
469
+ >
389
470
  <ul class="m-0 list-none p-0">
390
471
  {navigation.featured.map((link) => {
391
472
  const external = /^https?:\/\//u.test(link.href);
@@ -431,7 +512,7 @@ const bannerScript = banner?.dismissible
431
512
  // from opening blank.
432
513
  navigation.tabs.length > 0 && (
433
514
  <nav
434
- aria-label="Sections"
515
+ aria-label={navStrings.sections}
435
516
  class:list={[
436
517
  "mb-4 border-border border-b pb-4",
437
518
  sidebar.length > 0 && "md:hidden",
@@ -463,20 +544,36 @@ const bannerScript = banner?.dismissible
463
544
  MobileNavSlot ? (
464
545
  <>
465
546
  <div class="lg:hidden">
466
- <MobileNavSlot currentRoute={page.route} items={sidebar} />
547
+ <MobileNavSlot
548
+ currentRoute={page.route}
549
+ items={sidebar}
550
+ strings={navStrings}
551
+ />
467
552
  </div>
468
553
  <div class="hidden lg:block">
469
- <SidebarSlot currentRoute={page.route} items={sidebar} />
554
+ <SidebarSlot
555
+ currentRoute={page.route}
556
+ items={sidebar}
557
+ strings={navStrings}
558
+ />
470
559
  </div>
471
560
  </>
472
561
  ) : (
473
- <SidebarSlot currentRoute={page.route} items={sidebar} />
562
+ <SidebarSlot
563
+ currentRoute={page.route}
564
+ items={sidebar}
565
+ strings={navStrings}
566
+ />
474
567
  )
475
568
  }
476
569
  </nav>
477
570
  </aside>
478
571
  <main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
479
- <BreadcrumbsSlot crumbs={crumbs} wide={isApiOperation} />
572
+ <BreadcrumbsSlot
573
+ crumbs={crumbs}
574
+ strings={navStrings}
575
+ wide={isApiOperation}
576
+ />
480
577
  <TableOfContentsSlot
481
578
  headings={tocHeadings}
482
579
  title={strings.toc.title}
@@ -542,7 +639,7 @@ const bannerScript = banner?.dismissible
542
639
  )
543
640
  }
544
641
  <button
545
- aria-label="Close navigation"
642
+ aria-label={navStrings.closeNavigation}
546
643
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
547
644
  data-blume-nav-toggle
548
645
  type="button"
@@ -559,12 +656,45 @@ const bannerScript = banner?.dismissible
559
656
  // Tree-shaken out of production builds.
560
657
  import "./hydration-hint.ts";
561
658
 
659
+ // The closed mobile drawer is only translated off-canvas, so its links
660
+ // would stay in the tab order on every page. Mirror the header's
661
+ // `data-blume-nav-open` toggle into `inert`/`aria-hidden` — but only
662
+ // below `lg` (64rem), where the same element isn't the static sidebar.
663
+ const drawer = document.querySelector<HTMLElement>(
664
+ "[data-blume-nav-drawer]"
665
+ );
666
+ if (drawer) {
667
+ const desktop = window.matchMedia("(min-width: 64rem)");
668
+ const syncDrawer = () => {
669
+ const hidden =
670
+ !desktop.matches &&
671
+ !document.documentElement.hasAttribute("data-blume-nav-open");
672
+ drawer.inert = hidden;
673
+ if (hidden) {
674
+ drawer.setAttribute("aria-hidden", "true");
675
+ } else {
676
+ drawer.removeAttribute("aria-hidden");
677
+ }
678
+ };
679
+ syncDrawer();
680
+ desktop.addEventListener("change", syncDrawer);
681
+ new MutationObserver(syncDrawer).observe(document.documentElement, {
682
+ attributeFilter: ["data-blume-nav-open"],
683
+ });
684
+ }
685
+
562
686
  const svg = (name: string) =>
563
687
  `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>`;
564
688
 
565
689
  const buttonClass =
566
690
  "absolute right-3 inline-flex size-7 items-center justify-center rounded-full bg-transparent text-muted-foreground opacity-70 transition hover:bg-muted hover:text-foreground hover:opacity-100 focus-visible:opacity-100";
567
691
 
692
+ // Localized copy-button label, stamped on <body> by the layout markup
693
+ // (the Search.astro data-attribute channel) since this bundled script
694
+ // can't interpolate server values directly.
695
+ const copyCodeLabel =
696
+ document.body.getAttribute("data-i18n-copy-code") || "Copy code";
697
+
568
698
  const languageLabels: Record<string, string> = {
569
699
  astro: "Astro",
570
700
  bash: "Bash",
@@ -612,7 +742,7 @@ const bannerScript = banner?.dismissible
612
742
  : "top-2";
613
743
  button.className = `${buttonClass} ${topClass}`;
614
744
  button.setAttribute("data-blume-copy", "");
615
- button.setAttribute("aria-label", "Copy code");
745
+ button.setAttribute("aria-label", copyCodeLabel);
616
746
  button.innerHTML = svg("copy");
617
747
  button.addEventListener("click", async () => {
618
748
  const code = pre.querySelector("code");
@@ -33,8 +33,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
33
33
  <blume-search
34
34
  class="contents"
35
35
  data-ask={askEnabled ? "" : undefined}
36
+ data-i18n-all={s.all}
37
+ data-i18n-ask={s.askAi}
38
+ data-i18n-ask-hint={s.askAiHint}
36
39
  data-i18n-dev={s.devOnly}
37
40
  data-i18n-empty={s.noResults}
41
+ data-i18n-error={s.error}
42
+ data-i18n-popular={s.popular}
43
+ data-i18n-results={s.results}
38
44
  data-locale={locale || undefined}
39
45
  >
40
46
  <button
@@ -62,7 +68,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
62
68
  <input
63
69
  aria-label={s.label}
64
70
  autocomplete="off"
65
- class="flex-1 border-0 bg-transparent text-foreground text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
71
+ class="flex-1 border-0 bg-transparent text-foreground text-sm pointer-coarse:text-base focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
66
72
  data-blume-search-input
67
73
  placeholder={s.placeholder}
68
74
  type="search"
@@ -119,15 +125,17 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
119
125
  <span class="flex items-center gap-1">
120
126
  <kbd class={`${kbd} text-[0.65rem]`}>↑</kbd>
121
127
  <kbd class={`${kbd} text-[0.65rem]`}>↓</kbd>
122
- navigate
128
+ {s.navigate}
123
129
  </span>
124
130
  <span class="flex items-center gap-1">
125
131
  <kbd class={`${kbd} text-[0.65rem]`}>↵</kbd>
126
- open
132
+ {s.open}
127
133
  </span>
128
134
  <span class="flex items-center gap-1 max-md:hidden">
129
- <kbd class={`${kbd} text-[0.65rem]`}>⌘J</kbd>
130
- preview
135
+ <kbd class={`${kbd} text-[0.65rem]`} data-blume-search-preview-kbd
136
+ >⌘J</kbd
137
+ >
138
+ {s.preview}
131
139
  </span>
132
140
  </div>
133
141
  </div>
@@ -189,6 +197,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
189
197
  preview!: HTMLElement;
190
198
  searchFn: SearchFn | null = null;
191
199
  loaded = false;
200
+ loadFailed = false;
201
+ /** First-open client/index load still in flight. */
202
+ loading = false;
192
203
  askEnabled = false;
193
204
  popular: PopularPage[] = [];
194
205
  selectables: Selectable[] = [];
@@ -198,6 +209,12 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
198
209
  previewOn = true;
199
210
  devOnlyMsg = "Search is available in the production build.";
200
211
  noResultsMsg = "No results found.";
212
+ errorMsg = "Something went wrong. Please try again.";
213
+ askMsg = "Ask AI";
214
+ askHintMsg = "Get an instant answer from AI";
215
+ allMsg = "All";
216
+ popularMsg = "Popular";
217
+ resultsMsg = "Results";
201
218
  // The active locale to filter to (null when i18n is off), and whether the
202
219
  // reader has opted to search across every language instead.
203
220
  locale: string | null = null;
@@ -208,6 +225,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
208
225
  this.getAttribute("data-i18n-dev") || this.devOnlyMsg;
209
226
  this.noResultsMsg =
210
227
  this.getAttribute("data-i18n-empty") || this.noResultsMsg;
228
+ this.errorMsg = this.getAttribute("data-i18n-error") || this.errorMsg;
229
+ this.askMsg = this.getAttribute("data-i18n-ask") || this.askMsg;
230
+ this.askHintMsg =
231
+ this.getAttribute("data-i18n-ask-hint") || this.askHintMsg;
232
+ this.allMsg = this.getAttribute("data-i18n-all") || this.allMsg;
233
+ this.popularMsg =
234
+ this.getAttribute("data-i18n-popular") || this.popularMsg;
235
+ this.resultsMsg =
236
+ this.getAttribute("data-i18n-results") || this.resultsMsg;
211
237
  this.locale = this.getAttribute("data-locale");
212
238
  this.dialog = this.querySelector("[data-blume-search-dialog]")!;
213
239
  this.input = this.querySelector("[data-blume-search-input]")!;
@@ -250,12 +276,18 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
250
276
  });
251
277
  }
252
278
 
253
- // The handler acceptsK and Ctrl+K; show the right one per platform.
279
+ // The handlers accept both ⌘ and Ctrl chords; show the right modifier
280
+ // per platform on the button hint and the footer's preview hint.
281
+ const isApple = /mac|iphone|ipad|ipod/iu.test(navigator.platform);
254
282
  const hint = this.querySelector("[data-blume-search-kbd]");
255
283
  if (hint) {
256
- hint.textContent = /mac|iphone|ipad|ipod/iu.test(navigator.platform)
257
- ? "⌘K"
258
- : "Ctrl K";
284
+ hint.textContent = isApple ? "⌘K" : "Ctrl K";
285
+ }
286
+ const previewHint = this.querySelector(
287
+ "[data-blume-search-preview-kbd]"
288
+ );
289
+ if (previewHint) {
290
+ previewHint.textContent = isApple ? "⌘J" : "Ctrl J";
259
291
  }
260
292
 
261
293
  this.querySelector("[data-blume-search-open]")?.addEventListener(
@@ -264,12 +296,22 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
264
296
  );
265
297
 
266
298
  document.addEventListener("keydown", (event) => {
267
- const inField = this.isField(event.target);
268
299
  if (
269
- ((event.key === "k" || event.key === "K") &&
270
- (event.metaKey || event.ctrlKey)) ||
271
- (event.key === "/" && !inField)
300
+ (event.key === "k" || event.key === "K") &&
301
+ (event.metaKey || event.ctrlKey)
272
302
  ) {
303
+ // ⌘K toggles, mirroring the Ask AI panel's ⌘I: pressing it with
304
+ // the dialog open must close it, not re-showModal an open dialog
305
+ // (an InvalidStateError on older engines).
306
+ event.preventDefault();
307
+ if (this.dialog.open) {
308
+ this.dialog.close();
309
+ } else {
310
+ this.open();
311
+ }
312
+ } else if (event.key === "/" && !this.isField(event.target)) {
313
+ // "/" stays open-only; the field guard keeps it inert while
314
+ // typing (including in the search input itself).
273
315
  event.preventDefault();
274
316
  this.open();
275
317
  }
@@ -297,10 +339,16 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
297
339
  }
298
340
 
299
341
  async open() {
342
+ // Re-entrant opens (e.g. the open button while already shown) must
343
+ // not call showModal on an open dialog.
344
+ if (this.dialog.open) {
345
+ return;
346
+ }
300
347
  this.dialog.showModal();
301
348
  this.input.focus();
302
349
  this.input.select();
303
350
  if (!this.loaded) {
351
+ this.loading = true;
304
352
  try {
305
353
  const { createSearch } = await import("blume:search-client");
306
354
  this.searchFn = await createSearch();
@@ -308,8 +356,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
308
356
  // fetching the index) must retry on the next open, not disable
309
357
  // search until a full page reload.
310
358
  this.loaded = true;
359
+ this.loadFailed = false;
311
360
  } catch {
312
361
  this.searchFn = null;
362
+ // In dev a client can be missing by design (Pagefind's bundle
363
+ // only exists in the production build) — that's the "dev only"
364
+ // hint. The same failure in production is a real error.
365
+ this.loadFailed = !import.meta.env.DEV;
366
+ } finally {
367
+ this.loading = false;
313
368
  }
314
369
  }
315
370
  this.render();
@@ -322,7 +377,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
322
377
  } else if (event.key === "ArrowUp") {
323
378
  event.preventDefault();
324
379
  this.move(-1);
325
- } else if (event.key === "Enter") {
380
+ } else if (event.key === "Enter" && !event.isComposing) {
381
+ // `isComposing` guards IME input: Enter confirming a CJK conversion
382
+ // must commit the text, not activate the selected result.
326
383
  const item = this.selectables[this.selectedIndex];
327
384
  if (item) {
328
385
  event.preventDefault();
@@ -361,16 +418,36 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
361
418
  if (!this.searchFn) {
362
419
  this.renderFilters([]);
363
420
  this.clearPreview();
364
- this.setMessage(this.devOnlyMsg);
421
+ if (this.loading) {
422
+ // Typing while the first-open load (client import + index fetch)
423
+ // is still in flight: the dev-only hint would be wrong in
424
+ // production and the error message premature. Show a neutral
425
+ // placeholder; `open()` re-renders once the load settles.
426
+ this.setMessage("…");
427
+ } else {
428
+ this.setMessage(this.loadFailed ? this.errorMsg : this.devOnlyMsg);
429
+ }
365
430
  return;
366
431
  }
367
432
 
368
433
  const localeFilter =
369
434
  this.locale && !this.allLocales ? this.locale : undefined;
370
- const result = await this.searchFn(query, {
371
- locale: localeFilter,
372
- section: this.activeSection ?? undefined,
373
- });
435
+ let result: Awaited<ReturnType<SearchFn>>;
436
+ try {
437
+ result = await this.searchFn(query, {
438
+ locale: localeFilter,
439
+ section: this.activeSection ?? undefined,
440
+ });
441
+ } catch {
442
+ // A hosted provider can reject (network error, outage); the results
443
+ // list is already cleared, so show a message instead of a blank pane.
444
+ if (generation === this.renderGeneration) {
445
+ this.renderFilters([]);
446
+ this.clearPreview();
447
+ this.setMessage(this.errorMsg);
448
+ }
449
+ return;
450
+ }
374
451
  // Any newer render — a keystroke, a section pill, a locale toggle —
375
452
  // supersedes this one mid-await, even for the same query text;
376
453
  // appending the stale hits would duplicate rows and desync selection.
@@ -378,17 +455,30 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
378
455
  return;
379
456
  }
380
457
 
458
+ // A section picked for an earlier query can be missing from the new
459
+ // pool — and when the pool has fewer than two sections the pills that
460
+ // would clear it are hidden too, so the stale filter would silently
461
+ // empty the results. Drop it and search again unfiltered.
462
+ if (
463
+ this.activeSection &&
464
+ !result.sections.some((s) => s.label === this.activeSection)
465
+ ) {
466
+ this.activeSection = null;
467
+ this.render();
468
+ return;
469
+ }
470
+
381
471
  this.renderFilters(result.sections);
382
472
 
383
473
  if (this.askEnabled) {
384
- const group = this.addGroup("Ask AI");
474
+ const group = this.addGroup(this.askMsg);
385
475
  const ask = this.createAskRow(query);
386
476
  group.appendChild(ask.el);
387
477
  this.selectables.push(ask);
388
478
  }
389
479
 
390
480
  if (result.hits.length > 0) {
391
- const group = this.addGroup("Results");
481
+ const group = this.addGroup(this.resultsMsg);
392
482
  for (const hit of result.hits) {
393
483
  const item = this.createHitRow(hit, query);
394
484
  group.appendChild(item.el);
@@ -403,13 +493,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
403
493
 
404
494
  renderEmpty() {
405
495
  if (this.askEnabled) {
406
- const group = this.addGroup("Ask AI");
496
+ const group = this.addGroup(this.askMsg);
407
497
  const ask = this.createAskRow("");
408
498
  group.appendChild(ask.el);
409
499
  this.selectables.push(ask);
410
500
  }
411
501
  if (this.popular.length > 0) {
412
- const group = this.addGroup("Popular");
502
+ const group = this.addGroup(this.popularMsg);
413
503
  for (const page of this.popular) {
414
504
  const item = this.createLinkRow(page.route, page.label);
415
505
  group.appendChild(item.el);
@@ -427,7 +517,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
427
517
  this.filters.hidden = false;
428
518
  this.filters.replaceChildren();
429
519
  const total = sections.reduce((sum, s) => sum + s.count, 0);
430
- this.filters.appendChild(this.createPill("All", total, null));
520
+ this.filters.appendChild(this.createPill(this.allMsg, total, null));
431
521
  for (const section of sections) {
432
522
  this.filters.appendChild(
433
523
  this.createPill(section.label, section.count, section.label)
@@ -464,13 +554,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
464
554
  el.type = "button";
465
555
  el.className = `${ROW_CLASS} ${MARK}`;
466
556
  const title = query
467
- ? `Ask AI: <span class="text-muted-foreground">“${escapeHtml(query)}”</span>`
468
- : "Ask AI";
557
+ ? `${escapeHtml(this.askMsg)}: <span class="text-muted-foreground">“${escapeHtml(query)}”</span>`
558
+ : escapeHtml(this.askMsg);
469
559
  el.innerHTML = `
470
560
  <span class="mt-0.5 shrink-0 text-accent">${svg("sparkles")}</span>
471
561
  <span class="flex-1">
472
562
  <span class="block truncate font-normal text-foreground text-sm">${title}</span>
473
- <span class="block truncate text-muted-foreground text-sm">Get an instant answer from AI</span>
563
+ <span class="block truncate text-muted-foreground text-sm">${escapeHtml(this.askHintMsg)}</span>
474
564
  </span>`;
475
565
  const item: Selectable = { el, kind: "ask" };
476
566
  this.bindRow(item);
@@ -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 {
@@ -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. */