@brandon_m_behring/book-scaffold-astro 4.25.2 → 4.26.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/components/ChapterNav.astro +23 -6
- package/components/EvidenceTag.astro +6 -3
- package/components/NavContent.astro +286 -0
- package/components/Provenance.astro +2 -2
- package/components/Sidebar.astro +16 -210
- package/dist/index.d.ts +50 -3
- package/dist/index.mjs +65 -3
- package/dist/schemas.d.ts +1 -1
- package/dist/{types-Bn7rKhgP.d.ts → types-BO5qyDqT.d.ts} +42 -0
- package/layouts/Base.astro +106 -1
- package/package.json +2 -1
- package/recipes/08-decisions-ledger.md +10 -0
- package/recipes/22-responsive-nav-and-multibook-routing.md +92 -0
- package/src/lib/chapters.ts +22 -9
- package/src/lib/nav-href.ts +100 -0
- package/styles/callouts.css +10 -6
- package/styles/layout.css +94 -3
- package/styles/section-map.css +5 -1
- package/styles/tokens.css +17 -13
- package/styles/typography.css +16 -0
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* ChapterNav — prev / next chapter links at the bottom of each chapter.
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
|
+
* v4.26.0 (#80): book-aware. `getNeighbors` scopes prev/next to the current
|
|
6
|
+
* book (multi-book), and links resolve through the `chapterRoute` pattern, so a
|
|
7
|
+
* multi-book consumer gets `/<book>/<slug>/` links that never bleed across books.
|
|
8
|
+
* Single-book consumers are byte-identical (defaults → global order + `/chapters/<id>/`).
|
|
5
9
|
*/
|
|
6
10
|
import { getNeighbors } from '../src/lib/chapters';
|
|
11
|
+
import { chapterHref } from '../src/lib/nav-href';
|
|
12
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
7
13
|
|
|
8
14
|
interface Props {
|
|
9
15
|
currentId: string;
|
|
10
16
|
}
|
|
11
17
|
const { currentId } = Astro.props;
|
|
12
|
-
|
|
18
|
+
|
|
19
|
+
const chapterRoute = (bookConfig as { chapterRoute?: string }).chapterRoute ?? '/chapters/:id/';
|
|
20
|
+
const bookField = (bookConfig as { bookField?: string }).bookField ?? 'book';
|
|
13
21
|
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
22
|
+
|
|
23
|
+
const { prev, next } = await getNeighbors(currentId, { bookField });
|
|
24
|
+
// Resolve hrefs in frontmatter (casts here, never in the JSX expression container).
|
|
25
|
+
const prevHref = prev
|
|
26
|
+
? chapterHref({ id: prev.id, data: prev.data as Record<string, unknown> }, chapterRoute, baseUrl, bookField)
|
|
27
|
+
: null;
|
|
28
|
+
const nextHref = next
|
|
29
|
+
? chapterHref({ id: next.id, data: next.data as Record<string, unknown> }, chapterRoute, baseUrl, bookField)
|
|
30
|
+
: null;
|
|
14
31
|
---
|
|
15
32
|
{(prev || next) && (
|
|
16
33
|
<nav class="chapter-nav" aria-label="Chapter navigation">
|
|
17
|
-
{prev && (
|
|
18
|
-
<a href={
|
|
34
|
+
{prev && prevHref && (
|
|
35
|
+
<a href={prevHref} class="prev">
|
|
19
36
|
<span class="nav-label">← Previous</span>
|
|
20
37
|
<span class="nav-title">{prev.data.title}</span>
|
|
21
38
|
</a>
|
|
22
39
|
)}
|
|
23
|
-
{next && (
|
|
24
|
-
<a href={
|
|
40
|
+
{next && nextHref && (
|
|
41
|
+
<a href={nextHref} class="next">
|
|
25
42
|
<span class="nav-label">Next →</span>
|
|
26
43
|
<span class="nav-title">{next.data.title}</span>
|
|
27
44
|
</a>
|
|
@@ -84,19 +84,22 @@ const TITLE: Record<Kind, string> = {
|
|
|
84
84
|
}
|
|
85
85
|
/* Tinted background + matching text/border per kind. Tokens only, so each
|
|
86
86
|
* recolors in the [data-theme="dark"] scope without a dark-block edit. */
|
|
87
|
+
/* a11y (v4.26.0, #80): the hue is carried by the border + tinted background;
|
|
88
|
+
* the TEXT uses the high-contrast body color (the accent as small text failed
|
|
89
|
+
* WCAG-AA 4.5:1 — verified green was 4.35/3.47). */
|
|
87
90
|
.evidence-tag[data-kind="verified"] {
|
|
88
91
|
border-color: var(--warm-green);
|
|
89
|
-
color: var(--
|
|
92
|
+
color: var(--color-text);
|
|
90
93
|
background: var(--warm-green-tint);
|
|
91
94
|
}
|
|
92
95
|
.evidence-tag[data-kind="inference"] {
|
|
93
96
|
border-color: var(--warm-blue);
|
|
94
|
-
color: var(--
|
|
97
|
+
color: var(--color-text);
|
|
95
98
|
background: var(--warm-blue-tint);
|
|
96
99
|
}
|
|
97
100
|
.evidence-tag[data-kind="audit-corrected"] {
|
|
98
101
|
border-color: var(--warm-rose);
|
|
99
|
-
color: var(--
|
|
102
|
+
color: var(--color-text);
|
|
100
103
|
background: var(--warm-rose-tint);
|
|
101
104
|
}
|
|
102
105
|
</style>
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* NavContent — the shared chapter-navigation list (v4.26.0, #80).
|
|
4
|
+
*
|
|
5
|
+
* Self-contained nav model + markup, rendered by BOTH the desktop `Sidebar`
|
|
6
|
+
* and the mobile `NavDrawer` so there is ONE nav source. It:
|
|
7
|
+
* - reads the chapters collection,
|
|
8
|
+
* - derives the CURRENT BOOK from the URL's first path segment (validated
|
|
9
|
+
* against the set of books that actually exist), so multi-book consumers
|
|
10
|
+
* get a book-SCOPED list on every page type (chapter / book-index /
|
|
11
|
+
* apparatus) with no prop threading,
|
|
12
|
+
* - resolves every href through the `chapterRoute` token pattern (nav-href),
|
|
13
|
+
* - groups by `part` exactly as the old Sidebar did (academic part-enum order
|
|
14
|
+
* preserved).
|
|
15
|
+
*
|
|
16
|
+
* Backward compatibility: single-book schemas (academic/tools/minimal) have no
|
|
17
|
+
* `book` field → `knownBooks` is empty → `currentBook` is null → ALL chapters
|
|
18
|
+
* are shown and `chapterRoute` defaults to `/chapters/:id/` → byte-identical to
|
|
19
|
+
* the pre-4.26 nav.
|
|
20
|
+
*/
|
|
21
|
+
import { getCollection } from 'astro:content';
|
|
22
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
23
|
+
import { academicParts } from '../src/schemas';
|
|
24
|
+
import { academicPartHeading } from '../src/lib/academic-parts';
|
|
25
|
+
import { chapterHref, apparatusHref, bookOf, isCurrentChapter, type ChapterLike } from '../src/lib/nav-href';
|
|
26
|
+
|
|
27
|
+
const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
|
|
28
|
+
// #142: normalize the base trailing slash (Astro allows base:'/foo' with none).
|
|
29
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
|
|
30
|
+
|
|
31
|
+
// v4.26.0 (#80): nav route config (defaults reproduce single-book behavior).
|
|
32
|
+
const chapterRoute = (bookConfig as { chapterRoute?: string }).chapterRoute ?? '/chapters/:id/';
|
|
33
|
+
const bookField = (bookConfig as { bookField?: string }).bookField ?? 'book';
|
|
34
|
+
const apparatusRoute = (bookConfig as { apparatusRoute?: string }).apparatusRoute ?? '/:route/';
|
|
35
|
+
const apparatusRoutes = (bookConfig as { apparatusRoutes?: readonly string[] }).apparatusRoutes ?? [];
|
|
36
|
+
|
|
37
|
+
const ACADEMIC_PART_ORDER = academicParts;
|
|
38
|
+
const currentPath = Astro.url.pathname;
|
|
39
|
+
|
|
40
|
+
const rawChapters = await getCollection('chapters', (entry) => !entry.data.draft);
|
|
41
|
+
|
|
42
|
+
// Derive the current book from the URL (first path segment after BASE_URL),
|
|
43
|
+
// validated against the books that actually exist. Empty set (single-book) →
|
|
44
|
+
// null → no filtering.
|
|
45
|
+
function entryOf(e: (typeof rawChapters)[number]): ChapterLike {
|
|
46
|
+
return { id: e.id, data: e.data as Record<string, unknown> };
|
|
47
|
+
}
|
|
48
|
+
const knownBooks = new Set(
|
|
49
|
+
rawChapters.map((e) => bookOf(entryOf(e), bookField)).filter((b): b is string => b !== null),
|
|
50
|
+
);
|
|
51
|
+
const relPath = currentPath.startsWith(baseUrl) ? currentPath.slice(baseUrl.length) : currentPath.replace(/^\//, '');
|
|
52
|
+
const urlBook = relPath.split('/')[0] ?? '';
|
|
53
|
+
const currentBook = knownBooks.has(urlBook) ? urlBook : null;
|
|
54
|
+
|
|
55
|
+
const scopedChapters = currentBook
|
|
56
|
+
? rawChapters.filter((e) => bookOf(entryOf(e), bookField) === currentBook)
|
|
57
|
+
: rawChapters;
|
|
58
|
+
|
|
59
|
+
// Pre-shape into render-ready rows (all TS casts in frontmatter — Astro's JSX
|
|
60
|
+
// parser mis-tokenizes casts inside expression containers).
|
|
61
|
+
interface ChapterRow {
|
|
62
|
+
id: string;
|
|
63
|
+
partKey: string;
|
|
64
|
+
prefix: string;
|
|
65
|
+
title: string;
|
|
66
|
+
href: string;
|
|
67
|
+
isCurrent: boolean;
|
|
68
|
+
sortPart: number;
|
|
69
|
+
sortRank: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rowOf(entry: (typeof rawChapters)[number]): ChapterRow {
|
|
73
|
+
const d = entry.data as Record<string, unknown>;
|
|
74
|
+
const e = entryOf(entry);
|
|
75
|
+
const href = chapterHref(e, chapterRoute, baseUrl, bookField);
|
|
76
|
+
const isCurrent = isCurrentChapter(e, currentPath, chapterRoute, baseUrl, bookField);
|
|
77
|
+
if (profile === 'academic') {
|
|
78
|
+
const week = (d.week as number) ?? 0;
|
|
79
|
+
return {
|
|
80
|
+
id: entry.id,
|
|
81
|
+
partKey: (d.part as string) ?? '',
|
|
82
|
+
prefix: `W${String(week).padStart(2, '0')}`,
|
|
83
|
+
title: (d.title as string) ?? entry.id,
|
|
84
|
+
href,
|
|
85
|
+
isCurrent,
|
|
86
|
+
sortPart: ACADEMIC_PART_ORDER.indexOf((d.part as (typeof ACADEMIC_PART_ORDER)[number]) ?? 'foundations'),
|
|
87
|
+
sortRank: week,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const partNum = (d.part as number) ?? 0;
|
|
91
|
+
const chapter = (d.chapter as number) ?? 0;
|
|
92
|
+
return {
|
|
93
|
+
id: entry.id,
|
|
94
|
+
partKey: String(partNum),
|
|
95
|
+
prefix: `Ch${chapter}`,
|
|
96
|
+
title: (d.title as string) ?? entry.id,
|
|
97
|
+
href,
|
|
98
|
+
isCurrent,
|
|
99
|
+
sortPart: partNum,
|
|
100
|
+
sortRank: chapter,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rows: ChapterRow[] = scopedChapters.map(rowOf).sort((a, b) => {
|
|
105
|
+
if (a.sortPart !== b.sortPart) return a.sortPart - b.sortPart;
|
|
106
|
+
return a.sortRank - b.sortRank;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const byPart = new Map<string, ChapterRow[]>();
|
|
110
|
+
if (profile === 'academic') {
|
|
111
|
+
for (const part of ACADEMIC_PART_ORDER) byPart.set(part, []);
|
|
112
|
+
}
|
|
113
|
+
for (const r of rows) {
|
|
114
|
+
if (!byPart.has(r.partKey)) byPart.set(r.partKey, []);
|
|
115
|
+
byPart.get(r.partKey)!.push(r);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Index link: multi-book → the book's TOC at /<book>/; single-book → /chapters/.
|
|
119
|
+
const chaptersIndexHref = currentBook ? `${baseUrl}${currentBook}/` : `${baseUrl}chapters/`;
|
|
120
|
+
const referencesHref = `${baseUrl}references/`;
|
|
121
|
+
|
|
122
|
+
// Apparatus links (study-guide routes) — surfaced only when the consumer opts in
|
|
123
|
+
// via `apparatusRoutes`. Book-scoped via the same resolver as chapters.
|
|
124
|
+
const APPARATUS_LABELS: Record<string, string> = {
|
|
125
|
+
'practice-exam': 'Practice exam',
|
|
126
|
+
glossary: 'Glossary',
|
|
127
|
+
flashcards: 'Flashcards',
|
|
128
|
+
answers: 'Answers',
|
|
129
|
+
};
|
|
130
|
+
const apparatusLinks = apparatusRoutes.map((route) => {
|
|
131
|
+
const href = apparatusHref(route, currentBook, apparatusRoute, baseUrl);
|
|
132
|
+
return {
|
|
133
|
+
href,
|
|
134
|
+
label: APPARATUS_LABELS[route] ?? route.replace(/-/g, ' '),
|
|
135
|
+
isCurrent: currentPath === href || currentPath === href.replace(/\/$/, ''),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function partLabel(key: string): string {
|
|
140
|
+
if (profile === 'academic') return academicPartHeading(key);
|
|
141
|
+
return `Part ${key}`;
|
|
142
|
+
}
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
<nav class="sidebar-nav" aria-label="Chapters">
|
|
146
|
+
<a
|
|
147
|
+
href={chaptersIndexHref}
|
|
148
|
+
class={`sidebar-link sidebar-link-index ${currentPath === chaptersIndexHref ? 'is-current' : ''}`}
|
|
149
|
+
>
|
|
150
|
+
All chapters
|
|
151
|
+
</a>
|
|
152
|
+
{profile === 'academic' && (
|
|
153
|
+
<a
|
|
154
|
+
href={referencesHref}
|
|
155
|
+
class={`sidebar-link sidebar-link-index ${currentPath === referencesHref ? 'is-current' : ''}`}
|
|
156
|
+
>
|
|
157
|
+
References
|
|
158
|
+
</a>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{Array.from(byPart.entries()).map(([part, list]) => list.length === 0 ? null : (
|
|
162
|
+
<section class="sidebar-part">
|
|
163
|
+
<h3 class="sidebar-part-heading">{partLabel(part)}</h3>
|
|
164
|
+
<ol class="sidebar-list">
|
|
165
|
+
{list.map((r) => (
|
|
166
|
+
<li>
|
|
167
|
+
<a
|
|
168
|
+
href={r.href}
|
|
169
|
+
aria-current={r.isCurrent ? 'page' : undefined}
|
|
170
|
+
class={`sidebar-link ${r.isCurrent ? 'is-current' : ''}`}
|
|
171
|
+
>
|
|
172
|
+
<span class="sidebar-week">{r.prefix}</span>
|
|
173
|
+
<span class="sidebar-chapter-title">{r.title}</span>
|
|
174
|
+
</a>
|
|
175
|
+
</li>
|
|
176
|
+
))}
|
|
177
|
+
</ol>
|
|
178
|
+
</section>
|
|
179
|
+
))}
|
|
180
|
+
|
|
181
|
+
{apparatusLinks.length > 0 && (
|
|
182
|
+
<section class="sidebar-part sidebar-apparatus">
|
|
183
|
+
<h3 class="sidebar-part-heading">Study</h3>
|
|
184
|
+
<ol class="sidebar-list">
|
|
185
|
+
{apparatusLinks.map((a) => (
|
|
186
|
+
<li>
|
|
187
|
+
<a
|
|
188
|
+
href={a.href}
|
|
189
|
+
aria-current={a.isCurrent ? 'page' : undefined}
|
|
190
|
+
class={`sidebar-link sidebar-link-apparatus ${a.isCurrent ? 'is-current' : ''}`}
|
|
191
|
+
>
|
|
192
|
+
<span class="sidebar-chapter-title">{a.label}</span>
|
|
193
|
+
</a>
|
|
194
|
+
</li>
|
|
195
|
+
))}
|
|
196
|
+
</ol>
|
|
197
|
+
</section>
|
|
198
|
+
)}
|
|
199
|
+
</nav>
|
|
200
|
+
|
|
201
|
+
<style>
|
|
202
|
+
.sidebar-nav {
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-direction: column;
|
|
205
|
+
gap: var(--space-4);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.sidebar-link-index {
|
|
209
|
+
display: block;
|
|
210
|
+
padding: var(--space-2) var(--space-3);
|
|
211
|
+
border-radius: var(--radius-sm);
|
|
212
|
+
text-decoration: none;
|
|
213
|
+
color: var(--color-text);
|
|
214
|
+
font-weight: 500;
|
|
215
|
+
margin-bottom: 1px;
|
|
216
|
+
border-left: 2px solid transparent;
|
|
217
|
+
}
|
|
218
|
+
.sidebar-link-index:hover {
|
|
219
|
+
background: var(--color-bg);
|
|
220
|
+
}
|
|
221
|
+
.sidebar-link-index.is-current {
|
|
222
|
+
background: var(--warm-blue-tint);
|
|
223
|
+
border-left-color: var(--callout-info);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.sidebar-part {
|
|
227
|
+
border-top: 1px solid var(--color-border);
|
|
228
|
+
padding-top: var(--space-3);
|
|
229
|
+
}
|
|
230
|
+
.sidebar-part:first-of-type {
|
|
231
|
+
border-top: 0;
|
|
232
|
+
padding-top: 0;
|
|
233
|
+
}
|
|
234
|
+
.sidebar-part-heading {
|
|
235
|
+
font-family: var(--font-code);
|
|
236
|
+
font-size: var(--text-xs);
|
|
237
|
+
text-transform: uppercase;
|
|
238
|
+
letter-spacing: 0.04em;
|
|
239
|
+
color: var(--color-text-muted);
|
|
240
|
+
margin: 0 0 var(--space-2) 0;
|
|
241
|
+
padding: 0 var(--space-3);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.sidebar-list {
|
|
245
|
+
list-style: none;
|
|
246
|
+
padding: 0;
|
|
247
|
+
margin: 0;
|
|
248
|
+
}
|
|
249
|
+
.sidebar-list li {
|
|
250
|
+
margin: 0;
|
|
251
|
+
}
|
|
252
|
+
.sidebar-link {
|
|
253
|
+
display: grid;
|
|
254
|
+
grid-template-columns: 2.4em 1fr;
|
|
255
|
+
gap: var(--space-2);
|
|
256
|
+
align-items: baseline;
|
|
257
|
+
padding: var(--space-2) var(--space-3);
|
|
258
|
+
border-radius: var(--radius-sm);
|
|
259
|
+
text-decoration: none;
|
|
260
|
+
color: var(--color-text);
|
|
261
|
+
line-height: var(--leading-normal);
|
|
262
|
+
border-left: 2px solid transparent;
|
|
263
|
+
}
|
|
264
|
+
.sidebar-link:hover {
|
|
265
|
+
background: var(--color-bg);
|
|
266
|
+
text-decoration: none;
|
|
267
|
+
}
|
|
268
|
+
.sidebar-link.is-current {
|
|
269
|
+
background: var(--warm-blue-tint);
|
|
270
|
+
border-left-color: var(--callout-info);
|
|
271
|
+
font-weight: 500;
|
|
272
|
+
}
|
|
273
|
+
.sidebar-week {
|
|
274
|
+
font-family: var(--font-code);
|
|
275
|
+
font-size: var(--text-xs);
|
|
276
|
+
color: var(--color-text-muted);
|
|
277
|
+
text-transform: uppercase;
|
|
278
|
+
}
|
|
279
|
+
.sidebar-chapter-title {
|
|
280
|
+
color: inherit;
|
|
281
|
+
}
|
|
282
|
+
/* Apparatus links carry no chapter-number column → single-column grid. */
|
|
283
|
+
.sidebar-link-apparatus {
|
|
284
|
+
grid-template-columns: 1fr;
|
|
285
|
+
}
|
|
286
|
+
</style>
|
|
@@ -131,7 +131,7 @@ const teaser = teaserParts.length ? `How this was made · ${teaserParts.join('
|
|
|
131
131
|
.provenance-details > summary {
|
|
132
132
|
cursor: pointer;
|
|
133
133
|
font-weight: 500;
|
|
134
|
-
color: var(--
|
|
134
|
+
color: var(--color-heading); /* a11y: plum accent as text failed 4.5:1 */
|
|
135
135
|
letter-spacing: 0.02em;
|
|
136
136
|
list-style: none;
|
|
137
137
|
}
|
|
@@ -158,7 +158,7 @@ const teaser = teaserParts.length ? `How this was made · ${teaserParts.join('
|
|
|
158
158
|
font-size: var(--text-xs);
|
|
159
159
|
text-transform: uppercase;
|
|
160
160
|
letter-spacing: 0.04em;
|
|
161
|
-
color: var(--
|
|
161
|
+
color: var(--color-heading); /* a11y: plum accent as text failed 4.5:1 */
|
|
162
162
|
vertical-align: top;
|
|
163
163
|
}
|
|
164
164
|
.provenance-chip,
|
package/components/Sidebar.astro
CHANGED
|
@@ -1,107 +1,28 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Sidebar — left-pinned chapter navigation.
|
|
3
|
+
* Sidebar — left-pinned chapter navigation (desktop ≥64rem).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* v4.26.0 (#80): the chapter-list logic moved to `NavContent.astro` (shared with
|
|
6
|
+
* the mobile `NavDrawer`), which is book-aware — it derives the current book from
|
|
7
|
+
* the URL and scopes the list, resolving hrefs through the configured
|
|
8
|
+
* `chapterRoute` pattern. Single-book consumers are unaffected (NavContent's
|
|
9
|
+
* defaults reproduce the old `/chapters/<id>/` list). Sidebar now owns only the
|
|
10
|
+
* brand header + the pinned/scrollable shell.
|
|
8
11
|
*
|
|
9
|
-
* Rendered by Base.astro when `showSidebar` is true (default).
|
|
10
|
-
*
|
|
11
|
-
* mobile gets single-column chapter content with no nav (user reaches
|
|
12
|
-
* the chapter index via /chapters/).
|
|
12
|
+
* Rendered by Base.astro when `showSidebar` is true (default). Hidden below
|
|
13
|
+
* 1024px via layout.css — sub-1024 navigation is the NavDrawer (v4.26.0).
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Customize (v4.23.0, #135): the brand reads `defineBookConfig({ title,
|
|
18
|
-
* subtitle })` via the book-config virtual module — consumers set both in
|
|
19
|
-
* astro.config.mjs with zero component overrides. The previous hardcoded
|
|
20
|
-
* strings remain the fallbacks, so existing books render unchanged until
|
|
21
|
-
* they configure a title.
|
|
15
|
+
* Brand (v4.23.0, #135): reads `defineBookConfig({ title, subtitle })` via the
|
|
16
|
+
* book-config virtual module; the previous hardcoded strings remain fallbacks.
|
|
22
17
|
*/
|
|
23
|
-
import { getCollection } from 'astro:content';
|
|
24
18
|
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
25
|
-
import
|
|
26
|
-
import { academicPartHeading } from '../src/lib/academic-parts';
|
|
19
|
+
import NavContent from './NavContent.astro';
|
|
27
20
|
|
|
28
|
-
const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
|
|
29
21
|
const siteTitle = bookConfig.title ?? 'Book';
|
|
30
22
|
const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// the canonical order, shared with the renderer and ChapterHeader (#95); the
|
|
35
|
-
// "Part {roman} · {name}" labels come from the same academic-parts module.
|
|
36
|
-
const ACADEMIC_PART_ORDER = academicParts;
|
|
37
|
-
|
|
38
|
-
const rawChapters = await getCollection('chapters', (entry) => !entry.data.draft);
|
|
39
|
-
|
|
40
|
-
// Pre-shape chapters into render-ready rows. All TS casts happen here in
|
|
41
|
-
// the frontmatter so the template stays free of type annotations (Astro's
|
|
42
|
-
// JSX parser mis-tokenizes `as Type<X, Y>` inside expression containers).
|
|
43
|
-
interface ChapterRow {
|
|
44
|
-
id: string;
|
|
45
|
-
partKey: string;
|
|
46
|
-
prefix: string;
|
|
47
|
-
title: string;
|
|
48
|
-
sortPart: number;
|
|
49
|
-
sortRank: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function rowOf(entry: typeof rawChapters[number]): ChapterRow {
|
|
53
|
-
const d = entry.data as Record<string, unknown>;
|
|
54
|
-
if (profile === 'academic') {
|
|
55
|
-
const week = (d.week as number) ?? 0;
|
|
56
|
-
return {
|
|
57
|
-
id: entry.id,
|
|
58
|
-
partKey: (d.part as string) ?? '',
|
|
59
|
-
prefix: `W${String(week).padStart(2, '0')}`,
|
|
60
|
-
title: (d.title as string) ?? entry.id,
|
|
61
|
-
sortPart: ACADEMIC_PART_ORDER.indexOf((d.part as typeof ACADEMIC_PART_ORDER[number]) ?? 'foundations'),
|
|
62
|
-
sortRank: week,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
const partNum = (d.part as number) ?? 0;
|
|
66
|
-
const chapter = (d.chapter as number) ?? 0;
|
|
67
|
-
return {
|
|
68
|
-
id: entry.id,
|
|
69
|
-
partKey: String(partNum),
|
|
70
|
-
prefix: `Ch${chapter}`,
|
|
71
|
-
title: (d.title as string) ?? entry.id,
|
|
72
|
-
sortPart: partNum,
|
|
73
|
-
sortRank: chapter,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const rows: ChapterRow[] = rawChapters.map(rowOf).sort((a, b) => {
|
|
78
|
-
if (a.sortPart !== b.sortPart) return a.sortPart - b.sortPart;
|
|
79
|
-
return a.sortRank - b.sortRank;
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
// Group rows by part. Academic profile uses the ACADEMIC_PART_ORDER as
|
|
83
|
-
// the canonical key sequence so empty parts still appear (in part headers
|
|
84
|
-
// rather than gaps).
|
|
85
|
-
const byPart = new Map<string, ChapterRow[]>();
|
|
86
|
-
if (profile === 'academic') {
|
|
87
|
-
for (const part of ACADEMIC_PART_ORDER) byPart.set(part, []);
|
|
88
|
-
}
|
|
89
|
-
for (const r of rows) {
|
|
90
|
-
if (!byPart.has(r.partKey)) byPart.set(r.partKey, []);
|
|
91
|
-
byPart.get(r.partKey)!.push(r);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const currentPath = Astro.url.pathname;
|
|
95
|
-
const chaptersIndexHref = `${baseUrl}chapters/`;
|
|
96
|
-
const referencesHref = `${baseUrl}references/`;
|
|
97
|
-
function isCurrent(id: string): boolean {
|
|
98
|
-
return currentPath === `${baseUrl}chapters/${id}/` || currentPath === `${baseUrl}chapters/${id}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function partLabel(key: string): string {
|
|
102
|
-
if (profile === 'academic') return academicPartHeading(key);
|
|
103
|
-
return `Part ${key}`;
|
|
104
|
-
}
|
|
23
|
+
// #142: Astro does not guarantee a trailing slash on `base` — normalize so the
|
|
24
|
+
// home link is `${base}/`, not a slash-less `/foo`.
|
|
25
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
|
|
105
26
|
---
|
|
106
27
|
|
|
107
28
|
<aside class="sidebar" aria-label="Chapter navigation">
|
|
@@ -111,41 +32,7 @@ function partLabel(key: string): string {
|
|
|
111
32
|
<span class="sidebar-subtitle">{siteSubtitle}</span>
|
|
112
33
|
</a>
|
|
113
34
|
|
|
114
|
-
<
|
|
115
|
-
<a
|
|
116
|
-
href={chaptersIndexHref}
|
|
117
|
-
class={`sidebar-link sidebar-link-index ${currentPath === chaptersIndexHref ? 'is-current' : ''}`}
|
|
118
|
-
>
|
|
119
|
-
All chapters
|
|
120
|
-
</a>
|
|
121
|
-
{profile === 'academic' && (
|
|
122
|
-
<a
|
|
123
|
-
href={referencesHref}
|
|
124
|
-
class={`sidebar-link sidebar-link-index ${currentPath === referencesHref ? 'is-current' : ''}`}
|
|
125
|
-
>
|
|
126
|
-
References
|
|
127
|
-
</a>
|
|
128
|
-
)}
|
|
129
|
-
|
|
130
|
-
{Array.from(byPart.entries()).map(([part, list]) => list.length === 0 ? null : (
|
|
131
|
-
<section class="sidebar-part">
|
|
132
|
-
<h3 class="sidebar-part-heading">{partLabel(part)}</h3>
|
|
133
|
-
<ol class="sidebar-list">
|
|
134
|
-
{list.map((r) => (
|
|
135
|
-
<li>
|
|
136
|
-
<a
|
|
137
|
-
href={`${baseUrl}chapters/${r.id}/`}
|
|
138
|
-
class={`sidebar-link ${isCurrent(r.id) ? 'is-current' : ''}`}
|
|
139
|
-
>
|
|
140
|
-
<span class="sidebar-week">{r.prefix}</span>
|
|
141
|
-
<span class="sidebar-chapter-title">{r.title}</span>
|
|
142
|
-
</a>
|
|
143
|
-
</li>
|
|
144
|
-
))}
|
|
145
|
-
</ol>
|
|
146
|
-
</section>
|
|
147
|
-
))}
|
|
148
|
-
</nav>
|
|
35
|
+
<NavContent />
|
|
149
36
|
</div>
|
|
150
37
|
</aside>
|
|
151
38
|
|
|
@@ -182,85 +69,4 @@ function partLabel(key: string): string {
|
|
|
182
69
|
color: var(--color-text-muted);
|
|
183
70
|
margin-top: 0.15em;
|
|
184
71
|
}
|
|
185
|
-
|
|
186
|
-
.sidebar-nav {
|
|
187
|
-
display: flex;
|
|
188
|
-
flex-direction: column;
|
|
189
|
-
gap: var(--space-4);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
.sidebar-link-index {
|
|
193
|
-
display: block;
|
|
194
|
-
padding: var(--space-2) var(--space-3);
|
|
195
|
-
border-radius: var(--radius-sm);
|
|
196
|
-
text-decoration: none;
|
|
197
|
-
color: var(--color-text);
|
|
198
|
-
font-weight: 500;
|
|
199
|
-
margin-bottom: 1px;
|
|
200
|
-
border-left: 2px solid transparent;
|
|
201
|
-
}
|
|
202
|
-
.sidebar-link-index:hover {
|
|
203
|
-
background: var(--color-bg);
|
|
204
|
-
}
|
|
205
|
-
.sidebar-link-index.is-current {
|
|
206
|
-
background: var(--warm-blue-tint);
|
|
207
|
-
border-left-color: var(--callout-info);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
.sidebar-part {
|
|
211
|
-
border-top: 1px solid var(--color-border);
|
|
212
|
-
padding-top: var(--space-3);
|
|
213
|
-
}
|
|
214
|
-
.sidebar-part:first-of-type {
|
|
215
|
-
border-top: 0;
|
|
216
|
-
padding-top: 0;
|
|
217
|
-
}
|
|
218
|
-
.sidebar-part-heading {
|
|
219
|
-
font-family: var(--font-code);
|
|
220
|
-
font-size: var(--text-xs);
|
|
221
|
-
text-transform: uppercase;
|
|
222
|
-
letter-spacing: 0.04em;
|
|
223
|
-
color: var(--color-text-muted);
|
|
224
|
-
margin: 0 0 var(--space-2) 0;
|
|
225
|
-
padding: 0 var(--space-3);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
.sidebar-list {
|
|
229
|
-
list-style: none;
|
|
230
|
-
padding: 0;
|
|
231
|
-
margin: 0;
|
|
232
|
-
}
|
|
233
|
-
.sidebar-list li {
|
|
234
|
-
margin: 0;
|
|
235
|
-
}
|
|
236
|
-
.sidebar-link {
|
|
237
|
-
display: grid;
|
|
238
|
-
grid-template-columns: 2.4em 1fr;
|
|
239
|
-
gap: var(--space-2);
|
|
240
|
-
align-items: baseline;
|
|
241
|
-
padding: var(--space-2) var(--space-3);
|
|
242
|
-
border-radius: var(--radius-sm);
|
|
243
|
-
text-decoration: none;
|
|
244
|
-
color: var(--color-text);
|
|
245
|
-
line-height: var(--leading-normal);
|
|
246
|
-
border-left: 2px solid transparent;
|
|
247
|
-
}
|
|
248
|
-
.sidebar-link:hover {
|
|
249
|
-
background: var(--color-bg);
|
|
250
|
-
text-decoration: none;
|
|
251
|
-
}
|
|
252
|
-
.sidebar-link.is-current {
|
|
253
|
-
background: var(--warm-blue-tint);
|
|
254
|
-
border-left-color: var(--callout-info);
|
|
255
|
-
font-weight: 500;
|
|
256
|
-
}
|
|
257
|
-
.sidebar-week {
|
|
258
|
-
font-family: var(--font-code);
|
|
259
|
-
font-size: var(--text-xs);
|
|
260
|
-
color: var(--color-text-muted);
|
|
261
|
-
text-transform: uppercase;
|
|
262
|
-
}
|
|
263
|
-
.sidebar-chapter-title {
|
|
264
|
-
color: inherit;
|
|
265
|
-
}
|
|
266
72
|
</style>
|