@dogsbay/docs-layout 0.2.0-beta.8 → 0.2.0-beta.80
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/package.json +4 -4
- package/src/DocsLayout.astro +183 -30
- package/src/DocsNavClient.astro +89 -0
- package/src/SearchDialog.astro +272 -32
- package/src/TaxonomyTerm.astro +15 -4
- package/src/docs-nav-client.ts +265 -0
- package/src/json-ld.ts +77 -0
- package/src/search-facets.ts +511 -9
- package/src/version-redirect.ts +23 -0
package/src/search-facets.ts
CHANGED
|
@@ -16,6 +16,33 @@ import type { PrefixDisplay } from "./tag-list-data.js";
|
|
|
16
16
|
export interface TaxonomyDisplay {
|
|
17
17
|
prefixes?: Record<string, PrefixDisplay>;
|
|
18
18
|
labels?: Record<string, string>;
|
|
19
|
+
/**
|
|
20
|
+
* Sort weight for the facet column in the search dialog. Lower
|
|
21
|
+
* numbers appear first. Facets without an `order` sort
|
|
22
|
+
* alphabetically *after* any pinned ones — so `{ type: { order: 1 } }`
|
|
23
|
+
* promotes "Type" to the top while everything else stays alpha.
|
|
24
|
+
*/
|
|
25
|
+
order?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Render this facet as a tree instead of a flat checkbox list.
|
|
28
|
+
* See `buildFacetTree` for the two derivation strategies (slash-
|
|
29
|
+
* encoded vs nav-driven segment-encoded) and the canonical
|
|
30
|
+
* @dogsbay/types definition for full semantics.
|
|
31
|
+
*/
|
|
32
|
+
hierarchical?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal nav item shape — mirrors `@dogsbay/types`' `NavItem`
|
|
37
|
+
* locally so this module has no runtime dependency on the types
|
|
38
|
+
* package. Used as input to `buildFacetTree` for segment-encoded
|
|
39
|
+
* facets (the auto-`category` case), where the document tree is
|
|
40
|
+
* the source of structure and order.
|
|
41
|
+
*/
|
|
42
|
+
export interface NavLike {
|
|
43
|
+
label: string;
|
|
44
|
+
href?: string;
|
|
45
|
+
children?: NavLike[];
|
|
19
46
|
}
|
|
20
47
|
|
|
21
48
|
/** Map of taxonomy name → display config. */
|
|
@@ -120,6 +147,40 @@ export function resolveFacetTitle(facetName: string): string {
|
|
|
120
147
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Return facet names in sidebar render order.
|
|
152
|
+
*
|
|
153
|
+
* Pagefind's `filters()` map iterates in index-discovery order
|
|
154
|
+
* (effectively the first page Pagefind happened to read), so without
|
|
155
|
+
* sorting the column order is arbitrary and shifts between builds.
|
|
156
|
+
* Order rule:
|
|
157
|
+
* 1. Facets with a numeric `order` in their `TaxonomyDisplay`
|
|
158
|
+
* come first, ascending — use this to pin "Type" or "Audience"
|
|
159
|
+
* to the top regardless of name.
|
|
160
|
+
* 2. Everything else sorts alphabetically by facet name.
|
|
161
|
+
* Stable within each tier so ties don't shuffle.
|
|
162
|
+
*/
|
|
163
|
+
export function sortFacetNames(
|
|
164
|
+
names: string[],
|
|
165
|
+
display?: TaxonomyDisplayMap,
|
|
166
|
+
): string[] {
|
|
167
|
+
const withOrder = (name: string): number | undefined => {
|
|
168
|
+
const o = display?.[name]?.order;
|
|
169
|
+
return typeof o === "number" ? o : undefined;
|
|
170
|
+
};
|
|
171
|
+
return [...names].sort((a, b) => {
|
|
172
|
+
const oa = withOrder(a);
|
|
173
|
+
const ob = withOrder(b);
|
|
174
|
+
if (oa !== undefined && ob !== undefined) {
|
|
175
|
+
if (oa !== ob) return oa - ob;
|
|
176
|
+
return a.localeCompare(b);
|
|
177
|
+
}
|
|
178
|
+
if (oa !== undefined) return -1;
|
|
179
|
+
if (ob !== undefined) return 1;
|
|
180
|
+
return a.localeCompare(b);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
// ── URL persistence ──────────────────────────────────────────────
|
|
124
185
|
|
|
125
186
|
/**
|
|
@@ -175,21 +236,32 @@ export function parseFiltersFromUrl(
|
|
|
175
236
|
}
|
|
176
237
|
|
|
177
238
|
/**
|
|
178
|
-
* Pagefind's `
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
239
|
+
* Build Pagefind's `filters` argument from internal facet state.
|
|
240
|
+
*
|
|
241
|
+
* Pagefind's array shape (`{ tag: ["a", "b"] }`) is AND by default —
|
|
242
|
+
* "page must have BOTH tags" — per
|
|
243
|
+
* https://pagefind.app/docs/js-api-filtering/ ("All filtering
|
|
244
|
+
* defaults to AND filtering"). That's the wrong UX for faceted
|
|
245
|
+
* search: clicking two checkboxes in the same group should widen
|
|
246
|
+
* the result set, not collapse it to zero. We wrap each facet in
|
|
247
|
+
* `{ any: [...] }` so multi-select within a facet is OR. Across
|
|
248
|
+
* different facets Pagefind already ANDs the keys, which matches
|
|
249
|
+
* the standard "narrow with each additional dimension" UX.
|
|
250
|
+
*
|
|
251
|
+
* Single-value selections also go through `{ any: ["x"] }` —
|
|
252
|
+
* Pagefind treats a one-element `any` identically to a bare string,
|
|
253
|
+
* so there's no behavioural delta and the code stays branch-free.
|
|
182
254
|
*
|
|
183
255
|
* Empty filter state → `{}` (Pagefind returns the unfiltered
|
|
184
|
-
* result set). A facet with an empty array
|
|
185
|
-
*
|
|
256
|
+
* result set). A facet with an empty array drops out so we don't
|
|
257
|
+
* accidentally narrow to "must equal nothing".
|
|
186
258
|
*/
|
|
187
259
|
export function filterStateToPagefindFilters(
|
|
188
260
|
filters: FilterState,
|
|
189
|
-
): Record<string, string[]> {
|
|
190
|
-
const out: Record<string, string[]> = {};
|
|
261
|
+
): Record<string, { any: string[] }> {
|
|
262
|
+
const out: Record<string, { any: string[] }> = {};
|
|
191
263
|
for (const [name, values] of Object.entries(filters)) {
|
|
192
|
-
if (values.length > 0) out[name] = [...values];
|
|
264
|
+
if (values.length > 0) out[name] = { any: [...values] };
|
|
193
265
|
}
|
|
194
266
|
return out;
|
|
195
267
|
}
|
|
@@ -230,3 +302,433 @@ export function countActiveFilters(filters: FilterState): number {
|
|
|
230
302
|
for (const values of Object.values(filters)) n += values.length;
|
|
231
303
|
return n;
|
|
232
304
|
}
|
|
305
|
+
|
|
306
|
+
// ── Hierarchical facets ──────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* One node in a hierarchical facet tree (see `buildFacetTree`).
|
|
310
|
+
*
|
|
311
|
+
* `value` is the canonical Pagefind filter value for this node —
|
|
312
|
+
* for slash-encoded facets the full slash-joined path
|
|
313
|
+
* (`concept/a11y`); for segment-encoded facets a single segment
|
|
314
|
+
* (`installing_aws`). `value` matches Pagefind's value key 1:1
|
|
315
|
+
* for "real" entries; synthetic parents (intermediate trie nodes
|
|
316
|
+
* that don't correspond to a Pagefind entry) reuse the same
|
|
317
|
+
* derived value but carry `hasValue: false` and `count: 0`.
|
|
318
|
+
*/
|
|
319
|
+
export interface FacetTreeNode {
|
|
320
|
+
value: string;
|
|
321
|
+
label: string;
|
|
322
|
+
count: number;
|
|
323
|
+
depth: number;
|
|
324
|
+
/** True if Pagefind reported this exact value; false for synthetic parents. */
|
|
325
|
+
hasValue: boolean;
|
|
326
|
+
children: FacetTreeNode[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a hierarchical facet tree from Pagefind's flat entry list.
|
|
331
|
+
*
|
|
332
|
+
* Two strategies depending on the values' shape:
|
|
333
|
+
*
|
|
334
|
+
* - **Slash-encoded.** When at least one entry value contains `/`,
|
|
335
|
+
* each value is treated as a path through the tree (`concept/a11y`
|
|
336
|
+
* → `concept → a11y`). Splits on `/`, inserts into a trie.
|
|
337
|
+
* Parent entries that don't have a Pagefind value of their own
|
|
338
|
+
* (e.g. `concept` standalone is unindexed but `concept/a11y` is)
|
|
339
|
+
* appear as synthetic intermediate nodes with `hasValue: false`.
|
|
340
|
+
* Within a parent, children sort by count desc then alpha — the
|
|
341
|
+
* same rule as flat facets.
|
|
342
|
+
*
|
|
343
|
+
* - **Segment-encoded.** When NO entry contains `/`, each value is
|
|
344
|
+
* a single path segment. Hierarchy comes from `nav` — every nav
|
|
345
|
+
* leaf's href segments become a chain in the trie. Counts come
|
|
346
|
+
* from Pagefind's entry list, attached to nodes whose segment
|
|
347
|
+
* name matches an entry value. **Caveat:** Pagefind reports one
|
|
348
|
+
* count per distinct segment name regardless of where in the
|
|
349
|
+
* tree it lives, so a segment that appears at multiple positions
|
|
350
|
+
* (`installing/.../ipi`, `installing_azure/.../ipi`) gets the
|
|
351
|
+
* same total count at every position — clicking selects the
|
|
352
|
+
* shared value, returning pages from every position. This
|
|
353
|
+
* matches the underlying Pagefind filter behaviour and is
|
|
354
|
+
* documented in plans/hierarchical-facets.md.
|
|
355
|
+
*
|
|
356
|
+
* Order is nav-document order, NOT count desc. Pagefind entries
|
|
357
|
+
* not represented in nav are appended as orphans at the root in
|
|
358
|
+
* alpha order.
|
|
359
|
+
*
|
|
360
|
+
* When `nav` is undefined for the segment-encoded case, falls
|
|
361
|
+
* back to a flat list (every entry a root-level node with no
|
|
362
|
+
* children, in count-desc order) — preserves render-ability
|
|
363
|
+
* when nav.json hasn't loaded yet.
|
|
364
|
+
*
|
|
365
|
+
* Returns the top-level nodes. Empty `entries` → empty array.
|
|
366
|
+
*/
|
|
367
|
+
export function buildFacetTree(
|
|
368
|
+
facetName: string,
|
|
369
|
+
entries: FacetEntry[],
|
|
370
|
+
options: {
|
|
371
|
+
nav?: NavLike[];
|
|
372
|
+
display?: TaxonomyDisplayMap;
|
|
373
|
+
} = {},
|
|
374
|
+
): FacetTreeNode[] {
|
|
375
|
+
if (entries.length === 0) return [];
|
|
376
|
+
|
|
377
|
+
const slashEncoded = entries.some((e) => e.value.includes("/"));
|
|
378
|
+
if (slashEncoded) {
|
|
379
|
+
// When nav is supplied, derive document-order. Otherwise fall
|
|
380
|
+
// back to count-desc + alpha. The map keys are full cumulative
|
|
381
|
+
// paths (matching Pagefind entry values 1:1).
|
|
382
|
+
const navOrder = options.nav
|
|
383
|
+
? buildNavOrderMap(
|
|
384
|
+
options.nav,
|
|
385
|
+
new Set(entries.map((e) => e.value)),
|
|
386
|
+
)
|
|
387
|
+
: undefined;
|
|
388
|
+
return buildSlashTree(facetName, entries, options.display, navOrder);
|
|
389
|
+
}
|
|
390
|
+
return buildSegmentTree(facetName, entries, options.nav, options.display);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Slash-encoded tree builder. Each entry value is a slash-joined
|
|
395
|
+
* path; split, walk a trie, attach Pagefind counts to terminal
|
|
396
|
+
* nodes. Synthetic intermediate nodes (a path component used by a
|
|
397
|
+
* descendant but absent from `entries`) get `hasValue: false` and
|
|
398
|
+
* `count: 0` so they render but don't contribute to selection.
|
|
399
|
+
*
|
|
400
|
+
* Sort order at every depth:
|
|
401
|
+
* 1. `navOrder` index if present (document order from nav.json).
|
|
402
|
+
* Without this, deep API-reference subtrees with high page
|
|
403
|
+
* counts would dominate the top of the tree even though they
|
|
404
|
+
* sit at the BOTTOM of the rendered nav.
|
|
405
|
+
* 2. Count desc (more pages → higher).
|
|
406
|
+
* 3. Alpha (final tiebreak).
|
|
407
|
+
*/
|
|
408
|
+
function buildSlashTree(
|
|
409
|
+
facetName: string,
|
|
410
|
+
entries: FacetEntry[],
|
|
411
|
+
display?: TaxonomyDisplayMap,
|
|
412
|
+
navOrder?: Map<string, number>,
|
|
413
|
+
): FacetTreeNode[] {
|
|
414
|
+
// Trie keyed by full slash-path. Children are tracked on each
|
|
415
|
+
// node's own `children` array (built unsorted in pass 1, sorted
|
|
416
|
+
// recursively in pass 2).
|
|
417
|
+
const byPath = new Map<string, FacetTreeNode>();
|
|
418
|
+
const rootValues: string[] = [];
|
|
419
|
+
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
const segments = entry.value.split("/").filter((s) => s.length > 0);
|
|
422
|
+
if (segments.length === 0) continue;
|
|
423
|
+
let pathSoFar = "";
|
|
424
|
+
let parent: FacetTreeNode | null = null;
|
|
425
|
+
for (let i = 0; i < segments.length; i++) {
|
|
426
|
+
pathSoFar = pathSoFar ? `${pathSoFar}/${segments[i]}` : segments[i];
|
|
427
|
+
let node = byPath.get(pathSoFar);
|
|
428
|
+
if (!node) {
|
|
429
|
+
node = {
|
|
430
|
+
value: pathSoFar,
|
|
431
|
+
label: resolveFacetLabel(facetName, pathSoFar, display),
|
|
432
|
+
count: 0,
|
|
433
|
+
depth: i,
|
|
434
|
+
hasValue: false,
|
|
435
|
+
children: [],
|
|
436
|
+
};
|
|
437
|
+
byPath.set(pathSoFar, node);
|
|
438
|
+
if (parent) parent.children.push(node);
|
|
439
|
+
else rootValues.push(pathSoFar);
|
|
440
|
+
}
|
|
441
|
+
if (i === segments.length - 1) {
|
|
442
|
+
node.count = entry.count;
|
|
443
|
+
node.hasValue = true;
|
|
444
|
+
}
|
|
445
|
+
parent = node;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Pass 2: sort children at every depth.
|
|
450
|
+
const cmp = (a: FacetTreeNode, b: FacetTreeNode): number => {
|
|
451
|
+
if (navOrder) {
|
|
452
|
+
const oa = navOrder.get(a.value);
|
|
453
|
+
const ob = navOrder.get(b.value);
|
|
454
|
+
if (oa !== undefined && ob !== undefined && oa !== ob) return oa - ob;
|
|
455
|
+
// Nodes IN the nav sort before nodes not in nav
|
|
456
|
+
if (oa !== undefined && ob === undefined) return -1;
|
|
457
|
+
if (oa === undefined && ob !== undefined) return 1;
|
|
458
|
+
}
|
|
459
|
+
if (a.count !== b.count) return b.count - a.count;
|
|
460
|
+
return a.value.localeCompare(b.value);
|
|
461
|
+
};
|
|
462
|
+
const sortChildren = (node: FacetTreeNode): void => {
|
|
463
|
+
if (node.children.length === 0) return;
|
|
464
|
+
node.children.sort(cmp);
|
|
465
|
+
for (const c of node.children) sortChildren(c);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const rootList = rootValues.map((v) => byPath.get(v)!);
|
|
469
|
+
rootList.sort(cmp);
|
|
470
|
+
for (const r of rootList) sortChildren(r);
|
|
471
|
+
return rootList;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Walk `nav` and assign each cumulative slash-path (matching a
|
|
476
|
+
* Pagefind facet value in `knownValues`) a sequential index in
|
|
477
|
+
* first-encounter order. Used by `buildSlashTree` to sort children
|
|
478
|
+
* in document order rather than count-desc.
|
|
479
|
+
*
|
|
480
|
+
* Auto-detects and strips a leading basePath: for each nav leaf, we
|
|
481
|
+
* try each starting offset of the href's parent segments. The first
|
|
482
|
+
* offset whose initial cumulative path is in `knownValues` is the
|
|
483
|
+
* "real" content root; the dropped prefix is the basePath. This way
|
|
484
|
+
* the helper works on any site without needing the basePath threaded
|
|
485
|
+
* through explicitly.
|
|
486
|
+
*
|
|
487
|
+
* Hrefs whose parents don't match anything in `knownValues` at any
|
|
488
|
+
* offset are skipped (e.g. top-level pages with no auto-derived
|
|
489
|
+
* category, or pages outside the indexed corpus).
|
|
490
|
+
*
|
|
491
|
+
* Exported for use by `buildFacetTree`. Pure — no I/O.
|
|
492
|
+
*/
|
|
493
|
+
export function buildNavOrderMap(
|
|
494
|
+
nav: NavLike[],
|
|
495
|
+
knownValues: Set<string>,
|
|
496
|
+
): Map<string, number> {
|
|
497
|
+
const order = new Map<string, number>();
|
|
498
|
+
let n = 0;
|
|
499
|
+
const walk = (items: NavLike[]): void => {
|
|
500
|
+
for (const item of items) {
|
|
501
|
+
if (item.href) {
|
|
502
|
+
const parents = hrefToCategorySegments(item.href);
|
|
503
|
+
// Try each starting offset; first one whose initial cumulative
|
|
504
|
+
// path is in knownValues wins (auto-detects basePath).
|
|
505
|
+
for (let start = 0; start < parents.length; start++) {
|
|
506
|
+
const first = parents[start];
|
|
507
|
+
if (!knownValues.has(first)) continue;
|
|
508
|
+
// Record all cumulative paths starting from `start` that
|
|
509
|
+
// exist in knownValues. The check at line below filters out
|
|
510
|
+
// synthetic intermediates the corpus didn't tag (e.g. a
|
|
511
|
+
// user-supplied frontmatter category that skipped a level).
|
|
512
|
+
let cumulative = "";
|
|
513
|
+
for (let i = start; i < parents.length; i++) {
|
|
514
|
+
cumulative = cumulative ? `${cumulative}/${parents[i]}` : parents[i];
|
|
515
|
+
if (knownValues.has(cumulative) && !order.has(cumulative)) {
|
|
516
|
+
order.set(cumulative, n++);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (item.children && item.children.length > 0) walk(item.children);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
walk(nav);
|
|
526
|
+
return order;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Segment-encoded tree builder. Walks `nav` to discover the
|
|
531
|
+
* canonical document hierarchy, then attaches Pagefind counts to
|
|
532
|
+
* any node whose segment name matches an entry. Pagefind entries
|
|
533
|
+
* not in nav are appended as alpha-sorted root orphans so they
|
|
534
|
+
* don't disappear.
|
|
535
|
+
*/
|
|
536
|
+
function buildSegmentTree(
|
|
537
|
+
facetName: string,
|
|
538
|
+
entries: FacetEntry[],
|
|
539
|
+
nav: NavLike[] | undefined,
|
|
540
|
+
display?: TaxonomyDisplayMap,
|
|
541
|
+
): FacetTreeNode[] {
|
|
542
|
+
const byValue = new Map<string, FacetEntry>();
|
|
543
|
+
for (const e of entries) byValue.set(e.value, e);
|
|
544
|
+
|
|
545
|
+
if (!nav || nav.length === 0) {
|
|
546
|
+
// Flat fallback in count-desc order — preserves render-ability
|
|
547
|
+
// when nav hasn't loaded.
|
|
548
|
+
return entries.map((e) => ({
|
|
549
|
+
value: e.value,
|
|
550
|
+
label: resolveFacetLabel(facetName, e.value, display),
|
|
551
|
+
count: e.count,
|
|
552
|
+
depth: 0,
|
|
553
|
+
hasValue: true,
|
|
554
|
+
children: [],
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const seenValues = new Set<string>();
|
|
559
|
+
const roots: FacetTreeNode[] = [];
|
|
560
|
+
const rootIndex = new Map<string, FacetTreeNode>();
|
|
561
|
+
|
|
562
|
+
/** Insert a path's parent segments (excluding the leaf filename) into the trie. */
|
|
563
|
+
const insertPath = (segments: string[]): void => {
|
|
564
|
+
if (segments.length === 0) return;
|
|
565
|
+
let parentList: FacetTreeNode[] = roots;
|
|
566
|
+
let parentIndex: Map<string, FacetTreeNode> = rootIndex;
|
|
567
|
+
for (let i = 0; i < segments.length; i++) {
|
|
568
|
+
const seg = segments[i];
|
|
569
|
+
let node = parentIndex.get(seg);
|
|
570
|
+
if (!node) {
|
|
571
|
+
const entry = byValue.get(seg);
|
|
572
|
+
node = {
|
|
573
|
+
value: seg,
|
|
574
|
+
label: resolveFacetLabel(facetName, seg, display),
|
|
575
|
+
count: entry ? entry.count : 0,
|
|
576
|
+
depth: i,
|
|
577
|
+
hasValue: entry !== undefined,
|
|
578
|
+
children: [],
|
|
579
|
+
};
|
|
580
|
+
parentList.push(node);
|
|
581
|
+
parentIndex.set(seg, node);
|
|
582
|
+
if (entry) seenValues.add(seg);
|
|
583
|
+
}
|
|
584
|
+
// Walk into the child list for the next iteration. Each
|
|
585
|
+
// node carries its own child index lazily via a WeakMap-like
|
|
586
|
+
// closure; here we use a regular Map keyed off the node.
|
|
587
|
+
let nextIndex = childIndex.get(node);
|
|
588
|
+
if (!nextIndex) {
|
|
589
|
+
nextIndex = new Map();
|
|
590
|
+
childIndex.set(node, nextIndex);
|
|
591
|
+
}
|
|
592
|
+
parentList = node.children;
|
|
593
|
+
parentIndex = nextIndex;
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const childIndex = new Map<FacetTreeNode, Map<string, FacetTreeNode>>();
|
|
598
|
+
|
|
599
|
+
// Walk every nav leaf. Use href segments (excluding the leaf
|
|
600
|
+
// filename) — those are the auto-derived `category` values
|
|
601
|
+
// emitted by parseMeta's deriveCategoryFromSlug.
|
|
602
|
+
const walkNav = (items: NavLike[]): void => {
|
|
603
|
+
for (const item of items) {
|
|
604
|
+
if (item.href) {
|
|
605
|
+
const parents = hrefToCategorySegments(item.href);
|
|
606
|
+
insertPath(parents);
|
|
607
|
+
}
|
|
608
|
+
if (item.children && item.children.length > 0) walkNav(item.children);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
walkNav(nav);
|
|
612
|
+
|
|
613
|
+
// Pagefind values not represented in nav → root-level orphans in
|
|
614
|
+
// alpha order. Without this they vanish from the UI silently.
|
|
615
|
+
const orphans: FacetTreeNode[] = [];
|
|
616
|
+
for (const e of entries) {
|
|
617
|
+
if (seenValues.has(e.value)) continue;
|
|
618
|
+
orphans.push({
|
|
619
|
+
value: e.value,
|
|
620
|
+
label: resolveFacetLabel(facetName, e.value, display),
|
|
621
|
+
count: e.count,
|
|
622
|
+
depth: 0,
|
|
623
|
+
hasValue: true,
|
|
624
|
+
children: [],
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
orphans.sort((a, b) => a.value.localeCompare(b.value));
|
|
628
|
+
return [...roots, ...orphans];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Strip an href down to its parent path segments — the `category`
|
|
633
|
+
* derivation logic from `parseMeta.deriveCategoryFromSlug`. Drops
|
|
634
|
+
* leading/trailing slashes, drops the leaf filename segment.
|
|
635
|
+
*/
|
|
636
|
+
function hrefToCategorySegments(href: string): string[] {
|
|
637
|
+
// Drop scheme/host if absolute (we only treat path segments)
|
|
638
|
+
let path = href;
|
|
639
|
+
try {
|
|
640
|
+
if (/^[a-z]+:\/\//i.test(href)) path = new URL(href).pathname;
|
|
641
|
+
} catch {
|
|
642
|
+
// not a URL — treat as-is
|
|
643
|
+
}
|
|
644
|
+
// Drop hash + query — facets care about the URL path only
|
|
645
|
+
path = path.split("#")[0].split("?")[0];
|
|
646
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
647
|
+
// Match deriveCategoryFromSlug: parents = everything except the leaf
|
|
648
|
+
if (segments.length < 2) return [];
|
|
649
|
+
return segments.slice(0, -1);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Flat list of every "real" Pagefind value at or under `node`
|
|
654
|
+
* (i.e. every node where `hasValue === true`). Synthetic parents
|
|
655
|
+
* are excluded since adding them to the filter would be a no-op
|
|
656
|
+
* (Pagefind has no pages tagged with the synthetic value).
|
|
657
|
+
*
|
|
658
|
+
* Used by `toggleTreeNode` when the user ticks a parent — we add
|
|
659
|
+
* every real descendant value to the selection in one shot.
|
|
660
|
+
*/
|
|
661
|
+
export function collectDescendantValues(node: FacetTreeNode): string[] {
|
|
662
|
+
const out: string[] = [];
|
|
663
|
+
const visit = (n: FacetTreeNode): void => {
|
|
664
|
+
if (n.hasValue) out.push(n.value);
|
|
665
|
+
for (const c of n.children) visit(c);
|
|
666
|
+
};
|
|
667
|
+
visit(node);
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Tri-state of a tree node given the current filter state.
|
|
673
|
+
*
|
|
674
|
+
* - `"checked"` — every real descendant value (and self if
|
|
675
|
+
* self has a value) is in `filters[facetName]`.
|
|
676
|
+
* - `"unchecked"` — none of them are.
|
|
677
|
+
* - `"indeterminate"`— at least one is, but not all.
|
|
678
|
+
*
|
|
679
|
+
* Synthetic parents with no real-value descendants resolve to
|
|
680
|
+
* "unchecked" (defensive default — shouldn't occur in valid trees).
|
|
681
|
+
*/
|
|
682
|
+
export function computeTreeState(
|
|
683
|
+
node: FacetTreeNode,
|
|
684
|
+
facetName: string,
|
|
685
|
+
filters: FilterState,
|
|
686
|
+
): "checked" | "unchecked" | "indeterminate" {
|
|
687
|
+
const descendants = collectDescendantValues(node);
|
|
688
|
+
if (descendants.length === 0) return "unchecked";
|
|
689
|
+
const selected = new Set(filters[facetName] ?? []);
|
|
690
|
+
let hits = 0;
|
|
691
|
+
for (const v of descendants) if (selected.has(v)) hits++;
|
|
692
|
+
if (hits === 0) return "unchecked";
|
|
693
|
+
if (hits === descendants.length) return "checked";
|
|
694
|
+
return "indeterminate";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Toggle a tree node's selection.
|
|
699
|
+
*
|
|
700
|
+
* - If currently checked → remove self + all descendant values
|
|
701
|
+
* from `filters[facetName]`.
|
|
702
|
+
* - If unchecked or indeterminate → add self + all descendant
|
|
703
|
+
* values (auto-select). Matches the decided UX: parent click
|
|
704
|
+
* fans out to children.
|
|
705
|
+
*
|
|
706
|
+
* Returns a new `FilterState` — input is not mutated. Same
|
|
707
|
+
* immutability contract as `toggleFilter`.
|
|
708
|
+
*/
|
|
709
|
+
export function toggleTreeNode(
|
|
710
|
+
filters: FilterState,
|
|
711
|
+
facetName: string,
|
|
712
|
+
node: FacetTreeNode,
|
|
713
|
+
currentState: "checked" | "unchecked" | "indeterminate",
|
|
714
|
+
): FilterState {
|
|
715
|
+
const descendants = collectDescendantValues(node);
|
|
716
|
+
if (descendants.length === 0) return filters;
|
|
717
|
+
const current = filters[facetName] ?? [];
|
|
718
|
+
let nextValues: string[];
|
|
719
|
+
if (currentState === "checked") {
|
|
720
|
+
const drop = new Set(descendants);
|
|
721
|
+
nextValues = current.filter((v) => !drop.has(v));
|
|
722
|
+
} else {
|
|
723
|
+
const merged = new Set(current);
|
|
724
|
+
for (const v of descendants) merged.add(v);
|
|
725
|
+
nextValues = Array.from(merged);
|
|
726
|
+
}
|
|
727
|
+
const next: FilterState = { ...filters };
|
|
728
|
+
if (nextValues.length === 0) {
|
|
729
|
+
delete next[facetName];
|
|
730
|
+
} else {
|
|
731
|
+
next[facetName] = nextValues;
|
|
732
|
+
}
|
|
733
|
+
return next;
|
|
734
|
+
}
|
package/src/version-redirect.ts
CHANGED
|
@@ -35,6 +35,20 @@ export interface AxisRedirectConfig {
|
|
|
35
35
|
defaultLocale?: string;
|
|
36
36
|
/** Full set of declared locale ids. Empty/undefined → axis inactive. */
|
|
37
37
|
knownLocales?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* First-segment names that aren't locale/version-axis-prefixable
|
|
40
|
+
* — e.g. taxonomy index paths like `tags`, `by-type`, `by-status`.
|
|
41
|
+
* Taxonomy routes emit a single global namespace shared across
|
|
42
|
+
* all locales / versions (one `/tags/` for the whole site, not
|
|
43
|
+
* one per locale), so the axis-redirect helper must skip them.
|
|
44
|
+
* Without this skip, chip hrefs to `/<basePath>/tags/...` would
|
|
45
|
+
* 302 to `/<basePath>/<defaultLocale>/tags/...` which 404s.
|
|
46
|
+
*
|
|
47
|
+
* Each entry is the first URL segment after basePath
|
|
48
|
+
* (no leading slash). Sourced from declared
|
|
49
|
+
* `taxonomies.<name>.indexPath` in `dogsbay.config.yml`.
|
|
50
|
+
*/
|
|
51
|
+
globalPrefixes?: string[];
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -88,6 +102,15 @@ export function shouldRedirectToDefaultVersion(
|
|
|
88
102
|
// Skip Astro / Pagefind asset paths.
|
|
89
103
|
if (segments[0].startsWith("_") || segments[0] === "pagefind") return null;
|
|
90
104
|
|
|
105
|
+
// Skip global-namespace prefixes (taxonomy index paths and similar
|
|
106
|
+
// routes that don't live under per-locale / per-version trees).
|
|
107
|
+
// Without this, chip hrefs to `/docs/tags/concept/rag/` get
|
|
108
|
+
// redirected to `/docs/<defaultLocale>/tags/concept/rag/` which
|
|
109
|
+
// 404s — the taxonomy routes are emitted once at the unprefixed
|
|
110
|
+
// path. See plans/beta-launch-followups.md.
|
|
111
|
+
const globalPrefixes = config.globalPrefixes ?? [];
|
|
112
|
+
if (globalPrefixes.includes(segments[0])) return null;
|
|
113
|
+
|
|
91
114
|
// Greedy axis detection — locale outermost, version next.
|
|
92
115
|
const knownLocales = new Set(config.knownLocales ?? []);
|
|
93
116
|
const knownVersions = new Set(config.knownVersions ?? []);
|