@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,138 @@
1
+ /**
2
+ * Shared logic for axis switchers (Version + Locale, with the
3
+ * same machinery extending to future axes). Pure functions —
4
+ * the Astro components are thin wrappers around this module.
5
+ *
6
+ * The switcherMap data structure is documented in
7
+ * `format-astro/src/project.ts` (the emitter side).
8
+ */
9
+
10
+ export interface AxisEntry {
11
+ id: string;
12
+ label?: string;
13
+ eol?: boolean;
14
+ default?: boolean;
15
+ }
16
+
17
+ export interface SwitcherVariant {
18
+ locale?: string;
19
+ version?: string;
20
+ url: string;
21
+ }
22
+
23
+ export interface SwitcherMap {
24
+ versions: AxisEntry[];
25
+ locales: AxisEntry[];
26
+ byLogicalKey: Record<string, SwitcherVariant[]>;
27
+ }
28
+
29
+ export interface MultiSourceMeta {
30
+ namespace?: string;
31
+ version?: string;
32
+ locale?: string;
33
+ originalSlug: string;
34
+ }
35
+
36
+ /** The axis the switcher is responsible for. */
37
+ export type SwitcherAxis = "version" | "locale";
38
+
39
+ /**
40
+ * Compose the logical key the switcher uses to look up the
41
+ * current page's variants in `switcherMap.byLogicalKey`.
42
+ */
43
+ export function logicalKeyFor(ms: MultiSourceMeta): string {
44
+ return `${ms.namespace ?? "docs"}/${ms.originalSlug}`;
45
+ }
46
+
47
+ /**
48
+ * Build the full row data the switcher dropdown needs to
49
+ * render: declared entries (in order) plus, per row, the
50
+ * resolved URL for that entry (or null if no variant exists
51
+ * in this dimension for the current page).
52
+ *
53
+ * Row resolution algorithm:
54
+ *
55
+ * For each declared entry on this axis:
56
+ * - Find a variant whose <axis>-id matches the entry AND
57
+ * whose OTHER axis values match the current page (the
58
+ * same locale when switching version, etc.).
59
+ * - If found: row.url is the variant's url.
60
+ * - Otherwise: row.url is null (rendered as "no equivalent"
61
+ * in the dropdown; clicking falls back to the axis's
62
+ * landing page).
63
+ */
64
+ export interface SwitcherRow {
65
+ entry: AxisEntry;
66
+ url: string | null;
67
+ isCurrent: boolean;
68
+ }
69
+
70
+ export interface BuildRowsInput {
71
+ axis: SwitcherAxis;
72
+ switcherMap: SwitcherMap;
73
+ multiSource: MultiSourceMeta;
74
+ }
75
+
76
+ export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
77
+ const { axis, switcherMap, multiSource } = input;
78
+ const entries = axis === "version" ? switcherMap.versions : switcherMap.locales;
79
+ const currentId =
80
+ axis === "version" ? multiSource.version : multiSource.locale;
81
+ const otherAxis: SwitcherAxis = axis === "version" ? "locale" : "version";
82
+ const otherCurrent =
83
+ otherAxis === "version" ? multiSource.version : multiSource.locale;
84
+
85
+ const variants = switcherMap.byLogicalKey[logicalKeyFor(multiSource)] ?? [];
86
+
87
+ return entries.map((entry) => {
88
+ const match = variants.find((v) => {
89
+ // Match on this axis.
90
+ const thisMatch = axis === "version" ? v.version === entry.id : v.locale === entry.id;
91
+ if (!thisMatch) return false;
92
+ // And on the other axis (when active for this page).
93
+ if (otherCurrent === undefined) return true;
94
+ const otherMatch =
95
+ otherAxis === "version" ? v.version === otherCurrent : v.locale === otherCurrent;
96
+ return otherMatch;
97
+ });
98
+ return {
99
+ entry,
100
+ url: match?.url ?? null,
101
+ isCurrent: entry.id === currentId,
102
+ };
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Whether the switcher should render at all. Two gates:
108
+ * - the axis has ≥2 declared entries (single-axis sites
109
+ * don't need a dropdown);
110
+ * - the current page has metadata for that axis (some pages
111
+ * in a multi-version site may sit outside the axis, e.g.
112
+ * a custom 404 — they should not show the dropdown).
113
+ */
114
+ export function shouldRenderSwitcher(
115
+ axis: SwitcherAxis,
116
+ switcherMap: SwitcherMap,
117
+ multiSource: MultiSourceMeta | undefined,
118
+ ): boolean {
119
+ if (!multiSource) return false;
120
+ const entries = axis === "version" ? switcherMap.versions : switcherMap.locales;
121
+ if (entries.length < 2) return false;
122
+ const id = axis === "version" ? multiSource.version : multiSource.locale;
123
+ return id !== undefined;
124
+ }
125
+
126
+ /**
127
+ * Fallback URL for a row whose target variant doesn't exist —
128
+ * lands on the axis's "landing page" (`<basePath>/<id>/`).
129
+ * Lets the switcher always be clickable even when a page is
130
+ * untranslated for some axis combo.
131
+ */
132
+ export function fallbackLandingUrl(
133
+ basePath: string,
134
+ axisEntryId: string,
135
+ ): string {
136
+ const trimmed = basePath.replace(/\/$/, "");
137
+ return `${trimmed}/${axisEntryId}/`;
138
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Pure data transform for `<TagList>`. Splitting it out from the
3
+ * Astro component lets vitest unit-test the chip-building logic
4
+ * without needing a render container.
5
+ *
6
+ * See plans/tag-display-config.md.
7
+ */
8
+
9
+ /** Closed palette of tag-chip colors. Mirror of `@dogsbay/types`. */
10
+ export type TagPaletteName =
11
+ | "blue"
12
+ | "amber"
13
+ | "emerald"
14
+ | "violet"
15
+ | "rose"
16
+ | "slate";
17
+
18
+ export interface PrefixDisplay {
19
+ label?: string;
20
+ color?: TagPaletteName;
21
+ }
22
+
23
+ export interface BuildChipsOptions {
24
+ /** Path prefix for term pages, e.g. `/tags`. */
25
+ indexPath: string;
26
+ /**
27
+ * Per-prefix display config. Key = top-level segment of the tag.
28
+ * Tag's first segment is matched here for two-part rendering.
29
+ */
30
+ prefixes?: Record<string, PrefixDisplay>;
31
+ /**
32
+ * Per-tag leaf-label overrides. Key = full slug; value = display
33
+ * text. URL still uses the slug.
34
+ */
35
+ labels?: Record<string, string>;
36
+ }
37
+
38
+ export interface ChipModel {
39
+ /** Original tag slug; used as `data-tag` and for the URL. */
40
+ tag: string;
41
+ /** Built href: `<indexPath>/<tag>/`. */
42
+ href: string;
43
+ /**
44
+ * Prefix label when two-part rendering applies (set when the
45
+ * tag's first segment matches a `prefixes` entry that supplies
46
+ * a `label`). `null` for flat chips.
47
+ */
48
+ prefixLabel: string | null;
49
+ /** Display text for the leaf. Used in both rendering modes. */
50
+ leafLabel: string;
51
+ /** Palette color name to look up; null = default chip styling. */
52
+ color: TagPaletteName | null;
53
+ }
54
+
55
+ /**
56
+ * Filter empty / non-string entries from a raw tag list. Exported
57
+ * so the component can keep its render guard simple.
58
+ */
59
+ export function filterTags(tags: readonly unknown[] | undefined): string[] {
60
+ return (tags ?? []).filter(
61
+ (t): t is string => typeof t === "string" && t.length > 0,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Resolve the human-readable display label for one tag.
67
+ *
68
+ * Used by both `<TagList>` chip rendering and HTML meta emission
69
+ * (`<meta property="article:tag">` / JSON-LD `Article.keywords`).
70
+ * Slug-based identifiers like `difficulty/1` mean nothing to a
71
+ * search-engine indexer or social-card preview — the label
72
+ * override (`Beginner`) is the unit that carries meaning.
73
+ *
74
+ * Resolution order:
75
+ * 1. `labels[fullSlug]` — explicit override wins.
76
+ * 2. Segment after the first `/` — for hierarchical tags without
77
+ * an override, the leaf carries the human-meaningful part.
78
+ * 3. The bare tag — flat tags / final fallback.
79
+ *
80
+ * Always returns a non-empty string; never returns the prefix
81
+ * label (that's chip-rendering chrome, not a stand-alone keyword).
82
+ */
83
+ export function resolveTagDisplayLabel(
84
+ tag: string,
85
+ labels?: Record<string, string>,
86
+ ): string {
87
+ const override = labels?.[tag];
88
+ if (override && override.length > 0) return override;
89
+ const slashIdx = tag.indexOf("/");
90
+ if (slashIdx === -1) return tag;
91
+ const remainder = tag.slice(slashIdx + 1);
92
+ return remainder.length > 0 ? remainder : tag;
93
+ }
94
+
95
+ /**
96
+ * Map a list of tag slugs to their human-readable display labels.
97
+ * Drops empty/non-string inputs (via `filterTags`); returns one
98
+ * label per tag in original order.
99
+ *
100
+ * The resulting array is the natural input for HTML meta tags
101
+ * (`<meta property="article:tag" content="${kw}">` per entry) and
102
+ * for JSON-LD `Article.keywords` (joined with `", "`).
103
+ */
104
+ export function resolveTagKeywords(
105
+ tags: readonly unknown[] | undefined,
106
+ labels?: Record<string, string>,
107
+ ): string[] {
108
+ return filterTags(tags).map((tag) => resolveTagDisplayLabel(tag, labels));
109
+ }
110
+
111
+ /**
112
+ * Resolve the display label for a term identified by its fullPath
113
+ * array (e.g. `["concept"]` or `["concept", "a11y"]`).
114
+ *
115
+ * Used by `<TaxonomyIndex>` term-cloud chips, `<TaxonomyTerm>`
116
+ * page headings and breadcrumbs, and sub-tag pills — anywhere a
117
+ * structural slug needs to render as a human label.
118
+ *
119
+ * Resolution order:
120
+ * 1. `labels[fullSlug]` (joined fullPath) — explicit override
121
+ * wins, regardless of depth.
122
+ * 2. For top-level terms (`fullPath.length === 1`):
123
+ * `prefixes[segment].label` — lets a top-level segment
124
+ * (`concept`, `difficulty`) render as "Concept", "Difficulty".
125
+ * 3. The leaf segment of the path — the rendered fallback for
126
+ * deeper terms with no override.
127
+ *
128
+ * URLs continue to use the slug-form `fullPath`. Display only.
129
+ */
130
+ export function resolveTermLabel(
131
+ fullPath: readonly string[],
132
+ prefixes?: Record<string, PrefixDisplay>,
133
+ labels?: Record<string, string>,
134
+ ): string {
135
+ if (fullPath.length === 0) return "";
136
+ const slug = fullPath.join("/");
137
+ const override = labels?.[slug];
138
+ if (override && override.length > 0) return override;
139
+ if (fullPath.length === 1) {
140
+ const prefixLabel = prefixes?.[fullPath[0]]?.label;
141
+ if (prefixLabel && prefixLabel.length > 0) return prefixLabel;
142
+ }
143
+ return fullPath[fullPath.length - 1];
144
+ }
145
+
146
+ /**
147
+ * Map raw tag slugs to render-ready chip models, applying prefix
148
+ * + label config rules.
149
+ *
150
+ * Rules:
151
+ * - URL is always `<indexPath>/<tag>/` regardless of label
152
+ * override — taxonomy term pages live at the slug path.
153
+ * - Two-part chip path requires both: a `prefixes` entry for the
154
+ * first segment AND that entry having `label` or `color` set.
155
+ * Without either, falls back to flat rendering (back-compat for
156
+ * sites that haven't declared display config).
157
+ * - Leaf label resolution order: `labels[fullSlug]` override,
158
+ * then the segment after the first slash, then the bare tag
159
+ * (used when a prefix-only-color entry matches a non-nested tag).
160
+ * - Color from prefix entry; null when absent (default styling).
161
+ */
162
+ export function buildChips(
163
+ tags: readonly unknown[] | undefined,
164
+ options: BuildChipsOptions,
165
+ ): ChipModel[] {
166
+ const safe = filterTags(tags);
167
+ const { indexPath, prefixes, labels } = options;
168
+
169
+ return safe.map((tag): ChipModel => {
170
+ const href = `${indexPath}/${tag}/`;
171
+ const slashIdx = tag.indexOf("/");
172
+ const firstSegment = slashIdx === -1 ? tag : tag.slice(0, slashIdx);
173
+ const prefixEntry = prefixes?.[firstSegment];
174
+ const overrideLeaf = labels?.[tag];
175
+
176
+ // Flat chip: preserves the full slug (`#concept/a11y`) when no
177
+ // prefix entry matches. The slash signals hierarchy visually
178
+ // even without explicit display config.
179
+ if (!prefixEntry || (!prefixEntry.label && !prefixEntry.color)) {
180
+ return {
181
+ tag,
182
+ href,
183
+ prefixLabel: null,
184
+ leafLabel: overrideLeaf ?? tag,
185
+ color: null,
186
+ };
187
+ }
188
+
189
+ // Two-part chip: prefix takes the structural segment, leaf
190
+ // gets the meaningful name (override or remainder after first
191
+ // slash). Falls back to the bare tag for non-nested entries.
192
+ const remainder = slashIdx === -1 ? tag : tag.slice(slashIdx + 1);
193
+ const leafLabel = overrideLeaf ?? remainder;
194
+ return {
195
+ tag,
196
+ href,
197
+ prefixLabel: prefixEntry.label ?? null,
198
+ leafLabel: leafLabel.length > 0 ? leafLabel : tag,
199
+ color: prefixEntry.color ?? null,
200
+ };
201
+ });
202
+ }
@@ -0,0 +1,52 @@
1
+ .toc-kind {
2
+ display: inline-block;
3
+ border-radius: 0.25rem;
4
+ padding: 0 0.3rem;
5
+ font-size: 0.6rem;
6
+ font-weight: 600;
7
+ letter-spacing: 0.02em;
8
+ text-transform: lowercase;
9
+ vertical-align: middle;
10
+ margin-right: 0.35rem;
11
+ line-height: 1.4;
12
+ }
13
+ .toc-kind-class {
14
+ background-color: oklch(0.55 0.18 265 / 0.15);
15
+ color: oklch(0.55 0.18 265);
16
+ }
17
+ .dark .toc-kind-class {
18
+ background-color: oklch(0.7 0.15 265 / 0.2);
19
+ color: oklch(0.7 0.15 265);
20
+ }
21
+ .toc-kind-meth {
22
+ background-color: oklch(0.55 0.15 155 / 0.15);
23
+ color: oklch(0.55 0.15 155);
24
+ }
25
+ .dark .toc-kind-meth {
26
+ background-color: oklch(0.7 0.15 155 / 0.2);
27
+ color: oklch(0.7 0.15 155);
28
+ }
29
+ .toc-kind-attr {
30
+ background-color: oklch(0.6 0.15 55 / 0.15);
31
+ color: oklch(0.6 0.15 55);
32
+ }
33
+ .dark .toc-kind-attr {
34
+ background-color: oklch(0.75 0.15 55 / 0.2);
35
+ color: oklch(0.75 0.15 55);
36
+ }
37
+ .toc-kind-func {
38
+ background-color: oklch(0.55 0.18 330 / 0.15);
39
+ color: oklch(0.55 0.18 330);
40
+ }
41
+ .dark .toc-kind-func {
42
+ background-color: oklch(0.7 0.15 330 / 0.2);
43
+ color: oklch(0.7 0.15 330);
44
+ }
45
+ .toc-kind-module {
46
+ background-color: oklch(0.5 0.12 200 / 0.15);
47
+ color: oklch(0.5 0.12 200);
48
+ }
49
+ .dark .toc-kind-module {
50
+ background-color: oklch(0.65 0.12 200 / 0.2);
51
+ color: oklch(0.65 0.12 200);
52
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Default-axis redirect decision helper.
3
+ *
4
+ * Combines the default-version redirect (PR 4c) with the
5
+ * default-locale redirect (PR 5c) into one helper. When a
6
+ * request URL is missing the locale or version segment that
7
+ * the site has declared, the helper returns the canonical URL
8
+ * with the missing segment(s) injected in the correct order
9
+ * (locale outermost, then version, per the site's URL
10
+ * convention).
11
+ *
12
+ * Pure function — no I/O, no globals. Inlined into the emitted
13
+ * Astro middleware. The middleware issues a 302 to the returned
14
+ * URL (302 not 301 — the switchers let readers navigate away
15
+ * from the default, so we don't want browsers permanently
16
+ * caching the unprefixed URL as default content).
17
+ *
18
+ * The function is exported under the historical name
19
+ * `shouldRedirectToDefaultVersion` for backward compat but now
20
+ * also handles locale. New code can import the same name.
21
+ */
22
+
23
+ export interface AxisRedirectConfig {
24
+ /**
25
+ * URL prefix where docs are served, e.g. "/docs" or "" for
26
+ * root-served sites. Should be a normalized basePath
27
+ * (no trailing slash for non-empty values).
28
+ */
29
+ basePath: string;
30
+ /** Default version id; required when version axis is active. */
31
+ defaultVersion?: string;
32
+ /** Full set of declared version ids. Empty/undefined → axis inactive. */
33
+ knownVersions?: string[];
34
+ /** Default locale id; required when locale axis is active. */
35
+ defaultLocale?: string;
36
+ /** Full set of declared locale ids. Empty/undefined → axis inactive. */
37
+ knownLocales?: string[];
38
+ }
39
+
40
+ /**
41
+ * Returns the redirect target when the pathname is missing one
42
+ * or more axis segments, or `null` when the request should pass
43
+ * through.
44
+ *
45
+ * Behaviour:
46
+ * - Skips when neither axis is active (knownVersions <2 AND
47
+ * knownLocales <2).
48
+ * - Skips paths outside the configured basePath.
49
+ * - Skips the bare basePath (root index handles its own
50
+ * redirect).
51
+ * - Skips asset paths (`_*` prefix, `pagefind`).
52
+ * - Determines which segments are PRESENT (locale first,
53
+ * version second per canonical order) by greedy matching
54
+ * against `knownLocales` then `knownVersions`. Missing
55
+ * segments are injected from the corresponding default.
56
+ */
57
+ export function shouldRedirectToDefaultVersion(
58
+ pathname: string,
59
+ config: AxisRedirectConfig,
60
+ ): string | null {
61
+ const versionAxisActive =
62
+ !!config.defaultVersion && (config.knownVersions ?? []).length >= 2;
63
+ const localeAxisActive =
64
+ !!config.defaultLocale && (config.knownLocales ?? []).length >= 2;
65
+ if (!versionAxisActive && !localeAxisActive) return null;
66
+
67
+ const basePath = config.basePath.replace(/\/$/, "");
68
+
69
+ // Strip basePath. If not under basePath, pass through.
70
+ let rest: string;
71
+ if (basePath === "") {
72
+ rest = pathname;
73
+ } else if (pathname === basePath || pathname === `${basePath}/`) {
74
+ rest = "";
75
+ } else if (pathname.startsWith(`${basePath}/`)) {
76
+ rest = pathname.slice(basePath.length);
77
+ } else {
78
+ return null;
79
+ }
80
+
81
+ if (!rest.startsWith("/")) rest = `/${rest}`;
82
+ const trimmed = rest.replace(/\/+$/, "");
83
+ if (trimmed === "" || trimmed === "/") return null;
84
+
85
+ const segments = trimmed.replace(/^\//, "").split("/");
86
+ if (segments.length === 0) return null;
87
+
88
+ // Skip Astro / Pagefind asset paths.
89
+ if (segments[0].startsWith("_") || segments[0] === "pagefind") return null;
90
+
91
+ // Greedy axis detection — locale outermost, version next.
92
+ const knownLocales = new Set(config.knownLocales ?? []);
93
+ const knownVersions = new Set(config.knownVersions ?? []);
94
+
95
+ let cursor = 0;
96
+ let presentLocale: string | undefined;
97
+ let presentVersion: string | undefined;
98
+
99
+ if (localeAxisActive && knownLocales.has(segments[0])) {
100
+ presentLocale = segments[0];
101
+ cursor = 1;
102
+ }
103
+ if (
104
+ versionAxisActive &&
105
+ cursor < segments.length &&
106
+ knownVersions.has(segments[cursor])
107
+ ) {
108
+ presentVersion = segments[cursor];
109
+ cursor += 1;
110
+ }
111
+
112
+ // What's the rest of the path beyond the recognised axis prefixes?
113
+ const tailSegments = segments.slice(cursor);
114
+ if (tailSegments.length === 0) {
115
+ // The path is JUST a known axis prefix (e.g. /docs/en/ or
116
+ // /docs/v1/). Treat as the axis's landing page; no redirect
117
+ // needed unless other axes are MISSING from the URL too.
118
+ if (
119
+ (localeAxisActive && presentLocale === undefined) ||
120
+ (versionAxisActive && presentVersion === undefined)
121
+ ) {
122
+ // Some axis still missing — fall through to redirect.
123
+ } else {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ // If all required-active axes are present, no redirect needed.
129
+ const localeNeeded = localeAxisActive && presentLocale === undefined;
130
+ const versionNeeded = versionAxisActive && presentVersion === undefined;
131
+ if (!localeNeeded && !versionNeeded) return null;
132
+
133
+ // Compose the redirect target with missing segments injected
134
+ // in canonical order (<locale>/<version>/<tail>).
135
+ const out: string[] = [];
136
+ if (localeAxisActive) {
137
+ out.push(presentLocale ?? config.defaultLocale!);
138
+ }
139
+ if (versionAxisActive) {
140
+ out.push(presentVersion ?? config.defaultVersion!);
141
+ }
142
+ out.push(...tailSegments);
143
+
144
+ // Preserve trailing slash from the original pathname.
145
+ const trailing = pathname.endsWith("/") && !pathname.endsWith(`${basePath}/`);
146
+ return `${basePath}/${out.join("/")}${trailing ? "/" : ""}`;
147
+ }