@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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration, MarkdownHeading } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, l as Style } from './types-Bn7rKhgP.js';
3
- export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, P as PartKey, j as PartialRouteToggles, k as ProfileDefinition, R as RouteToggles, S as StatusBadge, m as StyleInput, V as VolatilityBadge, n as composeStyles, o as defineProfile, p as defineStyle, q as normalizeFrontmatterConfig, r as resolvePreset, s as resolveProfile } from './types-Bn7rKhgP.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, l as Style } from './types-BO5qyDqT.js';
3
+ export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, P as PartKey, j as PartialRouteToggles, k as ProfileDefinition, R as RouteToggles, S as StatusBadge, m as StyleInput, V as VolatilityBadge, n as composeStyles, o as defineProfile, p as defineStyle, q as normalizeFrontmatterConfig, r as resolvePreset, s as resolveProfile } from './types-BO5qyDqT.js';
4
4
  import { E as volatilityLevels, c as academicParts, Q as Question } from './schemas-CKipJ5Ie.js';
5
5
  export { A as AcademicChapter, B as BloomLevel, C as CourseNotesChapter, G as GlossaryTerm, M as MinimalChapter, P as Provenance, a as QuestionType, R as ResearchPortfolioChapter, T as ToolsChapter, b as academicChapterSchema, d as bloomLevels, e as changeKinds, f as changelogSchema, g as chapterStatus, h as citationBackstops, i as courseNotesChapterSchema, j as glossarySchema, l as layoutModes, m as minimalChapterSchema, p as patternCategories, k as patternsSchema, n as provenanceObject, o as provenanceSchema, q as questionDifficulties, r as questionSchema, s as questionTypes, t as refineQuestion, u as refinedQuestionSchema, v as researchPortfolioChapterSchema, w as sourceTiers, x as sourceTiersResearch, y as sourcesSchema, z as toolSlugs, D as toolsChapterSchema } from './schemas-CKipJ5Ie.js';
6
6
  export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
@@ -294,6 +294,53 @@ declare function assertEnumProp<T extends string>(value: unknown, allowed: reado
294
294
  */
295
295
  declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
296
296
 
297
+ /**
298
+ * src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
299
+ *
300
+ * No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
301
+ * the DTS bundle without dragging Astro virtual modules into the build graph.
302
+ * The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
303
+ * instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
304
+ * of components serves both a single-book site (the default pattern) and a
305
+ * multi-book consumer whose chapters render at `/<book>/<slug>/`.
306
+ *
307
+ * Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
308
+ * survive the book-config virtual module's `JSON.stringify`, so the config
309
+ * surface is a declarative string resolved here):
310
+ * :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
311
+ * :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
312
+ * :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
313
+ * BASE_URL is applied here, so patterns are written base-relative (lead '/').
314
+ *
315
+ * Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
316
+ * chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
317
+ * bookField = 'book' (academic/tools schemas have no `book` → bookOf
318
+ * returns null → "show all chapters", today's nav)
319
+ * apparatusRoute = '/:route/' → `${base}<route>/`
320
+ */
321
+ /** Minimal shape the resolver needs from a chapter collection entry. */
322
+ interface ChapterLike {
323
+ id: string;
324
+ data: Record<string, unknown>;
325
+ }
326
+ /**
327
+ * The entry's book name from `data[bookField]`, or `null` when absent/blank.
328
+ * Single-book schemas (academic/tools/minimal) have no such field → `null`,
329
+ * which callers read as "this is the only book — show every chapter".
330
+ */
331
+ declare function bookOf(entry: ChapterLike, bookField?: string): string | null;
332
+ /** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
333
+ declare function slugOf(entry: ChapterLike, bookField?: string): string;
334
+ /** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
335
+ declare function chapterHref(entry: ChapterLike, pattern?: string, baseUrl?: string, bookField?: string): string;
336
+ /**
337
+ * Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
338
+ * answers) to a base-prefixed href via the `apparatusRoute` pattern.
339
+ */
340
+ declare function apparatusHref(route: string, book: string | null, pattern?: string, baseUrl?: string): string;
341
+ /** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
342
+ declare function isCurrentChapter(entry: ChapterLike, currentPath: string, pattern?: string, baseUrl?: string, bookField?: string): boolean;
343
+
297
344
  /**
298
345
  * exam-domains — validate a question's `domain` against the consumer's closed
299
346
  * `examDomains` taxonomy (Tier 3, #112).
@@ -551,4 +598,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
551
598
  */
552
599
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
553
600
 
554
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, assertEnumProp, assertKnownDomain, bookScaffoldIntegration, buildGithubUrl, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, minimalStyle, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
601
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, type ChapterLike, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, apparatusHref, assertEnumProp, assertKnownDomain, bookOf, bookScaffoldIntegration, buildGithubUrl, chapterHref, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, isCurrentChapter, minimalStyle, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, slugOf, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -1211,7 +1211,12 @@ function bookScaffoldIntegration(opts) {
1211
1211
  // v4.16.0 (#96): sibling-book registry for cross-book <BookLink>.
1212
1212
  siblingBooks,
1213
1213
  // v4.17.0 (#112): exam-domain taxonomy for the questions collection.
1214
- examDomains
1214
+ examDomains,
1215
+ // v4.26.0 (#80): book-aware nav route patterns.
1216
+ chapterRoute,
1217
+ bookField,
1218
+ apparatusRoute,
1219
+ apparatusRoutes
1215
1220
  } = opts;
1216
1221
  const def = PROFILES[profile];
1217
1222
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -1274,7 +1279,13 @@ function bookScaffoldIntegration(opts) {
1274
1279
  githubRepo: resolvedGithubRepo,
1275
1280
  githubBranch: resolvedGithubBranch,
1276
1281
  siblingBooks: siblingBooks ?? {},
1277
- examDomains: examDomains ?? []
1282
+ examDomains: examDomains ?? [],
1283
+ // v4.26.0 (#80): book-aware nav route patterns; defaults
1284
+ // reproduce the single-book `/chapters/<id>/` behavior exactly.
1285
+ chapterRoute: chapterRoute ?? "/chapters/:id/",
1286
+ bookField: bookField ?? "book",
1287
+ apparatusRoute: apparatusRoute ?? "/:route/",
1288
+ apparatusRoutes: apparatusRoutes ?? []
1278
1289
  })
