@brandon_m_behring/book-scaffold-astro 4.26.3 → 4.27.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.
Files changed (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CLAUDE.md +35 -10
  3. package/LATEX_TO_MDX_MAPPING.md +1 -1
  4. package/LICENSE +27 -0
  5. package/LICENSE-CONTENT +19 -0
  6. package/README.md +33 -0
  7. package/components/AssessmentTest.astro +2 -1
  8. package/components/ChapterNav.astro +2 -2
  9. package/components/Cite.astro +2 -1
  10. package/components/NavContent.astro +2 -2
  11. package/components/PartReview.astro +2 -1
  12. package/components/Rationale.astro +3 -2
  13. package/components/Sidebar.astro +2 -1
  14. package/components/Term.astro +2 -1
  15. package/components/TipsCard.astro +2 -1
  16. package/components/VersionSelector.tsx +39 -36
  17. package/components/WeekRef.astro +2 -1
  18. package/components/XRef.astro +2 -1
  19. package/dist/components/VersionSelector.d.ts +16 -2
  20. package/dist/components/VersionSelector.mjs +18 -17
  21. package/dist/index.d.ts +24 -6
  22. package/dist/index.mjs +89 -5
  23. package/dist/schemas.d.ts +1 -1
  24. package/dist/schemas.mjs +8 -1
  25. package/dist/{types-CWXP1S4b.d.ts → types-CZrkqzpC.d.ts} +58 -26
  26. package/layouts/Base.astro +20 -19
  27. package/package.json +7 -4
  28. package/pages/answers.astro +2 -1
  29. package/pages/chapters.astro +2 -1
  30. package/pages/exercises.astro +2 -1
  31. package/pages/index.astro +4 -3
  32. package/pages/search.astro +2 -1
  33. package/pages/tips.astro +2 -1
  34. package/recipes/01-add-math.md +40 -28
  35. package/recipes/04-component-library.md +14 -4
  36. package/recipes/05-deploy-cloudflare.md +44 -0
  37. package/recipes/06-mobile-first-layout.md +25 -2
  38. package/recipes/09-validation.md +18 -9
  39. package/recipes/15-defining-styles.md +5 -2
  40. package/recipes/19-prevalidate-hook.md +29 -83
  41. package/scripts/build-bib.mjs +7 -3
  42. package/scripts/build-labels.mjs +33 -16
  43. package/scripts/read-env.mjs +25 -0
  44. package/scripts/resolve-book-config.mjs +96 -0
  45. package/scripts/validate.mjs +125 -84
  46. package/src/lib/define-style.ts +15 -6
  47. package/src/lib/nav-href.ts +24 -2
  48. package/styles/layout.css +14 -8
  49. package/styles/tokens.css +8 -10
package/dist/index.mjs CHANGED
@@ -912,6 +912,7 @@ var BOOK_PROFILES = Object.keys(PROFILES);
912
912
  // src/types.ts
913
913
  import { existsSync, readFileSync } from "fs";
914
914
  var BOOK_PRESETS = BOOK_PROFILES;
915
+ var NUMBER_STYLES = ["shared", "per-kind"];
915
916
  var BookConfigError = class extends Error {
916
917
  constructor(message) {
917
918
  super(message);
@@ -936,6 +937,7 @@ function readEnvFile(path = ".env") {
936
937
  return {};
937
938
  }
938
939
  }
940
+ var PRESET_FALLBACK_WARNING = /* @__PURE__ */ Symbol.for("book-scaffold-astro:preset-fallback-warning");
939
941
  function resolvePreset(explicitPreset, explicitProfile) {
940
942
  let candidate = explicitPreset ?? explicitProfile ?? process.env.BOOK_PRESET ?? process.env.BOOK_PROFILE;
941
943
  let source = "default";
@@ -956,7 +958,13 @@ function resolvePreset(explicitPreset, explicitProfile) {
956
958
  );
957
959
  }
958
960
  if (source === "default") {
959
- console.warn("book-scaffold-astro: BOOK_PRESET not set; falling back to 'minimal'.");
961
+ const warningState = globalThis;
962
+ if (!warningState[PRESET_FALLBACK_WARNING]) {
963
+ warningState[PRESET_FALLBACK_WARNING] = true;
964
+ console.warn(
965
+ "book-scaffold-astro: no preset resolved; falling back to 'minimal' for v4 compatibility. This fallback will be removed in v5. Add a built-in style to defineBookConfig and pass the same preset to defineBookSchemas, or set BOOK_PRESET."
966
+ );
967
+ }
960
968
  }
961
969
  return candidate;
962
970
  }
@@ -966,7 +974,7 @@ function resolveProfile(explicit) {
966
974
 
967
975
  // src/integration.ts
968
976
  import { fileURLToPath } from "url";
969
- import { readFileSync as readFileSync2 } from "fs";
977
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync } from "fs";
970
978
  import { join } from "path";
971
979
 
972
980
  // src/lib/define-style.ts
@@ -981,6 +989,7 @@ function composeStyles(styles) {
981
989
  for (const style of styles) {
982
990
  if (style.name !== void 0) merged.name = style.name;
983
991
  if (style.preset !== void 0) merged.preset = style.preset;
992
+ if (style.numberStyle !== void 0) merged.numberStyle = style.numberStyle;
984
993
  if (style.site !== void 0) merged.site = style.site;
985
994
  if (style.deploy !== void 0) merged.deploy = style.deploy;
986
995
  if (style.releaseStatus !== void 0) {
@@ -1197,6 +1206,17 @@ var ROUTE_REGISTRY = {
1197
1206
  // empty string → '/[slug]'; arbitrary string → '/<prefix>/[slug]').
1198
1207
  frontmatter: { pattern: "/frontmatter/[slug]", file: "frontmatter/[...slug].astro" }
1199
1208
  };
1209
+ var DEFAULT_CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://cloudflareinsights.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'";
1210
+ function renderSecurityHeaders(contentSecurityPolicy) {
1211
+ const csp = contentSecurityPolicy ?? DEFAULT_CONTENT_SECURITY_POLICY;
1212
+ return `/*
1213
+ Strict-Transport-Security: max-age=31536000; includeSubDomains
1214
+ X-Content-Type-Options: nosniff
1215
+ Referrer-Policy: strict-origin-when-cross-origin
1216
+ Permissions-Policy: camera=(), microphone=(), geolocation=()
1217
+ Content-Security-Policy: ${csp}
1218
+ `;
1219
+ }
1200
1220
  function frontmatterPatternFromPrefix(prefix) {
1201
1221
  if (prefix === void 0) return ROUTE_REGISTRY.frontmatter.pattern;
1202
1222
  if (prefix === "") return "/[slug]";
@@ -1208,6 +1228,7 @@ function resolvePage(file) {
1208
1228
  function bookScaffoldIntegration(opts) {
1209
1229
  const {
1210
1230
  profile,
1231
+ numberStyle = "shared",
1211
1232
  routes: userOverrides = {},
1212
1233
  extraStyles = [],
1213
1234
  mdxComponentsModule,
@@ -1215,6 +1236,7 @@ function bookScaffoldIntegration(opts) {
1215
1236
  title,
1216
1237
  subtitle,
1217
1238
  releaseStatus,
1239
+ securityHeaders,
1218
1240
  description,
1219
1241
  portfolio,
1220
1242
  // v4.6.0: book-level author + SEO config, propagated through the
@@ -1245,7 +1267,7 @@ function bookScaffoldIntegration(opts) {
1245
1267
  ),
1246
1268
  frontmatter: fmEnabled
1247
1269
  };
1248
- return {
1270
+ const integration = {
1249
1271
  name: "book-scaffold-astro",
1250
1272
  hooks: {
1251
1273
  "astro:config:setup": ({ injectScript, injectRoute, updateConfig, config }) => {
@@ -1315,9 +1337,38 @@ function bookScaffoldIntegration(opts) {
1315
1337
  }
1316
1338
  }
1317
1339
  });
1340
+ },
1341
+ // v4.27.0 (#188): emit defaults for every build, not only newly
1342
+ // scaffolded projects, so existing consumers become protected on their
1343
+ // next package upgrade. Astro copies public/_headers into the output
1344
+ // before build:done; an existing target therefore means the consumer
1345
+ // owns the complete file and must win byte-for-byte.
1346
+ "astro:build:done": ({ dir, logger }) => {
1347
+ if (securityHeaders === false) {
1348
+ logger.info("security-header emission disabled by defineBookConfig (#188)");
1349
+ return;
1350
+ }
1351
+ const target = join(fileURLToPath(dir), "_headers");
1352
+ if (existsSync3(target)) {
1353
+ logger.info("consumer public/_headers present; scaffold defaults skipped (#188)");
1354
+ return;
1355
+ }
1356
+ writeFileSync(
1357
+ target,
1358
+ renderSecurityHeaders(securityHeaders?.contentSecurityPolicy),
1359
+ "utf8"
1360
+ );
1361
+ logger.info("emitted default security headers; public/_headers overrides them (#188)");
1318
1362
  }
1319
1363
  }
1320
1364
  };
1365
+ Object.defineProperty(integration, "__bookScaffoldResolvedConfig", {
1366
+ value: Object.freeze({ preset: profile, numberStyle }),
1367
+ enumerable: false,
1368
+ configurable: false,
1369
+ writable: false
1370
+ });
1371
+ return integration;
1321
1372
  }
1322
1373
 
1323
1374
  // src/config.ts
@@ -1354,8 +1405,19 @@ async function defineBookConfig(opts) {
1354
1405
  if ("preset" in opts || "profile" in opts) {
1355
1406
  throw v3MigrationError(opts);
1356
1407
  }
1408
+ if (Object.prototype.hasOwnProperty.call(opts, "deploy")) {
1409
+ console.warn(
1410
+ "book-scaffold-astro: defineBookConfig({ deploy }) is inert and deprecated; create-book chooses wrangler.toml from its CLI preset. Remove this field before upgrading to v5 (#180)."
1411
+ );
1412
+ }
1357
1413
  const composed = composeStyles(opts.styles ?? []);
1358
- const profile = opts.styles === void 0 && composed.preset === void 0 ? "minimal" : composed.preset ?? "minimal";
1414
+ const profile = resolvePreset(composed.preset);
1415
+ const numberStyle = opts.numberStyle ?? composed.numberStyle ?? "shared";
1416
+ if (!NUMBER_STYLES.includes(numberStyle)) {
1417
+ throw new BookConfigError(
1418
+ `numberStyle must be one of ${NUMBER_STYLES.join(" | ")} (got ${JSON.stringify(numberStyle)})`
1419
+ );
1420
+ }
1359
1421
  const site = opts.site ?? composed.site;
1360
1422
  if (!site) {
1361
1423
  throw new BookConfigError(
@@ -1423,6 +1485,7 @@ async function defineBookConfig(opts) {
1423
1485
  sitemap(sitemapOptions),
1424
1486
  bookScaffoldIntegration({
1425
1487
  profile,
1488
+ numberStyle,
1426
1489
  routes: mergedRoutes,
1427
1490
  mdxComponentsModule,
1428
1491
  extraStyles: mergedExtraStyles,
@@ -1433,6 +1496,11 @@ async function defineBookConfig(opts) {
1433
1496
  subtitle: opts.subtitle,
1434
1497
  // v4.26.2 (#149; style inheritance + opt-out fixed in v4.26.3).
1435
1498
  releaseStatus: resolvedReleaseStatus === false ? void 0 : resolvedReleaseStatus,
1499
+ // v4.27.0 (#188): undefined enables the audited defaults, false opts
1500
+ // out, and an object can replace only the CSP. The integration owns
1501
+ // the build:done emission because that is where Astro's output dir is
1502
+ // known and a copied consumer public/_headers can be detected.
1503
+ securityHeaders: opts.securityHeaders,
1436
1504
  description: opts.description,
1437
1505
  portfolio: resolvedPortfolio,
1438
1506
  // v4.6.0: book-level author + SEO config (ogImage, twitterHandle),
@@ -1479,6 +1547,7 @@ async function defineBookConfig(opts) {
1479
1547
  };
1480
1548
  const {
1481
1549
  styles: _styles,
1550
+ numberStyle: _numberStyle,
1482
1551
  site: _site,
1483
1552
  routes: _routes,
1484
1553
  deploy: _deploy,
@@ -1492,6 +1561,8 @@ async function defineBookConfig(opts) {
1492
1561
  subtitle: _subtitle,
1493
1562
  // v4.26.2 (#149): strip the release-state banner config.
1494
1563
  releaseStatus: _releaseStatus,
1564
+ // v4.27.0 (#188): consumed by the scaffold integration at build:done.
1565
+ securityHeaders: _securityHeaders,
1495
1566
  description: _description,
1496
1567
  portfolio: _portfolio,
1497
1568
  // v4.6.0: strip new book-level SEO opts (author + seo block).
@@ -1512,6 +1583,7 @@ async function defineBookConfig(opts) {
1512
1583
  ...rest
1513
1584
  } = opts;
1514
1585
  void _styles;
1586
+ void _numberStyle;
1515
1587
  void _site;
1516
1588
  void _routes;
1517
1589
  void _deploy;
@@ -1523,6 +1595,7 @@ async function defineBookConfig(opts) {
1523
1595
  void _title;
1524
1596
  void _subtitle;
1525
1597
  void _releaseStatus;
1598
+ void _securityHeaders;
1526
1599
  void _description;
1527
1600
  void _portfolio;
1528
1601
  void _author;
@@ -1628,6 +1701,9 @@ function assertEnumProp(value, allowed, ctx) {
1628
1701
  );
1629
1702
  }
1630
1703
 
1704
+ // src/index.ts
1705
+ init_katex_macros();
1706
+
1631
1707
  // src/lib/book-link.ts
1632
1708
  function resolveBookHref(siblingBooks, book, to) {
1633
1709
  const base = siblingBooks?.[book];
@@ -1641,9 +1717,13 @@ function resolveBookHref(siblingBooks, book, to) {
1641
1717
  }
1642
1718
 
1643
1719
  // src/lib/nav-href.ts
1644
- function normBase(baseUrl) {
1720
+ function normalizeBase(baseUrl) {
1645
1721
  return (baseUrl || "/").replace(/\/*$/, "/");
1646
1722
  }
1723
+ function baseNoSlash(baseUrl) {
1724
+ return (baseUrl || "/").replace(/\/+$/, "");
1725
+ }
1726
+ var normBase = normalizeBase;
1647
1727
  function fillTokens(pattern, tokens) {
1648
1728
  return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k) => tokens[k] ?? "");
1649
1729
  }
@@ -1916,6 +1996,7 @@ export {
1916
1996
  BookConfigError,
1917
1997
  DEFAULT_GITHUB_BRANCH,
1918
1998
  KIND_LABEL,
1999
+ NUMBER_STYLES,
1919
2000
  THEOREM_KINDS,
1920
2001
  UNKNOWN_PART_ORDINAL,
1921
2002
  academicChapterSchema,
@@ -1928,6 +2009,7 @@ export {
1928
2009
  apparatusHref,
1929
2010
  assertEnumProp,
1930
2011
  assertKnownDomain,
2012
+ baseNoSlash,
1931
2013
  bloomLevels,
1932
2014
  bookOf,
1933
2015
  bookScaffoldIntegration,
@@ -1962,6 +2044,7 @@ export {
1962
2044
  layoutModes,
1963
2045
  minimalChapterSchema,
1964
2046
  minimalStyle,
2047
+ normalizeBase,
1965
2048
  normalizeFrontmatterConfig,
1966
2049
  originUrlFromGitConfig,
1967
2050
  parseRepoSlug,
@@ -1993,6 +2076,7 @@ export {
1993
2076
  sourceTiersResearch,
1994
2077
  sourcesSchema,
1995
2078
  spreadBlueprint,
2079
+ ssmMacros,
1996
2080
  theoremLabel,
1997
2081
  tocHeadings,
1998
2082
  toolSlugs,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-CWXP1S4b.js';
2
+ import { g as BookSchemasOptions } from './types-CZrkqzpC.js';
3
3
  import 'astro';
4
4
  import './schemas-CKipJ5Ie.js';
5
5
  import 'astro/zod';
package/dist/schemas.mjs CHANGED
@@ -814,6 +814,7 @@ function readEnvFile(path = ".env") {
814
814
  return {};
815
815
  }
816
816
  }
817
+ var PRESET_FALLBACK_WARNING = /* @__PURE__ */ Symbol.for("book-scaffold-astro:preset-fallback-warning");
817
818
  function resolvePreset(explicitPreset, explicitProfile) {
818
819
  let candidate = explicitPreset ?? explicitProfile ?? process.env.BOOK_PRESET ?? process.env.BOOK_PROFILE;
819
820
  let source = "default";
@@ -834,7 +835,13 @@ function resolvePreset(explicitPreset, explicitProfile) {
834
835
  );
835
836
  }
836
837
  if (source === "default") {
837
- console.warn("book-scaffold-astro: BOOK_PRESET not set; falling back to 'minimal'.");
838
+ const warningState = globalThis;
839
+ if (!warningState[PRESET_FALLBACK_WARNING]) {
840
+ warningState[PRESET_FALLBACK_WARNING] = true;
841
+ console.warn(
842
+ "book-scaffold-astro: no preset resolved; falling back to 'minimal' for v4 compatibility. This fallback will be removed in v5. Add a built-in style to defineBookConfig and pass the same preset to defineBookSchemas, or set BOOK_PRESET."
843
+ );
844
+ }
838
845
  }
839
846
  return candidate;
840
847
  }
@@ -353,6 +353,8 @@ interface Style {
353
353
  readonly name?: string;
354
354
  /** Profile that backs this style — determines schema + default routes + styles + KaTeX wiring. */
355
355
  readonly preset?: BookPreset;
356
+ /** Theorem-family numbering strategy; shallow override (last wins). */
357
+ readonly numberStyle?: NumberStyle;
356
358
  /** Book's deployed origin (sitemap, canonical, Pagefind). Required at composition end;
357
359
  * optional inside a Style (so styles can omit it and consumers can provide per-book). */
358
360
  readonly site?: string;
@@ -374,10 +376,10 @@ interface Style {
374
376
  * `remarkPlugins` and `rehypePlugins` arrays concat across the chain;
375
377
  * scalar fields override. */
376
378
  readonly markdown?: AstroUserConfig['markdown'];
377
- /** Deploy target drives create-book's wrangler.toml shape.
378
- * - `'workers'`: Cloudflare Workers + Static Assets (default for academic/tools/minimal)
379
- * - `'pages'`: Cloudflare Pages (default for research-portfolio/course-notes)
380
- * Closes #50. */
379
+ /** Reserved legacy metadata. It is composed but has no runtime or
380
+ * create-book effect: create-book chooses wrangler.toml from its CLI
381
+ * preset before a consumer Style exists (#180).
382
+ * @deprecated Inert in v4; scheduled for removal in v5. */
381
383
  readonly deploy?: 'pages' | 'workers';
382
384
  /**
383
385
  * v4.26.2 (#149; style inheritance fixed in v4.26.3): release-state
@@ -445,7 +447,7 @@ declare function defineStyle(opts: StyleInput): Style;
445
447
  * - Top-level `defineBookConfig` fields beat any style (handled in config.ts)
446
448
  *
447
449
  * Per-key merge strategy:
448
- * - `preset`, `site`, `deploy`, `mdxComponentsModule`, `name`, `releaseStatus`
450
+ * - `preset`, `numberStyle`, `site`, `deploy`, `mdxComponentsModule`, `name`, `releaseStatus`
449
451
  * → shallow override (last defined wins; `releaseStatus: false` suppresses)
450
452
  * - `routes` → per-route spread (each route key independently overridable)
451
453
  * - `katexMacros` → per-macro spread (each macro key independently overridable)
@@ -482,6 +484,30 @@ declare function normalizeFrontmatterConfig(v: FrontmatterRouteConfig | undefine
482
484
 
483
485
  type BookPreset = BookProfile;
484
486
  declare const BOOK_PRESETS: readonly ("academic" | "tools" | "minimal" | "course-notes" | "research-portfolio")[];
487
+ /** Theorem-family numbering strategy used by build-labels and validate. */
488
+ type NumberStyle = 'shared' | 'per-kind';
489
+ declare const NUMBER_STYLES: readonly ["shared", "per-kind"];
490
+ /**
491
+ * v4.27.0 (#188): build-time security-header customization.
492
+ *
493
+ * The integration emits a Cloudflare-compatible `dist/_headers` file by
494
+ * default. Consumers that need a different CSP can replace that one header
495
+ * while retaining the scaffold's other audited defaults.
496
+ */
497
+ interface SecurityHeadersConfig {
498
+ contentSecurityPolicy?: string;
499
+ }
500
+ /**
501
+ * v4.26.2 (#149): book-level release state rendered by
502
+ * `<PreReleaseBanner>` across every page.
503
+ *
504
+ * Style inheritance and explicit suppression were fixed in v4.26.3.
505
+ */
506
+ interface ReleaseStatusConfig {
507
+ state: 'alpha' | 'beta' | 'rc' | 'locked';
508
+ dismissAt?: string;
509
+ message?: string;
510
+ }
485
511
  /**
486
512
  * v4.26.2 (#149): book-level release state rendered by
487
513
  * `<PreReleaseBanner>` across every page.
@@ -531,6 +557,13 @@ interface BookConfigOptions {
531
557
  * });
532
558
  */
533
559
  styles?: readonly Style[];
560
+ /**
561
+ * v4.27.0 (#175): theorem-family counter strategy. `shared` preserves the
562
+ * amsthm-style sequence used through v4.26; `per-kind` gives theorem,
563
+ * proposition, lemma, and each other theorem kind an independent sequence.
564
+ * May also be supplied by a Style; top-level config wins.
565
+ */
566
+ numberStyle?: NumberStyle;
534
567
  /**
535
568
  * Optional per-route override of the composed profile's defaults. Use to
536
569
  * disable an auto-injected route (e.g. multi-book consumer that ships
@@ -545,10 +578,13 @@ interface BookConfigOptions {
545
578
  */
546
579
  routes?: PartialRouteToggles;
547
580
  /**
548
- * v4.0.0 (NEW): deploy target drives `create-book`'s wrangler.toml shape.
549
- * Defaults from the composed style chain (academic/tools/minimal
550
- * 'workers'; course-notes/research-portfolio 'pages'); per-book override
551
- * here beats any style. Closes #50.
581
+ * v4.0.0 (#50): RESERVEDaccepted and style-chain-merged, but currently
582
+ * has NO runtime effect (#180). The wrangler.toml shape is decided once, at
583
+ * scaffold time, by `create-book` from the profile name (academic/tools/
584
+ * minimal Workers; course-notes/research-portfolio Pages); setting this
585
+ * field changes nothing afterward. Wire-or-remove is a v5 decision.
586
+ * @deprecated Inert in v4; remove this option before v5. Deployment shape
587
+ * is chosen by create-book or by the consumer's own deployment files.
552
588
  */
553
589
  deploy?: 'pages' | 'workers';
554
590
  /**
@@ -625,6 +661,14 @@ interface BookConfigOptions {
625
661
  * composed style chain; set `false` to suppress an inherited banner.
626
662
  */
627
663
  releaseStatus?: ReleaseStatusConfig | false;
664
+ /**
665
+ * v4.27.0 (#188): security headers emitted to `dist/_headers` after an
666
+ * Astro build. Omit for the scaffold defaults; set `false` to emit no
667
+ * scaffold-owned file; provide `contentSecurityPolicy` to replace only the
668
+ * default CSP while retaining HSTS, XCTO, Referrer-Policy, and
669
+ * Permissions-Policy. A consumer-owned `public/_headers` always wins.
670
+ */
671
+ securityHeaders?: SecurityHeadersConfig | false;
628
672
  /**
629
673
  * v4.5.0: Book description. Read by the auto-injected `/` landing page (lead paragraph + <meta description>).
630
674
  * Optional; landing renders no description paragraph if unset.
@@ -757,6 +801,8 @@ interface BookSchemasOptions {
757
801
  /** Options for the internal `bookScaffoldIntegration`. See PACKAGE_DESIGN.md §6. */
758
802
  interface BookScaffoldIntegrationOptions {
759
803
  profile: BookProfile;
804
+ /** Resolved theorem-family counter strategy exposed to package CLI tooling. */
805
+ numberStyle?: NumberStyle;
760
806
  /**
761
807
  * Per-route override; merged into the profile's defaults.
762
808
  * v4.0.0: `routes.frontmatter` widened to `boolean | { enabled, prefix? }`
@@ -780,6 +826,8 @@ interface BookScaffoldIntegrationOptions {
780
826
  /** v4.26.2 (#149; style inheritance fixed in v4.26.3): resolved
781
827
  * release-state banner propagated via the book-config virtual module. */
782
828
  releaseStatus?: ReleaseStatusConfig;
829
+ /** v4.27.0 (#188): resolved security-header emission policy. */
830
+ securityHeaders?: SecurityHeadersConfig | false;
783
831
  /** v4.5.0: book description, propagated to `/` landing via vite.define. */
784
832
  description?: string;
785
833
  /**
@@ -831,22 +879,6 @@ interface BookScaffoldIntegrationOptions {
831
879
  declare class BookConfigError extends Error {
832
880
  constructor(message: string);
833
881
  }
834
- /**
835
- * Resolve preset from explicit args → env → .env → default. Throws on invalid.
836
- *
837
- * v3.4.0 (closes #9): canonical resolver. Accepts both `preset` and `profile`
838
- * (back-compat) explicit args; reads both `BOOK_PRESET` (preferred) and
839
- * `BOOK_PROFILE` (alias) env vars; same for .env file lookups.
840
- *
841
- * Resolution order:
842
- * 1. explicitPreset (from defineBookConfig({ preset: ... }))
843
- * 2. explicitProfile (from defineBookConfig({ profile: ... }))
844
- * 3. process.env.BOOK_PRESET
845
- * 4. process.env.BOOK_PROFILE
846
- * 5. .env BOOK_PRESET
847
- * 6. .env BOOK_PROFILE
848
- * 7. 'minimal' (with console.warn)
849
- */
850
882
  declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: BookProfile): BookPreset;
851
883
  /**
852
884
  * Backward-compat alias. New code should use `resolvePreset()`.
@@ -855,4 +887,4 @@ declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: Bo
855
887
  */
856
888
  declare function resolveProfile(explicit?: BookProfile): BookProfile;
857
889
 
858
- export { BOOK_PRESETS as B, type ChapterFor as C, type FreshnessAffordance as F, type PartKey as P, type ReleaseStatusConfig as R, type StatusBadge as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookPreset as d, type BookProfile as e, type BookScaffoldIntegrationOptions as f, type BookSchemasOptions as g, type ChaptersRenderer as h, type FrontmatterRouteConfig as i, type PartialRouteToggles as j, type ProfileDefinition as k, type RouteToggles as l, type Style as m, type StyleInput as n, composeStyles as o, defineProfile as p, defineStyle as q, normalizeFrontmatterConfig as r, resolvePreset as s, resolveProfile as t };
890
+ export { BOOK_PRESETS as B, type ChapterFor as C, type FreshnessAffordance as F, NUMBER_STYLES as N, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookPreset as d, type BookProfile as e, type BookScaffoldIntegrationOptions as f, type BookSchemasOptions as g, type ChaptersRenderer as h, type FrontmatterRouteConfig as i, type NumberStyle as j, type PartialRouteToggles as k, type ProfileDefinition as l, type RouteToggles as m, type StatusBadge as n, type Style as o, type StyleInput as p, composeStyles as q, defineProfile as r, defineStyle as s, normalizeFrontmatterConfig as t, resolvePreset as u, resolveProfile as v };
@@ -15,15 +15,15 @@
15
15
  * lang — html lang attribute (default: en)
16
16
  * showSidebar — render the left chapter-navigation sidebar (default: true).
17
17
  * Set false for the landing page or any full-bleed surface.
18
- * Below 1024px the sidebar is auto-hidden via CSS regardless.
19
- * showChrome — render the tools chrome (ToolFilter + VersionSelector)
18
+ * Below 80rem (1280px) the sidebar is auto-hidden via CSS and
19
+ * the mobile nav drawer is the chapter nav (v4.26.0, #80).
20
+ * showChrome — render the automatic tools chrome (ToolFilter)
20
21
  * (default: true). Set false on a landing/hub page with no
21
- * chapters/tools/versions; search + theme toggle always render.
22
+ * chapter filtering; search + theme toggle always render.
22
23
  *
23
- * Tools chrome (ToolFilter, VersionSelector): only mounted when
24
- * BOOK_PROFILE !== 'academic' AND showChrome !== false. Academic-profile books
25
- * (and any page with showChrome={false}) get no JS islands in the chrome row
26
- * (search + theme toggle only).
24
+ * Tools chrome (ToolFilter): only mounted when BOOK_PROFILE !== 'academic' AND
25
+ * showChrome !== false. VersionSelector is a prop-driven public component that
26
+ * consumers mount explicitly once they have real deployed-version data.
27
27
  *
28
28
  * Usage:
29
29
  * ---
@@ -51,12 +51,11 @@ import '../styles/chapter.css';
51
51
  import '../styles/tool-filter.css';
52
52
  import '../styles/convergence.css';
53
53
  import '../styles/print.css';
54
- // Use package-path imports for the .tsx islands so Vite resolves them
54
+ // Use the package-path import for the .tsx island so Vite resolves it
55
55
  // via the exports map (→ pre-compiled dist/components/*.mjs with proper
56
56
  // preact JSX). The raw .tsx files are also shipped so consumers can
57
57
  // import them directly, but relative imports from inside the package
58
58
  // must route through the exports map, not the raw source.
59
- import VersionSelector from '@brandon_m_behring/book-scaffold-astro/components/VersionSelector';
60
59
  import ToolFilter from '@brandon_m_behring/book-scaffold-astro/components/ToolFilter';
61
60
  import Sidebar from '../components/Sidebar.astro';
62
61
  // v4.26.2 (#149; style inheritance fixed in v4.26.3): site-wide
@@ -68,6 +67,7 @@ import NavContent from '../components/NavContent.astro';
68
67
  // description fallback, ogImage default, twitterHandle) from the
69
68
  // book-config virtual module (was landing-config in v4.5.1).
70
69
  import bookConfig from 'virtual:book-scaffold/book-config';
70
+ import { normalizeBase } from '../src/lib/nav-href';
71
71
 
72
72
  const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
73
73
 
@@ -76,10 +76,10 @@ interface Props {
76
76
  description?: string;
77
77
  lang?: string;
78
78
  showSidebar?: boolean;
79
- /** #163: render the optional "tools chrome" — the ToolFilter +
80
- * VersionSelector islands (default true). Set false on landing/hub pages
81
- * with no chapters/tools/versions; the search + theme-toggle cluster always
82
- * renders. (No-op for the academic profile, which never shows them.) */
79
+ /** #163: render the optional automatic tools chrome — the ToolFilter island
80
+ * (default true). Set false on landing/hub pages with no chapter filtering;
81
+ * the search + theme-toggle cluster always renders. (No-op for the academic
82
+ * profile, which never shows the ToolFilter.) */
83
83
  showChrome?: boolean;
84
84
  /** v4.6.0: Open Graph image URL (relative to site root, or absolute). */
85
85
  ogImage?: string;
@@ -97,9 +97,9 @@ const {
97
97
  ogType = 'website',
98
98
  } = Astro.props;
99
99
 
100
- // Tools chrome (ToolFilter + VersionSelector) renders only for non-academic
100
+ // Automatic tools chrome (ToolFilter) renders only for non-academic
101
101
  // profiles AND when the page opts in via showChrome (default true). A
102
- // landing/hub page (no chapters/tools/versions) passes showChrome={false} to
102
+ // landing/hub page (no chapter filtering) passes showChrome={false} to
103
103
  // get the search + theme-toggle cluster only — the chrome-free path that
104
104
  // previously forced borrowing the academic profile (and its katex deps). #163.
105
105
  const showToolsChrome = (profile !== 'academic') && showChrome;
@@ -120,7 +120,7 @@ const absoluteOgImage = resolvedOgImage
120
120
  const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
121
121
  const ogSiteName = bookConfig.title ?? title;
122
122
  const ogDescription = description ?? bookConfig.description ?? '';
123
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
123
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
124
124
  ---
125
125
 
126
126
  <!doctype html>
@@ -196,7 +196,6 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
196
196
  </a>
197
197
  )}
198
198
  {showToolsChrome && <ToolFilter client:idle />}
199
- {showToolsChrome && <VersionSelector client:idle />}
200
199
  <a
201
200
  href={`${baseUrl}search/`}
202
201
  class="chrome-button"
@@ -217,7 +216,8 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
217
216
  </button>
218
217
  </div>
219
218
  {/* v4.26.0 (#80): mobile/tablet nav drawer (sibling of <main>, NOT a <main>).
220
- CSS-hidden ≥64rem where the Sidebar is the nav. role=dialog + the inline
219
+ CSS-hidden ≥80rem where the Sidebar is the nav (layout.css and the
220
+ controller's desktop auto-close agree on 80rem). role=dialog + the inline
221
221
  controller below provide focus-trap/ESC; `:target` is the no-JS fallback. */}
222
222
  {showSidebar && (
223
223
  <div id="nav-drawer" class="nav-drawer" data-nav-drawer>
@@ -390,7 +390,8 @@ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
390
390
  text-decoration: none;
391
391
  }
392
392
 
393
- /* Version selector dropdown */
393
+ /* Opt-in VersionSelector dropdown. Base provides its visual language but
394
+ * deliberately does not mount it without a consumer-owned version manifest. */
394
395
  .version-selector {
395
396
  position: relative;
396
397
  display: inline-block;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
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.26.3",
3
+ "description": "Astro 6 + MDX toolkit for long-form technical books with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
4
+ "version": "4.27.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -161,9 +161,11 @@
161
161
  "pedagogy",
162
162
  "examples",
163
163
  "CLAUDE.md",
164
+ "AGENTS.md",
164
165
  "README.md",
165
- "LATEX_TO_MDX_MAPPING.md",
166
- "examples"
166
+ "LICENSE",
167
+ "LICENSE-CONTENT",
168
+ "LATEX_TO_MDX_MAPPING.md"
167
169
  ],
168
170
  "scripts": {
169
171
  "build": "tsup",
@@ -195,6 +197,7 @@
195
197
  "@fontsource-variable/roboto": "^5.2.10",
196
198
  "@fontsource-variable/source-code-pro": "^5.2.7",
197
199
  "pagefind": "^1.5.2",
200
+ "vite": "^7.3.2",
198
201
  "yaml": "^2.9.0"
199
202
  },
200
203
  "devDependencies": {
@@ -26,6 +26,7 @@ import { render } from 'astro:content';
26
26
  import bookConfig from 'virtual:book-scaffold/book-config';
27
27
  import { getAllQuestions, groupByChapter } from '../src/lib/questions';
28
28
  import { assertKnownDomain } from '../src/lib/exam-domains';
29
+ import { normalizeBase } from '../src/lib/nav-href';
29
30
 
30
31
  // Presence-gate: only touch the collection when the directory holds files.
31
32
  const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
@@ -51,7 +52,7 @@ const rendered = await Promise.all(
51
52
  const byChapter = groupByChapter(rendered);
52
53
  const total = rendered.length;
53
54
  // #142: practice-exam + chapter links must prefix the deploy base.
54
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
55
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
55
56
  const practiceHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
56
57
  ? `${baseUrl}practice-exam`
57
58
  : null;
@@ -26,9 +26,10 @@ import { getAllChapters, type Chapter } from '../src/lib/chapters';
26
26
  import { PROFILES } from '../src/profiles/index';
27
27
  import { fallbackChaptersRenderer } from '../src/profiles/renderers/fallback-chapters';
28
28
  import type { ChaptersRenderer, PartKey } from '../src/lib/chapters-renderer';
29
+ import { normalizeBase } from '../src/lib/nav-href';
29
30
 
30
31
  // #142: prefix BASE_URL so chapter-card links stay inside a non-root deploy base.
31
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
32
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
32
33
 
33
34
  const profileName = (import.meta.env.BOOK_PROFILE ?? 'minimal') as keyof typeof PROFILES;
34
35
  const profileDef = PROFILES[profileName];
@@ -10,6 +10,7 @@
10
10
  * page (doesn't fail the build).
11
11
  */
12
12
  import Base from '../layouts/Base.astro';
13
+ import { normalizeBase } from '../src/lib/nav-href';
13
14
 
14
15
  // v4.4.0 fix: use Vite's import.meta.glob with a project-root-relative
15
16
  // path (same lesson as tips.astro). `/src/data/...` is consumer-project-
@@ -28,7 +29,7 @@ const chapters = Object.keys(byChapter).sort();
28
29
  const total = chapters.reduce((sum, c) => sum + byChapter[c].length, 0);
29
30
 
30
31
  // #142: chapter deep-links must prefix the deploy base.
31
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
32
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
32
33
  ---
33
34
  <Base title="Exercises" description="All exercises in this book, grouped by chapter with deep links.">
34
35
  <article class="prose">
package/pages/index.astro CHANGED
@@ -34,15 +34,16 @@
34
34
  */
35
35
  import Base from '../layouts/Base.astro';
36
36
  import bookConfig from 'virtual:book-scaffold/book-config';
37
+ import { normalizeBase } from '../src/lib/nav-href';
37
38
 
38
39
  const title = bookConfig.title ?? 'book-scaffold-astro';
39
40
  const description = bookConfig.description;
40
41
  const portfolio = bookConfig.portfolio;
41
42
  const enabledRoutes = bookConfig.enabledRoutes;
42
43
 
43
- // Astro guarantees BASE_URL carries a trailing slash ('/' or '/foo/'), so
44
- // `${baseUrl}chapters/` resolves correctly under any deploy base (#142).
45
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
44
+ // Astro accepts bases with or without a trailing slash. Route every value
45
+ // through the shared helper before composing links (#142, #182).
46
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
46
47
 
47
48
  // Map from internal route name → display label + URL. Only routes that
48
49
  // produce a single landing-list entry are listed here (frontmatter is a