@dogsbay/docs-layout 0.2.0-beta.14 → 0.2.0-beta.140

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/nav-filter.ts CHANGED
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * Multi-source nav filtering.
3
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).
4
+ * When a docs site has multiple products/versions/locales, every page's
5
+ * emitted nav.json contains entries from EVERY bucket. Without filtering,
6
+ * the sidebar shows a product's sections once per version, and every other
7
+ * product too — confusing UX. The fix: filter the nav tree to the current
8
+ * page's (namespace, locale, version) bucket.
9
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.
10
+ * The match is a single COMPOSED prefix in the canonical URL order
11
+ * `/<basePath>/<namespace>/<locale>/<version>/...` — whichever of those
12
+ * axes the current page carries. The axis switchers handle navigation
13
+ * BETWEEN buckets; the sidebar reflects only the active one. Pure
14
+ * function: nav + filter → pruned copy.
16
15
  */
17
16
 
18
17
  interface NavItem {
@@ -22,145 +21,87 @@ interface NavItem {
22
21
  }
23
22
 
24
23
  export interface NavFilter {
25
- /** Site basePath (e.g. "/docs"). Used to compose the version prefix. */
24
+ /** Site basePath (e.g. "" for root, "/docs"). */
26
25
  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
- */
26
+ /** Current page's product/namespace segment (outermost), if any. */
27
+ namespace?: string;
28
+ /** Current page's locale segment (after namespace), if any. */
29
+ locale?: string;
30
+ /** Current page's version segment (innermost, next to the page), if any. */
32
31
  version?: string;
33
32
  /**
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>/`).
33
+ * The version served at the site root (`versions[].path: ""`): its pages
34
+ * have no version segment. When `version` is this one, the bucket is the
35
+ * namespace/locale prefix minus the other versions' folders
36
+ * (`versionSegments`) — the longest matching prefix wins.
37
37
  */
38
- locale?: string;
38
+ rootVersion?: string;
39
+ /** The other versions' URL segments, left out of the root version's bucket. */
40
+ versionSegments?: string[];
39
41
  }
40
42
 
41
43
  /**
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.
44
+ * The root-version fields of a `NavFilter`, from the switcherMap header
45
+ * (`versions[].root`). Empty when no version is served at the root.
54
46
  */
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;
47
+ export function rootVersionFilter(
48
+ versions: Array<{ id: string; root?: boolean }> | undefined,
49
+ ): Pick<NavFilter, "rootVersion" | "versionSegments"> {
50
+ const root = versions?.find((v) => v.root)?.id;
51
+ if (!root) return {};
52
+ return { rootVersion: root, versionSegments: versions!.map((v) => v.id).filter((id) => id !== root) };
53
+ }
70
54
 
71
- return items.flatMap((item) =>
72
- filterOne(item, localePrefix, versionSegment, filter.basePath),
55
+ /**
56
+ * Prune the nav to the current page's bucket. Group nodes (no `href`,
57
+ * with `children`) survive iff a descendant survives; empty groups and
58
+ * childless/href-less nodes are dropped (else a non-current bucket's group
59
+ * lingers as a phantom header).
60
+ */
61
+ export function filterNavByAxis(items: NavItem[], filter: NavFilter): NavItem[] {
62
+ // Canonical order: namespace → locale → version. Only the axes the
63
+ // current page actually carries contribute to the match prefix.
64
+ const atRoot = filter.rootVersion !== undefined && filter.version === filter.rootVersion;
65
+ const segs = [filter.namespace, filter.locale, atRoot ? undefined : filter.version].filter(
66
+ (s): s is string => s !== undefined && s !== "",
73
67
  );
68
+ // The root version's bucket leaves out the other versions' folders under its prefix.
69
+ const others = atRoot ? (filter.versionSegments ?? []).filter((v) => v && v !== filter.rootVersion) : [];
70
+ if (segs.length === 0 && others.length === 0) return items;
71
+
72
+ const base = filter.basePath.replace(/\/$/, "");
73
+ const prefix = `${base}/${segs.join("/")}${segs.length ? "/" : ""}`;
74
+ const prefixNoSlash = segs.length ? prefix.replace(/\/$/, "") : base || "/";
75
+ const excluded = others.map((v) => `${prefix}${v}`);
76
+
77
+ return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash, excluded));
74
78
  }
75
79
 