1279
1290
  ],
1280
1291
  define: {
@@ -1416,7 +1427,12 @@ async function defineBookConfig(opts) {
1416
1427
  // v4.16.0 (#96): cross-book link registry.
1417
1428
  siblingBooks: opts.siblingBooks,
1418
1429
  // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1419
- examDomains: opts.examDomains
1430
+ examDomains: opts.examDomains,
1431
+ // v4.26.0 (#80): book-aware nav route patterns (undefined → single-book defaults).
1432
+ chapterRoute: opts.chapterRoute,
1433
+ bookField: opts.bookField,
1434
+ apparatusRoute: opts.apparatusRoute,
1435
+ apparatusRoutes: opts.apparatusRoutes
1420
1436
  }),
1421
1437
  ...mergedExtraIntegrations
1422
1438
  ];
@@ -1466,6 +1482,11 @@ async function defineBookConfig(opts) {
1466
1482
  siblingBooks: _siblingBooks,
1467
1483
  // v4.17.0: strip exam-domain taxonomy.
1468
1484
  examDomains: _examDomains,
1485
+ // v4.26.0 (#80): strip book-aware nav route patterns.
1486
+ chapterRoute: _chapterRoute,
1487
+ bookField: _bookField,
1488
+ apparatusRoute: _apparatusRoute,
1489
+ apparatusRoutes: _apparatusRoutes,
1469
1490
  ...rest
1470
1491
  } = opts;
1471
1492
  void _styles;
