@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.
- package/package.json +59 -0
- package/src/DocsFooter.astro +96 -0
- package/src/DocsHeader.astro +41 -0
- package/src/DocsLayout.astro +884 -0
- package/src/DocsNav.astro +62 -0
- package/src/DocsSidebar.astro +35 -0
- package/src/DocsToc.astro +50 -0
- package/src/LocaleSwitcher.astro +87 -0
- package/src/PageActions.astro +281 -0
- package/src/SearchDialog.astro +529 -0
- package/src/StatusBadge.astro +79 -0
- package/src/TagList.astro +124 -0
- package/src/TaxonomyIndex.astro +148 -0
- package/src/TaxonomyTerm.astro +181 -0
- package/src/TypeBadge.astro +63 -0
- package/src/VersionSwitcher.astro +86 -0
- package/src/json-ld.ts +55 -0
- package/src/llm-actions.ts +128 -0
- package/src/markdown-negotiation.ts +76 -0
- package/src/nav-filter.ts +166 -0
- package/src/pagination.ts +39 -0
- package/src/search-facets.ts +232 -0
- package/src/switcher.ts +138 -0
- package/src/tag-list-data.ts +202 -0
- package/src/toc-kind.css +52 -0
- package/src/version-redirect.ts +147 -0
|
@@ -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
|
+
}
|