@dogsbay/docs-layout 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Pure helpers for the per-page LLM action cluster ("Copy as
3
+ * markdown", "Open in Claude / ChatGPT / Perplexity / Gemini").
4
+ *
5
+ * The PageActions Astro component composes these helpers with
6
+ * existing CopyButton + DropdownMenu primitives. Keeping the
7
+ * pure logic in its own module makes it directly testable and
8
+ * lets renderers compute deep links at SSR time without any
9
+ * client-side JS.
10
+ *
11
+ * Provider deep-link templates (URL-encoded `?q={prompt}`):
12
+ * - claude → https://claude.ai/new?q=…
13
+ * - chatgpt → https://chatgpt.com/?q=…
14
+ * - perplexity → https://www.perplexity.ai/search?q=…
15
+ * - gemini → https://gemini.google.com/app?q=…
16
+ *
17
+ * Prompts are URL-only by default ("Read this docs page: <url>"):
18
+ * passing full markdown via query string hits the ~2KB practical
19
+ * URL length limit fast, and the page is already served as
20
+ * `text/markdown` from a stable URL.
21
+ */
22
+
23
+ export type LlmProviderName = "claude" | "chatgpt" | "perplexity" | "gemini";
24
+
25
+ export interface ProviderInfo {
26
+ name: LlmProviderName;
27
+ /** Human label for the menu item ("Open in Claude"). */
28
+ label: string;
29
+ /** Deep-link template; `{q}` is replaced by the URL-encoded prompt. */
30
+ urlTemplate: string;
31
+ }
32
+
33
+ export const DEFAULT_PROMPT_TEMPLATE = "Read this docs page: {url}";
34
+
35
+ export const DEFAULT_PROVIDERS: LlmProviderName[] = [
36
+ "claude",
37
+ "chatgpt",
38
+ "perplexity",
39
+ "gemini",
40
+ ];
41
+
42
+ const PROVIDER_REGISTRY: Record<LlmProviderName, ProviderInfo> = {
43
+ claude: {
44
+ name: "claude",
45
+ label: "Open in Claude",
46
+ urlTemplate: "https://claude.ai/new?q={q}",
47
+ },
48
+ chatgpt: {
49
+ name: "chatgpt",
50
+ label: "Open in ChatGPT",
51
+ urlTemplate: "https://chatgpt.com/?q={q}",
52
+ },
53
+ perplexity: {
54
+ name: "perplexity",
55
+ label: "Open in Perplexity",
56
+ urlTemplate: "https://www.perplexity.ai/search?q={q}",
57
+ },
58
+ gemini: {
59
+ name: "gemini",
60
+ label: "Open in Gemini",
61
+ urlTemplate: "https://gemini.google.com/app?q={q}",
62
+ },
63
+ };
64
+
65
+ /** All known provider names — useful for config validation. */
66
+ export function knownProviders(): LlmProviderName[] {
67
+ return Object.keys(PROVIDER_REGISTRY) as LlmProviderName[];
68
+ }
69
+
70
+ /**
71
+ * Substitute `{url}` in the template, then URL-encode the result for
72
+ * `?q=` placement. Used by `providerDeepLink`. Other placeholders
73
+ * are left intact so writers can include literal braces if needed.
74
+ */
75
+ export function buildPrompt(template: string, mdUrl: string): string {
76
+ return template.replace(/\{url\}/g, mdUrl);
77
+ }
78
+
79
+ /**
80
+ * Construct the full deep-link URL for one provider with a given
81
+ * page MD URL and prompt template.
82
+ *
83
+ * Throws when `provider` is not a known provider — config
84
+ * validation should reject unknown values upstream, but this
85
+ * keeps the helper honest.
86
+ */
87
+ export function providerDeepLink(
88
+ provider: LlmProviderName,
89
+ mdUrl: string,
90
+ template: string = DEFAULT_PROMPT_TEMPLATE,
91
+ ): string {
92
+ const info = PROVIDER_REGISTRY[provider];
93
+ if (!info) {
94
+ throw new Error(`Unknown LLM provider: ${String(provider)}`);
95
+ }
96
+ const prompt = buildPrompt(template, mdUrl);
97
+ return info.urlTemplate.replace(/\{q\}/g, encodeURIComponent(prompt));
98
+ }
99
+
100
+ /**
101
+ * Resolve a configured provider list to ProviderInfo objects, in
102
+ * order. Drops unknown names rather than throwing — at config-load
103
+ * time we validate; at render time we want a missing provider not
104
+ * to crash the page. Empty / undefined input returns an empty
105
+ * array (caller decides whether to render the dropdown at all).
106
+ */
107
+ export function resolveProviders(
108
+ providers: readonly LlmProviderName[] | undefined,
109
+ ): ProviderInfo[] {
110
+ const list = providers && providers.length > 0 ? providers : DEFAULT_PROVIDERS;
111
+ const out: ProviderInfo[] = [];
112
+ for (const name of list) {
113
+ const info = PROVIDER_REGISTRY[name];
114
+ if (info) out.push(info);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Get a single provider's metadata. Returns `null` for unknown names.
121
+ * Used by tests and callers that need just the label or template
122
+ * without going through deep-link construction.
123
+ */
124
+ export function getProviderInfo(
125
+ provider: LlmProviderName,
126
+ ): ProviderInfo | null {
127
+ return PROVIDER_REGISTRY[provider] ?? null;
128
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Decision helper for `Accept: text/markdown` content negotiation.
3
+ *
4
+ * Pure function — no Astro dep — so the generated `src/middleware.ts`
5
+ * can import and use it, and so it's unit-testable on its own.
6
+ *
7
+ * Returns the path to rewrite to (the `.md` mirror endpoint) when the
8
+ * request should be served as markdown; returns `null` when the normal
9
+ * HTML response should pass through.
10
+ */
11
+
12
+ const Q_PARAM_RE = /^\s*q\s*=\s*([0-9.]+)\s*$/i;
13
+
14
+ /**
15
+ * Decide whether to rewrite a request to its `.md` mirror.
16
+ *
17
+ * The request matches when ALL of the following are true:
18
+ * 1. The Accept header expresses a non-zero preference for
19
+ * `text/markdown` (or `text/*` accepts it implicitly).
20
+ * 2. The path doesn't already point at a `.md` file.
21
+ * 3. The path doesn't include a different extension (e.g.
22
+ * `/pagefind/pagefind.js`, `/assets/style.css`, `/llms.txt`,
23
+ * `/sitemap.xml`) — those have their own content type and must
24
+ * pass through unchanged.
25
+ *
26
+ * @returns the rewrite target path, or `null` to pass through.
27
+ */
28
+ export function shouldRewriteToMarkdown(
29
+ accept: string | null | undefined,
30
+ pathname: string,
31
+ ): string | null {
32
+ if (!accept) return null;
33
+ if (!acceptsMarkdown(accept)) return null;
34
+ if (pathname.endsWith(".md")) return null;
35
+ if (hasNonHtmlExtension(pathname)) return null;
36
+
37
+ const trimmed = pathname.replace(/\/$/, "");
38
+ const target = trimmed === "" ? "/.md" : `${trimmed}.md`;
39
+ return target;
40
+ }
41
+
42
+ function acceptsMarkdown(accept: string): boolean {
43
+ // Iterate the Accept entries; honor q=0 (explicit reject) for any
44
+ // matching media type. We accept `text/markdown` and `text/*` as
45
+ // valid markdown matchers; `*/*` alone is NOT enough — a default
46
+ // browser sends `*/*` and expects HTML.
47
+ const parts = accept.split(",").map((p) => p.trim());
48
+ for (const part of parts) {
49
+ const [type, ...params] = part.split(";").map((s) => s.trim());
50
+ if (type !== "text/markdown" && type !== "text/*") continue;
51
+ let q = 1;
52
+ for (const param of params) {
53
+ const m = param.match(Q_PARAM_RE);
54
+ if (m) q = parseFloat(m[1]);
55
+ }
56
+ if (q > 0) return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Path looks like an asset (has a non-`.md` extension on the last
63
+ * segment). We don't want to rewrite e.g. `/pagefind/pagefind.js`
64
+ * to `/pagefind/pagefind.js.md` — that file doesn't exist and the
65
+ * request would 404.
66
+ */
67
+ function hasNonHtmlExtension(pathname: string): boolean {
68
+ const lastSlash = pathname.lastIndexOf("/");
69
+ const tail = lastSlash >= 0 ? pathname.slice(lastSlash + 1) : pathname;
70
+ const dot = tail.lastIndexOf(".");
71
+ if (dot <= 0) return false;
72
+ const ext = tail.slice(dot + 1).toLowerCase();
73
+ // .html is treated as a regular page suffix — Astro can serve a .md
74
+ // mirror in that case, so don't bail out. Everything else is an asset.
75
+ return ext !== "html" && ext !== "htm";
76
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Multi-source nav filtering.
3
+ *
4
+ * When a docs site has multiple versions (or, in PR 5, locales)
5
+ * configured, every page's emitted nav.json contains entries
6
+ * from EVERY version. Without filtering, the sidebar shows
7
+ * duplicate sections — once per version — which is confusing
8
+ * UX (writers see "Glossary" twice).
9
+ *
10
+ * The fix: filter the nav tree to entries that match the
11
+ * current page's version (or, eventually, locale). Pure
12
+ * function; takes nav + axis filter, returns a pruned copy.
13
+ *
14
+ * The axis switchers handle navigation BETWEEN versions; the
15
+ * sidebar nav reflects only the active axis bucket.
16
+ */
17
+
18
+ interface NavItem {
19
+ label: string;
20
+ href?: string;
21
+ children?: NavItem[];
22
+ }
23
+
24
+ export interface NavFilter {
25
+ /** Site basePath (e.g. "/docs"). Used to compose the version prefix. */
26
+ basePath: string;
27
+ /**
28
+ * Current page's effective version. When undefined, no
29
+ * version filtering is applied — single-version sites pass
30
+ * the full nav through unchanged.
31
+ */
32
+ version?: string;
33
+ /**
34
+ * Current page's effective locale. When set, nav items are
35
+ * filtered to those whose href starts with the corresponding
36
+ * locale segment (`<basePath>/<locale>/`).
37
+ */
38
+ locale?: string;
39
+ }
40
+
41
+ /**
42
+ * Walk the nav tree and drop entries that don't belong to the
43
+ * current version + locale. Group nodes (no `href`, with
44
+ * `children`) survive iff any descendant survives — empty
45
+ * groups are pruned.
46
+ *
47
+ * Items without `href` AND without `children` are unusual but
48
+ * pass through unchanged (defensive — never silently drop a
49
+ * node we don't understand).
50
+ *
51
+ * Both filters apply concurrently: a multi-version multi-locale
52
+ * site filters by BOTH simultaneously, so an item must match
53
+ * /<basePath>/<locale>/.../<version>/... structurally.
54
+ */
55
+ export function filterNavByAxis(
56
+ items: NavItem[],
57
+ filter: NavFilter,
58
+ ): NavItem[] {
59
+ if (!filter.version && !filter.locale) return items;
60
+
61
+ // Locale axis prefix is the OUTERMOST per the canonical URL
62
+ // composition: /<basePath>/<locale>/<version>/<ns>/<slug>.
63
+ // We check the locale prefix first (basePath/<locale>/), then
64
+ // (when version is also active) check that <version> is the
65
+ // immediately-following segment.
66
+ const localePrefix = filter.locale
67
+ ? prefixFor(filter.basePath, filter.locale)
68
+ : null;
69
+ const versionSegment = filter.version ?? null;
70
+
71
+ return items.flatMap((item) =>
72
+ filterOne(item, localePrefix, versionSegment, filter.basePath),
73
+ );
74
+ }
75
+
76
+ function filterOne(
77
+ item: NavItem,
78
+ localePrefix: string | null,
79
+ versionSegment: string | null,
80
+ basePath: string,
81
+ ): NavItem[] {
82
+ if (item.children && item.children.length > 0) {
83
+ const kept = item.children.flatMap((c) =>
84
+ filterOne(c, localePrefix, versionSegment, basePath),
85
+ );
86
+ if (kept.length === 0) return [];
87
+ return [{ ...item, children: kept }];
88
+ }
89
+ if (item.href !== undefined) {
90
+ if (!hrefMatchesAxes(item.href, localePrefix, versionSegment, basePath)) {
91
+ return [];
92
+ }
93
+ return [item];
94
+ }
95
+ return [item];
96
+ }
97
+
98
+ /**
99
+ * Check that an href belongs to the requested (locale, version)
100
+ * combination. Either prefix can be null — meaning that axis
101
+ * isn't being filtered.
102
+ */
103
+ function hrefMatchesAxes(
104
+ href: string,
105
+ localePrefix: string | null,
106
+ versionSegment: string | null,
107
+ basePath: string,
108
+ ): boolean {
109
+ // External URLs aren't axis-bucketed.
110
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
111
+ return false;
112
+ }
113
+
114
+ // Step 1: locale check. If locale axis is active, the href
115
+ // must be inside /<basePath>/<locale>/.
116
+ if (localePrefix !== null) {
117
+ if (!hrefMatchesPrefix(href, localePrefix)) return false;
118
+ }
119
+
120
+ // Step 2: version check. The version segment is positioned
121
+ // AFTER the locale segment when both are active, otherwise
122
+ // immediately after basePath.
123
+ if (versionSegment !== null) {
124
+ const baseTrimmed = basePath.replace(/\/$/, "");
125
+ const localeSegStart = localePrefix
126
+ ? localePrefix.replace(/\/$/, "")
127
+ : baseTrimmed;
128
+ const versionPrefix = `${localeSegStart}/${versionSegment}/`;
129
+ const versionPrefixNoSlash = versionPrefix.replace(/\/$/, "");
130
+ if (
131
+ !href.startsWith(versionPrefix) &&
132
+ href !== versionPrefixNoSlash
133
+ ) {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ return true;
139
+ }
140
+
141
+ /**
142
+ * Compose the URL prefix for a given version under the
143
+ * configured basePath. Always ends in `/` so prefix-matching
144
+ * doesn't accept partial segments (`/docs/v1` shouldn't match
145
+ * `/docs/v10/...`).
146
+ */
147
+ function prefixFor(basePath: string, segment: string): string {
148
+ const base = basePath.replace(/\/$/, "");
149
+ return `${base}/${segment}/`;
150
+ }
151
+
152
+ /**
153
+ * Whether an href belongs to the given version prefix. Tolerates
154
+ * trailing slashes and missing-trailing-slash variants — nav
155
+ * importers don't all canonicalise the same way.
156
+ */
157
+ function hrefMatchesPrefix(href: string, prefix: string): boolean {
158
+ // Skip external URLs.
159
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
160
+ return false;
161
+ }
162
+ // Match `/docs/v1/...` AND `/docs/v1` (the version's landing
163
+ // page itself, if a writer linked to it directly).
164
+ const trimmedPrefix = prefix.replace(/\/$/, "");
165
+ return href.startsWith(prefix) || href === trimmedPrefix;
166
+ }
@@ -0,0 +1,39 @@
1
+ interface NavItem {
2
+ label: string;
3
+ href?: string;
4
+ children?: NavItem[];
5
+ }
6
+
7
+ interface PaginationLink {
8
+ label: string;
9
+ href: string;
10
+ }
11
+
12
+ interface PaginationResult {
13
+ prev: PaginationLink | undefined;
14
+ next: PaginationLink | undefined;
15
+ }
16
+
17
+ /**
18
+ * Flatten a nav tree into an ordered list of pages.
19
+ * Find prev/next for the given path.
20
+ */
21
+ export function getPagination(currentPath: string, nav: NavItem[]): PaginationResult {
22
+ const allPages = flattenNav(nav);
23
+ const normalized = currentPath.replace(/\/$/, "") || "/";
24
+ const index = allPages.findIndex(p => p.href.replace(/\/$/, "") === normalized);
25
+
26
+ return {
27
+ prev: index > 0 ? allPages[index - 1] : undefined,
28
+ next: index < allPages.length - 1 ? allPages[index + 1] : undefined,
29
+ };
30
+ }
31
+
32
+ function flattenNav(items: NavItem[]): PaginationLink[] {
33
+ const result: PaginationLink[] = [];
34
+ for (const item of items) {
35
+ if (item.href) result.push({ label: item.label, href: item.href });
36
+ if (item.children) result.push(...flattenNav(item.children));
37
+ }
38
+ return result;
39
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Pure helpers for search-facet UI in `<SearchDialog>`. Splitting
3
+ * the data transforms here lets vitest exercise them without
4
+ * needing a Pagefind runtime or rendered DOM.
5
+ *
6
+ * See plans/search-facets.md.
7
+ */
8
+ import { resolveTermLabel } from "./tag-list-data.js";
9
+ import type { PrefixDisplay } from "./tag-list-data.js";
10
+
11
+ /**
12
+ * Per-taxonomy display config — same shape as
13
+ * `SiteConfig.taxonomyDisplay[name]`. Keyed locally so this module
14
+ * has no runtime dependency on `@dogsbay/types`.
15
+ */
16
+ export interface TaxonomyDisplay {
17
+ prefixes?: Record<string, PrefixDisplay>;
18
+ labels?: Record<string, string>;
19
+ }
20
+
21
+ /** Map of taxonomy name → display config. */
22
+ export type TaxonomyDisplayMap = Record<string, TaxonomyDisplay>;
23
+
24
+ /** One facet entry — value plus the count of pages tagged with it. */
25
+ export interface FacetEntry {
26
+ /** The slug as Pagefind indexed it (e.g. `"concept/a11y"`, `"how-to"`). */
27
+ value: string;
28
+ /** Page count for this value within the current corpus. */
29
+ count: number;
30
+ }
31
+
32
+ /** Map of facet name (taxonomy) → ordered entries. */
33
+ export type FacetMap = Record<string, FacetEntry[]>;
34
+
35
+ /**
36
+ * Pagefind's filters API surface — a thin subset of the runtime
37
+ * shape, kept local so this module stays dependency-free.
38
+ *
39
+ * `pagefind.filters()` resolves to a nested map:
40
+ * `{ <facetName>: { <value>: <count> } }`. We sort + flatten it.
41
+ */
42
+ export interface PagefindLike {
43
+ filters(): Promise<Record<string, Record<string, number>>>;
44
+ }
45
+
46
+ /**
47
+ * Convert Pagefind's nested filter map into a sorted `FacetMap`.
48
+ *
49
+ * Sort order: count descending, then value ascending alphabetically
50
+ * (stable tie-break). Empty value lists are dropped — a facet with
51
+ * zero values isn't worth a row in the UI. Returns an empty
52
+ * `FacetMap` when Pagefind reports no filters at all (Pagefind log
53
+ * still says "Indexed 0 filters" — usually means the head→body fix
54
+ * hasn't propagated, not a real "no-tags" site).
55
+ */
56
+ export function shapeFacets(
57
+ raw: Record<string, Record<string, number>> | undefined | null,
58
+ ): FacetMap {
59
+ if (!raw) return {};
60
+ const out: FacetMap = {};
61
+ for (const [name, valueMap] of Object.entries(raw)) {
62
+ const entries = sortFacetEntries(
63
+ Object.entries(valueMap).map(([value, count]) => ({ value, count })),
64
+ );
65
+ if (entries.length > 0) out[name] = entries;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Sort facet entries by count descending, then by value
72
+ * ascending alphabetically. Stable for equal-count values so
73
+ * the UI doesn't shuffle between renders.
74
+ */
75
+ export function sortFacetEntries(entries: FacetEntry[]): FacetEntry[] {
76
+ return [...entries].sort((a, b) => {
77
+ if (a.count !== b.count) return b.count - a.count;
78
+ return a.value.localeCompare(b.value);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Resolve the human display label for one facet value.
84
+ *
85
+ * Maps the slug-form value to the configured display string by
86
+ * looking up the per-taxonomy display config. For slash-nested
87
+ * values (`concept/a11y`), the resolution rules match
88
+ * `<TagList>` chip rendering: explicit label override wins, then
89
+ * prefix label for top-level segments, then leaf segment as a
90
+ * final fallback.
91
+ *
92
+ * Slug paths are split into segments via `/`, mirroring how
93
+ * taxonomies indexes terms.
94
+ */
95
+ export function resolveFacetLabel(
96
+ facetName: string,
97
+ value: string,
98
+ display?: TaxonomyDisplayMap,
99
+ ): string {
100
+ const taxonomy = display?.[facetName];
101
+ const fullPath = value.split("/").filter((s) => s.length > 0);
102
+ return resolveTermLabel(fullPath, taxonomy?.prefixes, taxonomy?.labels);
103
+ }
104
+
105
+ /**
106
+ * Resolve the display label for a facet name itself (the column
107
+ * header text). Looks up `prefixes[facetName].label` for built-in
108
+ * taxonomies (`tags` → "Tags"), falling back to a humanized
109
+ * version of the facet name (`by-audience` → "By audience").
110
+ *
111
+ * Per-taxonomy `prefixes` aren't a perfect fit here (they're keyed
112
+ * by the segment, not by the taxonomy itself), so this is a
113
+ * separate humanizer. Future schema work can add an explicit
114
+ * `taxonomies.<name>.displayName` field if writers want full
115
+ * control.
116
+ */
117
+ export function resolveFacetTitle(facetName: string): string {
118
+ return facetName
119
+ .replace(/[-_]/g, " ")
120
+ .replace(/\b\w/g, (c) => c.toUpperCase());
121
+ }
122
+
123
+ // ── URL persistence ──────────────────────────────────────────────
124
+
125
+ /**
126
+ * Active filter state — keyed by facet name, value is the set of
127
+ * selected slugs (multi-select per facet, AND across facets).
128
+ */
129
+ export type FilterState = Record<string, string[]>;
130
+
131
+ /**
132
+ * Build URLSearchParams from the current filter state. Each
133
+ * facet:value combination produces one `filter=<name>:<value>`
134
+ * entry, repeated per selection. The `q` query param is added
135
+ * only when query is non-empty.
136
+ *
137
+ * Resulting URL is shareable — re-loading restores both the
138
+ * search query AND active filters.
139
+ */
140
+ export function filterStateToUrlParams(
141
+ query: string,
142
+ filters: FilterState,
143
+ ): URLSearchParams {
144
+ const params = new URLSearchParams();
145
+ if (query.trim().length > 0) params.set("q", query.trim());
146
+ for (const [name, values] of Object.entries(filters)) {
147
+ for (const value of values) {
148
+ params.append("filter", `${name}:${value}`);
149
+ }
150
+ }
151
+ return params;
152
+ }
153
+
154
+ /**
155
+ * Parse `URLSearchParams` back into `{ query, filters }`. The
156
+ * inverse of `filterStateToUrlParams`. Tolerant of malformed
157
+ * `filter=...` entries — anything missing the `:` separator is
158
+ * silently dropped (forward-compat with future schema).
159
+ */
160
+ export function parseFiltersFromUrl(
161
+ params: URLSearchParams,
162
+ ): { query: string; filters: FilterState } {
163
+ const query = params.get("q") ?? "";
164
+ const filters: FilterState = {};
165
+ for (const raw of params.getAll("filter")) {
166
+ const colonIdx = raw.indexOf(":");
167
+ if (colonIdx < 1) continue; // need at least 1 char before the colon
168
+ const name = raw.slice(0, colonIdx);
169
+ const value = raw.slice(colonIdx + 1);
170
+ if (!value) continue;
171
+ if (!filters[name]) filters[name] = [];
172
+ if (!filters[name].includes(value)) filters[name].push(value);
173
+ }
174
+ return { query, filters };
175
+ }
176
+
177
+ /**
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.
182
+ *
183
+ * 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".
186
+ */
187
+ export function filterStateToPagefindFilters(
188
+ filters: FilterState,
189
+ ): Record<string, string[]> {
190
+ const out: Record<string, string[]> = {};
191
+ for (const [name, values] of Object.entries(filters)) {
192
+ if (values.length > 0) out[name] = [...values];
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * Toggle a single (facetName, value) pair in the filter state.
199
+ * Returns a new state object — the input is never mutated, so
200
+ * callers can safely use this with React-style "set state to a
201
+ * new value" patterns or vanilla state diffing.
202
+ */
203
+ export function toggleFilter(
204
+ filters: FilterState,
205
+ name: string,
206
+ value: string,
207
+ ): FilterState {
208
+ const next: FilterState = { ...filters };
209
+ const current = next[name] ?? [];
210
+ if (current.includes(value)) {
211
+ const dropped = current.filter((v) => v !== value);
212
+ if (dropped.length === 0) {
213
+ delete next[name];
214
+ } else {
215
+ next[name] = dropped;
216
+ }
217
+ } else {
218
+ next[name] = [...current, value];
219
+ }
220
+ return next;
221
+ }
222
+
223
+ /**
224
+ * Count the total number of active filter selections across all
225
+ * facets. Used to render a "Clear all" button only when there's
226
+ * something to clear.
227
+ */
228
+ export function countActiveFilters(filters: FilterState): number {
229
+ let n = 0;
230
+ for (const values of Object.values(filters)) n += values.length;
231
+ return n;
232
+ }