@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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
|
|
3
|
+
*
|
|
4
|
+
* No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
|
|
5
|
+
* the DTS bundle without dragging Astro virtual modules into the build graph.
|
|
6
|
+
* The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
|
|
7
|
+
* instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
|
|
8
|
+
* of components serves both a single-book site (the default pattern) and a
|
|
9
|
+
* multi-book consumer whose chapters render at `/<book>/<slug>/`.
|
|
10
|
+
*
|
|
11
|
+
* Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
|
|
12
|
+
* survive the book-config virtual module's `JSON.stringify`, so the config
|
|
13
|
+
* surface is a declarative string resolved here):
|
|
14
|
+
* :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
|
|
15
|
+
* :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
|
|
16
|
+
* :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
|
|
17
|
+
* BASE_URL is applied here, so patterns are written base-relative (lead '/').
|
|
18
|
+
*
|
|
19
|
+
* Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
|
|
20
|
+
* chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
|
|
21
|
+
* bookField = 'book' (academic/tools schemas have no `book` → bookOf
|
|
22
|
+
* returns null → "show all chapters", today's nav)
|
|
23
|
+
* apparatusRoute = '/:route/' → `${base}<route>/`
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Minimal shape the resolver needs from a chapter collection entry. */
|
|
27
|
+
export interface ChapterLike {
|
|
28
|
+
id: string;
|
|
29
|
+
data: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Normalize a base URL to exactly one trailing slash (`''` → `'/'`). */
|
|
33
|
+
function normBase(baseUrl: string): string {
|
|
34
|
+
return (baseUrl || '/').replace(/\/*$/, '/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Replace `:book` / `:slug` / `:route` / `:id` tokens; `\b` keeps each token
|
|
38
|
+
* whole, so order is irrelevant and `:id` never matches inside `:identifier`. */
|
|
39
|
+
function fillTokens(pattern: string, tokens: Record<string, string>): string {
|
|
40
|
+
return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k: string) => tokens[k] ?? '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The entry's book name from `data[bookField]`, or `null` when absent/blank.
|
|
45
|
+
* Single-book schemas (academic/tools/minimal) have no such field → `null`,
|
|
46
|
+
* which callers read as "this is the only book — show every chapter".
|
|
47
|
+
*/
|
|
48
|
+
export function bookOf(entry: ChapterLike, bookField = 'book'): string | null {
|
|
49
|
+
const v = entry.data[bookField];
|
|
50
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
|
|
54
|
+
export function slugOf(entry: ChapterLike, bookField = 'book'): string {
|
|
55
|
+
const book = bookOf(entry, bookField);
|
|
56
|
+
return book && entry.id.startsWith(`${book}/`) ? entry.id.slice(book.length + 1) : entry.id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
|
|
60
|
+
export function chapterHref(
|
|
61
|
+
entry: ChapterLike,
|
|
62
|
+
pattern = '/chapters/:id/',
|
|
63
|
+
baseUrl = '/',
|
|
64
|
+
bookField = 'book',
|
|
65
|
+
): string {
|
|
66
|
+
const path = fillTokens(pattern, {
|
|
67
|
+
id: entry.id,
|
|
68
|
+
book: bookOf(entry, bookField) ?? '',
|
|
69
|
+
slug: slugOf(entry, bookField),
|
|
70
|
+
}).replace(/\/{2,}/g, '/').replace(/^\//, ''); // collapse empties (absent token) — never a protocol-relative //
|
|
71
|
+
return normBase(baseUrl) + path;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
|
|
76
|
+
* answers) to a base-prefixed href via the `apparatusRoute` pattern.
|
|
77
|
+
*/
|
|
78
|
+
export function apparatusHref(
|
|
79
|
+
route: string,
|
|
80
|
+
book: string | null,
|
|
81
|
+
pattern = '/:route/',
|
|
82
|
+
baseUrl = '/',
|
|
83
|
+
): string {
|
|
84
|
+
const path = fillTokens(pattern, { route, book: book ?? '' })
|
|
85
|
+
.replace(/\/{2,}/g, '/') // F2 (#80): an absent :book must collapse, never yield a protocol-relative //
|
|
86
|
+
.replace(/^\//, '');
|
|
87
|
+
return normBase(baseUrl) + path;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
|
|
91
|
+
export function isCurrentChapter(
|
|
92
|
+
entry: ChapterLike,
|
|
93
|
+
currentPath: string,
|
|
94
|
+
pattern = '/chapters/:id/',
|
|
95
|
+
baseUrl = '/',
|
|
96
|
+
bookField = 'book',
|
|
97
|
+
): boolean {
|
|
98
|
+
const href = chapterHref(entry, pattern, baseUrl, bookField);
|
|
99
|
+
return currentPath === href || currentPath === href.replace(/\/$/, '');
|
|
100
|
+
}
|
package/styles/callouts.css
CHANGED
|
@@ -192,7 +192,9 @@
|
|
|
192
192
|
padding: 0 var(--space-2);
|
|
193
193
|
border: 1px solid var(--callout-worked);
|
|
194
194
|
border-radius: var(--radius-sm);
|
|
195
|
-
|
|
195
|
+
/* v4.26.0 (#80, a11y): chip TEXT uses the high-contrast heading color (the
|
|
196
|
+
* accent stays as the border) — the accent as small text failed WCAG-AA 4.5:1. */
|
|
197
|
+
color: var(--color-heading);
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
/* Gold — YouWillLearn (v4.1.0, #59). Reuses callout-insight (gold) hue;
|
|
@@ -233,7 +235,7 @@
|
|
|
233
235
|
padding: 0 var(--space-2);
|
|
234
236
|
border: 1px solid var(--callout-diagnostic);
|
|
235
237
|
border-radius: var(--radius-sm);
|
|
236
|
-
color: var(--
|
|
238
|
+
color: var(--color-heading); /* a11y: accent as small text failed 4.5:1 (border keeps the hue) */
|
|
237
239
|
}
|
|
238
240
|
.callout-diagnostic .callout-routing {
|
|
239
241
|
font-size: var(--text-sm);
|
|
@@ -247,7 +249,7 @@
|
|
|
247
249
|
cursor: pointer;
|
|
248
250
|
font-weight: 600;
|
|
249
251
|
font-size: var(--text-sm);
|
|
250
|
-
color: var(--
|
|
252
|
+
color: var(--color-text); /* a11y: teal accent as text failed 4.5:1 */
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
/* PartReview (#111) — Part-level interleaved exercise-review aggregation.
|
|
@@ -575,6 +577,8 @@ figure.figure figcaption {
|
|
|
575
577
|
letter-spacing: 0.04em;
|
|
576
578
|
text-transform: uppercase;
|
|
577
579
|
}
|
|
578
|
-
.
|
|
579
|
-
|
|
580
|
-
.practice-tag[data-kind="
|
|
580
|
+
/* a11y (v4.26.0, #80): hue via the border; text uses the body color (accent as
|
|
581
|
+
* small text fails WCAG-AA 4.5:1). */
|
|
582
|
+
.practice-tag[data-kind="official"] { border-color: var(--callout-official); color: var(--color-text); }
|
|
583
|
+
.practice-tag[data-kind="convergence"] { border-color: var(--callout-insight); color: var(--color-text); }
|
|
584
|
+
.practice-tag[data-kind="practitioner"] { border-color: var(--callout-tip); color: var(--color-text); }
|
package/styles/layout.css
CHANGED
|
@@ -31,10 +31,15 @@
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/* At 1024px+: pin sidebar to left, main content fills remainder. */
|
|
34
|
-
|
|
34
|
+
/* v4.26.0 (#80): the full 3-column (sidebar + Tufte gutter) needs room the
|
|
35
|
+
* sidebar steals, so it activates at 80rem (1280px) — below that the drawer is
|
|
36
|
+
* the chapter nav and content runs full-width (the gutter/section-map then fits
|
|
37
|
+
* in the whole viewport). This is where the gutter genuinely fits beside the
|
|
38
|
+
* sidebar with the trimmed measures. */
|
|
39
|
+
@media (min-width: 80rem) {
|
|
35
40
|
.layout-with-sidebar {
|
|
36
41
|
display: grid;
|
|
37
|
-
grid-template-columns:
|
|
42
|
+
grid-template-columns: 14rem 1fr; /* trimmed so main+gutter fits beside it */
|
|
38
43
|
grid-template-areas: 'sidebar main';
|
|
39
44
|
min-height: 100vh;
|
|
40
45
|
}
|
|
@@ -51,7 +56,7 @@
|
|
|
51
56
|
* titles. */
|
|
52
57
|
@media (min-width: 90rem) {
|
|
53
58
|
.layout-with-sidebar {
|
|
54
|
-
grid-template-columns:
|
|
59
|
+
grid-template-columns: 16rem 1fr;
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -218,3 +223,89 @@
|
|
|
218
223
|
.prose[data-layout="wide"] {
|
|
219
224
|
--measure-main: 80ch;
|
|
220
225
|
}
|
|
226
|
+
|
|
227
|
+
/* ===== Mobile/tablet nav drawer (v4.26.0, #80) =====
|
|
228
|
+
* Below the sidebar breakpoint (64rem) the left Sidebar is display:none — this
|
|
229
|
+
* is the navigation in that range: a hamburger in the chrome row toggles a
|
|
230
|
+
* slide-in drawer reusing NavContent (the same book-scoped nav source as the
|
|
231
|
+
* sidebar). No-JS baseline: `:target` opens it (the toggle is <a href="#nav-drawer">);
|
|
232
|
+
* the inline controller in Base.astro enhances it with focus-trap + ESC + body
|
|
233
|
+
* scroll-lock. Hidden at ≥64rem, where the persistent sidebar is the nav. The
|
|
234
|
+
* ≥64rem gutter-fit (sidenote/section-map overflow) is a separate fix. */
|
|
235
|
+
@media (min-width: 80rem) {
|
|
236
|
+
.nav-toggle,
|
|
237
|
+
.nav-drawer {
|
|
238
|
+
display: none;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@media (max-width: 79.98rem) {
|
|
243
|
+
.nav-drawer {
|
|
244
|
+
position: fixed;
|
|
245
|
+
inset: 0;
|
|
246
|
+
z-index: var(--z-drawer, 30);
|
|
247
|
+
visibility: hidden;
|
|
248
|
+
}
|
|
249
|
+
.nav-drawer.is-open,
|
|
250
|
+
.nav-drawer:target {
|
|
251
|
+
visibility: visible;
|
|
252
|
+
}
|
|
253
|
+
.nav-drawer-backdrop {
|
|
254
|
+
position: absolute;
|
|
255
|
+
inset: 0;
|
|
256
|
+
border: 0;
|
|
257
|
+
background: rgba(0, 0, 0, 0.5);
|
|
258
|
+
opacity: 0;
|
|
259
|
+
transition: opacity 200ms ease;
|
|
260
|
+
}
|
|
261
|
+
.nav-drawer-panel {
|
|
262
|
+
position: absolute;
|
|
263
|
+
inset-block: 0;
|
|
264
|
+
inset-inline-start: 0;
|
|
265
|
+
width: min(20rem, 85vw);
|
|
266
|
+
max-height: 100vh;
|
|
267
|
+
overflow-y: auto;
|
|
268
|
+
background: var(--color-bg-subtle);
|
|
269
|
+
border-right: 1px solid var(--color-border);
|
|
270
|
+
padding: var(--space-6) var(--space-5);
|
|
271
|
+
transform: translateX(-100%);
|
|
272
|
+
transition: transform 220ms ease;
|
|
273
|
+
}
|
|
274
|
+
.nav-drawer.is-open .nav-drawer-panel,
|
|
275
|
+
.nav-drawer:target .nav-drawer-panel {
|
|
276
|
+
transform: translateX(0);
|
|
277
|
+
}
|
|
278
|
+
.nav-drawer.is-open .nav-drawer-backdrop,
|
|
279
|
+
.nav-drawer:target .nav-drawer-backdrop {
|
|
280
|
+
opacity: 1;
|
|
281
|
+
}
|
|
282
|
+
.nav-drawer-dismiss {
|
|
283
|
+
position: absolute;
|
|
284
|
+
top: var(--space-3);
|
|
285
|
+
inset-inline-end: var(--space-3);
|
|
286
|
+
z-index: 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* Body scroll-lock while the drawer is open (class toggled by the controller). */
|
|
291
|
+
.nav-drawer-locked {
|
|
292
|
+
overflow: hidden;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ===== Gutter-fit (v4.26.0, #80) =====
|
|
296
|
+
* The Tufte right-gutter (.section-map scrollspy + .sidenote / .column-margin /
|
|
297
|
+
* .margin-figure floats) used to size its main+gutter measure against the FULL
|
|
298
|
+
* viewport, ignoring that a left sidebar steals 14–16rem — so the negative-margin
|
|
299
|
+
* float landed PAST the viewport at 1024px AND 1440px. The fix is in tokens.css:
|
|
300
|
+
* the `--measure-main` / `--measure-side` tiers are trimmed (60/20ch → 66/20ch →
|
|
301
|
+
* 78/24ch) and the sidebar to 14/16rem, so `main + gutter ≤ layout-main` (viewport
|
|
302
|
+
* − sidebar) at every desktop width. The gutter scrollspy now FITS beside the
|
|
303
|
+
* sidebar instead of overflowing — no suppression, no breakpoint games. Verified
|
|
304
|
+
* scrollWidth == clientWidth across the responsive audit harness. */
|
|
305
|
+
|
|
306
|
+
@media (prefers-reduced-motion: reduce) {
|
|
307
|
+
.nav-drawer-panel,
|
|
308
|
+
.nav-drawer-backdrop {
|
|
309
|
+
transition: none;
|
|
310
|
+
}
|
|
311
|
+
}
|
package/styles/section-map.css
CHANGED
|
@@ -30,7 +30,11 @@
|
|
|
30
30
|
display: none !important;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/* v4.26.0 (#80): the gutter scrollspy floats only alongside the sidebar (≥80rem,
|
|
34
|
+
* matching layout.css) — below that the drawer is the chapter nav and the in-flow
|
|
35
|
+
* collapsed ChapterTOC is the "on this page". This keeps the negative-margin gutter
|
|
36
|
+
* from overflowing the viewport in the cramped 64–80rem band (iPad-landscape). */
|
|
37
|
+
@media (min-width: 80rem) {
|
|
34
38
|
/* Sidenote-aware handshake (#151). The gutter map and <Sidenote>s float into
|
|
35
39
|
* the SAME ~28ch right column (layout.css), so they cannot coexist there:
|
|
36
40
|
* - A chapter WITH sidenotes → the gutter belongs to the sidenotes; suppress
|
package/styles/tokens.css
CHANGED
|
@@ -106,8 +106,8 @@
|
|
|
106
106
|
* the breakpoints are encoded as literal rem values in the media queries
|
|
107
107
|
* (90rem ≈ 1440px, 120rem ≈ 1920px).
|
|
108
108
|
*/
|
|
109
|
-
--measure-main:
|
|
110
|
-
--measure-side:
|
|
109
|
+
--measure-main: 60ch; /* default: main+gutter fits beside the sidebar at ≥64rem (v4.26.0, #80) */
|
|
110
|
+
--measure-side: 20ch; /* right-margin sidenote / section-map column */
|
|
111
111
|
--breakpoint-narrow: 48rem; /* ~768px — mobile fold (sidenotes inline) */
|
|
112
112
|
--breakpoint-wide: 90rem; /* ~1440px — wide-desktop tier */
|
|
113
113
|
--breakpoint-ultrawide: 120rem; /* ~1920px — ultrawide tier */
|
|
@@ -134,30 +134,34 @@
|
|
|
134
134
|
* same visual language as prose callouts. Dark mode overrides below. */
|
|
135
135
|
--astro-code-foreground: var(--color-text);
|
|
136
136
|
--astro-code-background: var(--color-code-bg);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
--astro-code-token-
|
|
137
|
+
/* a11y (v4.26.0, #80): saturated accents fail 4.5:1 on the light code bg →
|
|
138
|
+
* darken (the dark-theme overrides below keep their lighter variants). */
|
|
139
|
+
--astro-code-token-constant: color-mix(in srgb, var(--warm-rose) 60%, black);
|
|
140
|
+
--astro-code-token-string: color-mix(in srgb, var(--warm-green) 55%, black);
|
|
141
|
+
--astro-code-token-string-expression: color-mix(in srgb, var(--warm-green) 55%, black);
|
|
140
142
|
--astro-code-token-comment: var(--color-text-muted);
|
|
141
|
-
--astro-code-token-keyword: var(--warm-plum);
|
|
143
|
+
--astro-code-token-keyword: color-mix(in srgb, var(--warm-plum) 60%, black);
|
|
142
144
|
--astro-code-token-parameter: var(--warm-blue);
|
|
143
|
-
--astro-code-token-function: var(--warm-gold);
|
|
145
|
+
--astro-code-token-function: color-mix(in srgb, var(--warm-gold) 55%, black); /* a11y: warm-gold alone is 2.46:1 on the code bg (dark-mode override below) */
|
|
144
146
|
--astro-code-token-punctuation: var(--color-text-muted);
|
|
145
147
|
--astro-code-token-link: var(--color-link);
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
/* Tier 2: wide desktop (1440px+).
|
|
150
|
+
/* Tier 2: wide desktop (1440px+). v4.26.0 (#80): main 70ch + gutter 22ch keeps
|
|
151
|
+
* the main+gutter measure inside `layout-main` (viewport − 16rem sidebar) so the
|
|
152
|
+
* Tufte gutter/section-map fits beside the sidebar instead of overflowing. */
|
|
149
153
|
@media (min-width: 90rem) {
|
|
150
154
|
:root {
|
|
151
|
-
--measure-main:
|
|
152
|
-
--measure-side:
|
|
155
|
+
--measure-main: 66ch;
|
|
156
|
+
--measure-side: 20ch;
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
/* Tier 3: ultrawide (1920px+).
|
|
160
|
+
/* Tier 3: ultrawide (1920px+). Room to widen both again and still fit. */
|
|
157
161
|
@media (min-width: 120rem) {
|
|
158
162
|
:root {
|
|
159
|
-
--measure-main:
|
|
160
|
-
--measure-side:
|
|
163
|
+
--measure-main: 78ch;
|
|
164
|
+
--measure-side: 24ch;
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
167
|
|
package/styles/typography.css
CHANGED
|
@@ -196,3 +196,19 @@ th {
|
|
|
196
196
|
color: var(--warm-blue);
|
|
197
197
|
font-style: italic;
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/* ===== Narrow-viewport overflow guards (v4.26.0, #80) =====
|
|
201
|
+
* Defensive: stop a long INLINE-code identifier (e.g. a dotted module path) or a
|
|
202
|
+
* wide table from forcing horizontal PAGE scroll on a phone. `overflow-wrap:
|
|
203
|
+
* anywhere` only breaks when a token would otherwise overflow, so normal prose is
|
|
204
|
+
* untouched; fenced blocks are excluded (`:not(pre) > code`) so they keep their
|
|
205
|
+
* own `pre { overflow-x: auto }` inner scroll. Wide tables scroll within their own
|
|
206
|
+
* box (`display:block; overflow-x:auto`) instead of stretching the page. */
|
|
207
|
+
:not(pre) > code {
|
|
208
|
+
overflow-wrap: anywhere;
|
|
209
|
+
}
|
|
210
|
+
.prose > table {
|
|
211
|
+
display: block;
|
|
212
|
+
overflow-x: auto;
|
|
213
|
+
max-width: 100%;
|
|
214
|
+
}
|