@@ -1487,6 +1508,10 @@ async function defineBookConfig(opts) {
1487
1508
  void _githubBranch;
1488
1509
  void _siblingBooks;
1489
1510
  void _examDomains;
1511
+ void _chapterRoute;
1512
+ void _bookField;
1513
+ void _apparatusRoute;
1514
+ void _apparatusRoutes;
1490
1515
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1491
1516
  const restVite = rest.vite ?? {};
1492
1517
  const restSsr = restVite.ssr ?? {};
@@ -1592,6 +1617,38 @@ function resolveBookHref(siblingBooks, book, to) {
1592
1617
  return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
1593
1618
  }
1594
1619
 
1620
+ // src/lib/nav-href.ts
1621
+ function normBase(baseUrl) {
1622
+ return (baseUrl || "/").replace(/\/*$/, "/");
1623
+ }
1624
+ function fillTokens(pattern, tokens) {
1625
+ return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k) => tokens[k] ?? "");
1626
+ }
1627
+ function bookOf(entry, bookField = "book") {
1628
+ const v = entry.data[bookField];
1629
+ return typeof v === "string" && v.length > 0 ? v : null;
1630
+ }
1631
+ function slugOf(entry, bookField = "book") {
1632
+ const book = bookOf(entry, bookField);
1633
+ return book && entry.id.startsWith(`${book}/`) ? entry.id.slice(book.length + 1) : entry.id;
1634
+ }
1635
+ function chapterHref(entry, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
1636
+ const path = fillTokens(pattern, {
1637
+ id: entry.id,
1638
+ book: bookOf(entry, bookField) ?? "",
1639
+ slug: slugOf(entry, bookField)
1640
+ }).replace(/\/{2,}/g, "/").replace(/^\//, "");
1641
+ return normBase(baseUrl) + path;
1642
+ }
1643
+ function apparatusHref(route, book, pattern = "/:route/", baseUrl = "/") {
1644
+ const path = fillTokens(pattern, { route, book: book ?? "" }).replace(/\/{2,}/g, "/").replace(/^\//, "");
1645
+ return normBase(baseUrl) + path;
1646
+ }
1647
+ function isCurrentChapter(entry, currentPath, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
1648
+ const href = chapterHref(entry, pattern, baseUrl, bookField);
1649
+ return currentPath === href || currentPath === href.replace(/\/$/, "");
1650
+ }
1651
+
1595
1652
  // src/lib/exam-domains.ts
1596
1653
  function assertKnownDomain(examDomains, domain, ctx) {
1597
1654
  if (!examDomains || !examDomains.includes(domain)) {
@@ -1845,15 +1902,18 @@ export {
1845
1902
  academicPartOrdinal,
1846
1903
  academicParts,
1847
1904
  academicStyle,
1905
+ apparatusHref,
1848
1906
  assertEnumProp,
1849
1907
  assertKnownDomain,
1850
1908
  bloomLevels,
1909
+ bookOf,
1851
1910
  bookScaffoldIntegration,
1852
1911
  buildExamManifest,
1853
1912
  buildFlashcardDeck,
1854
1913
  buildGithubUrl,
1855
1914
  changeKinds,
1856
1915
  changelogSchema,
1916
+ chapterHref,
1857
1917
  chapterLabel,
1858
1918
  chapterSortKey,
1859
1919
  chapterStatus,
@@ -1875,6 +1935,7 @@ export {
1875
1935
  glossarySchema,
1876
1936
  groupByChapter,
1877
1937
  groupByDomain,
1938
+ isCurrentChapter,
1878
1939
  layoutModes,
1879
1940
  minimalChapterSchema,
1880
1941
  minimalStyle,
@@ -1902,6 +1963,7 @@ export {
1902
1963
  scoreExam,
1903
1964
  selectPartExercises,
1904
1965
  shuffle,
1966
+ slugOf,
1905
1967
  sortQuestions,
1906
1968
  sourceTiers,
1907
1969
  sourceTiersResearch,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-Bn7rKhgP.js';
2
+ import { g as BookSchemasOptions } from './types-BO5qyDqT.js';
3
3
  import 'astro';
4
4
  import './schemas-CKipJ5Ie.js';
5
5
  import 'astro/zod';
@@ -684,6 +684,39 @@ interface BookConfigOptions {
684
684
  * @example examDomains: ['secure-network-architecture', 'identity-and-access']
685
685
  */
686
686
  examDomains?: readonly string[];
687
+ /**
688
+ * v4.26.0 (#80): URL pattern for a chapter entry, consumed by the book-aware
689
+ * Sidebar / ChapterNav / NavContent so they emit correct links on a multi-book
690
+ * consumer. Base-relative token string — `:id` (entry.id), `:book` (the
691
+ * entry's book, see `bookField`), `:slug` (id minus the leading `<book>/`).
692
+ * Default `'/chapters/:id/'` reproduces the single-book behavior byte-for-byte;
693
+ * a multi-book consumer whose chapters render at `/<book>/<slug>/` (entry.id =
694
+ * `'<book>/<slug>'`) sets `'/:id/'`. Resolved by the pure `chapterHref` helper.
695
+ */
696
+ chapterRoute?: string;
697
+ /**
698
+ * v4.26.0 (#80): frontmatter field naming a chapter's book (multi-book book
699
+ * scoping). Default `'book'`. Absent on single-book schemas → the sidebar shows
700
+ * every chapter (today's behavior); present → the sidebar filters to the
701
+ * current book and the `:book` / `:slug` route tokens resolve.
702
+ */
703
+ bookField?: string;
704
+ /**
705
+ * v4.26.0 (#80): URL pattern for a per-book apparatus route (practice-exam /
706
+ * glossary / flashcards / answers) surfaced in the nav. Tokens `:book` +
707
+ * `:route`. Default `'/:route/'` (single-book, flat); a multi-book consumer
708
+ * sets `'/:book/:route/'`. Resolved by the pure `apparatusHref` helper.
709
+ */
710
+ apparatusRoute?: string;
711
+ /**
712
+ * v4.26.0 (#80): the apparatus route slugs to surface in the nav (sidebar +
713
+ * drawer) — a subset of `practice-exam | glossary | flashcards | answers`.
714
+ * Default `[]` (no apparatus links in nav — existing single-book books render
715
+ * unchanged). A multi-book consumer that owns per-book apparatus routes sets
716
+ * e.g. `['practice-exam','glossary','flashcards','answers']`; each is rendered
717
+ * via `apparatusHref(slug, currentBook, apparatusRoute, base)`.
718
+ */
719
+ apparatusRoutes?: readonly string[];
687
720
  /** Escape hatch for any other AstroUserConfig field. */
688
721
  [key: string]: unknown;
689
722
  }
@@ -756,6 +789,15 @@ interface BookScaffoldIntegrationOptions {
756
789
  siblingBooks?: Record<string, string>;
757
790
  /** v4.17.0 (#112): closed exam-domain taxonomy for the questions collection. */
758
791
  examDomains?: readonly string[];
792
+ /** v4.26.0 (#80): chapter-route token pattern, propagated via the book-config
793
+ * virtual module to the book-aware nav. Default '/chapters/:id/'. */
794
+ chapterRoute?: string;
795
+ /** v4.26.0 (#80): frontmatter field naming a chapter's book. Default 'book'. */
796
+ bookField?: string;
797
+ /** v4.26.0 (#80): apparatus-route token pattern. Default '/:route/'. */
798
+ apparatusRoute?: string;
799
+ /** v4.26.0 (#80): apparatus route slugs to surface in the nav. Default []. */
800
+ apparatusRoutes?: readonly string[];
759
801
  }
760
802
  /** Raised when the resolved profile is not one of `BOOK_PROFILES`. */
761
803
  declare class BookConfigError extends Error {
@@ -59,6 +59,8 @@ import '../styles/print.css';
59
59
  import VersionSelector from '@brandon_m_behring/book-scaffold-astro/components/VersionSelector';
60
60
  import ToolFilter from '@brandon_m_behring/book-scaffold-astro/components/ToolFilter';
61
61
  import Sidebar from '../components/Sidebar.astro';
62
+ // v4.26.0 (#80): the mobile/tablet drawer reuses the same book-scoped nav source.
63
+ import NavContent from '../components/NavContent.astro';
62
64
  // v4.6.0: SEO meta tags read book-level identity (title fallback,
63
65
  // description fallback, ogImage default, twitterHandle) from the
64
66
  // book-config virtual module (was landing-config in v4.5.1).
@@ -115,7 +117,7 @@ const absoluteOgImage = resolvedOgImage
115
117
  const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
116
118
  const ogSiteName = bookConfig.title ?? title;
117
119
  const ogDescription = description ?? bookConfig.description ?? '';
118
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
120
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
119
121
  ---
120
122
 
121
123
  <!doctype html>
@@ -164,6 +166,22 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
164
166
  </head>
165
167
  <body>
166
168
  <div class="chrome-buttons">
169
+ {/* v4.26.0 (#80): hamburger → mobile/tablet nav drawer. <a href="#nav-drawer">
170
+ so it opens via :target with no JS; the controller below enhances it. */}
171
+ {showSidebar && (
172
+ <a
173
+ id="nav-toggle"
174
+ class="chrome-button nav-toggle"
175
+ href="#nav-drawer"
176
+ role="button"
177
+ aria-controls="nav-drawer"
178
+ aria-expanded="false"
179
+ aria-haspopup="dialog"
180
+ aria-label="Open chapter navigation"
181
+ >
182
+ <span aria-hidden="true">☰</span>
183
+ </a>
184
+ )}
167
185
  {showToolsChrome && <ToolFilter client:idle />}
168
186
  {showToolsChrome && <VersionSelector client:idle />}
169
187
  <a
@@ -185,6 +203,20 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
185
203
  <span class="theme-toggle-icon dark-icon" aria-hidden="true">☾</span>
186
204
  </button>
187
205
  </div>
206
+ {/* v4.26.0 (#80): mobile/tablet nav drawer (sibling of <main>, NOT a <main>).
207
+ CSS-hidden ≥64rem where the Sidebar is the nav. role=dialog + the inline
208
+ controller below provide focus-trap/ESC; `:target` is the no-JS fallback. */}
209
+ {showSidebar && (
210
+ <div id="nav-drawer" class="nav-drawer" data-nav-drawer>
211
+ <a class="nav-drawer-backdrop" href="#" data-nav-close tabindex="-1" aria-hidden="true"></a>
212
+ <div class="nav-drawer-panel" role="dialog" aria-modal="true" aria-label="Chapter navigation" tabindex="-1">
213
+ <a class="nav-drawer-dismiss chrome-button" href="#" data-nav-close aria-label="Close navigation">
214
+ <span aria-hidden="true">✕</span>
215
+ </a>
216
+ <NavContent />
217
+ </div>
218
+ </div>
219
+ )}
188
220
  {showSidebar ? (
189
221
  <div class="layout-with-sidebar">
190
222
  <Sidebar />
@@ -236,6 +268,79 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
236
268
  } catch (e) { /* older browsers: no media-query change events */ }
237
269
  })();
238
270
  </script>
271
+ {/*
272
+ Nav-drawer controller (v4.26.0, #80). Progressive enhancement over the
273
+ `:target` no-JS baseline: intercepts the hamburger to toggle `.is-open`
274
+ (so no history hash is pushed), traps focus inside the dialog, closes on
275
+ ESC / backdrop / dismiss, restores focus to the opener, and locks body
276
+ scroll. No-op on pages without the drawer (landing: showSidebar=false).
277
+ */}
278
+ <script is:inline>
279
+ (function () {
280
+ var drawer = document.getElementById('nav-drawer');
281
+ var toggle = document.getElementById('nav-toggle');
282
+ if (!drawer || !toggle) return;
283
+ var panel = drawer.querySelector('.nav-drawer-panel');
284
+ var opener = null;
285
+ function focusables() {
286
+ return Array.prototype.slice
287
+ .call(panel.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))
288
+ .filter(function (el) { return el.offsetParent !== null; });
289
+ }
290
+ // C1 (#80, codex@medium): count the :target no-JS-fallback state too, so a
291
+ // drawer opened via a '#nav-drawer' hash (with JS active) is still closable by
292
+ // backdrop / dismiss / ESC (close() clears the hash); else it would be stranded.
293
+ function isOpen() { return drawer.classList.contains('is-open') || location.hash === '#nav-drawer'; }
294
+ function open(e) {
295
+ if (e) e.preventDefault();
296
+ opener = document.activeElement;
297
+ drawer.classList.add('is-open');
298
+ toggle.setAttribute('aria-expanded', 'true');
299
+ document.documentElement.classList.add('nav-drawer-locked');
300
+ var f = focusables();
301
+ (f[0] || panel).focus();
302
+ }
303
+ function close(e) {
304
+ if (e) e.preventDefault();
305
+ if (!isOpen()) return;
306
+ drawer.classList.remove('is-open');
307
+ toggle.setAttribute('aria-expanded', 'false');
308
+ document.documentElement.classList.remove('nav-drawer-locked');
309
+ if (location.hash === '#nav-drawer') {
310
+ history.replaceState(null, '', location.pathname + location.search);
311
+ }
312
+ if (opener && typeof opener.focus === 'function') opener.focus();
313
+ }
314
+ toggle.addEventListener('click', function (e) { isOpen() ? close(e) : open(e); });
315
+ drawer.addEventListener('click', function (e) {
316
+ var t = e.target;
317
+ if (t && t.closest && t.closest('[data-nav-close]')) close(e);
318
+ });
319
+ document.addEventListener('keydown', function (e) {
320
+ if (!isOpen()) return;
321
+ if (e.key === 'Escape') { close(e); return; }
322
+ if (e.key === 'Tab') {
323
+ var f = focusables();
324
+ if (!f.length) return;
325
+ var first = f[0], last = f[f.length - 1];
326
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
327
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
328
+ }
329
+ });
330
+ // F5 (a11y, #80): role="button" must activate on Space; native anchors only
331
+ // fire click on Enter, so bind Space explicitly (preventDefault stops scroll).
332
+ toggle.addEventListener('keydown', function (e) {
333
+ if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); isOpen() ? close(e) : open(e); }
334
+ });
335
+ // F1 (#80): if the viewport grows into the desktop range (>=80rem) while the
336
+ // drawer is open, the drawer + toggle + backdrop go display:none — close() to
337
+ // clear .is-open and release the body scroll-lock so the page isn't stranded.
338
+ var mq = window.matchMedia('(min-width: 80rem)');
339
+ var onDesktop = function (ev) { if (ev.matches) close(); };
340
+ if (mq.addEventListener) mq.addEventListener('change', onDesktop);
341
+ else if (mq.addListener) mq.addListener(onDesktop);
342
+ })();
343
+ </script>
239
344
  </body>
