@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.
@@ -1,27 +1,44 @@
1
1
  ---
2
2
  /**
3
3
  * ChapterNav — prev / next chapter links at the bottom of each chapter.
4
- * Derived from the ordered chapters collection via getNeighbors.
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
- const { prev, next } = await getNeighbors(currentId);
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={`${baseUrl}chapters/${prev.id}/`} class="prev">
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={`${baseUrl}chapters/${next.id}/`} class="next">
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(--warm-green);
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(--warm-blue);
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(--warm-rose);
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(--warm-plum);
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(--warm-plum);
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,
@@ -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
- * Profile-aware: reads BOOK_PROFILE to decide how to group + sort.
6
- * academic: groups by part (foundations / ssm-core / ...), sorts by week
7
- * tools/minimal: groups by part (numeric 0-10), sorts by chapter
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
- * Hidden below 1024px (`@media (max-width: 64rem)` in layout.css)
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
- * Visual: ~280px wide, sticky to top, scrollable independently of main.
15
- * Site title at top doubles as a "home" link.
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 { academicParts } from '../src/schemas';
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
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
32
-
33
- // Academic profile: part is a string enum. `academicParts` (schemas.ts) is
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
- <nav class="sidebar-nav">
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>