@brandon_m_behring/book-scaffold-astro 4.25.3 → 4.26.2

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-Hue-uSeQ.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-Hue-uSeQ.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';
@@ -144,18 +144,31 @@ declare const academicChaptersRenderer: ChaptersRenderer;
144
144
 
145
145
  /**
146
146
  * src/profiles/renderers/fallback-chapters.ts — ChaptersRenderer used by
147
- * profiles that don't ship a dedicated renderer (minimal, course-notes,
148
- * research-portfolio). Dispatches by field presence — exactly the v3.5.2
147
+ * profiles that don't ship a dedicated renderer (minimal, course-notes).
148
+ * Dispatches by field presence — exactly the v3.5.2
149
149
  * logic that lived inline in pages/chapters.astro before #35.
150
150
  *
151
151
  * Safety net for shapes we haven't designed for explicitly. If a consumer
152
- * opts a course-notes or research-portfolio book into `routes.chapters: true`,
152
+ * opts a course-notes book into `routes.chapters: true`,
153
153
  * the fallback renders reasonably without crashing. Custom output for those
154
154
  * profiles is a v4+ extension point (consumer-overridable renderer).
155
155
  */
156
156
 
157
157
  declare const fallbackChaptersRenderer: ChaptersRenderer;
158
158
 
159
+ /**
160
+ * Research-portfolio /chapters renderer.
161
+ *
162
+ * The portfolio schema intentionally permits numeric Parts through 20. The
163
+ * generic fallback renderer inherits the tools profile's historical
164
+ * `part >= 6` appendix convention, which mislabels a portfolio's sixth Part
165
+ * as "Appendices" and its chapters as Appendix a/b/.... Keep the fallback's
166
+ * field-shape dispatch for every other affordance, but make numeric Parts
167
+ * ordinary numbered Parts throughout the portfolio schema's full range.
168
+ */
169
+
170
+ declare const researchPortfolioChaptersRenderer: ChaptersRenderer;
171
+
159
172
  /**
160
173
  * src/lib/academic-parts.ts — single source of truth for academic-profile
161
174
  * part labels (#95).
@@ -294,6 +307,53 @@ declare function assertEnumProp<T extends string>(value: unknown, allowed: reado
294
307
  */
295
308
  declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
296
309
 
310
+ /**
311
+ * src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
312
+ *
313
+ * No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
314
+ * the DTS bundle without dragging Astro virtual modules into the build graph.
315
+ * The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
316
+ * instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
317
+ * of components serves both a single-book site (the default pattern) and a
318
+ * multi-book consumer whose chapters render at `/<book>/<slug>/`.
319
+ *
320
+ * Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
321
+ * survive the book-config virtual module's `JSON.stringify`, so the config
322
+ * surface is a declarative string resolved here):
323
+ * :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
324
+ * :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
325
+ * :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
326
+ * BASE_URL is applied here, so patterns are written base-relative (lead '/').
327
+ *
328
+ * Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
329
+ * chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
330
+ * bookField = 'book' (academic/tools schemas have no `book` → bookOf
331
+ * returns null → "show all chapters", today's nav)
332
+ * apparatusRoute = '/:route/' → `${base}<route>/`
333
+ */
334
+ /** Minimal shape the resolver needs from a chapter collection entry. */
335
+ interface ChapterLike {
336
+ id: string;
337
+ data: Record<string, unknown>;
338
+ }
339
+ /**
340
+ * The entry's book name from `data[bookField]`, or `null` when absent/blank.
341
+ * Single-book schemas (academic/tools/minimal) have no such field → `null`,
342
+ * which callers read as "this is the only book — show every chapter".
343
+ */
344
+ declare function bookOf(entry: ChapterLike, bookField?: string): string | null;
345
+ /** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
346
+ declare function slugOf(entry: ChapterLike, bookField?: string): string;
347
+ /** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
348
+ declare function chapterHref(entry: ChapterLike, pattern?: string, baseUrl?: string, bookField?: string): string;
349
+ /**
350
+ * Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
351
+ * answers) to a base-prefixed href via the `apparatusRoute` pattern.
352
+ */
353
+ declare function apparatusHref(route: string, book: string | null, pattern?: string, baseUrl?: string): string;
354
+ /** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
355
+ declare function isCurrentChapter(entry: ChapterLike, currentPath: string, pattern?: string, baseUrl?: string, bookField?: string): boolean;
356
+
297
357
  /**
298
358
  * exam-domains — validate a question's `domain` against the consumer's closed
299
359
  * `examDomains` taxonomy (Tier 3, #112).
@@ -551,4 +611,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
551
611
  */