240
345
  </html>
241
346
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.25.2",
4
+ "version": "4.26.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -69,6 +69,7 @@
69
69
  "./components/KeyIdea.astro": "./components/KeyIdea.astro",
70
70
  "./components/MarginFigure.astro": "./components/MarginFigure.astro",
71
71
  "./components/MarginNote.astro": "./components/MarginNote.astro",
72
+ "./components/NavContent.astro": "./components/NavContent.astro",
72
73
  "./components/Newthought.astro": "./components/Newthought.astro",
73
74
  "./components/NoteBox.astro": "./components/NoteBox.astro",
74
75
  "./components/ObjectiveMap.astro": "./components/ObjectiveMap.astro",
@@ -122,6 +122,16 @@ Until v3.0 triggers, accept that bug fixes don't auto-flow to existing books. Bo
122
122
  **Reasoning**: #82 itself was a coverage gap — don't trade coverage for speed.
123
123
  **Deviate when**: Never delete a visual gate without equivalent-or-better coverage in the replacement.
124
124
 
125
+ ### D21 — Book-aware nav config is declarative token strings, not resolver functions (#80, v4.26.0)
126
+ **Decision**: `defineBookConfig`'s nav-route fields (`chapterRoute` / `bookField` / `apparatusRoute` / `apparatusRoutes`) are declarative TOKEN STRINGS (`'/:id/'`, `':book'`, `':slug'`, `':route'`) resolved by a pure `nav-href` lib — NOT a `chapterHref(entry) => string` callback.
127
+ **Reasoning**: the book-config virtual module serializes via `JSON.stringify` (`integration.ts`); a function serializes to `undefined`. Token strings cross the boundary intact and cover every known multi-book route shape. A consumer needing real logic already owns its route components (recipe 18) and can import `chapterHref` directly.
128
+ **Deviate when**: a consumer's URL scheme can't be expressed with `:id`/`:book`/`:slug` token substitution — then they own the route components, no worse than pre-4.26.
129
+
130
+ ### D22 — Mobile nav is an inline controller + the 3-column gutter FITS (not suppressed) (#80, v4.26.0)
131
+ **Decision**: (a) the sub-1280px nav drawer is an inline vanilla `<script>` controller over a `:target` no-JS baseline, NOT a Preact island. (b) The right-gutter overflow (the Tufte `.section-map`/sidenote float overflowing beside the sidebar at 1024/1440px) is fixed by making it FIT — trim the shared measures (`--measure-main` 60/66/78ch, `--measure-side` 20/20/24ch) + the sidebar (14/16rem), and activate the full 3-column (sidebar + floated gutter) at **≥80rem (1280px)** instead of 64rem (below that: drawer + full-width content + ChapterTOC). NOT suppressing/hiding the scrollspy.
132
+ **Reasoning**: the drawer is event-wiring (open/close/focus-trap), not Preact rendering — the inline script is instant (no hydration flash), zero `.tsx` compile risk, and matches the existing theme-toggle precedent. The fit-not-suppress gutter keeps the Tufte scrollspy at true-desktop widths (it's the design's point); narrower measures are also closer to the ideal reading column. Verified `scrollWidth == clientWidth` across the consumer's responsive audit harness (390–1440px).
133
+ **Deviate when**: a consumer wants the gutter scrollspy at 1024–1280px — they'd need an even narrower measure or a different sidebar treatment at that band; document the trade-off.
134
+
125
135
  ## How to use this ledger
