@dogsbay/ssg 0.2.0-beta.152 → 0.2.0-beta.154

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.
@@ -196,7 +196,7 @@ if (document.readyState === "loading") {
196
196
  document.addEventListener("astro:page-load", init);
197
197
 
198
198
  // src/client/search.ts
199
- import { loadDetails, parseQuery as parseQuery2, scriptData, search } from "@dogsbay/search/query";
199
+ import { loadDetails, parseQuery as parseQuery2, scriptData, search, searchSources } from "@dogsbay/search/query";
200
200
 
201
201
  // src/client/pagefind.ts
202
202
  import { parseQuery } from "@dogsbay/search/query";
@@ -221,14 +221,118 @@ function pagefindSearch(unitRoot) {
221
221
  const more = Promise.all(hits.slice(10, 30).map((h, i) => toResult(h, i + 10)));
222
222
  more.catch(() => {
223
223
  });
224
- return { results: first, more, total: hits.length, highlight: parseQuery(q).highlight };
224
+ const later = (from, n) => Promise.all(hits.slice(from, from + n).map((h, i) => toResult(h, from + i)));
225
+ return { results: first, more, later, total: hits.length, highlight: parseQuery(q).highlight };
225
226
  };
226
227
  }
227
228
  function text(html) {
228
229
  return new DOMParser().parseFromString(html, "text/html").body.textContent ?? "";
229
230
  }
230
231
 
232
+ // src/client/search-scope.ts
233
+ function searchScopes(site, ownPrefix) {
234
+ const units = site?.units ?? [];
235
+ const own = units.find((u) => u.prefix === ownPrefix);
236
+ if (!own) return {};
237
+ const productKey = (u) => u.product ?? "";
238
+ const versionsOf = (product) => site?.products?.[product] ?? { versions: site?.versions, defaultVersion: site?.defaultVersion };
239
+ const versionDef = (u) => versionsOf(productKey(u)).versions?.find((v) => v.id === u.version);
240
+ const productLabel = (product) => site?.products?.[product]?.label ?? (product || site?.name || "");
241
+ const label = (u) => [
242
+ productKey(u) !== productKey(own) ? productLabel(productKey(u)) : "",
243
+ u.version !== void 0 ? versionDef(u)?.label ?? u.version : ""
244
+ ].filter(Boolean).join(" \xB7 ");
245
+ const scoped = (u) => ({ prefix: u.prefix, product: productKey(u), label: label(u), own: u === own, versioned: u.version !== void 0 });
246
+ const sameLocale = units.filter((u) => u.locale === own.locale);
247
+ const own_ = versionsOf(productKey(own));
248
+ const order = (own_.versions ?? []).map((v) => v.id);
249
+ const rank = (u) => u === own ? -2 : u.version === own_.defaultVersion ? -1 : versionDef(u)?.prerelease ? order.length + order.indexOf(u.version) : order.indexOf(u.version);
250
+ const versions = sameLocale.filter((u) => productKey(u) === productKey(own) && u.version !== void 0 && (u === own || !versionDef(u)?.hidden)).sort((a, b) => rank(a) - rank(b));
251
+ const products = [];
252
+ for (const u of sameLocale) {
253
+ const key = productKey(u);
254
+ if (products.some((p) => productKey(p) === key)) continue;
255
+ if (key === productKey(own)) {
256
+ products.push(own);
257
+ continue;
258
+ }
259
+ const mine = sameLocale.filter((x) => productKey(x) === key);
260
+ const def = versionsOf(key).defaultVersion;
261
+ products.push(mine.find((x) => x.version === def) ?? mine.find((x) => !versionDef(x)?.hidden) ?? mine[0]);
262
+ }
263
+ const out = {};
264
+ if (versions.length > 1) out.versions = versions.map(scoped);
265
+ if (products.length > 1) out.products = products.map(scoped);
266
+ if (out.versions || out.products) out.version = [scoped(own)];
267
+ return out;
268
+ }
269
+ var OWN_UNIT_BOOST = 1.5;
270
+
271
+ // src/client/search-facets.ts
272
+ function docFacets(meta, id) {
273
+ const out = {};
274
+ const tags = meta.docs[id]?.[3];
275
+ if (tags?.length) out.tag = tags.map((x) => meta.tags[x]).filter((x) => typeof x === "string");
276
+ const ids = meta.docs[id]?.[4];
277
+ if (!ids || !meta.facets) return out;
278
+ meta.facets.forEach((f, i) => {
279
+ const v = ids[i];
280
+ if (v?.length) out[f.name] = v.map((x) => f.values[x]).filter((x) => typeof x === "string");
281
+ });
282
+ return out;
283
+ }
284
+ var norm = (v) => v.trim().toLowerCase();
285
+ function passes(values, filters, except) {
286
+ for (const [name, wanted] of Object.entries(filters)) {
287
+ if (name === except || !wanted.length) continue;
288
+ const have = new Set((values[name] ?? []).map(norm));
289
+ if (!wanted.some((w) => have.has(norm(w)))) return false;
290
+ }
291
+ return true;
292
+ }
293
+ function filterResults(results, valuesOf, filters) {
294
+ return results.filter((r) => passes(valuesOf(r), filters));
295
+ }
296
+ function countFacets(results, valuesOf, filters, names) {
297
+ const out = {};
298
+ for (const name of names) {
299
+ const counts = {};
300
+ const seen = /* @__PURE__ */ new Map();
301
+ for (const r of results) {
302
+ const values = valuesOf(r);
303
+ if (!values[name]?.length || !passes(values, filters, name)) continue;
304
+ const mine = /* @__PURE__ */ new Set();
305
+ for (const v of values[name]) {
306
+ if (mine.has(norm(v))) continue;
307
+ mine.add(norm(v));
308
+ const key = seen.get(norm(v)) ?? (seen.set(norm(v), v), v);
309
+ counts[key] = (counts[key] ?? 0) + 1;
310
+ }
311
+ }
312
+ for (const w of filters[name] ?? []) if (!seen.has(norm(w))) counts[w] = 0;
313
+ if (Object.keys(counts).length) out[name] = counts;
314
+ }
315
+ return out;
316
+ }
317
+ function filtersFromParams(params, names) {
318
+ const out = {};
319
+ for (const f of params.getAll("filter")) {
320
+ const i = f.indexOf(":");
321
+ if (i <= 0) continue;
322
+ const name = f.slice(0, i);
323
+ const value = f.slice(i + 1);
324
+ if (!names.includes(name) || !value) continue;
325
+ if (!(out[name] ??= []).some((v) => norm(v) === norm(value))) out[name].push(value);
326
+ }
327
+ return out;
328
+ }
329
+
231
330
  // src/client/search.ts
