@brandon_m_behring/book-scaffold-astro 4.25.3 → 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/chapter.css +0 -11
- package/styles/layout.css +94 -3
- package/styles/section-map.css +5 -1
- package/styles/tokens.css +17 -14
- package/styles/typography.css +16 -37
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-
|
|
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-
|
|
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
|
@@ -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 {
|
package/layouts/Base.astro
CHANGED
|
@@ -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.
|
|
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.
|
package/src/lib/chapters.ts
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 ?
|
|
42
|
-
next: idx <
|
|
54
|
+
prev: idx > 0 ? scoped[idx - 1]! : null,
|
|
55
|
+
next: idx < scoped.length - 1 ? scoped[idx + 1]! : null,
|
|
43
56
|
};
|
|
44
57
|
}
|