@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/package.json +4 -4
- package/src/DocsLayout.astro +252 -31
- package/src/DocsNavClient.astro +89 -0
- package/src/DocsToc.astro +1 -1
- package/src/SearchDialog.astro +272 -32
- package/src/docs-nav-client.ts +265 -0
- package/src/json-ld.ts +77 -0
- package/src/search-facets.ts +511 -9
- package/src/toc-placement.ts +71 -0
- package/src/version-redirect.ts +23 -0
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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|