331
+ import { buildFacetTree, countActiveFilters, resolveFacetLabel, shapeFacets, sortFacetNames, toggleFilter } from "@dogsbay/search/facets";
332
+ var RETRY_AFTER_MS = 6e4;
333
+ var DIALOG_RESULTS = 30;
334
+ var PAGE_RESULTS = 20;
335
+ var SCOPES = ["version", "versions", "products"];
232
336
  function initSearch(opts) {
233
337
  const { signal } = opts;
234
338
  const dialog = document.getElementById("db-search");
@@ -236,29 +340,119 @@ function initSearch(opts) {
236
340
  const input = dialog.querySelector("[data-db-search-input]");
237
341
  const list = dialog.querySelector("[data-part='listbox']");
238
342
  const status = dialog.querySelector("[data-db-search-status]");
239
- const hint = status.textContent ?? "";
240
- const data = scriptData(`${opts.unitRoot}_dogsbay/search/`, opts.loadScript, window);
241
- const engine = document.body.dataset.search ?? "builtin";
242
- const served = location.protocol !== "file:";
243
- const pagefind = engine === "pagefind" || engine === "both" && served ? pagefindSearch(opts.unitRoot) : void 0;
244
- const needsServer = engine === "pagefind" && !served;
343
+ const ui = wire(opts, { input, list, status, page: false });
245
344
  dialog.addEventListener("db-dialog:open", () => {
246
345
  input.focus();
247
346
  input.select();
248
- if (needsServer) status.textContent = list.dataset.needsServer ?? "";
249
- else if (!pagefind) void data.meta().catch(() => status.textContent = "Search index could not be loaded.");
347
+ ui.activate();
250
348
  }, { signal });
251
349
  document.addEventListener("keydown", (e) => {
252
350
  const typing = e.target.closest("input, textarea, select, [contenteditable]");
253
351
  if (e.key === "/" && !typing || e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
254
352
  e.preventDefault();
255
- if (!dialog.open) dialog.show();
353
+ const own = document.querySelector("[data-db-search-page] [data-db-search-input]");
354
+ if (own && !dialog.open) own.focus();
355
+ else if (!dialog.open) dialog.show();
256
356
  else input.focus();
257
357
  }
258
358
  }, { signal });
259
- list.addEventListener("click", (e) => {
359
+ dialog.addEventListener("click", (e) => {
260
360
  if (e.target.closest("a")) dialog.close("api");
261
361
  }, { signal });
362
+ }
363
+ function initSearchPage(opts) {
364
+ const root = document.querySelector("[data-db-search-page]");
365
+ if (!root) return;
366
+ const input = root.querySelector("[data-db-search-input]");
367
+ const list = root.querySelector("[data-db-search-list]");
368
+ const status = root.querySelector("[data-db-search-status]");
369
+ const params = readParams();
370
+ const p = params.get("p") || void 0;
371
+ const v = params.get("v") || void 0;
372
+ const scope = params.get("scope") ?? (params.get("q") ? "version" : void 0);
373
+ const ui = wire(opts, { input, list, status, page: true, params, scope, forward: p !== void 0 || v !== void 0 ? { product: p, version: v } : void 0 });
374
+ root.querySelector("form")?.addEventListener("submit", (e) => e.preventDefault(), { signal: opts.signal });
375
+ input.value = params.get("q") ?? "";
376
+ ui.activate();
377
+ if (input.value.trim()) ui.run();
378
+ }
379
+ function readParams() {
380
+ const q = new URLSearchParams(location.search);
381
+ if (location.hash.includes("=")) {
382
+ for (const [k, v] of new URLSearchParams(location.hash.slice(1))) if (!q.has(k)) q.set(k, v);
383
+ }
384
+ return q;
385
+ }
386
+ var inFragment = () => !location.search && location.hash.includes("=");
387
+ function wire(opts, { input, list, status, page, scope: askedScope, forward, params }) {
388
+ const { signal } = opts;
389
+ const hint = status.textContent ?? "";
390
+ const data = scriptData(`${opts.unitRoot}_dogsbay/search/`, opts.loadScript, window);
391
+ const engine = document.body.dataset.search ?? "builtin";
392
+ const served = location.protocol !== "file:";
393
+ const pagefind = engine === "pagefind" || engine === "both" && served ? pagefindSearch(opts.unitRoot) : void 0;
394
+ const needsServer = engine === "pagefind" && !served;
395
+ const siteRoot = opts.siteRoot ?? "";
396
+ const ownPrefix = opts.prefix ?? "";
397
+ let scopes = {};
398
+ let scope = "version";
399
+ let scopesLoaded;
400
+ const scopeKey = `db-search-scope:${new URL(siteRoot || "./", location.href).pathname}`;
401
+ const applyScopes = () => {
402
+ const site = window.__DOGSBAY_SITE__;
403
+ if (forward && site && forwardTo(site)) return;
404
+ forward = void 0;
405
+ if (pagefind) return;
406
+ scopes = searchScopes(site, ownPrefix);
407
+ const wanted = askedScope ?? session(() => sessionStorage.getItem(scopeKey));
408
+ const before = scope;
409
+ if (wanted && Object.prototype.hasOwnProperty.call(scopes, wanted)) scope = wanted;
410
+ chips();
411
+ if (scope !== before && input.value.trim()) void run(++seq);
412
+ };
413
+ const forwardTo = (site) => {
414
+ const units = site.units ?? [];
415
+ const own = units.find((u) => u.prefix === ownPrefix);
416
+ if (!own) return false;
417
+ const product = forward.product ?? own.product ?? "";
418
+ const mine = units.filter((u) => u.locale === own.locale && (u.product ?? "") === product);
419
+ const def = site.products?.[product]?.defaultVersion ?? site.defaultVersion;
420
+ const target = forward.version !== void 0 ? mine.find((u) => u.version === forward.version || u.prefix && u.prefix.split("/").pop() === forward.version) : mine.find((u) => u === own) ?? mine.find((u) => u.version === def) ?? mine[0];
421
+ if (!target || target.prefix === ownPrefix || !target.search) return false;
422
+ const keep = new URLSearchParams(params);
423
+ keep.delete("p");
424
+ keep.delete("v");
425
+ const query = keep.toString();
426
+ location.replace(`${rootOf(target.prefix)}${target.search}${query ? inFragment() ? `#${query}` : `?${query}` : ""}`);
427
+ return true;
428
+ };
429
+ const loadScopes = () => scopesLoaded ??= opts.loadScript(`${siteRoot}_dogsbay/site.js`).then(applyScopes, applyScopes);
430
+ const rootOf = (prefix) => `${siteRoot}${prefix ? `${prefix}/` : ""}`;
431
+ const sources = /* @__PURE__ */ new Map([[ownPrefix, data]]);
432
+ const dataOf = (prefix) => {
433
+ let d = sources.get(prefix);
434
+ if (!d) sources.set(prefix, d = scriptData(`${rootOf(prefix)}_dogsbay/search/`, opts.loadScript, window));
435
+ return d;
436
+ };
437
+ const failedAt = /* @__PURE__ */ new Map();
438
+ const facetsBox = (page ? list.closest("[data-db-search-page]") : list.closest("dialog"))?.querySelector("[data-db-search-facets]") ?? void 0;
439
+ const display = (() => {
440
+ try {
441
+ return JSON.parse(list.dataset.facets ?? "{}");
442
+ } catch {
443
+ return {};
444
+ }
445
+ })();
446
+ const facetNames = facetsBox ? Object.keys(display) : [];
447
+ let filters = page && params ? filtersFromParams(params, facetNames) : {};
448
+ const activate = () => {
449
+ if (forward) void loadScopes();
450
+ if (needsServer) status.textContent = list.dataset.needsServer ?? "";
451
+ else if (!pagefind) {
452
+ void data.meta().catch(() => status.textContent = "Search index could not be loaded.");
453
+ void loadScopes();
454
+ }
455
+ };
262
456
  let seq = 0;
263
457
  let parsed;
264
458
  let timer;
@@ -275,51 +469,137 @@ function initSearch(opts) {
275
469
  observer.unobserve(e.target);
276
470
  }
277
471
  }) : void 0;
472
+ const keepAddress = (q) => {
473
+ if (!page || forward) return;
474
+ const p = new URLSearchParams();
475
+ if (q) p.set("q", q);
476
+ if (scope !== "version") p.set("scope", scope);
477
+ for (const [name, values] of Object.entries(filters)) for (const v of values) p.append("filter", `${name}:${v}`);
478
+ const s = p.toString();
479
+ const hashed = inFragment();
480
+ session(() => history.replaceState(history.state, "", `${location.pathname}${s ? hashed ? `${location.search}#${s}` : `?${s}` : ""}`));
481
+ };
278
482
  const run = async (mine) => {
279
483
  const q = input.value.trim();
484
+ keepAddress(q);
280
485
  if (!q) {
281
486
  observer?.disconnect();
282
487
  pending.clear();
283
488
  filterBar.hidden = true;
489
+ if (facetsBox) facetsBox.hidden = true;
490
+ last = void 0;
284
491
  list.replaceChildren();
492
+ more.hidden = seeAll.hidden = true;
285
493
  status.textContent = hint;
286
494
  return;
287
495
  }
288
496
  if (needsServer) return;
497
+ last = void 0;
289
498
  try {
290
499
  if (pagefind) {
291
500
  const found = await pagefind(q);
292
501
  if (mine !== seq) return;
293
- render(q, found.results, found.highlight, found.total);
294
- const more = await found.more;
295
- if (mine === seq && more.length) append(q, more, found.highlight);
502
+ if (page) {
503
+ const rest = await found.more;
504
+ if (mine !== seq) return;
505
+ render(q, [...found.results, ...rest].slice(0, PAGE_RESULTS), found.highlight, found.total, found.later);
506
+ } else {
507
+ render(q, found.results, found.highlight, found.total);
508
+ const rest = await found.more;
509
+ if (mine === seq && rest.length) append(q, rest, found.highlight);
510
+ }
511
+ } else if (scope !== "version" && scopes[scope]) {
512
+ const now = Date.now();
513
+ const all = scopes[scope];
514
+ const units = all.filter((u) => u.own || now - (failedAt.get(u.prefix) ?? -Infinity) > RETRY_AFTER_MS);
515
+ const found = await searchSources(q, units.map((u) => ({ data: dataOf(u.prefix), boost: u.own ? OWN_UNIT_BOOST : 1, collapse: u.product })));
516
+ for (const i of found.missing) failedAt.set(units[i].prefix, Date.now());
517
+ if (mine !== seq) return;
518
+ parsed = found.parsed;
519
+ const shown = found.results.map((r) => {
520
+ const u = units[r.source];
521
+ return Object.assign(r, {
522
+ base: rootOf(u.prefix),
523
+ from: u.own ? void 0 : u.label,
524
+ also: r.others?.map((o) => units[o.source].label).filter(Boolean),
525
+ data: dataOf(u.prefix)
526
+ });
527
+ });
528
+ const missing = found.missing.length + all.length - units.length;
529
+ await show(mine, q, shown, found.parsed.highlight, missing ? ` \xB7 ${(list.dataset.unavailable ?? "{n}").replace("{n}", String(missing))}` : "");
296
530
  } else {
297
531
  const found = await search(q, data);
298
532
  if (mine !== seq) return;
299
533
  parsed = found.parsed;
300
- render(q, found.results.slice(0, 30), found.parsed.highlight, found.results.length, found.results);
534
+ await show(mine, q, found.results, found.parsed.highlight, "");
301
535
  }
302
536
  } catch {
303
- if (mine === seq) status.textContent = "Search index could not be loaded.";
537
+ if (mine !== seq) return;
538
+ status.textContent = "Search index could not be loaded.";
539
+ if (facetsBox) facetsBox.hidden = true;
304
540
  }
305
541
  };
306
542
  const filterBar = document.createElement("div");
307
543
  filterBar.setAttribute("role", "group");
308
544
  filterBar.setAttribute("aria-label", list.dataset.filterSection ?? "Filter by section");
309
- filterBar.className = "flex flex-wrap gap-1 px-3 pb-2";
545
+ filterBar.className = page ? "flex flex-wrap gap-1 pb-3" : "flex flex-wrap gap-1 px-3 pb-2";
310
546
  filterBar.hidden = true;
311
547
  list.before(filterBar);
548
+ const scopeBar = document.createElement("div");
549
+ scopeBar.setAttribute("role", "group");
550
+ scopeBar.setAttribute("aria-label", list.dataset.scope ?? "Search in");
551
+ scopeBar.className = page ? "flex flex-wrap items-center gap-1 pb-3" : "flex flex-wrap items-center gap-1 px-3 pb-2";
552
+ scopeBar.hidden = true;
553
+ filterBar.before(scopeBar);
554
+ const seeAll = document.createElement("a");
555
+ seeAll.className = "mx-3 mb-2 mt-1 block rounded-md px-3 py-2 text-center text-sm font-medium hover:bg-accent";
556
+ seeAll.dataset.dbSearchSeeAll = "";
557
+ seeAll.hidden = true;
558
+ const more = document.createElement("button");
559
+ more.type = "button";
560
+ more.className = "mt-4 rounded-md border px-3 py-1.5 text-sm hover:bg-accent";
561
+ more.textContent = list.dataset.more ?? "More results";
562
+ more.hidden = true;
563
+ list.after(page ? more : seeAll);
564
+ const chips = () => {
565
+ const names = SCOPES.filter((n) => scopes[n]);
566
+ const label = { version: scopes.version?.[0]?.versioned === false ? list.dataset.scopeProduct : list.dataset.scopeVersion, versions: list.dataset.scopeVersions, products: list.dataset.scopeProducts };
567
+ scopeBar.replaceChildren(
568
+ ...names.map((n) => {
569
+ const b = document.createElement("button");
570
+ b.type = "button";
571
+ b.dataset.scope = n;
572
+ b.setAttribute("aria-pressed", String(n === scope));
573
+ b.className = "rounded-md border px-2.5 py-0.5 text-xs hover:bg-accent aria-pressed:bg-primary aria-pressed:text-primary-foreground";
574
+ b.textContent = label[n] ?? n;
575
+ b.addEventListener("click", () => {
576
+ if (scope === n) return;
577
+ scope = n;
578
+ session(() => sessionStorage.setItem(scopeKey, n));
579
+ chips();
580
+ scopeBar.querySelector(`[data-scope="${n}"]`)?.focus();
581
+ if (input.value.trim()) void run(++seq);
582
+ else keepAddress("");
583
+ }, { signal });
584
+ return b;
585
+ })
586
+ );
587
+ scopeBar.hidden = names.length < 2;
588
+ };
312
589
  signal?.addEventListener("abort", () => {
313
590
  clearTimeout(timer);
314
591
  seq++;
315
592
  observer?.disconnect();
316
593
  pending.clear();
317
594
  filterBar.remove();
595
+ scopeBar.remove();
596
+ seeAll.remove();
597
+ more.remove();
318
598
  });
319
599
  const bare = (name) => name.replace(/"/g, "");
320
600
  const sectionToken = (name) => `section:"${bare(name)}"`;
321
601
  let refocus;
322
- const filters = (q, all) => {
602
+ const sectionFilters = (q, all) => {
323
603
  const counts = /* @__PURE__ */ new Map();
324
604
  for (const r of all) if (r.section) counts.set(r.section, (counts.get(r.section) ?? 0) + 1);
325
605
  const active = [...q.matchAll(/section:"([^"]*)"/gi)].map((m) => m[1].toLowerCase());
@@ -351,37 +631,199 @@ function initSearch(opts) {
351
631
  refocus = void 0;
352
632
  }
353
633
  };
354
- const render = (q, results, terms, total, all = results) => {
634
+ let last;
635
+ const show = async (mine, q, results, terms, note) => {
636
+ if (!facetNames.length) {
637
+ render(q, results, terms, results.length);
638
+ status.textContent += note;
639
+ return;
640
+ }
641
+ const metas = /* @__PURE__ */ new Map();
642
+ for (const r of results) {
643
+ const d = r.data ?? data;
644
+ if (!metas.has(d)) metas.set(d, await d.meta());
645
+ }
646
+ if (mine !== seq) return;
647
+ const values = new Map(results.map((r) => [r, docFacets(metas.get(r.data ?? data), r.id)]));
648
+ last = { q, results, terms, note, valuesOf: (r) => values.get(r) ?? {} };
649
+ applyFacets();
650
+ };
651
+ const applyFacets = () => {
652
+ if (!last) return;
653
+ const { q, results, terms, note, valuesOf } = last;
654
+ const kept = filterResults(results, valuesOf, filters);
655
+ renderFacets(countFacets(results, valuesOf, filters, facetNames));
656
+ render(q, kept, terms, kept.length);
657
+ status.textContent += note;
658
+ };
659
+ let refocusFacet;
660
+ const renderFacets = (counts) => {
661
+ if (!facetsBox) return;
662
+ const names = sortFacetNames(Object.keys(counts), display);
663
+ facetsBox.hidden = !names.length;
664
+ if (!names.length) return facetsBox.replaceChildren();
665
+ const box = (name, value, label, count) => {
666
+ const row = document.createElement("label");
667
+ row.className = "flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent";
668
+ const cb = document.createElement("input");
669
+ cb.type = "checkbox";
670
+ cb.dataset.facetName = name;
671
+ cb.dataset.facetValue = value;
672
+ cb.checked = (filters[name] ?? []).some((v) => v.toLowerCase() === value.toLowerCase());
673
+ cb.className = "size-3.5 rounded border-border accent-primary";
674
+ const text2 = document.createElement("span");
675
+ text2.className = "flex-1 truncate";
676
+ text2.textContent = label;
677
+ const n = document.createElement("span");
678
+ n.className = "text-xs text-muted-foreground";
679
+ n.textContent = String(count);
680
+ row.append(cb, text2, n);
681
+ return row;
682
+ };
683
+ const tree = (name, nodes) => {
684
+ const ul = document.createElement("ul");
685
+ ul.className = "space-y-0.5";
686
+ for (const node of nodes) {
687
+ const li = document.createElement("li");
688
+ if (node.hasValue) li.append(box(name, node.value, node.label, node.count));
689
+ else {
690
+ const span = document.createElement("span");
691
+ span.className = "block px-2 py-1 text-muted-foreground";
692
+ span.textContent = node.label;
693
+ li.append(span);
694
+ }
695
+ if (node.children.length) {
696
+ const sub = tree(name, node.children);
697
+ sub.classList.add("ml-4");
698
+ li.append(sub);
699
+ }
700
+ ul.append(li);
701
+ }
702
+ return ul;
703
+ };
704
+ const parts = [];
705
+ const active = countActiveFilters(filters);
706
+ if (active) {
707
+ const clear = document.createElement("button");
708
+ clear.type = "button";
709
+ clear.dataset.clearFilters = "";
710
+ clear.className = "mb-3 w-full rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
711
+ clear.textContent = (list.dataset.facetsClear ?? "Clear all ({n})").replace("{n}", String(active));
712
+ parts.push(clear);
713
+ }
714
+ for (const name of names) {
715
+ const entries = shapeFacets({ [name]: counts[name] })[name] ?? [];
716
+ const set = document.createElement("fieldset");
717
+ set.className = "mb-3";
718
+ const legend = document.createElement("legend");
719
+ legend.className = "mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground";
720
+ legend.textContent = display[name]?.title ?? name;
721
+ set.append(legend);
722
+ if (display[name]?.hierarchical || entries.some((e) => e.value.includes("/"))) set.append(tree(name, buildFacetTree(name, entries, { display })));
723
+ else {
724
+ const ul = document.createElement("ul");
725
+ ul.className = "space-y-0.5";
726
+ for (const e of entries) {
727
+ const li = document.createElement("li");
728
+ li.append(box(name, e.value, resolveFacetLabel(name, e.value, display), e.count));
729
+ ul.append(li);
730
+ }
731
+ set.append(ul);
732
+ }
733
+ parts.push(set);
734
+ }
735
+ facetsBox.replaceChildren(...parts);
736
+ if (refocusFacet !== void 0) {
737
+ const again = refocusFacet ? [...facetsBox.querySelectorAll("input[data-facet-name]")].find((c) => `${c.dataset.facetName}\0${c.dataset.facetValue}` === refocusFacet) : facetsBox.querySelector("[data-clear-filters]");
738
+ (again ?? input).focus();
739
+ refocusFacet = void 0;
740
+ }
741
+ };
742
+ facetsBox?.addEventListener("change", (e) => {
743
+ const cb = e.target;
744
+ if (!cb.dataset.facetName) return;
745
+ const name = cb.dataset.facetName;
746
+ const value = cb.dataset.facetValue;
747
+ const selected = (filters[name] ?? []).find((v) => v.toLowerCase() === value.toLowerCase());
748
+ filters = toggleFilter(filters, name, selected ?? value);
749
+ refocusFacet = `${name}\0${value}`;
750
+ keepAddress(input.value.trim());
751
+ applyFacets();
752
+ }, { signal });
753
+ facetsBox?.addEventListener("click", (e) => {
754
+ if (!e.target.closest("[data-clear-filters]")) return;
755
+ filters = {};
756
+ refocusFacet = "";
757
+ keepAddress(input.value.trim());
758
+ applyFacets();
759
+ }, { signal });
760
+ let current2;
761
+ const render = (q, results, terms, total, later) => {
355
762
  observer?.disconnect();
356
763
  pending.clear();
357
- filters(q, all);
358
- list.replaceChildren(...items(q, results, terms));
764
+ sectionFilters(q, results);
765
+ const first = results.slice(0, page ? PAGE_RESULTS : DIALOG_RESULTS);
766
+ current2 = { q, results, terms, shown: first.length, total, later };
767
+ list.replaceChildren(...items(q, first, terms));
359
768
  status.textContent = total ? `${total} result${total === 1 ? "" : "s"}` : `${list.dataset.noResults} \u201C${q}\u201D`;
769
+ more.hidden = !page || !hasMore();
770
+ seeAll.hidden = page || !total || !list.dataset.searchPage;
771
+ if (!seeAll.hidden) {
772
+ const p = new URLSearchParams({ q });
773
+ if (scope !== "version") p.set("scope", scope);
774
+ for (const [name, values] of Object.entries(filters)) for (const v of values) p.append("filter", `${name}:${v}`);
775
+ seeAll.href = `${list.dataset.searchPage}?${p}`;
776
+ seeAll.textContent = (list.dataset.seeAll ?? "See all {n} results").replace("{n}", String(total));
777
+ }
360
778
  };
361
779
  const append = (q, results, terms) => list.append(...items(q, results, terms));
780
+ const hasMore = () => !!current2 && (current2.results.length > current2.shown || !!current2.later && current2.shown < current2.total);
781
+ more.addEventListener("click", async () => {
782
+ const c = current2;
783
+ if (!c) return;
784
+ let next = c.results.slice(c.shown, c.shown + PAGE_RESULTS);
785
+ if (!next.length && c.later) {
786
+ next = await c.later(c.shown, PAGE_RESULTS).catch(() => []);
787
+ if (c !== current2) return;
788
+ c.results.push(...next);
789
+ }
790
+ const added = items(c.q, next, c.terms);
791
+ list.append(...added);
792
+ c.shown += next.length;
793
+ more.hidden = !next.length || !hasMore();
794
+ added[0]?.querySelector("a")?.focus();
795
+ }, { signal });
362
796
  const items = (q, results, terms) => {
363
797
  const items2 = [];
364
798
  for (const r of results) {
365
- const page = option(r.url, terms, "block rounded-md p-3 hover:bg-accent");
799
+ const base = r.base ?? opts.unitRoot;
800
+ const result = option(base, r.url, terms, "block rounded-md p-3 hover:bg-accent");
366
801
  const t = document.createElement("div");
367
802
  t.className = "font-medium";
368
803
  t.append(...highlight(r.title, terms));
369
- page.a.appendChild(t);
370
- if (r.section) {
804
+ result.a.appendChild(t);
805
+ const where = [r.from, r.section].filter(Boolean).join(" \xB7 ");
806
+ if (where) {
371
807
  const s = document.createElement("div");
372
808
  s.className = "text-xs text-muted-foreground";
373
- s.textContent = r.section;
374
- page.a.appendChild(s);
809
+ s.textContent = where;
810
+ result.a.appendChild(s);
375
811
  }
376
812
  const excerpt = document.createElement("div");
377
813
  excerpt.className = "mt-1 line-clamp-2 text-xs text-muted-foreground";
378
- page.a.appendChild(excerpt);
379
- items2.push(page.li);
814
+ result.a.appendChild(excerpt);
815
+ if (r.also?.length) {
816
+ const also = document.createElement("div");
817
+ also.className = "mt-1 text-xs text-muted-foreground";
818
+ also.textContent = `${list.dataset.alsoIn ?? "Also in"} ${r.also.join(", ")}`;
819
+ result.a.appendChild(also);
820
+ }
821
+ items2.push(result.li);
380
822
  const fill = () => {
381
823
  if (!r.fragment) return [];
382
824
  if (r.fragment[0]) excerpt.replaceChildren(...highlight(r.fragment[0], terms));
383
825
  return (r.sections ?? []).map(([text2, id]) => {
384
- const sub = option(`${r.url}#${id}`, terms, "ml-4 block rounded-md border-l px-3 py-1.5 text-sm hover:bg-accent");
826
+ const sub = option(base, `${r.url}#${id}`, terms, "ml-4 block rounded-md border-l px-3 py-1.5 text-sm hover:bg-accent");
385
827
  sub.a.append(...highlight(text2, terms));
386
828
  return sub.li;
387
829
  });
@@ -389,26 +831,36 @@ function initSearch(opts) {
389
831
  if (r.fragment) items2.push(...fill());
390
832
  else if (observer) {
391
833
  const p = parsed ?? parseQuery2(q);
392
- pending.set(page.li, () => void loadDetails(p, [r], data).then(() => page.li.after(...fill()), () => {
834
+ pending.set(result.li, () => void loadDetails(p, [r], r.data ?? data).then(() => result.li.after(...fill()), () => {
393
835
  }));
394
- observer.observe(page.li);
836
+ observer.observe(result.li);
395
837
  }
396
838
  }
397
839
  return items2;
398
840
  };
399
- const option = (url, terms, className) => {
841
+ const option = (base, url, terms, className) => {
400
842
  const li = document.createElement("li");
401
- li.setAttribute("role", "none");
843
+ if (!page) li.setAttribute("role", "none");
402
844
  li.className = "mb-1";
403
845
  const a = document.createElement("a");
404
- a.setAttribute("data-part", "option");
405
- a.dataset.value = url;
406
- a.href = withHighlight(opts.unitRoot + url, terms);
407
- a.tabIndex = -1;
846
+ if (!page) {
847
+ a.setAttribute("data-part", "option");
848
+ a.dataset.value = url;
849
+ a.tabIndex = -1;
850
+ }
851
+ a.href = withHighlight(base + url, terms);
408
852
  a.className = className;
409
853
  li.appendChild(a);
410
854
  return { li, a };
411
855
  };
856
+ return { activate, run: () => void run(++seq) };
857
+ }
858
+ function session(f) {
859
+ try {
860
+ return f();
861
+ } catch {
862
+ return void 0;
863
+ }
412
864
  }
413
865
  function withHighlight(href, terms) {
414
866
  if (!terms.length) return href;
@@ -557,20 +1009,28 @@ function isRelativeTarget(t) {
557
1009
 
558
1010
  // src/client/mount.ts
559
1011
  var loaded = /* @__PURE__ */ new Map();
1012
+ var LOAD_TIMEOUT_MS = 15e3;
560
1013
  function loadScript(src) {
561
1014
  let p = loaded.get(src);
562
1015
  if (!p) {
563
- p = new Promise((resolve, reject) => {
1016
+ const mine = new Promise((resolve, reject) => {
564
1017
  const s = document.createElement("script");
565
1018
  s.src = src;
566
- s.onload = () => resolve();
567
- s.onerror = () => {
568
- loaded.delete(src);
1019
+ const timer = setTimeout(() => fail(), LOAD_TIMEOUT_MS);
1020
+ const fail = () => {
1021
+ clearTimeout(timer);
1022
+ s.onload = s.onerror = null;
1023
+ if (loaded.get(src) === mine) loaded.delete(src);
569
1024
  reject(new Error(`could not load ${src}`));
570
1025
  };
1026
+ s.onload = () => {
1027
+ clearTimeout(timer);
1028
+ resolve();
1029
+ };
1030
+ s.onerror = fail;
571
1031
  document.head.appendChild(s);
572
1032
  });
573
- loaded.set(src, p);
1033
+ loaded.set(src, p = mine);
574
1034
  }
575
1035
  return p;
576
1036
  }
@@ -693,7 +1153,10 @@ function mount(o = {}) {
693
1153
  initPageActions(signal);
694
1154
  if (o.switchers !== false) initSwitchers(siteRoot, signal);
695
1155
  initAnchors(signal);
696
- if (o.search !== false) initSearch({ unitRoot, loadScript, signal });
1156
+ if (o.search !== false) {
1157
+ initSearch({ unitRoot, siteRoot, prefix: body.dataset.prefix ?? "", loadScript, signal });
1158
+ initSearchPage({ unitRoot, siteRoot, prefix: body.dataset.prefix ?? "", loadScript, signal });
1159
+ }
697
1160
  initHighlights(signal);
698
1161
  initImageZoom(signal);
699
1162
  const page = {