@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,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
|
+
}
|