126
136
 
127
137
  When a future change in the scaffold contradicts a decision above, update this ledger first. Don't change behavior silently — the ledger is the durable record of "why this is shaped like it is."
@@ -0,0 +1,92 @@
1
+ # Recipe 22 — Responsive navigation & multi-book routing (v4.26.0, #80)
2
+
3
+ The scaffold's navigation (left `Sidebar`, prev/next `ChapterNav`) is **book-aware**
4
+ and **responsive**: it serves a single-book site unchanged, a multi-book consumer
5
+ (one Astro app at `/<book>/<slug>/`) correctly, and adds a mobile/tablet drawer.
6
+
7
+ ## Single-book — zero config (byte-identical)
8
+
9
+ Do nothing. The defaults reproduce the pre-4.26 behavior:
10
+
11
+ - chapter links are `${BASE_URL}chapters/<id>/`,
12
+ - the sidebar lists every chapter, prev/next walk the full collection,
13
+ - you additionally get a **mobile drawer** for free (hamburger in the chrome row,
14
+ below 80rem), where the auto-hidden sidebar previously left no nav.
15
+
16
+ ## Multi-book — four `defineBookConfig` fields
17
+
18
+ For a consumer that owns `/[book]/[...chapter]` routing and serves chapters at
19
+ `/<book>/<slug>/` (each chapter's `entry.id` is `'<book>/<slug>'` and carries a
20
+ `book` frontmatter field):
21
+
22
+ ```js
23
+ // astro.config.mjs
24
+ export default await defineBookConfig({
25
+ // … styles, routes, etc. …
26
+ chapterRoute: '/:id/', // entry.id is '<book>/<slug>' → '/<book>/<slug>/'
27
+ bookField: 'book', // scope sidebar/drawer/prev-next to the current book
28
+ apparatusRoute: '/:book/:route/', // → '/<book>/practice-exam/', etc.
29
+ apparatusRoutes: ['practice-exam', 'glossary', 'flashcards', 'answers'],
30
+ });
31
+ ```
32
+
33
+ Tokens (base-relative; `BASE_URL` is applied for you):
34
+
35
+ | token | value |
36
+ |---------|-----------------------------------------|
37
+ | `:id` | `entry.id` verbatim (slashes kept) |
38
+ | `:book` | the entry's book (`bookField`), or `''` |
39
+ | `:slug` | `entry.id` minus the leading `<book>/` |
40
+ | `:route`| an apparatus route slug |
41
+
42
+ The nav then:
43
+
44
+ - lists **only the current book's** chapters (the current book is derived from the
45
+ URL's first path segment, validated against the books that exist),
46
+ - emits `/<book>/<slug>/` links and highlights the current chapter (`aria-current`),
47
+ - keeps prev/next **within the current book** (`getNeighbors` is book-scoped),
48
+ - surfaces the per-book apparatus links from `apparatusRoutes`.
49
+
50
+ **Landing pages** (a multi-book corpus front door) should pass
51
+ `showSidebar={false}` to `Base` — there is no "current book" there, so the chapter
52
+ nav would otherwise fall back to the single-book all-chapters list.
53
+
54
+ ## The mobile/tablet drawer
55
+
56
+ Below **80rem (1280px)** the full 3-column layout doesn't fit beside the sidebar,
57
+ so the sidebar is replaced by a slide-in **drawer** reached from a hamburger in
58
+ the chrome row. It reuses `NavContent` (the same book-scoped nav), is a
59
+ `role="dialog"` with focus-trap / ESC / backdrop close (an inline controller in
60
+ `Base.astro`), and degrades to a `:target` CSS open with **no JS**. Nothing to
61
+ configure — it appears automatically wherever the sidebar is enabled.
62
+
63
+ ## The Tufte right-gutter ("on this page" scrollspy)
64
+
65
+ The floated gutter scrollspy (`SectionMap`) + sidenotes need room the sidebar
66
+ steals, so the **full 3-column activates at ≥80rem (1280px)**. Below that the
67
+ drawer is the nav and the collapsed in-flow `ChapterTOC` is the "on this page".
68
+ The shared text measure (`--measure-main` / `--measure-side`, `tokens.css`) is
69
+ tuned so `main + gutter` fits inside `viewport − sidebar` at every desktop width
70
+ — the scrollspy **fits**, it is not hidden. (BC note: single-book sidebar
71
+ consumers see the sidebar + scrollspy from 1280px up, was 1024px, with a slightly
72
+ narrower body measure.)
73
+
74
+ ## Owning your own route components
75
+
76
+ The resolver is exported for consumers who render their own nav (recipe 18):
77
+
78
+ ```ts
79
+ import { chapterHref, apparatusHref, bookOf, isCurrentChapter }
80
+ from '@brandon_m_behring/book-scaffold-astro';
81
+ ```
82
+
83
+ Pure functions, no `astro:content` — pass a `{ id, data }` and the same
84
+ `chapterRoute` / `bookField` you set in `defineBookConfig`.
85
+
86
+ ## Verify
87
+
88
+ A consumer should drive a cross-device audit (Playwright is ideal): for each rich
89
+ page across `{390, 768, 1024, 1440}` × `{light, dark}`, assert no horizontal page
90
+ overflow (`scrollWidth ≤ clientWidth`), a visible sidebar ≥1280 / hamburger+drawer
91
+ below, and that no nav link points outside the current book. See the
92
+ `dlai-study-notes` consumer's `tests/responsive.spec.ts` for a worked harness.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { getCollection, type CollectionEntry } from 'astro:content';
13
13
  import { chapterSortKey } from './chapter-sort.js';