76
- function filterOne(
77
- item: NavItem,
78
- localePrefix: string | null,
79
- versionSegment: string | null,
80
- basePath: string,
81
- ): NavItem[] {
80
+ function filterOne(item: NavItem, prefix: string, prefixNoSlash: string, excluded: string[]): NavItem[] {
82
81
  if (item.children && item.children.length > 0) {
83
- const kept = item.children.flatMap((c) =>
84
- filterOne(c, localePrefix, versionSegment, basePath),
85
- );
82
+ const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash, excluded));
86
83
  if (kept.length === 0) return [];
87
84
  return [{ ...item, children: kept }];
88
85
  }
89
86
  if (item.href !== undefined) {
90
- if (!hrefMatchesAxes(item.href, localePrefix, versionSegment, basePath)) {
91
- return [];
92
- }
93
- return [item];
87
+ return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) &&
88
+ !excluded.some((x) => hrefMatchesPrefix(item.href!, `${x}/`, x))
89
+ ? [item]
90
+ : [];
94
91
  }
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}/`;
92
+ // Childless AND href-less: no navigation, no bucket membership. In a
93
+ // filtered view it must be dropped.
94
+ return [];
150
95
  }
151
96
 
152
97
  /**
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.
98
+ * Whether an href belongs to the composed bucket prefix. Tolerates the
99
+ * bucket's landing page itself (`/<prefix>` with no trailing slash);
100
+ * external URLs never match.
156
101
  */
157
- function hrefMatchesPrefix(href: string, prefix: string): boolean {
158
- // Skip external URLs.
102
+ function hrefMatchesPrefix(href: string, prefix: string, prefixNoSlash: string): boolean {
159
103
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
160
104
  return false;
161
105
  }
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;
106
+ return href.startsWith(prefix) || href === prefixNoSlash;
166
107
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pagefind result URLs, and the base they need.
3
+ *
4
+ * Pagefind stores each page's URL relative to the directory it indexed
5
+ * (`pagefind --site dist`), then prefixes every result with `baseUrl`. When
6
+ * the caller sets none, Pagefind derives it from where the bundle was loaded:
7
+ * the folder above `pagefind/`.
8
+ *
9
+ * That default is right for one of our two subpath layouts and wrong for the
10
+ * other, measured by decoding index fragments on live sites:
11
+ *
12
+ * | Deploy | Pages built into | Stored URL | Default base | Result |
13
+ * |---|---|---|---|---|
14
+ * | Workers subpath mount | `dist/<mount>/` (nested) | `/<mount>/page/` | `/<mount>/` | `/<mount>/<mount>/page/` — 404 |
15
+ * | GitHub Pages project | `dist/` (Astro `base`) | `/page/` | `/<repo>/` | `/<repo>/page/` — correct |
16
+ *
17
+ * So no single constant works: forcing `/` fixes the Workers mount and breaks
18
+ * every GitHub Pages project site. The emitter knows which layout it built and
19
+ * passes the base explicitly; this applies it.
20
+ */
21
+
22
+ /** The slice of the Pagefind module this touches. */
23
+ export interface PagefindOptionsModule {
24
+ options?: (opts: { baseUrl?: string }) => Promise<void> | void;
25
+ }
26
+
27
+ /**
28
+ * The base Pagefind should prefix results with: the served path that the
29
+ * INDEXED directory maps to.
30
+ *
31
+ * - Workers subpath mount — pages are built INTO `dist/<mount>/`, so the
32
+ * index already carries the mount in every URL: `/`.
33
+ * - Anything else — pages sit at the `dist/` root and Astro's `base` (the
34
+ * site.url path) is added at serve time, so that is the missing prefix:
35
+ * `<urlBase>/`, or `/` at the host root.
36
+ *
37
+ * Note `basePath` (dogsbay's own prefix) is never part of it: pages are
38
+ * physically written under it, so the index already contains it.
39
+ */
40
+ export function pagefindBaseUrl(urlBase: string, nestedOutDir: boolean): string {
41
+ if (nestedOutDir) return "/";
42
+ const base = urlBase.replace(/\/+$/, "");
43
+ return base ? `${base}/` : "/";
44
+ }
45
+
46
+ /**
47
+ * Apply an emitted base to a loaded Pagefind module. A missing base (a page
48
+ * emitted before the base was passed) leaves Pagefind's own default, so an
49
+ * un-rebuilt site behaves exactly as before rather than changing under it.
50
+ */
51
+ export async function applyPagefindBaseUrl(
52
+ mod: PagefindOptionsModule,
53
+ baseUrl: string | undefined,
54
+ ): Promise<boolean> {
55
+ if (!baseUrl) return false;
56
+ if (typeof mod.options !== "function") return false;
57
+ await mod.options({ baseUrl });
58
+ return true;
59
+ }