@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,124 @@
1
+ ---
2
+ /**
3
+ * TagList — inline list of tag chips linking to taxonomy term pages.
4
+ *
5
+ * Used by DocsLayout to surface page-level tags near the title.
6
+ * Tags can be flat (`api`) or slash-nested (`api/rest`); each chip
7
+ * links to `<indexPath>/<tag>/`.
8
+ *
9
+ * Two render modes:
10
+ *
11
+ * - **Flat chip** (default) — single muted background, `#tag` style.
12
+ * Used when no `prefixes` entry matches the tag's first segment.
13
+ *
14
+ * - **Two-part chip** — when the prefix has a `label` set, the chip
15
+ * splits into a muted prefix label + bold leaf value, both
16
+ * wearing the prefix's palette color. Different prefixes get
17
+ * different colors so a page tagged `concept/a11y` and
18
+ * `format/markdown` reads as two visually distinct facets.
19
+ *
20
+ * Chip-building logic lives in `./tag-list-data.ts` so it can be
21
+ * vitested without a render container.
22
+ *
23
+ * Hide automatically when `tags` is empty / undefined.
24
+ *
25
+ * See plans/tag-display-config.md.
26
+ */
27
+ import {
28
+ buildChips,
29
+ type PrefixDisplay,
30
+ type TagPaletteName,
31
+ } from "./tag-list-data.js";
32
+
33
+ interface Props {
34
+ /** Tag values from `page.meta.tags`. Slash-nested allowed. */
35
+ tags?: string[];
36
+ /**
37
+ * Path prefix for term pages. Default `/tags`. Match the
38
+ * `taxonomies.tags.indexPath` used at emit time.
39
+ */
40
+ indexPath?: string;
41
+ /**
42
+ * Per-prefix display config. Key = top-level segment of the tag.
43
+ * Absent → flat chip rendering for that tag.
44
+ */
45
+ prefixes?: Record<string, PrefixDisplay>;
46
+ /**
47
+ * Per-tag leaf-label overrides. Key = full slug; value = display
48
+ * text. URL still uses the slug.
49
+ */
50
+ labels?: Record<string, string>;
51
+ /** Optional aria-label override; default "Tags". */
52
+ label?: string;
53
+ class?: string;
54
+ }
55
+
56
+ const {
57
+ tags,
58
+ indexPath = "/tags",
59
+ prefixes,
60
+ labels,
61
+ label: ariaLabel = "Tags",
62
+ class: className,
63
+ } = Astro.props;
64
+
65
+ const chips = buildChips(tags, { indexPath, prefixes, labels });
66
+
67
+ /**
68
+ * Closed palette → Tailwind class strings. Each entry is one
69
+ * background+text pair; prefix-keyed colors stay readable in
70
+ * both light and dark mode by leaning on the `/15` alpha and
71
+ * `dark:` variants.
72
+ */
73
+ const PALETTE: Record<TagPaletteName, string> = {
74
+ blue: "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300",
75
+ amber:
76
+ "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300",
77
+ emerald:
78
+ "border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
79
+ violet:
80
+ "border-violet-500/40 bg-violet-500/15 text-violet-700 dark:text-violet-300",
81
+ rose: "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300",
82
+ slate:
83
+ "border-slate-500/40 bg-slate-500/15 text-slate-700 dark:text-slate-300",
84
+ };
85
+
86
+ const DEFAULT_CLASS =
87
+ "border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground";
88
+ ---
89
+
90
+ {chips.length > 0 && (
91
+ <ul
92
+ class:list={[
93
+ "not-prose flex flex-wrap items-center gap-1.5 text-xs",
94
+ className,
95
+ ]}
96
+ aria-label={ariaLabel}
97
+ data-tag-list
98
+ >
99
+ {chips.map((chip) => (
100
+ <li>
101
+ <a
102
+ href={chip.href}
103
+ class:list={[
104
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-medium transition-colors",
105
+ chip.color ? PALETTE[chip.color] : DEFAULT_CLASS,
106
+ ]}
107
+ data-tag={chip.tag}
108
+ >
109
+ {chip.prefixLabel ? (
110
+ <Fragment>
111
+ <span class="opacity-70">{chip.prefixLabel}:</span>
112
+ <span class="font-semibold">{chip.leafLabel}</span>
113
+ </Fragment>
114
+ ) : (
115
+ <Fragment>
116
+ <span aria-hidden="true">#</span>
117
+ <span>{chip.leafLabel}</span>
118
+ </Fragment>
119
+ )}
120
+ </a>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ )}
@@ -0,0 +1,148 @@
1
+ ---
2
+ /**
3
+ * TaxonomyIndex — listing of all terms for a taxonomy.
4
+ *
5
+ * Reads a `taxonomy-<name>.json` data file (emitted by
6
+ * `format-astro/src/taxonomy.ts` during `dogsbay site build`) and
7
+ * renders a flat / hierarchical list of terms with page counts.
8
+ *
9
+ * Auto-generated route files (`src/pages<indexPath>/index.astro`)
10
+ * import this and pass the imported JSON through, plus the
11
+ * per-taxonomy `prefixes` / `labels` display config so chip text
12
+ * shows human labels instead of slugs (e.g. "Accessibility"
13
+ * instead of "concept/a11y").
14
+ */
15
+ import {
16
+ resolveTermLabel,
17
+ type PrefixDisplay,
18
+ } from "./tag-list-data.js";
19
+
20
+ interface TaxonomyPageRef {
21
+ slug: string;
22
+ title: string;
23
+ url: string;
24
+ }
25
+
26
+ interface TaxonomyTerm {
27
+ fullPath: string[];
28
+ label: string;
29
+ pages: TaxonomyPageRef[];
30
+ }
31
+
32
+ interface TaxonomyData {
33
+ name: string;
34
+ indexPath: string;
35
+ hierarchical: boolean;
36
+ terms: TaxonomyTerm[];
37
+ pageCount: number;
38
+ }
39
+
40
+ interface Props {
41
+ /** The data emitted to `src/data/taxonomy-<name>.json`. */
42
+ data: TaxonomyData;
43
+ /**
44
+ * Per-prefix display config from `taxonomies.<name>.prefixes`.
45
+ * Provides the human label for top-level segments
46
+ * (`concept` → "Concept").
47
+ */
48
+ prefixes?: Record<string, PrefixDisplay>;
49
+ /**
50
+ * Per-slug label overrides from `taxonomies.<name>.labels`.
51
+ * Display text only — URLs continue to use the slug.
52
+ */
53
+ labels?: Record<string, string>;
54
+ /** Optional override for the rendered heading (default: humanized taxonomy name). */
55
+ heading?: string;
56
+ class?: string;
57
+ }
58
+
59
+ const { data, prefixes, labels, heading, class: className } = Astro.props;
60
+
61
+ const displayName = heading
62
+ ?? data.name
63
+ .replace(/[-_]/g, " ")
64
+ .replace(/\b\w/g, (c) => c.toUpperCase());
65
+
66
+ // For hierarchical taxonomies, build a parent→children index so we
67
+ // can render a tree. The data already contains all prefix entries.
68
+ function topLevelTerms(): TaxonomyTerm[] {
69
+ if (!data.hierarchical) return data.terms;
70
+ return data.terms.filter((t) => t.fullPath.length === 1);
71
+ }
72
+
73
+ function childTerms(parent: TaxonomyTerm): TaxonomyTerm[] {
74
+ if (!data.hierarchical) return [];
75
+ const parentKey = parent.fullPath.join("/");
76
+ return data.terms.filter((t) =>
77
+ t.fullPath.length === parent.fullPath.length + 1 &&
78
+ t.fullPath.slice(0, -1).join("/") === parentKey
79
+ );
80
+ }
81
+
82
+ function termHref(term: TaxonomyTerm): string {
83
+ return `${data.indexPath}/${term.fullPath.join("/")}/`;
84
+ }
85
+
86
+ function termText(term: TaxonomyTerm): string {
87
+ return resolveTermLabel(term.fullPath, prefixes, labels);
88
+ }
89
+ ---
90
+
91
+ <section
92
+ class:list={["mx-auto max-w-3xl px-6 py-8", className]}
93
+ data-taxonomy-index
94
+ data-taxonomy={data.name}
95
+ >
96
+ <header class="mb-6">
97
+ <h1 class="text-3xl font-bold tracking-tight">{displayName}</h1>
98
+ <p class="mt-2 text-sm text-muted-foreground">
99
+ {data.pageCount} {data.pageCount === 1 ? "page" : "pages"} across{" "}
100
+ {data.terms.length} {data.terms.length === 1 ? "term" : "terms"}.
101
+ </p>
102
+ </header>
103
+
104
+ {data.hierarchical ? (
105
+ <ul class="space-y-3" data-tree>
106
+ {topLevelTerms().map((term) => (
107
+ <li>
108
+ <a
109
+ href={termHref(term)}
110
+ class="inline-flex items-baseline gap-2 font-medium text-foreground hover:underline"
111
+ >
112
+ <span>{termText(term)}</span>
113
+ <span class="text-xs text-muted-foreground">({term.pages.length})</span>
114
+ </a>
115
+ {childTerms(term).length > 0 && (
116
+ <ul class="ml-4 mt-1 space-y-1 border-l border-border pl-3">
117
+ {childTerms(term).map((child) => (
118
+ <li>
119
+ <a
120
+ href={termHref(child)}
121
+ class="inline-flex items-baseline gap-2 text-sm text-muted-foreground hover:text-foreground hover:underline"
122
+ >
123
+ <span>{termText(child)}</span>
124
+ <span class="text-xs">({child.pages.length})</span>
125
+ </a>
126
+ </li>
127
+ ))}
128
+ </ul>
129
+ )}
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ ) : (
134
+ <ul class="flex flex-wrap gap-2">
135
+ {data.terms.map((term) => (
136
+ <li>
137
+ <a
138
+ href={termHref(term)}
139
+ class="inline-flex items-center gap-1.5 rounded-full border border-border bg-secondary px-3 py-1 text-sm font-medium text-secondary-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
140
+ >
141
+ <span>{termText(term)}</span>
142
+ <span class="text-xs text-muted-foreground">{term.pages.length}</span>
143
+ </a>
144
+ </li>
145
+ ))}
146
+ </ul>
147
+ )}
148
+ </section>
@@ -0,0 +1,181 @@
1
+ ---
2
+ /**
3
+ * TaxonomyTerm — page-list view for a single taxonomy term.
4
+ *
5
+ * Auto-generated `[...path].astro` term routes import this and pass
6
+ * a single resolved term entry. For hierarchical taxonomies, the
7
+ * route file's `getStaticPaths` produces one entry per term path
8
+ * (including parents), and this component renders pages tagged at
9
+ * that exact term — children are listed as drill-down links.
10
+ *
11
+ * Layout uses `<LinkCard>` from `@dogsbay/ui` so cards stay visually
12
+ * consistent with anywhere else those components are used (e.g. nav
13
+ * landing pages, search results). The page descriptions powering
14
+ * the cards are populated by `format-astro/src/taxonomy.ts`'s
15
+ * `derivePageDescription` (frontmatter.description, then first-
16
+ * paragraph fallback).
17
+ *
18
+ * Heading, breadcrumbs, and sub-tag pills run through
19
+ * `resolveTermLabel` so configured `prefixes` / `labels` display
20
+ * config produces human strings ("Concept / Accessibility") instead
21
+ * of raw slugs ("concept / a11y"). URLs always use the slug.
22
+ *
23
+ * See plans/tag-display-config.md.
24
+ */
25
+ import LinkCard from "@dogsbay/ui/link-card/LinkCard.astro";
26
+ import Badge from "@dogsbay/ui/badge/Badge.astro";
27
+ import {
28
+ resolveTermLabel,
29
+ type PrefixDisplay,
30
+ } from "./tag-list-data.js";
31
+
32
+ interface TaxonomyPageRef {
33
+ slug: string;
34
+ title: string;
35
+ url: string;
36
+ description?: string;
37
+ }
38
+
39
+ interface TaxonomyTerm {
40
+ fullPath: string[];
41
+ label: string;
42
+ pages: TaxonomyPageRef[];
43
+ }
44
+
45
+ interface TaxonomyData {
46
+ name: string;
47
+ indexPath: string;
48
+ hierarchical: boolean;
49
+ terms: TaxonomyTerm[];
50
+ pageCount: number;
51
+ }
52
+
53
+ interface Props {
54
+ /** The whole data file (so we can compute child terms for breadcrumbs). */
55
+ data: TaxonomyData;
56
+ /** The single term being rendered. */
57
+ term: TaxonomyTerm;
58
+ /**
59
+ * Per-prefix display config from `taxonomies.<name>.prefixes`.
60
+ * Top-level segments resolve to the configured human label.
61
+ */
62
+ prefixes?: Record<string, PrefixDisplay>;
63
+ /**
64
+ * Per-slug label overrides from `taxonomies.<name>.labels`.
65
+ * URLs continue to use the slug.
66
+ */
67
+ labels?: Record<string, string>;
68
+ class?: string;
69
+ }
70
+
71
+ const { data, term, prefixes, labels, class: className } = Astro.props;
72
+
73
+ const displayName = data.name
74
+ .replace(/[-_]/g, " ")
75
+ .replace(/\b\w/g, (c) => c.toUpperCase());
76
+
77
+ function childTerms(): TaxonomyTerm[] {
78
+ if (!data.hierarchical) return [];
79
+ const parentKey = term.fullPath.join("/");
80
+ return data.terms.filter((t) =>
81
+ t.fullPath.length === term.fullPath.length + 1 &&
82
+ t.fullPath.slice(0, -1).join("/") === parentKey
83
+ );
84
+ }
85
+
86
+ // Per-segment human labels for the page title and crumbs. Each
87
+ // position in the fullPath gets its own resolution (top-level uses
88
+ // `prefixes`, deeper levels use `labels[fullSlugUpToHere]`, with
89
+ // the leaf segment as the final fallback).
90
+ function segmentLabel(depth: number): string {
91
+ return resolveTermLabel(term.fullPath.slice(0, depth + 1), prefixes, labels);
92
+ }
93
+
94
+ const titleSegments = term.fullPath.map((_, i) => segmentLabel(i));
95
+
96
+ // Breadcrumb segments — clickable parent paths.
97
+ function breadcrumbs() {
98
+ const out: { label: string; href: string }[] = [
99
+ { label: displayName, href: `${data.indexPath}/` },
100
+ ];
101
+ for (let i = 0; i < term.fullPath.length - 1; i++) {
102
+ out.push({
103
+ label: segmentLabel(i),
104
+ href: `${data.indexPath}/${term.fullPath.slice(0, i + 1).join("/")}/`,
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ const children = childTerms();
111
+ const crumbs = breadcrumbs();
112
+ ---
113
+
114
+ <section
115
+ class:list={["mx-auto max-w-5xl px-6 py-8", className]}
116
+ data-taxonomy-term
117
+ data-taxonomy={data.name}
118
+ data-term-path={term.fullPath.join("/")}
119
+ >
120
+ <nav class="mb-3 text-sm text-muted-foreground" aria-label="Breadcrumbs">
121
+ {crumbs.map((c, i) => (
122
+ <span>
123
+ <a href={c.href} class="hover:text-foreground hover:underline">{c.label}</a>
124
+ {i < crumbs.length - 1 && <span class="mx-1.5">/</span>}
125
+ </span>
126
+ ))}
127
+ </nav>
128
+
129
+ <header class="mb-6">
130
+ <h1 class="text-3xl font-bold tracking-tight">{titleSegments.join(" / ")}</h1>
131
+ <p class="mt-2 text-sm text-muted-foreground">
132
+ {term.pages.length} {term.pages.length === 1 ? "page" : "pages"}
133
+ {children.length > 0 && (
134
+ <span> · {children.length} sub-{children.length === 1 ? "term" : "terms"}</span>
135
+ )}
136
+ </p>
137
+ </header>
138
+
139
+ {children.length > 0 && (
140
+ <div class="mb-8">
141
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
142
+ Sub-tags
143
+ </h2>
144
+ <ul class="flex flex-wrap gap-2">
145
+ {children.map((child) => (
146
+ <li>
147
+ <a
148
+ href={`${data.indexPath}/${child.fullPath.join("/")}/`}
149
+ class="group inline-flex"
150
+ >
151
+ <Badge
152
+ variant="secondary"
153
+ class="gap-1.5 transition-colors group-hover:bg-accent group-hover:text-accent-foreground"
154
+ >
155
+ <span>{resolveTermLabel(child.fullPath, prefixes, labels)}</span>
156
+ <span class="text-muted-foreground">{child.pages.length}</span>
157
+ </Badge>
158
+ </a>
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ </div>
163
+ )}
164
+
165
+ {term.pages.length > 0 ? (
166
+ <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 border-t border-border pt-6">
167
+ {term.pages.map((page) => (
168
+ <li>
169
+ <LinkCard
170
+ title={page.title}
171
+ description={page.description}
172
+ href={page.url}
173
+ class="h-full"
174
+ />
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ ) : (
179
+ <p class="text-sm text-muted-foreground">No pages tagged here yet.</p>
180
+ )}
181
+ </section>
@@ -0,0 +1,63 @@
1
+ ---
2
+ /**
3
+ * TypeBadge — surfaces the page's `type` (open string).
4
+ *
5
+ * Diátaxis values (`tutorial`, `how-to`, `reference`, `explanation`)
6
+ * get conventional colors; any other type renders with the neutral
7
+ * fallback. Open string by design — users can add their own types
8
+ * (e.g. `api-endpoint`, `release-notes`) and they render with the
9
+ * fallback styling.
10
+ *
11
+ * When `href` is set, the badge renders as a link (e.g. to a
12
+ * `/types/<value>/` browse page); otherwise it's a plain span.
13
+ * `<DocsLayout>` computes the href from `siteConfig.taxonomyIndexPaths.type`
14
+ * — set when the user has declared `taxonomies.type:` in their config.
15
+ */
16
+ interface Props {
17
+ type?: string;
18
+ /** Optional link target — renders the badge as `<a>` when set. */
19
+ href?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const { type, href, class: className } = Astro.props;
24
+
25
+ const visible = type && type.length > 0;
26
+
27
+ const conventional: Record<string, string> = {
28
+ tutorial:
29
+ "border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
30
+ "how-to":
31
+ "border-sky-500/40 bg-sky-500/15 text-sky-700 dark:text-sky-300",
32
+ reference:
33
+ "border-violet-500/40 bg-violet-500/15 text-violet-700 dark:text-violet-300",
34
+ explanation:
35
+ "border-orange-500/40 bg-orange-500/15 text-orange-700 dark:text-orange-300",
36
+ };
37
+
38
+ const fallback =
39
+ "border-border bg-muted text-muted-foreground";
40
+
41
+ const style = (type && conventional[type]) || fallback;
42
+ const label = type ? type.replace(/[-_]/g, " ") : "";
43
+ const baseClasses = "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold capitalize";
44
+ const linkClasses = "transition-colors hover:opacity-80";
45
+ ---
46
+
47
+ {visible && href && (
48
+ <a
49
+ href={href}
50
+ class:list={[baseClasses, linkClasses, style, className]}
51
+ data-page-type={type}
52
+ >
53
+ {label}
54
+ </a>
55
+ )}
56
+ {visible && !href && (
57
+ <span
58
+ class:list={[baseClasses, style, className]}
59
+ data-page-type={type}
60
+ >
61
+ {label}
62
+ </span>
63
+ )}
@@ -0,0 +1,86 @@
1
+ ---
2
+ /**
3
+ * VersionSwitcher — dropdown letting readers switch between
4
+ * declared versions of a docs page. Pure-CSS interaction via
5
+ * <details>; works without JS.
6
+ *
7
+ * Decision logic lives in `./switcher.ts` (shared with
8
+ * LocaleSwitcher); this file is just rendering.
9
+ */
10
+ import {
11
+ type SwitcherMap,
12
+ type MultiSourceMeta,
13
+ buildSwitcherRows,
14
+ fallbackLandingUrl,
15
+ shouldRenderSwitcher,
16
+ } from "./switcher.js";
17
+
18
+ interface Props {
19
+ switcherMap: SwitcherMap;
20
+ multiSource?: MultiSourceMeta;
21
+ basePath?: string;
22
+ }
23
+
24
+ const { switcherMap, multiSource, basePath = "/docs" } = Astro.props;
25
+ const visible = shouldRenderSwitcher("version", switcherMap, multiSource);
26
+ const rows = visible
27
+ ? buildSwitcherRows({ axis: "version", switcherMap, multiSource: multiSource! })
28
+ : [];
29
+ const currentRow = rows.find((r) => r.isCurrent);
30
+ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version";
31
+ ---
32
+
33
+ {visible && (
34
+ <details class="version-switcher relative">
35
+ <summary class="flex cursor-pointer items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent">
36
+ <span class="font-medium">{currentLabel}</span>
37
+ {currentRow?.entry.eol && (
38
+ <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
39
+ )}
40
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-1 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
41
+ </summary>
42
+ <ul class="absolute right-0 z-50 mt-1 min-w-[10rem] rounded-md border border-border bg-popover p-1 text-sm shadow-md">
43
+ {rows.map((row) => {
44
+ const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id);
45
+ const isFallback = row.url === null && !row.isCurrent;
46
+ return (
47
+ <li>
48
+ <a
49
+ href={href}
50
+ aria-current={row.isCurrent ? "true" : undefined}
51
+ class:list={[
52
+ "flex items-center gap-2 rounded-sm px-2 py-1.5 no-underline",
53
+ row.isCurrent
54
+ ? "bg-accent font-medium text-foreground"
55
+ : "text-foreground hover:bg-accent",
56
+ ]}
57
+ >
58
+ <span class="flex-1">{row.entry.label ?? row.entry.id}</span>
59
+ {row.entry.eol && (
60
+ <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
61
+ )}
62
+ {row.entry.default && !row.isCurrent && (
63
+ <span class="text-[10px] text-muted-foreground">default</span>
64
+ )}
65
+ {isFallback && (
66
+ <span title="Page not available in this version" class="text-[10px] text-muted-foreground">→</span>
67
+ )}
68
+ </a>
69
+ </li>
70
+ );
71
+ })}
72
+ </ul>
73
+ </details>
74
+ )}
75
+
76
+ <style>
77
+ .version-switcher > summary {
78
+ list-style: none;
79
+ }
80
+ .version-switcher > summary::-webkit-details-marker {
81
+ display: none;
82
+ }
83
+ .version-switcher[open] > summary > svg:last-child {
84
+ transform: rotate(180deg);
85
+ }
86
+ </style>
package/src/json-ld.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * JSON-LD helpers — pure functions consumed by DocsLayout to
3
+ * decide which Schema.org `@type` to emit and to normalize the
4
+ * `customJsonLd` prop shape.
5
+ *
6
+ * The 100daysofdocs SEO audit flagged that DocsLayout was emitting
7
+ * `Article` for every page regardless of intent — tutorials and
8
+ * how-to pages should render as `HowTo` for SERP rich results.
9
+ * This module owns the type selection so it's testable without
10
+ * spinning up an Astro renderer.
11
+ */
12
+
13
+ /**
14
+ * Map a `meta.type` value (the open-string `pageType` prop on
15
+ * DocsLayout) to a Schema.org `@type`.
16
+ *
17
+ * The mapping intentionally accepts a few common spelling
18
+ * variants (`"how-to"` / `"howto"`, `"reference"` / `"ref"` /
19
+ * `"api"`) so writers don't have to learn one canonical spelling
20
+ * to get the right structured data. Anything else falls back to
21
+ * `Article`, which is Google's "general docs page" assumption.
22
+ */
23
+ export function jsonLdTypeFor(pageType: string | undefined): string {
24
+ switch (pageType) {
25
+ case "tutorial":
26
+ case "how-to":
27
+ case "howto":
28
+ return "HowTo";
29
+ case "reference":
30
+ case "ref":
31
+ case "api":
32
+ return "TechArticle";
33
+ case "course":
34
+ return "Course";
35
+ default:
36
+ return "Article";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Normalize the `customJsonLd` prop into an array. DocsLayout
42
+ * accepts either a single object (most common case — one extra
43
+ * Course / Organization block) or an array (sites that layer
44
+ * several blocks per page).
45
+ *
46
+ * Returns an empty array for `undefined` so the template can
47
+ * iterate uniformly without conditional rendering.
48
+ */
49
+ export function normalizeCustomJsonLd(
50
+ raw: Record<string, unknown> | Record<string, unknown>[] | undefined,
51
+ ): Record<string, unknown>[] {
52
+ if (!raw) return [];
53
+ if (Array.isArray(raw)) return raw;
54
+ return [raw];
55
+ }