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.
- package/CHANGELOG.md +666 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1852 -380
- package/dist/cli/index.js.map +98 -91
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +47 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +7 -0
- package/dist/types/core/i18n-ui.d.ts +526 -132
- package/dist/types/core/schema.d.ts +293 -146
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +35 -5
- package/docs/content/components.mdx +2 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +6 -4
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +9 -7
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +22 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/mcp/tools.ts +1 -1
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +77 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +150 -19
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +120 -23
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +86 -11
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +19 -5
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +85 -9
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +20 -2
- package/src/components/layout/RootLayout.astro +142 -12
- package/src/components/layout/Search.astro +117 -27
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +48 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +4 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +54 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +71 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +8 -0
- package/src/core/ui-packs/bg.ts +8 -0
- package/src/core/ui-packs/bn.ts +8 -0
- package/src/core/ui-packs/ca.ts +8 -0
- package/src/core/ui-packs/cs.ts +8 -0
- package/src/core/ui-packs/da.ts +8 -0
- package/src/core/ui-packs/de.ts +8 -0
- package/src/core/ui-packs/el.ts +8 -0
- package/src/core/ui-packs/es.ts +8 -0
- package/src/core/ui-packs/fa.ts +8 -0
- package/src/core/ui-packs/fi.ts +8 -0
- package/src/core/ui-packs/fr.ts +9 -1
- package/src/core/ui-packs/he.ts +8 -0
- package/src/core/ui-packs/hi.ts +8 -0
- package/src/core/ui-packs/hr.ts +8 -0
- package/src/core/ui-packs/hu.ts +8 -0
- package/src/core/ui-packs/id.ts +8 -0
- package/src/core/ui-packs/it.ts +8 -0
- package/src/core/ui-packs/ja.ts +8 -0
- package/src/core/ui-packs/ko.ts +8 -0
- package/src/core/ui-packs/nl.ts +8 -0
- package/src/core/ui-packs/no.ts +8 -0
- package/src/core/ui-packs/pl.ts +8 -0
- package/src/core/ui-packs/pt-br.ts +8 -0
- package/src/core/ui-packs/pt.ts +8 -0
- package/src/core/ui-packs/ro.ts +8 -0
- package/src/core/ui-packs/ru.ts +8 -0
- package/src/core/ui-packs/sk.ts +8 -0
- package/src/core/ui-packs/sr.ts +8 -0
- package/src/core/ui-packs/sv.ts +8 -0
- package/src/core/ui-packs/th.ts +8 -0
- package/src/core/ui-packs/tr.ts +8 -0
- package/src/core/ui-packs/uk.ts +8 -0
- package/src/core/ui-packs/vi.ts +8 -0
- package/src/core/ui-packs/zh-tw.ts +8 -0
- package/src/core/ui-packs/zh.ts +8 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/render-mdx.ts +62 -1
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +20 -13
- 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 {
|
|
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:
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
554
|
+
<SidebarSlot
|
|
555
|
+
currentRoute={page.route}
|
|
556
|
+
items={sidebar}
|
|
557
|
+
strings={navStrings}
|
|
558
|
+
/>
|
|
470
559
|
</div>
|
|
471
560
|
</>
|
|
472
561
|
) : (
|
|
473
|
-
<SidebarSlot
|
|
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
|
|
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=
|
|
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",
|
|
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]`}
|
|
130
|
-
|
|
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
|
|
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 =
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
(
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
?
|
|
468
|
-
:
|
|
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"
|
|
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
|
-
{
|
|
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
|
|
7
|
-
*
|
|
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
|
|
21
|
-
|
|
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({
|
|
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
|
-
|
|
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. */
|