@dogsbay/docs-layout 0.2.0-beta.9 → 0.2.0-beta.91

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/src/json-ld.ts CHANGED
@@ -53,3 +53,80 @@ export function normalizeCustomJsonLd(
53
53
  if (Array.isArray(raw)) return raw;
54
54
  return [raw];
55
55
  }
56
+
57
+ export interface BuildArticleJsonLdOptions {
58
+ /** Already-resolved Schema.org `@type` (output of jsonLdTypeFor). */
59
+ type: string;
60
+ title: string;
61
+ /** Site name — used as the `provider` Organization for Course. */
62
+ siteName: string;
63
+ keywords: string[];
64
+ description?: string;
65
+ image?: string;
66
+ url?: string;
67
+ /**
68
+ * Page headings — used to synthesize `step[]` for HowTo. Each
69
+ * H2 (or H3 if no H2s exist) becomes a HowToStep with a deep
70
+ * link to the heading id.
71
+ */
72
+ headings?: ReadonlyArray<{ depth: number; slug: string; text: string }>;
73
+ }
74
+
75
+ /**
76
+ * Build the JSON-LD payload for the page's primary structured-data
77
+ * block, shaped per `@type`. The earlier implementation emitted
78
+ * Article-shaped fields (`headline`) regardless of @type, which
79
+ * meant HowTo / Course pages failed Google's Rich Results
80
+ * requirements (HowTo needs `name` + `step`, Course needs `name` +
81
+ * `description` + `provider`). See
82
+ * `packages/cli/src/audit/rules/seo/json-ld-required-fields.ts`
83
+ * for the validator that catches this.
84
+ *
85
+ * Schema.org's `name` is a Thing-level field accepted by every
86
+ * @type, so we always emit it. `headline` is added on top for
87
+ * Article-family types (Article, TechArticle) because Google's
88
+ * Rich Results validator specifically requires it there. HowTo
89
+ * gets a synthesized `step[]` from the page's H2 headings (each
90
+ * H2 = one procedure step); Course gets a `provider` Organization
91
+ * built from `siteName`.
92
+ */
93
+ export function buildArticleJsonLd(
94
+ opts: BuildArticleJsonLdOptions,
95
+ ): Record<string, unknown> {
96
+ const { type, title, siteName, keywords, description, image, url, headings } =
97
+ opts;
98
+
99
+ const block: Record<string, unknown> = {
100
+ "@context": "https://schema.org",
101
+ "@type": type,
102
+ name: title,
103
+ keywords: keywords.join(", "),
104
+ };
105
+
106
+ if (type === "Article" || type === "TechArticle") {
107
+ block.headline = title;
108
+ }
109
+
110
+ if (description) block.description = description;
111
+ if (image) block.image = image;
112
+ if (url) block.url = url;
113
+
114
+ if (type === "HowTo") {
115
+ let stepHeadings = (headings ?? []).filter((h) => h.depth === 2);
116
+ if (stepHeadings.length === 0) {
117
+ stepHeadings = (headings ?? []).filter((h) => h.depth === 3);
118
+ }
119
+ block.step = stepHeadings.map((h) => ({
120
+ "@type": "HowToStep",
121
+ name: h.text,
122
+ url: url ? `${url}#${h.slug}` : `#${h.slug}`,
123
+ }));
124
+ }
125
+
126
+ if (type === "Course") {
127
+ block.provider = { "@type": "Organization", name: siteName };
128
+ if (!block.description) block.description = title;
129
+ }
130
+
131
+ return block;
132
+ }
@@ -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 `search()` accepts filters keyed by facet name with
179
- * either a single value, an array (OR), or a nested operator
180
- * object. We always pass the array form so multi-select works
181
- * within a facet without special-casing single-select.
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 also drops out so we
185
- * don't accidentally narrow to "must equal nothing".
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
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Decide which TOC container(s) and right-rail region `DocsLayout` renders,
3
+ * given the `toc` placement mode and per-page facts. Factored out of
4
+ * `DocsLayout.astro` so the branching logic is unit-testable (the .astro
5
+ * file just consumes the result) — same pattern as `nav-filter.ts` etc.
6
+ *
7
+ * See plans/ask-branch1-placement-toc.md.
8
+ */
9
+
10
+ export type TocMode = "top" | "popover" | "rail" | "off";
11
+
12
+ export interface TocPlacement {
13
+ /** Classic right-hand TOC sidebar (the pre-`toc`-option layout). */
14
+ railToc: boolean;
15
+ /** Expandable "On this page" disclosure at the top of the article. */
16
+ topToc: boolean;
17
+ /** "On this page" dropdown in the header. */
18
+ popoverToc: boolean;
19
+ /**
20
+ * The right-rail plugin region (host for the `right-rail` named slot, e.g.
21
+ * an Ask AI panel). Present whenever the rail isn't the classic TOC's, and
22
+ * deliberately NOT gated on headings — so a plugin can dock on heading-less
23
+ * pages. Never shown on wide/API pages (no rail there).
24
+ */
25
+ regionRail: boolean;
26
+ }
27
+
28
+ /**
29
+ * Whether a page has enough displayable headings to warrant a TOC. The TOC
30
+ * (`DocsToc`) only lists depth `minDepth`–`maxDepth` (2–3 by default), so the
31
+ * page H1 never counts; and a TOC of a single entry isn't useful, so require
32
+ * `min` (2) by default. Pages like a landing/welcome page — H1 + prose, no
33
+ * sub-sections — therefore get no "On this page".
34
+ */
35
+ export function hasDisplayableToc(
36
+ headings: { depth: number }[],
37
+ opts: { minDepth?: number; maxDepth?: number; min?: number } = {},
38
+ ): boolean {
39
+ const { minDepth = 2, maxDepth = 3, min = 2 } = opts;
40
+ let count = 0;
41
+ for (const h of headings) {
42
+ if (h.depth >= minDepth && h.depth <= maxDepth) count++;
43
+ if (count >= min) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ export interface TocPlacementInput {
49
+ /** Page has at least one heading to list. */
50
+ hasHeadings: boolean;
51
+ /** Wide/API layout — composes its own columns, so no right rail. */
52
+ wideLayout: boolean;
53
+ }
54
+
55
+ /**
56
+ * Resolve the placement flags. A TOC container only renders when there are
57
+ * headings to show; the region rail is independent of headings. `rail` and
58
+ * the region rail are mutually exclusive (they compete for the same column),
59
+ * and both are suppressed on wide pages.
60
+ */
61
+ export function resolveTocPlacement(
62
+ toc: TocMode,
63
+ { hasHeadings, wideLayout }: TocPlacementInput,
64
+ ): TocPlacement {
65
+ return {
66
+ railToc: toc === "rail" && hasHeadings && !wideLayout,
67
+ topToc: toc === "top" && hasHeadings,
68
+ popoverToc: toc === "popover" && hasHeadings,
69
+ regionRail: toc !== "rail" && !wideLayout,
70
+ };
71
+ }