@pagefind/component-ui 1.5.0-alpha.3

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.
@@ -0,0 +1,714 @@
1
+ import {
2
+ findNextComponentInTabOrder,
3
+ findPreviousComponentInTabOrder,
4
+ } from "./focus-utils";
5
+ import { getTranslations, interpolate } from "./translations";
6
+ import { Announcer } from "./announcer";
7
+ import type {
8
+ AnnouncerPriority,
9
+ PagefindSearchResult,
10
+ PagefindResultData,
11
+ PagefindSubResult,
12
+ FilterCounts,
13
+ FilterSelection,
14
+ InstanceOptions,
15
+ MergeIndexConfig,
16
+ InstanceEvent,
17
+ PagefindError,
18
+ TranslationStrings,
19
+ TextDirection,
20
+ ComponentCapabilities,
21
+ Shortcut,
22
+ HookCallback,
23
+ HookEntry,
24
+ PagefindAPI,
25
+ } from "../types";
26
+
27
+ export interface PagefindComponent extends HTMLElement {
28
+ instance: Instance | null;
29
+ componentType?: string;
30
+ componentSubtype?: string | null;
31
+ capabilities?: ComponentCapabilities;
32
+ inputEl?: HTMLInputElement | null;
33
+ reconcileAria?: () => void;
34
+ register?: (instance: Instance) => void;
35
+ render?: () => void;
36
+ getResultElements?: () => HTMLElement[];
37
+ }
38
+
39
+ let scriptBundlePath: string | undefined;
40
+ try {
41
+ // Important: Check that the element is indeed a <script> node, to avoid a DOM clobbering vulnerability
42
+ if (
43
+ document?.currentScript &&
44
+ document.currentScript.tagName.toUpperCase() === "SCRIPT"
45
+ ) {
46
+ const match = new URL(
47
+ (document.currentScript as HTMLScriptElement).src,
48
+ ).pathname.match(/^(.*\/)(?:pagefind[-_])?component[-_]?ui.js.*$/);
49
+ if (match) {
50
+ scriptBundlePath = match[1];
51
+ }
52
+ }
53
+ } catch (e) {
54
+ scriptBundlePath = "/pagefind/";
55
+ }
56
+
57
+ type HookMap = {
58
+ [K in InstanceEvent]: Array<HookCallback | HookEntry>;
59
+ };
60
+
61
+ export class Instance {
62
+ private __pagefind__: PagefindAPI | null = null;
63
+ private __loadPromise__: Promise<void> | null = null;
64
+ private __searchID__: number = 0;
65
+ private __hooks__: HookMap;
66
+
67
+ private _translations: TranslationStrings | null = null;
68
+ private _userTranslations: Record<string, string> = {};
69
+ private _direction: TextDirection = "ltr";
70
+ private _languageSet: boolean = false;
71
+
72
+ private _announcer: Announcer;
73
+
74
+ components: PagefindComponent[] = [];
75
+ componentsByType: Record<string, PagefindComponent[]> = {};
76
+
77
+ searchTerm: string = "";
78
+ searchFilters: FilterSelection = {};
79
+ searchResult: PagefindSearchResult = { results: [] };
80
+ availableFilters: FilterCounts | null = null;
81
+ totalFilters: FilterCounts | null = null;
82
+ activeShortcuts: Shortcut[] = [];
83
+ faceted: boolean = false;
84
+
85
+ name: string;
86
+ private generatedIds: Set<string> = new Set();
87
+ options: {
88
+ bundlePath: string;
89
+ mergeIndex: MergeIndexConfig[];
90
+ };
91
+ pagefindOptions: Record<string, unknown>;
92
+
93
+ constructor(name: string, opts: InstanceOptions = {}) {
94
+ this.name = name;
95
+ this.__hooks__ = {
96
+ search: [],
97
+ filters: [],
98
+ loading: [],
99
+ results: [],
100
+ error: [],
101
+ translations: [],
102
+ };
103
+
104
+ this.options = {
105
+ bundlePath: opts.bundlePath ?? scriptBundlePath ?? "/pagefind/",
106
+ mergeIndex: opts.mergeIndex ?? [],
107
+ };
108
+
109
+ const pagefindOpts = { ...opts };
110
+ delete pagefindOpts.bundlePath;
111
+ delete pagefindOpts.mergeIndex;
112
+
113
+ this.pagefindOptions = pagefindOpts;
114
+
115
+ this._announcer = new Announcer(this.generateId.bind(this));
116
+ }
117
+
118
+ generateId(prefix: string, length = 2): string {
119
+ const idChars = "abcdef";
120
+ const randomSeg = (len = 3): string => {
121
+ let word = "";
122
+ for (let i = 0; i < len; i++) {
123
+ word += idChars[Math.floor(Math.random() * idChars.length)];
124
+ }
125
+ return word;
126
+ };
127
+
128
+ const instancePart = this.name !== "default" ? `${this.name}-` : "";
129
+ const segments = Array.from({ length }, () => randomSeg()).join("-");
130
+ const id = `${prefix}-${instancePart}${segments}`;
131
+
132
+ if (this.generatedIds.has(id) || document.getElementById(id)) {
133
+ return this.generateId(prefix, length + 1);
134
+ }
135
+
136
+ this.generatedIds.add(id);
137
+ return id;
138
+ }
139
+
140
+ add(component: PagefindComponent): void {
141
+ component?.register?.(this);
142
+ this.components.push(component);
143
+ }
144
+
145
+ registerInput(
146
+ component: PagefindComponent,
147
+ capabilities: ComponentCapabilities = {},
148
+ ): void {
149
+ this._registerComponent(component, "input", null, capabilities);
150
+ }
151
+
152
+ registerResults(
153
+ component: PagefindComponent,
154
+ capabilities: ComponentCapabilities = {},
155
+ ): void {
156
+ this._registerComponent(component, "results", null, capabilities);
157
+ }
158
+
159
+ registerSummary(
160
+ component: PagefindComponent,
161
+ capabilities: ComponentCapabilities = {},
162
+ ): void {
163
+ this._registerComponent(component, "summary", null, capabilities);
164
+ }
165
+
166
+ registerFilter(
167
+ component: PagefindComponent,
168
+ capabilities: ComponentCapabilities = {},
169
+ ): void {
170
+ this._registerComponent(component, "filter", null, capabilities);
171
+ }
172
+
173
+ registerSort(
174
+ component: PagefindComponent,
175
+ capabilities: ComponentCapabilities = {},
176
+ ): void {
177
+ this._registerComponent(component, "sort", null, capabilities);
178
+ }
179
+
180
+ registerUtility(
181
+ component: PagefindComponent,
182
+ subtype: string | null = null,
183
+ capabilities: ComponentCapabilities = {},
184
+ ): void {
185
+ this._registerComponent(component, "utility", subtype, capabilities);
186
+ }
187
+
188
+ private _registerComponent(
189
+ component: PagefindComponent,
190
+ type: string,
191
+ subtype: string | null = null,
192
+ capabilities: ComponentCapabilities = {},
193
+ ): void {
194
+ if (!this.componentsByType[type]) {
195
+ this.componentsByType[type] = [];
196
+ }
197
+
198
+ // Auto-detect the language of this html page
199
+ // on first component registration
200
+ if (!this._languageSet) {
201
+ this.setLanguage();
202
+ }
203
+
204
+ if (this.components.includes(component)) {
205
+ // Update capabilities but don't re-add
206
+ component.capabilities = capabilities;
207
+ this.reconcileAria();
208
+ return;
209
+ }
210
+
211
+ component.componentType = type;
212
+ component.componentSubtype = subtype;
213
+ component.capabilities = capabilities;
214
+ this.componentsByType[type].push(component);
215
+ this.components.push(component);
216
+
217
+ this.reconcileAria();
218
+ }
219
+
220
+ getInputs(requiredCapability: string | null = null): PagefindComponent[] {
221
+ const components = this.componentsByType["input"] || [];
222
+ if (!requiredCapability) return components;
223
+ return components.filter((c) => c.capabilities?.[requiredCapability]);
224
+ }
225
+
226
+ getResults(requiredCapability: string | null = null): PagefindComponent[] {
227
+ const components = this.componentsByType["results"] || [];
228
+ if (!requiredCapability) return components;
229
+ return components.filter((c) => c.capabilities?.[requiredCapability]);
230
+ }
231
+
232
+ getSummaries(requiredCapability: string | null = null): PagefindComponent[] {
233
+ const components = this.componentsByType["summary"] || [];
234
+ if (!requiredCapability) return components;
235
+ return components.filter((c) => c.capabilities?.[requiredCapability]);
236
+ }
237
+
238
+ getFilters(requiredCapability: string | null = null): PagefindComponent[] {
239
+ const components = this.componentsByType["filter"] || [];
240
+ if (!requiredCapability) return components;
241
+ return components.filter((c) => c.capabilities?.[requiredCapability]);
242
+ }
243
+
244
+ getSorts(requiredCapability: string | null = null): PagefindComponent[] {
245
+ const components = this.componentsByType["sort"] || [];
246
+ if (!requiredCapability) return components;
247
+ return components.filter((c) => c.capabilities?.[requiredCapability]);
248
+ }
249
+
250
+ getUtilities(
251
+ subtype: string | null = null,
252
+ requiredCapability: string | null = null,
253
+ ): PagefindComponent[] {
254
+ let utilities = this.componentsByType["utility"] || [];
255
+ if (subtype !== null) {
256
+ utilities = utilities.filter((u) => u.componentSubtype === subtype);
257
+ }
258
+ if (requiredCapability) {
259
+ utilities = utilities.filter((c) => c.capabilities?.[requiredCapability]);
260
+ }
261
+ return utilities;
262
+ }
263
+
264
+ /**
265
+ * Check if any component has registered announcement capability.
266
+ * Used to determine if Instance should handle announcements as a fallback.
267
+ */
268
+ hasAnnouncementCapability(): boolean {
269
+ return this.components.some((c) => c.capabilities?.announcements === true);
270
+ }
271
+
272
+ /**
273
+ * Register an active shortcut. Triggers hints to re-render.
274
+ */
275
+ registerShortcut(shortcut: Omit<Shortcut, "owner">, owner: Element): void {
276
+ const entry: Shortcut = { ...shortcut, owner };
277
+ this.activeShortcuts.push(entry);
278
+ this.notifyShortcutsChanged();
279
+ }
280
+
281
+ /**
282
+ * Deregister a shortcut by owner + label.
283
+ */
284
+ deregisterShortcut(label: string, owner: Element): void {
285
+ this.activeShortcuts = this.activeShortcuts.filter(
286
+ (s) => !(s.label === label && s.owner === owner),
287
+ );
288
+ this.notifyShortcutsChanged();
289
+ }
290
+
291
+ /**
292
+ * Deregister all shortcuts from an owner.
293
+ */
294
+ deregisterAllShortcuts(owner: Element): void {
295
+ this.activeShortcuts = this.activeShortcuts.filter(
296
+ (s) => s.owner !== owner,
297
+ );
298
+ this.notifyShortcutsChanged();
299
+ }
300
+
301
+ /**
302
+ * Get currently active shortcuts.
303
+ */
304
+ getActiveShortcuts(): Shortcut[] {
305
+ return this.activeShortcuts;
306
+ }
307
+
308
+ /**
309
+ * Notify keyboard-hints utilities to re-render
310
+ * due to shortcuts changing
311
+ */
312
+ notifyShortcutsChanged(): void {
313
+ const hints = this.getUtilities("keyboard-hints");
314
+ hints.forEach((h) => h.render?.());
315
+ }
316
+
317
+ /**
318
+ * Focus the first result in the next keyboard-navigable results component.
319
+ */
320
+ focusNextResults(fromElement: Element): boolean {
321
+ const results = this.getResults("keyboardNavigation");
322
+ const resultsComponent = findNextComponentInTabOrder(fromElement, results);
323
+ if (!resultsComponent) return false;
324
+
325
+ const resultEls =
326
+ (resultsComponent as PagefindComponent).getResultElements?.() ||
327
+ resultsComponent.querySelectorAll(".pf-result") ||
328
+ [];
329
+ const firstLink = resultEls[0]?.querySelector("a");
330
+ if (firstLink) {
331
+ (firstLink as HTMLElement).focus();
332
+ return true;
333
+ }
334
+ return false;
335
+ }
336
+
337
+ /**
338
+ * Focus the previous keyboard-navigable input component.
339
+ */
340
+ focusPreviousInput(fromElement: Element): boolean {
341
+ const inputs = this.getInputs("keyboardNavigation");
342
+ const inputComponent = findPreviousComponentInTabOrder(
343
+ fromElement,
344
+ inputs,
345
+ ) as PagefindComponent | null;
346
+ if (!inputComponent) return false;
347
+
348
+ if (inputComponent.focus) {
349
+ inputComponent.focus();
350
+ return true;
351
+ }
352
+ const inputEl = inputComponent.querySelector("input");
353
+ if (inputEl) {
354
+ inputEl.focus();
355
+ return true;
356
+ }
357
+ return false;
358
+ }
359
+
360
+ /**
361
+ * Focus previous keyboard-navigable input and append a character.
362
+ */
363
+ focusInputAndType(fromElement: Element, char: string): void {
364
+ const inputs = this.getInputs("keyboardNavigation");
365
+ const inputComponent = findPreviousComponentInTabOrder(
366
+ fromElement,
367
+ inputs,
368
+ ) as PagefindComponent | null;
369
+ const inputEl =
370
+ inputComponent?.inputEl || inputComponent?.querySelector("input");
371
+ if (inputEl) {
372
+ inputEl.value += char;
373
+ inputEl.focus();
374
+ inputEl.dispatchEvent(new Event("input", { bubbles: true }));
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Focus previous keyboard-navigable input and delete last character.
380
+ */
381
+ focusInputAndDelete(fromElement: Element): void {
382
+ const inputs = this.getInputs("keyboardNavigation");
383
+ const inputComponent = findPreviousComponentInTabOrder(
384
+ fromElement,
385
+ inputs,
386
+ ) as PagefindComponent | null;
387
+ const inputEl =
388
+ inputComponent?.inputEl || inputComponent?.querySelector("input");
389
+ if (inputEl) {
390
+ inputEl.value = inputEl.value.slice(0, -1);
391
+ inputEl.focus();
392
+ inputEl.dispatchEvent(new Event("input", { bubbles: true }));
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Trigger ARIA reconciliation on all registered components.
398
+ */
399
+ reconcileAria(): void {
400
+ this.components.forEach((c) => c.reconcileAria?.());
401
+ }
402
+
403
+ /**
404
+ * Get current text direction.
405
+ */
406
+ get direction(): TextDirection {
407
+ return this._direction;
408
+ }
409
+
410
+ /**
411
+ * Set the language for translations.
412
+ */
413
+ setLanguage(langCode?: string): void {
414
+ if (!langCode) {
415
+ langCode = document?.documentElement?.lang || "en";
416
+ }
417
+
418
+ this._translations = getTranslations(langCode);
419
+ this._direction = (this._translations.direction as TextDirection) || "ltr";
420
+ this._languageSet = true;
421
+
422
+ this.__dispatch__("translations", this._translations, this._direction);
423
+ }
424
+
425
+ /**
426
+ * Set user translation overrides.
427
+ */
428
+ setTranslations(overrides: Record<string, string>): void {
429
+ this._userTranslations = { ...this._userTranslations, ...overrides };
430
+ this.__dispatch__("translations", this._translations, this._direction);
431
+ }
432
+
433
+ /**
434
+ * Get a translated string.
435
+ */
436
+ translate(
437
+ key: string,
438
+ replacements: Record<string, string | number> = {},
439
+ ): string {
440
+ const str = this._userTranslations[key] ?? this._translations?.[key];
441
+ return interpolate(typeof str === "string" ? str : undefined, replacements);
442
+ }
443
+
444
+ /**
445
+ * Announce a message to screen readers using a translation key.
446
+ */
447
+ announce(
448
+ key: string,
449
+ replacements: Record<string, string | number> = {},
450
+ priority: AnnouncerPriority = "polite",
451
+ ): void {
452
+ const message = this.translate(key, replacements);
453
+ if (message) {
454
+ this._announcer.announce(message, priority);
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Announce a raw message to screen readers (bypasses translation system).
460
+ */
461
+ announceRaw(message: string, priority: AnnouncerPriority = "polite"): void {
462
+ this._announcer.announce(message, priority);
463
+ }
464
+
465
+ /**
466
+ * Clear any pending announcements.
467
+ */
468
+ clearAnnouncements(): void {
469
+ this._announcer.clear();
470
+ }
471
+
472
+ on(
473
+ event: InstanceEvent,
474
+ callback: HookCallback,
475
+ owner: Element | null = null,
476
+ ): void {
477
+ if (!this.__hooks__[event]) {
478
+ const supportedEvents = Object.keys(this.__hooks__).join(", ");
479
+ console.error(
480
+ `[Pagefind Component UI]: Unknown event type ${event}. Supported events: [${supportedEvents}]`,
481
+ );
482
+ return;
483
+ }
484
+ if (typeof callback !== "function") {
485
+ console.error(
486
+ `[Pagefind Component UI]: Expected callback to be a function, received ${typeof callback}`,
487
+ );
488
+ return;
489
+ }
490
+
491
+ // If owner provided, check for existing handler from same owner to prevent duplicates
492
+ if (owner) {
493
+ const existingIndex = this.__hooks__[event].findIndex(
494
+ (h) => typeof h === "object" && h.owner === owner,
495
+ );
496
+ if (existingIndex !== -1) {
497
+ this.__hooks__[event][existingIndex] = { callback, owner };
498
+ return;
499
+ }
500
+ this.__hooks__[event].push({ callback, owner });
501
+ } else {
502
+ this.__hooks__[event].push(callback);
503
+ }
504
+ }
505
+
506
+ triggerLoad(): Promise<void> {
507
+ return this.__load__();
508
+ }
509
+
510
+ triggerSearch(term: string): void {
511
+ this.searchTerm = term;
512
+ this.__dispatch__("search", term, this.searchFilters);
513
+ this.__search__(term, this.searchFilters);
514
+ }
515
+
516
+ triggerSearchWithFilters(term: string, filters: FilterSelection): void {
517
+ this.searchTerm = term;
518
+ this.searchFilters = filters;
519
+ this.__dispatch__("search", term, filters);
520
+ this.__search__(term, filters);
521
+ }
522
+
523
+ triggerFilters(filters: FilterSelection): void {
524
+ this.searchFilters = filters;
525
+ this.__dispatch__("search", this.searchTerm, filters);
526
+ this.__search__(this.searchTerm, filters);
527
+ }
528
+
529
+ triggerFilter(filter: string, values: string[]): void {
530
+ this.searchFilters = this.searchFilters || {};
531
+ this.searchFilters[filter] = values;
532
+ this.__dispatch__("search", this.searchTerm, this.searchFilters);
533
+ this.__search__(this.searchTerm, this.searchFilters);
534
+ }
535
+
536
+ __dispatch__(e: InstanceEvent, ...args: unknown[]): void {
537
+ this.__hooks__[e]?.forEach((hook) => {
538
+ if (typeof hook === "function") {
539
+ hook(...args);
540
+ } else if (hook?.callback) {
541
+ hook.callback(...args);
542
+ }
543
+ });
544
+ }
545
+
546
+ async __clear__(): Promise<void> {
547
+ this.__dispatch__("results", { results: [], unfilteredTotalCount: 0 });
548
+ if (this.__pagefind__) {
549
+ this.availableFilters = await this.__pagefind__.filters();
550
+ this.totalFilters = this.availableFilters;
551
+ this.__dispatch__("filters", {
552
+ available: this.availableFilters,
553
+ total: this.totalFilters,
554
+ });
555
+ }
556
+ }
557
+
558
+ async __search__(term: string, filters: FilterSelection): Promise<void> {
559
+ this.__dispatch__("loading");
560
+ await this.__load__();
561
+ const thisSearch = ++this.__searchID__;
562
+
563
+ // In faceted mode, search even with empty term to show all/filtered results
564
+ if ((!term || !term.length) && !this.faceted) {
565
+ return this.__clear__();
566
+ }
567
+
568
+ if (!this.__pagefind__) return;
569
+
570
+ const searchTerm = term && term.length ? term : null;
571
+ const results = await this.__pagefind__.search(searchTerm, { filters });
572
+ if (results && this.__searchID__ === thisSearch) {
573
+ if (results.filters && Object.keys(results.filters)?.length) {
574
+ this.availableFilters = results.filters;
575
+ this.totalFilters = results.totalFilters ?? null;
576
+ this.__dispatch__("filters", {
577
+ available: this.availableFilters,
578
+ total: this.totalFilters,
579
+ });
580
+ }
581
+ this.searchResult = results;
582
+ this.__dispatch__("results", this.searchResult);
583
+
584
+ // Fallback: announce results if no component has claimed announcement capability
585
+ if (!this.hasAnnouncementCapability() && term) {
586
+ const count = results.results?.length ?? 0;
587
+ const key =
588
+ count === 0
589
+ ? "zero_results"
590
+ : count === 1
591
+ ? "one_result"
592
+ : "many_results";
593
+ const priority = count === 0 ? "assertive" : "polite";
594
+ this.announce(key, { SEARCH_TERM: term, COUNT: count }, priority);
595
+ }
596
+ }
597
+ }
598
+
599
+ async __load__(): Promise<void> {
600
+ if (this.__pagefind__) {
601
+ return;
602
+ }
603
+
604
+ if (this.__loadPromise__) {
605
+ return this.__loadPromise__;
606
+ }
607
+
608
+ this.__loadPromise__ = this.__doLoad__();
609
+
610
+ try {
611
+ await this.__loadPromise__;
612
+ } finally {
613
+ this.__loadPromise__ = null;
614
+ }
615
+ }
616
+
617
+ private async __doLoad__(): Promise<void> {
618
+ if (this.__pagefind__) return;
619
+
620
+ let imported_pagefind: PagefindAPI;
621
+ try {
622
+ imported_pagefind = await import(
623
+ /* @vite-ignore */
624
+ `${this.options.bundlePath}pagefind.js`
625
+ );
626
+ } catch (e) {
627
+ console.error(e);
628
+ console.error(
629
+ [
630
+ `Pagefind couldn't be loaded from ${this.options.bundlePath}pagefind.js`,
631
+ `You can configure this by passing a bundlePath option to the Pagefind Component UI`,
632
+ ].join("\n"),
633
+ );
634
+ // Important: Check that the element is indeed a <script> node, to avoid a DOM clobbering vulnerability
635
+ if (
636
+ document?.currentScript &&
637
+ document.currentScript.tagName.toUpperCase() === "SCRIPT"
638
+ ) {
639
+ console.error(
640
+ `[DEBUG: Loaded from ${
641
+ (document.currentScript as HTMLScriptElement)?.src ??
642
+ "bad script location"
643
+ }]`,
644
+ );
645
+ } else {
646
+ console.error("no known script location");
647
+ }
648
+
649
+ this.__dispatch__("error", {
650
+ type: "bundle_load_failed",
651
+ message: "Could not load search bundle",
652
+ bundlePath: this.options.bundlePath,
653
+ error: e,
654
+ } as PagefindError);
655
+
656
+ // Fallback: announce error if no component has claimed announcement capability
657
+ if (!this.hasAnnouncementCapability()) {
658
+ this.announce("error_search", {}, "assertive");
659
+ }
660
+ return;
661
+ }
662
+
663
+ await imported_pagefind.options(this.pagefindOptions || {});
664
+ for (const index of this.options.mergeIndex) {
665
+ if (!index.bundlePath) {
666
+ throw new Error("mergeIndex requires a bundlePath parameter");
667
+ }
668
+ const { bundlePath: url, ...indexOpts } = index;
669
+ await imported_pagefind.mergeIndex(url, indexOpts);
670
+ }
671
+ this.__pagefind__ = imported_pagefind;
672
+
673
+ this.availableFilters = await this.__pagefind__.filters();
674
+ this.totalFilters = this.availableFilters;
675
+ this.__dispatch__("filters", {
676
+ available: this.availableFilters,
677
+ total: this.totalFilters,
678
+ });
679
+
680
+ // In faceted mode, trigger initial search to show all results
681
+ if (this.faceted && this.__searchID__ === 0) {
682
+ this.triggerSearch("");
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Thin sub-results to the top N by relevance (location count).
688
+ * Preserves original order while keeping only the most relevant entries.
689
+ */
690
+ thinSubResults(results: PagefindSubResult[], limit = 3): PagefindSubResult[] {
691
+ if (results.length <= limit) return results;
692
+ const topUrls = [...results]
693
+ .sort((a, b) => (b.locations?.length ?? 0) - (a.locations?.length ?? 0))
694
+ .slice(0, limit)
695
+ .map((r) => r.url);
696
+ return results.filter((r) => topUrls.includes(r.url));
697
+ }
698
+
699
+ /**
700
+ * Get sub-results for display, excluding the root result and thinning to limit.
701
+ */
702
+ getDisplaySubResults(
703
+ result: PagefindResultData,
704
+ limit = 3,
705
+ ): PagefindSubResult[] {
706
+ if (!Array.isArray(result.sub_results)) return [];
707
+ const hasRootSubResult =
708
+ result.sub_results[0]?.url === (result.meta?.url || result.url);
709
+ const subResults = hasRootSubResult
710
+ ? result.sub_results.slice(1)
711
+ : result.sub_results;
712
+ return this.thinSubResults(subResults, limit);
713
+ }
714
+ }