14
+ import { bookOf } from './nav-href.js';
14
15
 
15
16
  export type Chapter = CollectionEntry<'chapters'>;
16
17
 
@@ -27,18 +28,30 @@ export async function getAllChapters(): Promise<Chapter[]> {
27
28
  }
28
29
 
29
30
  /**
30
- * Given a chapter id, return its ordered neighbors.
31
- * Either may be null at the edges of the book.
31
+ * Given a chapter id, return its ordered neighbors. Either may be null at the
32
+ * edges of the book.
33
+ *
34
+ * v4.26.0 (#80): book-aware. When the entry carries a book (multi-book consumer,
35
+ * `data[bookField]`), neighbors are scoped to the SAME book so prev/next never
36
+ * bleed across books. Single-book schemas have no book field → no scoping → the
37
+ * pre-4.26 global ordering (byte-identical BC).
32
38
  */
33
- export async function getNeighbors(id: string): Promise<{
34
- prev: Chapter | null;
35
- next: Chapter | null;
36
- }> {
39
+ export async function getNeighbors(
40
+ id: string,
41
+ opts: { bookField?: string } = {},
42
+ ): Promise<{ prev: Chapter | null; next: Chapter | null }> {
43
+ const { bookField = 'book' } = opts;
37
44
  const all = await getAllChapters();
38
- const idx = all.findIndex((c) => c.id === id);
45
+ const self = all.find((c) => c.id === id);
46
+ if (!self) return { prev: null, next: null };
47
+ const selfBook = bookOf({ id: self.id, data: self.data as Record<string, unknown> }, bookField);
48
+ const scoped = selfBook
49
+ ? all.filter((c) => bookOf({ id: c.id, data: c.data as Record<string, unknown> }, bookField) === selfBook)
50
+ : all;
51
+ const idx = scoped.findIndex((c) => c.id === id);
39
52
  if (idx === -1) return { prev: null, next: null };
40
53
  return {
41
- prev: idx > 0 ? all[idx - 1] : null,
42
- next: idx < all.length - 1 ? all[idx + 1] : null,
54
+ prev: idx > 0 ? scoped[idx - 1]! : null,
55
+ next: idx < scoped.length - 1 ? scoped[idx + 1]! : null,
43
56
  };
44
57
  }