552
612
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
553
613
 
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 };
614
+ 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, researchPortfolioChaptersRenderer, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, slugOf, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -851,6 +851,17 @@ var courseNotesProfile = defineProfile({
851
851
  sitemapFilter: (page) => !page.includes("/print/")
852
852
  });
853
853
 
854
+ // src/profiles/renderers/research-portfolio-chapters.ts
855
+ var researchPortfolioChaptersRenderer = {
856
+ ...fallbackChaptersRenderer,
857
+ formatPartLabel(part) {
858
+ return typeof part === "number" ? `Part ${part}` : fallbackChaptersRenderer.formatPartLabel(part);
859
+ },
860
+ isAppendix(_part) {
861
+ return false;
862
+ }
863
+ };
864
+
854
865
  // src/profiles/research-portfolio.ts
855
866
  var researchPortfolioProfile = defineProfile({
856
867
  name: "research-portfolio",
@@ -883,8 +894,9 @@ var researchPortfolioProfile = defineProfile({
883
894
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css", "section-map.css"],
884
895
  katex: true,
885
896
  // math is common in research content
886
- // v3.7.0 (#35): portfolio schema is a union of academic + tools shapes fallback renderer dispatches per chapter via field presence
887
- chaptersRenderer: fallbackChaptersRenderer
897
+ // Portfolio schema is a union of academic + tools shapes, but unlike the
898
+ // tools profile its numeric Parts remain numbered through the schema max 20.
899
+ chaptersRenderer: researchPortfolioChaptersRenderer
888
900
  });
889
901
 
890
902
  // src/profiles/index.ts
@@ -1199,6 +1211,7 @@ function bookScaffoldIntegration(opts) {
1199
1211
  // v4.5.0: landing-page data, propagated via virtual module to /index.astro.
1200
1212
  title,
1201
1213
  subtitle,
1214
+ releaseStatus,
1202
1215
  description,
1203
1216
  portfolio,
1204
1217
  // v4.6.0: book-level author + SEO config, propagated through the
@@ -1211,7 +1224,12 @@ function bookScaffoldIntegration(opts) {
1211
1224
  // v4.16.0 (#96): sibling-book registry for cross-book <BookLink>.
1212
1225
  siblingBooks,
1213
1226
  // v4.17.0 (#112): exam-domain taxonomy for the questions collection.
1214
- examDomains
1227
+ examDomains,
1228
+ // v4.26.0 (#80): book-aware nav route patterns.
1229
+ chapterRoute,
1230
+ bookField,
1231
+ apparatusRoute,
1232
+ apparatusRoutes
1215
1233
  } = opts;
1216
1234
  const def = PROFILES[profile];
1217
1235
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -1263,6 +1281,7 @@ function bookScaffoldIntegration(opts) {
1263
1281
  makeBookConfigVitePlugin({
1264
1282
  title: title ?? null,
1265
1283
  subtitle: subtitle ?? null,
1284
+ releaseStatus: releaseStatus ?? null,
1266
1285
  description: description ?? null,
1267
1286
  portfolio: portfolio ?? false,
1268
1287
  enabledRoutes: enabledRouteNames,
@@ -1274,7 +1293,13 @@ function bookScaffoldIntegration(opts) {
1274
1293
  githubRepo: resolvedGithubRepo,
1275
1294
  githubBranch: resolvedGithubBranch,
1276
1295
  siblingBooks: siblingBooks ?? {},
1277
- examDomains: examDomains ?? []
1296
+ examDomains: examDomains ?? [],
1297
+ // v4.26.0 (#80): book-aware nav route patterns; defaults
1298
+ // reproduce the single-book `/chapters/<id>/` behavior exactly.
1299
+ chapterRoute: chapterRoute ?? "/chapters/:id/",
1300
+ bookField: bookField ?? "book",
1301
+ apparatusRoute: apparatusRoute ?? "/:route/",
1302
+ apparatusRoutes: apparatusRoutes ?? []
1278
1303
  })
1279
1304
  ],
1280
1305
  define: {
@@ -1402,6 +1427,8 @@ async function defineBookConfig(opts) {
1402
1427
  title: opts.title,
1403
1428
  // v4.23.0 (#135): sidebar brand subtitle.
1404
1429
  subtitle: opts.subtitle,
1430
+ // v4.27.0 (#149): release-state banner (top-level beats the style chain).
1431
+ releaseStatus: opts.releaseStatus ?? composed.releaseStatus,
1405
1432
  description: opts.description,
1406
1433
  portfolio: resolvedPortfolio,
1407
1434
  // v4.6.0: book-level author + SEO config (ogImage, twitterHandle),
@@ -1416,7 +1443,12 @@ async function defineBookConfig(opts) {
1416
1443
  // v4.16.0 (#96): cross-book link registry.
1417
1444
  siblingBooks: opts.siblingBooks,
1418
1445
  // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1419
- examDomains: opts.examDomains
1446
+ examDomains: opts.examDomains,
1447
+ // v4.26.0 (#80): book-aware nav route patterns (undefined → single-book defaults).
1448
+ chapterRoute: opts.chapterRoute,
1449
+ bookField: opts.bookField,
1450
+ apparatusRoute: opts.apparatusRoute,
1451
+ apparatusRoutes: opts.apparatusRoutes
1420
1452
  }),
1421
1453
  ...mergedExtraIntegrations
1422
1454
  ];
@@ -1454,6 +1486,8 @@ async function defineBookConfig(opts) {
1454
1486
  // v4.5.0: strip new landing-related opts so they don't leak into AstroUserConfig.
1455
1487
  title: _title,
1456
1488
  subtitle: _subtitle,
1489
+ // v4.27.0 (#149): strip the release-state banner config.
1490
+ releaseStatus: _releaseStatus,
1457
1491
  description: _description,
1458
1492
  portfolio: _portfolio,
1459
1493
  // v4.6.0: strip new book-level SEO opts (author + seo block).
@@ -1466,6 +1500,11 @@ async function defineBookConfig(opts) {
1466
1500
  siblingBooks: _siblingBooks,
1467
1501
  // v4.17.0: strip exam-domain taxonomy.
1468
1502
  examDomains: _examDomains,
1503
+ // v4.26.0 (#80): strip book-aware nav route patterns.
1504
+ chapterRoute: _chapterRoute,
1505
+ bookField: _bookField,
1506
+ apparatusRoute: _apparatusRoute,
1507
+ apparatusRoutes: _apparatusRoutes,
1469
1508
  ...rest
1470
1509
  } = opts;
1471
1510
  void _styles;
@@ -1479,6 +1518,7 @@ async function defineBookConfig(opts) {
1479
1518
  void _katexMacros;
1480
1519
  void _title;
1481
1520
  void _subtitle;
1521
+ void _releaseStatus;
1482
1522
  void _description;
1483
1523
  void _portfolio;
1484
1524
  void _author;
@@ -1487,6 +1527,10 @@ async function defineBookConfig(opts) {
1487
1527
  void _githubBranch;
1488
1528
  void _siblingBooks;
1489
1529
  void _examDomains;
1530
+ void _chapterRoute;
1531
+ void _bookField;
1532
+ void _apparatusRoute;
1533
+ void _apparatusRoutes;
1490
1534
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1491
1535
  const restVite = rest.vite ?? {};
1492
1536
  const restSsr = restVite.ssr ?? {};
@@ -1592,6 +1636,38 @@ function resolveBookHref(siblingBooks, book, to) {
1592
1636
  return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
1593
1637
  }
1594
1638
 
1639
+ // src/lib/nav-href.ts
1640
+ function normBase(baseUrl) {
1641
+ return (baseUrl || "/").replace(/\/*$/, "/");
1642
+ }
1643
+ function fillTokens(pattern, tokens) {
1644
+ return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k) => tokens[k] ?? "");
1645
+ }
1646
+ function bookOf(entry, bookField = "book") {
1647
+ const v = entry.data[bookField];
1648
+ return typeof v === "string" && v.length > 0 ? v : null;
1649
+ }
1650
+ function slugOf(entry, bookField = "book") {
1651
+ const book = bookOf(entry, bookField);
1652
+ return book && entry.id.startsWith(`${book}/`) ? entry.id.slice(book.length + 1) : entry.id;
1653
+ }
1654
+ function chapterHref(entry, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
1655
+ const path = fillTokens(pattern, {
1656
+ id: entry.id,
1657
+ book: bookOf(entry, bookField) ?? "",
1658
+ slug: slugOf(entry, bookField)
1659
+ }).replace(/\/{2,}/g, "/").replace(/^\//, "");
1660
+ return normBase(baseUrl) + path;
1661
+ }
1662
+ function apparatusHref(route, book, pattern = "/:route/", baseUrl = "/") {
1663
+ const path = fillTokens(pattern, { route, book: book ?? "" }).replace(/\/{2,}/g, "/").replace(/^\//, "");
1664
+ return normBase(baseUrl) + path;
1665
+ }
1666
+ function isCurrentChapter(entry, currentPath, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
1667
+ const href = chapterHref(entry, pattern, baseUrl, bookField);
1668
+ return currentPath === href || currentPath === href.replace(/\/$/, "");
1669
+ }
1670
+
1595
1671
  // src/lib/exam-domains.ts
1596
1672
  function assertKnownDomain(examDomains, domain, ctx) {
1597
1673
  if (!examDomains || !examDomains.includes(domain)) {
@@ -1845,15 +1921,18 @@ export {
1845
1921
  academicPartOrdinal,
1846
1922
  academicParts,
1847
1923
  academicStyle,
1924
+ apparatusHref,
1848
1925
  assertEnumProp,
1849
1926
  assertKnownDomain,
1850
1927
  bloomLevels,
1928
+ bookOf,
1851
1929
  bookScaffoldIntegration,
1852
1930
  buildExamManifest,
1853
1931
  buildFlashcardDeck,
1854
1932
  buildGithubUrl,
1855
1933
  changeKinds,
1856
1934
  changelogSchema,
1935
+ chapterHref,
1857
1936
  chapterLabel,
1858
1937
  chapterSortKey,
1859
1938
  chapterStatus,
@@ -1875,6 +1954,7 @@ export {
1875
1954
  glossarySchema,
1876
1955
  groupByChapter,
1877
1956
  groupByDomain,
1957
+ isCurrentChapter,
1878
1958
  layoutModes,
1879
1959
  minimalChapterSchema,
1880
1960
  minimalStyle,
@@ -1892,6 +1972,7 @@ export {
1892
1972
  refineQuestion,
1893
1973
  refinedQuestionSchema,
1894
1974
  researchPortfolioChapterSchema,
1975
+ researchPortfolioChaptersRenderer,
1895
1976
  researchPortfolioStyle,
1896
1977
  resolveBookHref,
1897
1978
  resolveGithubRepo,
@@ -1902,6 +1983,7 @@ export {
1902
1983
  scoreExam,
1903
1984
  selectPartExercises,
1904
1985
  shuffle,
1986
+ slugOf,
1905
1987
  sortQuestions,
1906
1988
  sourceTiers,
1907
1989
  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-Hue-uSeQ.js';
3
3
  import 'astro';
4
4
  import './schemas-CKipJ5Ie.js';
5
5
  import 'astro/zod';
package/dist/schemas.mjs CHANGED
@@ -729,6 +729,17 @@ var courseNotesProfile = defineProfile({
729
729
  sitemapFilter: (page) => !page.includes("/print/")
730
730
  });
731
731
 
732
+ // src/profiles/renderers/research-portfolio-chapters.ts
733
+ var researchPortfolioChaptersRenderer = {
734
+ ...fallbackChaptersRenderer,
735
+ formatPartLabel(part) {
736
+ return typeof part === "number" ? `Part ${part}` : fallbackChaptersRenderer.formatPartLabel(part);
737
+ },
738
+ isAppendix(_part) {
739
+ return false;
740
+ }
741
+ };
742
+
732
743
  // src/profiles/research-portfolio.ts
733
744
  var researchPortfolioProfile = defineProfile({
734
745
  name: "research-portfolio",
@@ -761,8 +772,9 @@ var researchPortfolioProfile = defineProfile({
761
772
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css", "section-map.css"],
762
773
  katex: true,
763
774
  // math is common in research content
764
- // v3.7.0 (#35): portfolio schema is a union of academic + tools shapes fallback renderer dispatches per chapter via field presence
765
- chaptersRenderer: fallbackChaptersRenderer
775
+ // Portfolio schema is a union of academic + tools shapes, but unlike the
776
+ // tools profile its numeric Parts remain numbered through the schema max 20.
777
+ chaptersRenderer: researchPortfolioChaptersRenderer
766
778
  });
767
779
 
768
780
  // src/profiles/index.ts
@@ -379,6 +379,12 @@ interface Style {
379
379
  * - `'pages'`: Cloudflare Pages (default for research-portfolio/course-notes)
380
380
  * Closes #50. */
381
381
  readonly deploy?: 'pages' | 'workers';
382
+ /** v4.27.0 (#149): release-state banner; shallow override (last wins). */
383
+ readonly releaseStatus?: {
384
+ state: 'alpha' | 'beta' | 'rc' | 'locked';
385
+ dismissAt?: string;
386
+ message?: string;
387
+ };
382
388
  /**
383
389
  * Scoped consumer-side metadata. Ignored by the toolkit; survives composition
384
390
  * as per-key spread (last wins per key). Use this for workflow data that
@@ -600,6 +606,17 @@ interface BookConfigOptions {
600
606
  * hardcoded both strings, so every consumer shipped the placeholder).
601
607
  */
602
608
  subtitle?: string;
609
+ /**
610
+ * v4.27.0 (#149): book-level release state. When set, Base.astro renders
611
+ * the existing <PreReleaseBanner> site-wide (top of <body>) with these
612
+ * props — previously the banner was author-import-only, so auto-layout
613
+ * consumers (ssm-foundations) had no way to wire it. Omit for no banner.
614
+ */
615
+ releaseStatus?: {
616
+ state: 'alpha' | 'beta' | 'rc' | 'locked';
617
+ dismissAt?: string;
618
+ message?: string;
619
+ };
603
620
  /**
604
621
  * v4.5.0: Book description. Read by the auto-injected `/` landing page (lead paragraph + <meta description>).
605
622
  * Optional; landing renders no description paragraph if unset.
@@ -684,6 +701,39 @@ interface BookConfigOptions {
684
701
  * @example examDomains: ['secure-network-architecture', 'identity-and-access']
685
702
  */
686
703
  examDomains?: readonly string[];
704
+ /**
705
+ * v4.26.0 (#80): URL pattern for a chapter entry, consumed by the book-aware
706
+ * Sidebar / ChapterNav / NavContent so they emit correct links on a multi-book
707
+ * consumer. Base-relative token string — `:id` (entry.id), `:book` (the
708
+ * entry's book, see `bookField`), `:slug` (id minus the leading `<book>/`).
709
+ * Default `'/chapters/:id/'` reproduces the single-book behavior byte-for-byte;
710
+ * a multi-book consumer whose chapters render at `/<book>/<slug>/` (entry.id =
711
+ * `'<book>/<slug>'`) sets `'/:id/'`. Resolved by the pure `chapterHref` helper.
712
+ */
713
+ chapterRoute?: string;
714
+ /**
715
+ * v4.26.0 (#80): frontmatter field naming a chapter's book (multi-book book
716
+ * scoping). Default `'book'`. Absent on single-book schemas → the sidebar shows
717
+ * every chapter (today's behavior); present → the sidebar filters to the
718
+ * current book and the `:book` / `:slug` route tokens resolve.
719
+ */
720
+ bookField?: string;
721
+ /**
722
+ * v4.26.0 (#80): URL pattern for a per-book apparatus route (practice-exam /
723
+ * glossary / flashcards / answers) surfaced in the nav. Tokens `:book` +
724
+ * `:route`. Default `'/:route/'` (single-book, flat); a multi-book consumer
725
+ * sets `'/:book/:route/'`. Resolved by the pure `apparatusHref` helper.
726
+ */
727
+ apparatusRoute?: string;
728
+ /**
729
+ * v4.26.0 (#80): the apparatus route slugs to surface in the nav (sidebar +
730
+ * drawer) — a subset of `practice-exam | glossary | flashcards | answers`.
731
+ * Default `[]` (no apparatus links in nav — existing single-book books render
732
+ * unchanged). A multi-book consumer that owns per-book apparatus routes sets
733
+ * e.g. `['practice-exam','glossary','flashcards','answers']`; each is rendered
734
+ * via `apparatusHref(slug, currentBook, apparatusRoute, base)`.
735
+ */
736
+ apparatusRoutes?: readonly string[];
687
737
  /** Escape hatch for any other AstroUserConfig field. */
688
738
  [key: string]: unknown;
689
739
  }
@@ -719,6 +769,13 @@ interface BookScaffoldIntegrationOptions {
719
769
  /** v4.23.0 (#135): sidebar brand subtitle, propagated via the book-config
720
770
  * virtual module to Sidebar.astro. */
721
771
  subtitle?: string;
772
+ /** v4.27.0 (#149): release-state banner, propagated via the book-config
773
+ * virtual module; Base.astro renders <PreReleaseBanner> when set. */
774
+ releaseStatus?: {
775
+ state: 'alpha' | 'beta' | 'rc' | 'locked';
776
+ dismissAt?: string;
777
+ message?: string;
778
+ };
722
779
  /** v4.5.0: book description, propagated to `/` landing via vite.define. */
723
780
  description?: string;
724
781
  /**
@@ -756,6 +813,15 @@ interface BookScaffoldIntegrationOptions {
756
813
  siblingBooks?: Record<string, string>;
757
814
  /** v4.17.0 (#112): closed exam-domain taxonomy for the questions collection. */
758
815
  examDomains?: readonly string[];
816
+ /** v4.26.0 (#80): chapter-route token pattern, propagated via the book-config
817
+ * virtual module to the book-aware nav. Default '/chapters/:id/'. */
818
+ chapterRoute?: string;
819
+ /** v4.26.0 (#80): frontmatter field naming a chapter's book. Default 'book'. */
820
+ bookField?: string;
821
+ /** v4.26.0 (#80): apparatus-route token pattern. Default '/:route/'. */
822
+ apparatusRoute?: string;
823
+ /** v4.26.0 (#80): apparatus route slugs to surface in the nav. Default []. */
824
+ apparatusRoutes?: readonly string[];
759
825
  }
760
826
  /** Raised when the resolved profile is not one of `BOOK_PROFILES`. */
761
827
  declare class BookConfigError extends Error {
@@ -59,6 +59,10 @@ 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.27.0 (#149): site-wide release-state banner, driven by defineBookConfig.
63
+ import PreReleaseBanner from '../components/PreReleaseBanner.astro';
64
+ // v4.26.0 (#80): the mobile/tablet drawer reuses the same book-scoped nav source.
65
+ import NavContent from '../components/NavContent.astro';
62
66
  // v4.6.0: SEO meta tags read book-level identity (title fallback,
63
67
  // description fallback, ogImage default, twitterHandle) from the
64
68
  // book-config virtual module (was landing-config in v4.5.1).
@@ -115,7 +119,7 @@ const absoluteOgImage = resolvedOgImage
115
119
  const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
116
120
  const ogSiteName = bookConfig.title ?? title;
117
121
  const ogDescription = description ?? bookConfig.description ?? '';
118
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
122
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
119
123
  ---
120
124
 
121
125
  <!doctype html>
@@ -163,7 +167,33 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
163
167
  <slot name="head" />
164
168
  </head>
165
169
  <body>
170
+ {/* v4.27.0 (#149): book-level release banner — first element in <body> so
171
+ every page (chapters, apparatus, landing) declares its state. Renders
172
+ only when defineBookConfig({ releaseStatus }) is set. */}
173
+ {bookConfig.releaseStatus && (
174
+ <PreReleaseBanner
175
+ state={bookConfig.releaseStatus.state}
176
+ dismissAt={bookConfig.releaseStatus.dismissAt}
177
+ message={bookConfig.releaseStatus.message}
178
+ />
179
+ )}
166
180
  <div class="chrome-buttons">
181
+ {/* v4.26.0 (#80): hamburger → mobile/tablet nav drawer. <a href="#nav-drawer">
182
+ so it opens via :target with no JS; the controller below enhances it. */}
183
+ {showSidebar && (
184
+ <a
185
+ id="nav-toggle"
186
+ class="chrome-button nav-toggle"
187
+ href="#nav-drawer"
188
+ role="button"
189
+ aria-controls="nav-drawer"
190
+ aria-expanded="false"
191
+ aria-haspopup="dialog"
192
+ aria-label="Open chapter navigation"
193
+ >
194
+ <span aria-hidden="true">☰</span>
195
+ </a>
196
+ )}
167
197
  {showToolsChrome && <ToolFilter client:idle />}
168
198
  {showToolsChrome && <VersionSelector client:idle />}
169
199
  <a
@@ -185,6 +215,20 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
185
215
  <span class="theme-toggle-icon dark-icon" aria-hidden="true">☾</span>
186
216
  </button>
187
217
  </div>
218
+ {/* v4.26.0 (#80): mobile/tablet nav drawer (sibling of <main>, NOT a <main>).
219
+ CSS-hidden ≥64rem where the Sidebar is the nav. role=dialog + the inline
220
+ controller below provide focus-trap/ESC; `:target` is the no-JS fallback. */}
221
+ {showSidebar && (
222
+ <div id="nav-drawer" class="nav-drawer" data-nav-drawer>
223
+ <a class="nav-drawer-backdrop" href="#" data-nav-close tabindex="-1" aria-hidden="true"></a>
224
+ <div class="nav-drawer-panel" role="dialog" aria-modal="true" aria-label="Chapter navigation" tabindex="-1">
225
+ <a class="nav-drawer-dismiss chrome-button" href="#" data-nav-close aria-label="Close navigation">
226
+ <span aria-hidden="true">✕</span>
227
+ </a>
228
+ <NavContent />
229
+ </div>
230
+ </div>
231
+ )}
188
232
  {showSidebar ? (
189
233
  <div class="layout-with-sidebar">
190
234
  <Sidebar />
@@ -236,6 +280,79 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
236
280
  } catch (e) { /* older browsers: no media-query change events */ }
237
281
  })();
238
282
  </script>
283
+ {/*
284
+ Nav-drawer controller (v4.26.0, #80). Progressive enhancement over the
285
+ `:target` no-JS baseline: intercepts the hamburger to toggle `.is-open`
286
+ (so no history hash is pushed), traps focus inside the dialog, closes on
287
+ ESC / backdrop / dismiss, restores focus to the opener, and locks body
288
+ scroll. No-op on pages without the drawer (landing: showSidebar=false).
289
+ */}
290
+ <script is:inline>
291
+ (function () {
292
+ var drawer = document.getElementById('nav-drawer');
293
+ var toggle = document.getElementById('nav-toggle');
294
+ if (!drawer || !toggle) return;
295
+ var panel = drawer.querySelector('.nav-drawer-panel');
296
+ var opener = null;
297
+ function focusables() {
298
+ return Array.prototype.slice
299
+ .call(panel.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))
300
+ .filter(function (el) { return el.offsetParent !== null; });
301
+ }
302
+ // C1 (#80, codex@medium): count the :target no-JS-fallback state too, so a
303
+ // drawer opened via a '#nav-drawer' hash (with JS active) is still closable by
304
+ // backdrop / dismiss / ESC (close() clears the hash); else it would be stranded.
305
+ function isOpen() { return drawer.classList.contains('is-open') || location.hash === '#nav-drawer'; }
306
+ function open(e) {
307
+ if (e) e.preventDefault();
308
+ opener = document.activeElement;
309
+ drawer.classList.add('is-open');
310
+ toggle.setAttribute('aria-expanded', 'true');
311
+ document.documentElement.classList.add('nav-drawer-locked');
312
+ var f = focusables();
313
+ (f[0] || panel).focus();
314
+ }
315
+ function close(e) {
316
+ if (e) e.preventDefault();
317
+ if (!isOpen()) return;
318
+ drawer.classList.remove('is-open');
319
+ toggle.setAttribute('aria-expanded', 'false');
320
+ document.documentElement.classList.remove('nav-drawer-locked');
321
+ if (location.hash === '#nav-drawer') {
322
+ history.replaceState(null, '', location.pathname + location.search);
323
+ }
324
+ if (opener && typeof opener.focus === 'function') opener.focus();
325
+ }
326
+ toggle.addEventListener('click', function (e) { isOpen() ? close(e) : open(e); });
327
+ drawer.addEventListener('click', function (e) {
328
+ var t = e.target;
329
+ if (t && t.closest && t.closest('[data-nav-close]')) close(e);
330
+ });
331
+ document.addEventListener('keydown', function (e) {
332
+ if (!isOpen()) return;
333
+ if (e.key === 'Escape') { close(e); return; }
334
+ if (e.key === 'Tab') {
335
+ var f = focusables();
336
+ if (!f.length) return;
337
+ var first = f[0], last = f[f.length - 1];
338
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
339
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
340
+ }
341
+ });
342
+ // F5 (a11y, #80): role="button" must activate on Space; native anchors only
343
+ // fire click on Enter, so bind Space explicitly (preventDefault stops scroll).
344
+ toggle.addEventListener('keydown', function (e) {
345
+ if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); isOpen() ? close(e) : open(e); }
346
+ });
347
+ // F1 (#80): if the viewport grows into the desktop range (>=80rem) while the
348
+ // drawer is open, the drawer + toggle + backdrop go display:none — close() to
349
+ // clear .is-open and release the body scroll-lock so the page isn't stranded.
350
+ var mq = window.matchMedia('(min-width: 80rem)');
351
+ var onDesktop = function (ev) { if (ev.matches) close(); };
352
+ if (mq.addEventListener) mq.addEventListener('change', onDesktop);
353
+ else if (mq.addListener) mq.addListener(onDesktop);
354
+ })();
355
+ </script>
239
356
  </body>
240
357
  </html>
241
358
 
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.3",
4
+ "version": "4.26.2",
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."