@brandon_m_behring/book-scaffold-astro 4.30.0 → 5.0.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 (90) hide show
  1. package/CLAUDE.md +40 -3
  2. package/MIGRATION-v4-to-v5.md +183 -0
  3. package/README.md +30 -1
  4. package/bin/book-scaffold.mjs +1 -1
  5. package/components/AssessmentTest.astro +26 -5
  6. package/components/BookLink.astro +6 -3
  7. package/components/ChapterNav.astro +1 -1
  8. package/components/Cite.astro +37 -4
  9. package/components/ExerciseSolutions.astro +24 -4
  10. package/components/Figure.astro +7 -6
  11. package/components/Flashcards.tsx +19 -11
  12. package/components/NavContent.astro +60 -11
  13. package/components/ObjectiveMap.astro +14 -2
  14. package/components/PartReview.astro +32 -4
  15. package/components/PatternTimeline.astro +6 -2
  16. package/components/Rationale.astro +20 -3
  17. package/components/Sidebar.astro +11 -3
  18. package/components/SourceArchive.astro +33 -3
  19. package/components/Term.astro +18 -1
  20. package/components/Theorem.astro +15 -5
  21. package/components/TipsCard.astro +40 -3
  22. package/components/XRef.astro +14 -2
  23. package/dist/components/ExamRunner.d.ts +1 -1
  24. package/dist/components/Flashcards.d.ts +6 -1
  25. package/dist/components/Flashcards.mjs +14 -11
  26. package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
  27. package/dist/index.d.ts +76 -8
  28. package/dist/index.mjs +517 -43
  29. package/dist/schemas.d.ts +1 -1
  30. package/dist/schemas.mjs +311 -32
  31. package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
  32. package/layouts/Base.astro +38 -9
  33. package/layouts/Chapter.astro +10 -2
  34. package/package.json +6 -2
  35. package/pages/answers.astro +25 -6
  36. package/pages/book.astro +75 -0
  37. package/pages/chapters/[...slug].astro +39 -7
  38. package/pages/chapters-book.astro +17 -0
  39. package/pages/chapters.astro +87 -9
  40. package/pages/convergence.astro +40 -10
  41. package/pages/corpus-apparatus/answers.astro +15 -0
  42. package/pages/corpus-apparatus/convergence.astro +15 -0
  43. package/pages/corpus-apparatus/exercises.astro +15 -0
  44. package/pages/corpus-apparatus/flashcards.astro +15 -0
  45. package/pages/corpus-apparatus/glossary.astro +15 -0
  46. package/pages/corpus-apparatus/practice-exam.astro +15 -0
  47. package/pages/corpus-apparatus/print.astro +15 -0
  48. package/pages/corpus-apparatus/references.astro +15 -0
  49. package/pages/corpus-apparatus/tips.astro +15 -0
  50. package/pages/exercises.astro +18 -5
  51. package/pages/flashcards.astro +26 -5
  52. package/pages/glossary.astro +20 -3
  53. package/pages/index.astro +54 -1
  54. package/pages/practice-exam.astro +8 -2
  55. package/pages/print.astro +7 -2
  56. package/pages/references.astro +26 -6
  57. package/pages/search.astro +65 -4
  58. package/pages/tips.astro +17 -4
  59. package/recipes/03-asset-pipelines.md +12 -4
  60. package/recipes/09-validation.md +1 -1
  61. package/recipes/15-defining-styles.md +6 -6
  62. package/recipes/16-tikz-figures.md +13 -2
  63. package/recipes/20-anki-export.md +15 -8
  64. package/recipes/21-multi-guide-single-app.md +293 -44
  65. package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
  66. package/recipes/24-figure-authoring-standard.md +241 -0
  67. package/recipes/README.md +3 -2
  68. package/scripts/build-bib.mjs +113 -35
  69. package/scripts/build-exercises.mjs +101 -24
  70. package/scripts/build-figures.mjs +13 -10
  71. package/scripts/build-labels.mjs +199 -194
  72. package/scripts/build-tips.mjs +95 -23
  73. package/scripts/corpus-tooling.mjs +268 -0
  74. package/scripts/render-notebooks.mjs +2 -1
  75. package/scripts/resolve-book-config.mjs +99 -1
  76. package/scripts/sync-figure-tokens.mjs +44 -0
  77. package/scripts/validate.mjs +676 -100
  78. package/scripts/walk-mdx.mjs +16 -4
  79. package/src/lib/book-link.ts +72 -0
  80. package/src/lib/chapters.ts +10 -4
  81. package/src/lib/corpus-collateral.ts +9 -0
  82. package/src/lib/corpus.ts +458 -0
  83. package/src/lib/define-style.ts +28 -15
  84. package/src/lib/exam-manifest.ts +4 -1
  85. package/src/lib/figure-palette.mjs +162 -0
  86. package/src/lib/figure.mjs +113 -37
  87. package/src/lib/patterns.ts +15 -6
  88. package/src/lib/questions.ts +8 -3
  89. package/src/types.ts +603 -0
  90. package/styles/tokens.css +73 -9
package/dist/index.mjs CHANGED
@@ -913,6 +913,17 @@ var BOOK_PROFILES = Object.keys(PROFILES);
913
913
  import { existsSync, readFileSync } from "fs";
914
914
  var BOOK_PRESETS = BOOK_PROFILES;
915
915
  var NUMBER_STYLES = ["shared", "per-kind"];
916
+ var CORPUS_APPARATUS_ROUTES = [
917
+ "references",
918
+ "print",
919
+ "convergence",
920
+ "tips",
921
+ "exercises",
922
+ "practice-exam",
923
+ "glossary",
924
+ "flashcards",
925
+ "answers"
926
+ ];
916
927
  var BookConfigError = class extends Error {
917
928
  constructor(message) {
918
929
  super(message);
@@ -937,35 +948,25 @@ function readEnvFile(path = ".env") {
937
948
  return {};
938
949
  }
939
950
  }
940
- var PRESET_FALLBACK_WARNING = /* @__PURE__ */ Symbol.for("book-scaffold-astro:preset-fallback-warning");
941
951
  function resolvePreset(explicitPreset, explicitProfile) {
942
952
  let candidate = explicitPreset ?? explicitProfile ?? process.env.BOOK_PRESET ?? process.env.BOOK_PROFILE;
943
- let source = "default";
944
- if (explicitPreset || explicitProfile) source = "param";
945
- else if (process.env.BOOK_PRESET || process.env.BOOK_PROFILE) source = "env";
946
953
  if (!candidate) {
947
954
  const env = readEnvFile();
948
955
  const fromFile = env.BOOK_PRESET ?? env.BOOK_PROFILE;
949
956
  if (fromFile) {
950
957
  candidate = fromFile;
951
- source = "dotenv";
952
958
  }
953
959
  }
954
- candidate = candidate ?? "minimal";
960
+ if (!candidate) {
961
+ throw new BookConfigError(
962
+ `book-scaffold-astro: no book preset was resolved. Add a built-in Style to \`defineBookConfig({ styles: [...] })\` and pass the same preset to \`defineBookSchemas({ preset: "..." })\`, pass one \`defineBookCorpus\` manifest to both entrypoints, or set BOOK_PRESET in the environment or .env. Valid presets: ${BOOK_PRESETS.join(" | ")}. See https://github.com/brandon-behring/book-scaffold-astro/blob/main/package/MIGRATION-v4-to-v5.md.`
963
+ );
964
+ }
955
965
  if (!BOOK_PRESETS.includes(candidate)) {
956
966
  throw new BookConfigError(
957
967
  `preset must be one of ${BOOK_PRESETS.join(" | ")} (got ${JSON.stringify(candidate)})`
958
968
  );
959
969
  }
960
- if (source === "default") {
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
- }
968
- }
969
970
  return candidate;
970
971
  }
971
972
  function resolveProfile(explicit) {
@@ -977,8 +978,316 @@ import { fileURLToPath } from "url";
977
978
  import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync } from "fs";
978
979
  import { join } from "path";
979
980
 
981
+ // src/lib/corpus.ts
982
+ var BOOK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
983
+ var BOOK_CORPUS_BRAND = /* @__PURE__ */ Symbol.for(
984
+ "@brandon_m_behring/book-scaffold-astro/BookCorpus/v1"
985
+ );
986
+ var RESERVED_CORPUS_BOOK_IDS = Object.freeze([
987
+ "assets",
988
+ "chapters",
989
+ "search",
990
+ "questions",
991
+ "glossary",
992
+ "frontmatter",
993
+ "_astro",
994
+ "_og",
995
+ "pagefind"
996
+ ]);
997
+ var CORPUS_OWNED_ROUTE_FIELDS = Object.freeze([
998
+ "chapterRoute",
999
+ "bookField",
1000
+ "apparatusRoute",
1001
+ "apparatusRoutes"
1002
+ ]);
1003
+ var CORPUS_APPARATUS_TOGGLE_BY_ROUTE = Object.freeze({
1004
+ references: "references",
1005
+ print: "print",
1006
+ convergence: "convergence",
1007
+ tips: "tips",
1008
+ exercises: "exercises",
1009
+ "practice-exam": "practiceExam",
1010
+ glossary: "glossary",
1011
+ flashcards: "flashcards",
1012
+ answers: "answers"
1013
+ });
1014
+ var RESERVED = new Set(RESERVED_CORPUS_BOOK_IDS);
1015
+ var APPARATUS = new Set(CORPUS_APPARATUS_ROUTES);
1016
+ var RESERVED_CONTENT_ROOTS = Object.freeze({
1017
+ questions: "src/content/questions/ is the dedicated questions collection root",
1018
+ glossary: "src/content/glossary/ is the dedicated glossary collection root",
1019
+ frontmatter: "src/content/frontmatter/ is the shared frontmatter collection root"
1020
+ });
1021
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["preset", "books"]);
1022
+ var BRANDED_CORPUS_KEYS = /* @__PURE__ */ new Set(["__bookCorpusVersion", "preset", "books"]);
1023
+ var CORPUS_ARTIFACT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "books"]);
1024
+ var BOOK_KEYS = /* @__PURE__ */ new Set([
1025
+ "id",
1026
+ "title",
1027
+ "subtitle",
1028
+ "description",
1029
+ "author",
1030
+ "image",
1031
+ "apparatus"
1032
+ ]);
1033
+ function objectRecord(value, label) {
1034
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1035
+ throw new BookConfigError(`${label} must be an object.`);
1036
+ }
1037
+ return value;
1038
+ }
1039
+ function rejectUnknownKeys(value, allowed, label) {
1040
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
1041
+ if (unknown.length > 0) {
1042
+ throw new BookConfigError(
1043
+ `${label} has unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`
1044
+ );
1045
+ }
1046
+ }
1047
+ function optionalNonBlankString(value, label) {
1048
+ if (value === void 0) return void 0;
1049
+ if (typeof value !== "string" || value.trim().length === 0) {
1050
+ throw new BookConfigError(`${label} must be a non-blank string when provided.`);
1051
+ }
1052
+ return value;
1053
+ }
1054
+ function validateBook(value, index) {
1055
+ const label = `defineBookCorpus books[${index}]`;
1056
+ const input = objectRecord(value, label);
1057
+ rejectUnknownKeys(input, BOOK_KEYS, label);
1058
+ const id = optionalNonBlankString(input.id, `${label}.id`);
1059
+ if (id === void 0 || !BOOK_ID.test(id)) {
1060
+ throw new BookConfigError(
1061
+ `${label}.id must match [a-z0-9]+(?:-[a-z0-9]+)* (got ${JSON.stringify(input.id)}).`
1062
+ );
1063
+ }
1064
+ if (RESERVED.has(id)) {
1065
+ const reason = RESERVED_CONTENT_ROOTS[id] ?? "the scaffold owns that route or asset namespace";
1066
+ throw new BookConfigError(
1067
+ `${label}.id ${JSON.stringify(id)} is reserved because ${reason}; choose a book-specific id.`
1068
+ );
1069
+ }
1070
+ const title = optionalNonBlankString(input.title, `${label}.title`);
1071
+ if (title === void 0) {
1072
+ throw new BookConfigError(`${label}.title must be a non-blank string.`);
1073
+ }
1074
+ let apparatus;
1075
+ if (input.apparatus !== void 0) {
1076
+ if (!Array.isArray(input.apparatus)) {
1077
+ throw new BookConfigError(`${label}.apparatus must be an array when provided.`);
1078
+ }
1079
+ const seen = /* @__PURE__ */ new Set();
1080
+ const routes = [];
1081
+ for (const route of input.apparatus) {
1082
+ if (typeof route !== "string" || !APPARATUS.has(route)) {
1083
+ throw new BookConfigError(
1084
+ `${label}.apparatus contains ${JSON.stringify(route)}; expected a subset of ` + CORPUS_APPARATUS_ROUTES.join(" | ") + "."
1085
+ );
1086
+ }
1087
+ if (seen.has(route)) {
1088
+ throw new BookConfigError(`${label}.apparatus contains duplicate route ${JSON.stringify(route)}.`);
1089
+ }
1090
+ seen.add(route);
1091
+ routes.push(route);
1092
+ }
1093
+ apparatus = Object.freeze(routes);
1094
+ }
1095
+ const book = {
1096
+ id,
1097
+ title,
1098
+ ...optionalField("subtitle", optionalNonBlankString(input.subtitle, `${label}.subtitle`)),
1099
+ ...optionalField("description", optionalNonBlankString(input.description, `${label}.description`)),
1100
+ ...optionalField("author", optionalNonBlankString(input.author, `${label}.author`)),
1101
+ ...optionalField("image", optionalNonBlankString(input.image, `${label}.image`)),
1102
+ ...apparatus === void 0 ? {} : { apparatus }
1103
+ };
1104
+ return Object.freeze(book);
1105
+ }
1106
+ function optionalField(key, value) {
1107
+ return value === void 0 ? {} : { [key]: value };
1108
+ }
1109
+ function defineBookCorpus(inputValue) {
1110
+ const input = objectRecord(inputValue, "defineBookCorpus input");
1111
+ rejectUnknownKeys(input, TOP_LEVEL_KEYS, "defineBookCorpus input");
1112
+ if (typeof input.preset !== "string" || !BOOK_PRESETS.includes(input.preset)) {
1113
+ throw new BookConfigError(
1114
+ `defineBookCorpus preset must be one of ${BOOK_PRESETS.join(" | ")} (got ${JSON.stringify(input.preset)}).`
1115
+ );
1116
+ }
1117
+ if (!Array.isArray(input.books) || input.books.length === 0) {
1118
+ throw new BookConfigError("defineBookCorpus books must be a non-empty array.");
1119
+ }
1120
+ const books = input.books.map(validateBook);
1121
+ const seen = /* @__PURE__ */ new Set();
1122
+ for (const book of books) {
1123
+ if (seen.has(book.id)) {
1124
+ throw new BookConfigError(`defineBookCorpus book id ${JSON.stringify(book.id)} is duplicated.`);
1125
+ }
1126
+ seen.add(book.id);
1127
+ }
1128
+ const corpus = {
1129
+ __bookCorpusVersion: 1,
1130
+ preset: input.preset,
1131
+ books: Object.freeze(books)
1132
+ };
1133
+ Object.defineProperty(corpus, BOOK_CORPUS_BRAND, {
1134
+ value: true,
1135
+ enumerable: false,
1136
+ configurable: false,
1137
+ writable: false
1138
+ });
1139
+ return Object.freeze(corpus);
1140
+ }
1141
+ function assertBookCorpus(value) {
1142
+ const invalid = () => new BookConfigError(
1143
+ "corpus must be created by defineBookCorpus() from this book-scaffold major."
1144
+ );
1145
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1146
+ throw invalid();
1147
+ }
1148
+ const input = value;
1149
+ if (input[BOOK_CORPUS_BRAND] !== true || input.__bookCorpusVersion !== 1 || !Object.isFrozen(value) || !Object.isFrozen(input.books)) {
1150
+ throw invalid();
1151
+ }
1152
+ try {
1153
+ rejectUnknownKeys(input, BRANDED_CORPUS_KEYS, "corpus");
1154
+ if (typeof input.preset !== "string" || !BOOK_PRESETS.includes(input.preset)) {
1155
+ throw invalid();
1156
+ }
1157
+ if (!Array.isArray(input.books) || input.books.length === 0) throw invalid();
1158
+ const seen = /* @__PURE__ */ new Set();
1159
+ for (const [index, rawBook] of input.books.entries()) {
1160
+ if (!Object.isFrozen(rawBook)) throw invalid();
1161
+ const book = validateBook(rawBook, index);
1162
+ if (seen.has(book.id)) throw invalid();
1163
+ seen.add(book.id);
1164
+ if (book.apparatus !== void 0) {
1165
+ const original = rawBook.apparatus;
1166
+ if (!Object.isFrozen(original)) throw invalid();
1167
+ }
1168
+ }
1169
+ } catch (error) {
1170
+ if (error instanceof BookConfigError && error.message.startsWith("corpus must be created")) {
1171
+ throw error;
1172
+ }
1173
+ throw invalid();
1174
+ }
1175
+ }
1176
+ function corpusApparatusRoutesForBook(corpus, bookId, inheritedRoutes = []) {
1177
+ const book = resolveCorpusBook(corpus, bookId);
1178
+ return book.apparatus ?? inheritedRoutes;
1179
+ }
1180
+ function corpusBookHasApparatusRoute(corpus, bookId, route, inheritedRoutes = []) {
1181
+ return corpusApparatusRoutesForBook(corpus, bookId, inheritedRoutes).includes(route);
1182
+ }
1183
+ function selectBookArtifact(value, corpus, bookId, artifact = "artifact") {
1184
+ if (!corpus) return value;
1185
+ if (!bookId) {
1186
+ throw new Error(`${artifact}: cannot select a corpus payload without a current book.`);
1187
+ }
1188
+ resolveCorpusBook(corpus, bookId);
1189
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1190
+ throw new Error(
1191
+ `${artifact} is not a corpus artifact envelope; expected \`{ "schemaVersion": 1, "books": { ... } }\`.`
1192
+ );
1193
+ }
1194
+ const envelopeRecord = value;
1195
+ rejectUnknownKeys(envelopeRecord, CORPUS_ARTIFACT_KEYS, artifact);
1196
+ const envelope = envelopeRecord;
1197
+ if (envelope.schemaVersion !== 1 || envelope.books === null || typeof envelope.books !== "object" || Array.isArray(envelope.books)) {
1198
+ throw new BookConfigError(
1199
+ `${artifact} is not a corpus artifact envelope; expected \`{ "schemaVersion": 1, "books": { ... } }\`.`
1200
+ );
1201
+ }
1202
+ const books = envelope.books;
1203
+ const expected = corpus.books.map((book) => book.id);
1204
+ const actual = Object.keys(books);
1205
+ const missing = expected.filter((id) => !Object.prototype.hasOwnProperty.call(books, id));
1206
+ const unknown = actual.filter((id) => !expected.includes(id));
1207
+ if (missing.length > 0 || unknown.length > 0) {
1208
+ throw new Error(
1209
+ `${artifact} book keys do not match the corpus manifest` + (missing.length > 0 ? `; missing ${missing.join(", ")}` : "") + (unknown.length > 0 ? `; unknown ${unknown.join(", ")}` : "") + "."
1210
+ );
1211
+ }
1212
+ return books[bookId];
1213
+ }
1214
+ function resolveCorpusBook(corpus, id) {
1215
+ const book = corpus.books.find((candidate) => candidate.id === id);
1216
+ if (!book) {
1217
+ throw new BookConfigError(
1218
+ `Unknown corpus book ${JSON.stringify(id)}; expected one of ${corpus.books.map((candidate) => candidate.id).join(" | ")}.`
1219
+ );
1220
+ }
1221
+ return book;
1222
+ }
1223
+ function corpusBookIdOf(corpus, entryId) {
1224
+ const candidate = entryId.split("/")[0] ?? "";
1225
+ return corpus.books.some((book) => book.id === candidate) ? candidate : null;
1226
+ }
1227
+ function localCorpusEntryId(corpus, bookId, entryId) {
1228
+ resolveCorpusBook(corpus, bookId);
1229
+ const prefix = `${bookId}/`;
1230
+ if (!entryId.startsWith(prefix) || entryId.length === prefix.length) {
1231
+ throw new Error(
1232
+ `Collection entry ${JSON.stringify(entryId)} is outside corpus book ${JSON.stringify(bookId)}.`
1233
+ );
1234
+ }
1235
+ return entryId.slice(prefix.length);
1236
+ }
1237
+ function filterCorpusEntries(entries, corpus, bookId) {
1238
+ if (!corpus) return [...entries];
1239
+ if (!bookId) throw new Error("Cannot scope corpus content without a current book.");
1240
+ resolveCorpusBook(corpus, bookId);
1241
+ const prefix = `${bookId}/`;
1242
+ return entries.filter((entry) => entry.id.startsWith(prefix));
1243
+ }
1244
+ function corpusCollectionEntryId(corpus, entry, data, options = {}) {
1245
+ const label = options.label ?? "Content entry";
1246
+ const normalized = entry.replaceAll("\\", "/").replace(/^\.\//, "");
1247
+ const [bookId, ...localParts] = normalized.split("/");
1248
+ if (!bookId || !corpus.books.some((book) => book.id === bookId)) {
1249
+ throw new Error(`${label} ${JSON.stringify(entry)} is outside the registered corpus books.`);
1250
+ }
1251
+ if (Object.prototype.hasOwnProperty.call(data, "book") && data.book !== bookId) {
1252
+ throw new Error(
1253
+ `${label} ${JSON.stringify(entry)} has frontmatter book ${JSON.stringify(data.book)}, but its registered path owner is ${JSON.stringify(bookId)}.`
1254
+ );
1255
+ }
1256
+ const fileId = localParts.join("/").replace(/\.(?:md|mdx)$/i, "");
1257
+ const configured = options.slugField ? data[options.slugField] : void 0;
1258
+ const localId = typeof configured === "string" ? configured : fileId;
1259
+ const localSegments = localId.split("/");
1260
+ let hasInvalidEncodedSegment = false;
1261
+ for (const segment of localSegments) {
1262
+ try {
1263
+ const decoded = decodeURIComponent(segment);
1264
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
1265
+ hasInvalidEncodedSegment = true;
1266
+ }
1267
+ } catch {
1268
+ hasInvalidEncodedSegment = true;
1269
+ }
1270
+ }
1271
+ if (localId.length === 0 || localId.startsWith("/") || localId.endsWith("/") || localId.includes("\\") || localSegments.some((part) => part === "." || part === ".." || part.length === 0) || hasInvalidEncodedSegment) {
1272
+ throw new Error(
1273
+ `${label} ${JSON.stringify(entry)} resolved invalid corpus id ${JSON.stringify(localId)}.`
1274
+ );
1275
+ }
1276
+ return `${bookId}/${localId}`;
1277
+ }
1278
+ function corpusBookIdFromPath(corpus, pathname, baseUrl = "/") {
1279
+ const normalizedBase2 = baseUrl === "/" ? "/" : `/${baseUrl.replace(/^\/+|\/+$/g, "")}/`;
1280
+ const relative = pathname.startsWith(normalizedBase2) ? pathname.slice(normalizedBase2.length) : pathname.replace(/^\/+/, "");
1281
+ const segments = relative.split("/").filter(Boolean);
1282
+ const candidate = segments[0] === "chapters" ? segments[1] : segments[0];
1283
+ return candidate && corpus.books.some((book) => book.id === candidate) ? candidate : null;
1284
+ }
1285
+
980
1286
  // src/lib/define-style.ts
981
1287
  function defineStyle(opts) {
1288
+ if (Object.prototype.hasOwnProperty.call(opts, "deploy")) {
1289
+ throw removedDeployError("defineStyle");
1290
+ }
982
1291
  return { __styleVersion: 1, ...opts };
983
1292
  }
984
1293
  function composeStyles(styles) {
@@ -987,11 +1296,13 @@ function composeStyles(styles) {
987
1296
  }
988
1297
  const merged = {};
989
1298
  for (const style of styles) {
1299
+ if (Object.prototype.hasOwnProperty.call(style, "deploy")) {
1300
+ throw removedDeployError("composeStyles");
1301
+ }
990
1302
  if (style.name !== void 0) merged.name = style.name;
991
1303
  if (style.preset !== void 0) merged.preset = style.preset;
992
1304
  if (style.numberStyle !== void 0) merged.numberStyle = style.numberStyle;
993
1305
  if (style.site !== void 0) merged.site = style.site;
994
- if (style.deploy !== void 0) merged.deploy = style.deploy;
995
1306
  if (style.releaseStatus !== void 0) {
996
1307
  merged.releaseStatus = style.releaseStatus;
997
1308
  }
@@ -1031,6 +1342,11 @@ function composeStyles(styles) {
1031
1342
  }
1032
1343
  return defineStyle(merged);
1033
1344
  }
1345
+ function removedDeployError(context) {
1346
+ return new BookConfigError(
1347
+ `book-scaffold-astro v5 removed ${context}({ deploy }) because the field never controlled deployment. Remove it and configure wrangler.toml or your deployment platform directly. See https://github.com/brandon-behring/book-scaffold-astro/blob/main/package/MIGRATION-v4-to-v5.md.`
1348
+ );
1349
+ }
1034
1350
  function mergeMarkdown(a, b) {
1035
1351
  if (!a) return b;
1036
1352
  if (!b) return a;
@@ -1189,6 +1505,9 @@ var ROUTE_REGISTRY = {
1189
1505
  // Pre-v4.3.0 each consumer wrote this file by hand; all instances were
1190
1506
  // mechanical copies of the same boilerplate.
1191
1507
  chaptersSlug: { pattern: "/chapters/[...slug]", file: "chapters/[...slug].astro" },
1508
+ // v5.0.0 (#80): corpus-only landing and per-book chapter index.
1509
+ corpusBook: { pattern: "/[book]", file: "book.astro" },
1510
+ corpusChapters: { pattern: "/chapters/[book]", file: "chapters-book.astro" },
1192
1511
  convergence: { pattern: "/convergence", file: "convergence.astro" },
1193
1512
  // v4.3.0 (#70): cross-volume numbered-tips index. Opt-in via
1194
1513
  // routes.tips: true; pairs with build-tips script + <Tip> component.
@@ -1241,8 +1560,30 @@ function resolvePage(file) {
1241
1560
  return fileURLToPath(new URL(`../pages/${file}`, import.meta.url));
1242
1561
  }
1243
1562
  function bookScaffoldIntegration(opts) {
1563
+ if (!BOOK_PRESETS.includes(opts.profile)) {
1564
+ throw new BookConfigError(
1565
+ `bookScaffoldIntegration profile must be one of ${BOOK_PRESETS.join(" | ")} (got ${JSON.stringify(opts.profile)}).`
1566
+ );
1567
+ }
1568
+ if (opts.corpus !== void 0) {
1569
+ assertBookCorpus(opts.corpus);
1570
+ if (opts.corpus.preset !== opts.profile) {
1571
+ throw new BookConfigError(
1572
+ `bookScaffoldIntegration corpus preset ${JSON.stringify(opts.corpus.preset)} does not match profile ${JSON.stringify(opts.profile)}. One preset applies to the whole corpus.`
1573
+ );
1574
+ }
1575
+ const incompatible = CORPUS_OWNED_ROUTE_FIELDS.filter(
1576
+ (field) => Object.prototype.hasOwnProperty.call(opts, field)
1577
+ );
1578
+ if (incompatible.length > 0) {
1579
+ throw new BookConfigError(
1580
+ `Corpus mode owns ${incompatible.join(", ")}; remove the explicit ${incompatible.length === 1 ? "override" : "overrides"} so injected routes and navigation agree.`
1581
+ );
1582
+ }
1583
+ }
1244
1584
  const {
1245
1585
  profile,
1586
+ corpus,
1246
1587
  numberStyle = "shared",
1247
1588
  routes: userOverrides = {},
1248
1589
  extraStyles = [],
@@ -1282,6 +1623,29 @@ function bookScaffoldIntegration(opts) {
1282
1623
  ),
1283
1624
  frontmatter: fmEnabled
1284
1625
  };
1626
+ if (corpus) {
1627
+ for (const book of corpus.books) {
1628
+ for (const route of book.apparatus ?? []) {
1629
+ const toggle = CORPUS_APPARATUS_TOGGLE_BY_ROUTE[route];
1630
+ if (!enabledRoutes[toggle]) {
1631
+ throw new BookConfigError(
1632
+ `Corpus book ${JSON.stringify(book.id)} enables apparatus route ${JSON.stringify(route)}, but RouteToggles.${toggle} is disabled. Enable routes.${toggle} globally or remove the book apparatus entry.`
1633
+ );
1634
+ }
1635
+ }
1636
+ }
1637
+ }
1638
+ const inheritedCorpusApparatusRoutes = corpus ? CORPUS_APPARATUS_ROUTES.filter(
1639
+ (route) => enabledRoutes[CORPUS_APPARATUS_TOGGLE_BY_ROUTE[route]]
1640
+ ) : [];
1641
+ const corpusApparatusRoutes = corpus ? CORPUS_APPARATUS_ROUTES.filter(
1642
+ (route) => corpus.books.some(
1643
+ (book) => (book.apparatus ?? inheritedCorpusApparatusRoutes).includes(route)
1644
+ )
1645
+ ) : [];
1646
+ const corpusApparatusToggleKeys = new Set(
1647
+ Object.values(CORPUS_APPARATUS_TOGGLE_BY_ROUTE)
1648
+ );
1285
1649
  const integration = {
1286
1650
  name: "book-scaffold-astro",
1287
1651
  hooks: {
@@ -1296,18 +1660,34 @@ function bookScaffoldIntegration(opts) {
1296
1660
  const routesToInject = [];
1297
1661
  for (const [name, on] of Object.entries(enabledRoutes)) {
1298
1662
  if (!on) continue;
1663
+ if (corpus && corpusApparatusToggleKeys.has(name)) continue;
1299
1664
  routesToInject.push(name);
1300
- if (name === "chapters") routesToInject.push("chaptersSlug");
1665
+ if (name === "chapters") {
1666
+ routesToInject.push("chaptersSlug");
1667
+ if (corpus) routesToInject.push("corpusChapters");
1668
+ }
1669
+ if (name === "landing" && corpus) routesToInject.push("corpusBook");
1301
1670
  }
1302
1671
  for (const name of routesToInject) {
1303
1672
  const route = ROUTE_REGISTRY[name];
1304
1673
  if (!route) continue;
1305
- const pattern = name === "frontmatter" ? frontmatterPatternFromPrefix(fmPrefix) : route.pattern;
1674
+ let pattern = name === "frontmatter" ? frontmatterPatternFromPrefix(fmPrefix) : route.pattern;
1675
+ if (corpus && name === "chaptersSlug") {
1676
+ pattern = "/chapters/[book]/[...slug]";
1677
+ }
1306
1678
  injectRoute({
1307
1679
  pattern,
1308
1680
  entrypoint: resolvePage(route.file)
1309
1681
  });
1310
1682
  }
1683
+ if (corpus) {
1684
+ for (const route of corpusApparatusRoutes) {
1685
+ injectRoute({
1686
+ pattern: `/[book]/${route}`,
1687
+ entrypoint: resolvePage(`corpus-apparatus/${route}.astro`)
1688
+ });
1689
+ }
1690
+ }
1311
1691
  const consumerRoot = fileURLToPath(config.root);
1312
1692
  const resolvedMdxPath = resolveMdxComponentsPath(consumerRoot, mdxComponentsModule);
1313
1693
  const resolvedGithubRepo = resolveBookGithubRepo(githubRepo, consumerRoot);
@@ -1339,8 +1719,9 @@ function bookScaffoldIntegration(opts) {
1339
1719
  // reproduce the single-book `/chapters/<id>/` behavior exactly.
1340
1720
  chapterRoute: chapterRoute ?? "/chapters/:id/",
1341
1721
  bookField: bookField ?? "book",
1342
- apparatusRoute: apparatusRoute ?? "/:route/",
1343
- apparatusRoutes: apparatusRoutes ?? []
1722
+ apparatusRoute: corpus ? "/:book/:route/" : apparatusRoute ?? "/:route/",
1723
+ apparatusRoutes: corpus ? inheritedCorpusApparatusRoutes : apparatusRoutes ?? [],
1724
+ corpus: corpus ?? null
1344
1725
  })
1345
1726
  ],
1346
1727
  define: {
@@ -1383,8 +1764,11 @@ function bookScaffoldIntegration(opts) {
1383
1764
  preset: profile,
1384
1765
  numberStyle,
1385
1766
  siblingBooks: siblingBooks ?? {},
1386
- chapterRoute: chapterRoute ?? "/chapters/:id/",
1387
- bookField: bookField ?? "book"
1767
+ corpus: corpus ?? null,
1768
+ chapterRoute: corpus ? "/chapters/:id/" : chapterRoute ?? "/chapters/:id/",
1769
+ bookField: bookField ?? "book",
1770
+ apparatusRoute: corpus ? "/:book/:route/" : apparatusRoute ?? "/:route/",
1771
+ apparatusRoutes: corpus ? inheritedCorpusApparatusRoutes : apparatusRoutes ?? []
1388
1772
  }),
1389
1773
  enumerable: false,
1390
1774
  configurable: false,
@@ -1428,12 +1812,38 @@ async function defineBookConfig(opts) {
1428
1812
  throw v3MigrationError(opts);
1429
1813
  }
1430
1814
  if (Object.prototype.hasOwnProperty.call(opts, "deploy")) {
1431
- console.warn(
1432
- "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)."
1815
+ throw new BookConfigError(
1816
+ "book-scaffold-astro v5 removed defineBookConfig({ deploy }) because the field never controlled deployment. Remove it and configure wrangler.toml or your deployment platform directly. See https://github.com/brandon-behring/book-scaffold-astro/blob/main/package/MIGRATION-v4-to-v5.md."
1433
1817
  );
1434
1818
  }
1435
1819
  const composed = composeStyles(opts.styles ?? []);
1436
- const profile = resolvePreset(composed.preset);
1820
+ const corpus = opts.corpus;
1821
+ if (corpus !== void 0) {
1822
+ assertBookCorpus(corpus);
1823
+ }
1824
+ const profile = resolvePreset(composed.preset ?? corpus?.preset);
1825
+ if (corpus !== void 0) {
1826
+ if (corpus.preset !== profile) {
1827
+ throw new BookConfigError(
1828
+ `defineBookConfig corpus preset ${JSON.stringify(corpus.preset)} does not match the composed Style preset ${JSON.stringify(profile)}. One preset applies to the whole corpus.`
1829
+ );
1830
+ }
1831
+ const incompatible = CORPUS_OWNED_ROUTE_FIELDS.filter(
1832
+ (field) => Object.prototype.hasOwnProperty.call(opts, field)
1833
+ );
1834
+ if (incompatible.length > 0) {
1835
+ throw new BookConfigError(
1836
+ `Corpus mode owns ${incompatible.join(", ")}; remove the explicit ${incompatible.length === 1 ? "override" : "overrides"} so injected routes and navigation agree.`
1837
+ );
1838
+ }
1839
+ const siblingKeys = new Set(Object.keys(opts.siblingBooks ?? {}));
1840
+ const duplicateOwner = corpus.books.find((book) => siblingKeys.has(book.id));
1841
+ if (duplicateOwner) {
1842
+ throw new BookConfigError(
1843
+ `Book id ${JSON.stringify(duplicateOwner.id)} exists in both corpus.books and siblingBooks. A key must be either local or externally deployed, never both.`
1844
+ );
1845
+ }
1846
+ }
1437
1847
  const numberStyle = opts.numberStyle ?? composed.numberStyle ?? "shared";
1438
1848
  if (!NUMBER_STYLES.includes(numberStyle)) {
1439
1849
  throw new BookConfigError(
@@ -1507,6 +1917,7 @@ async function defineBookConfig(opts) {
1507
1917
  sitemap(sitemapOptions),
1508
1918
  bookScaffoldIntegration({
1509
1919
  profile,
1920
+ corpus,
1510
1921
  numberStyle,
1511
1922
  routes: mergedRoutes,
1512
1923
  mdxComponentsModule,
@@ -1538,11 +1949,16 @@ async function defineBookConfig(opts) {
1538
1949
  siblingBooks: opts.siblingBooks,
1539
1950
  // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1540
1951
  examDomains: opts.examDomains,
1541
- // v4.26.0 (#80): book-aware nav route patterns (undefined → single-book defaults).
1542
- chapterRoute: opts.chapterRoute,
1543
- bookField: opts.bookField,
1544
- apparatusRoute: opts.apparatusRoute,
1545
- apparatusRoutes: opts.apparatusRoutes
1952
+ // v4.26.0 (#80): book-aware nav route patterns (undefined → single-book
1953
+ // defaults). Corpus mode owns these fields, so do not manufacture own
1954
+ // properties with undefined values when handing the validated manifest
1955
+ // to the independently exported integration.
1956
+ ...corpus ? {} : {
1957
+ chapterRoute: opts.chapterRoute,
1958
+ bookField: opts.bookField,
1959
+ apparatusRoute: opts.apparatusRoute,
1960
+ apparatusRoutes: opts.apparatusRoutes
1961
+ }
1546
1962
  }),
1547
1963
  ...mergedExtraIntegrations
1548
1964
  ];
@@ -1569,10 +1985,10 @@ async function defineBookConfig(opts) {
1569
1985
  };
1570
1986
  const {
1571
1987
  styles: _styles,
1988
+ corpus: _corpus,
1572
1989
  numberStyle: _numberStyle,
1573
1990
  site: _site,
1574
1991
  routes: _routes,
1575
- deploy: _deploy,
1576
1992
  mdxComponentsModule: _mdxComponentsModule,
1577
1993
  extraIntegrations: _extraIntegrations,
1578
1994
  extraStyles: _extraStyles,
@@ -1605,10 +2021,10 @@ async function defineBookConfig(opts) {
1605
2021
  ...rest
1606
2022
  } = opts;
1607
2023
  void _styles;
2024
+ void _corpus;
1608
2025
  void _numberStyle;
1609
2026
  void _site;
1610
2027
  void _routes;
1611
- void _deploy;
1612
2028
  void _mdxComponentsModule;
1613
2029
  void _extraIntegrations;
1614
2030
  void _extraStyles;
@@ -1727,6 +2143,54 @@ function assertEnumProp(value, allowed, ctx) {
1727
2143
  init_katex_macros();
1728
2144
 
1729
2145
  // src/lib/book-link.ts
2146
+ var CORPUS_BOOK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2147
+ function normalizedBase(baseUrl) {
2148
+ const inner = baseUrl.replace(/^\/+|\/+$/g, "");
2149
+ return inner.length === 0 ? "/" : `/${inner}/`;
2150
+ }
2151
+ function invalidLocalTarget(book, to, reason) {
2152
+ throw new Error(
2153
+ `<BookLink book="${book}" to=${JSON.stringify(to)}>: invalid local corpus target (${reason}).`
2154
+ );
2155
+ }
2156
+ function resolveCorpusBookHref(book, to, baseUrl = "/") {
2157
+ if (!CORPUS_BOOK_ID.test(book)) {
2158
+ throw new Error(`<BookLink book=${JSON.stringify(book)}>: invalid corpus book id.`);
2159
+ }
2160
+ if (typeof to !== "string" || to.trim().length === 0) {
2161
+ invalidLocalTarget(book, String(to), "target must be non-empty");
2162
+ }
2163
+ if (to !== to.trim()) invalidLocalTarget(book, to, "surrounding whitespace is not allowed");
2164
+ if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith("/") || to.includes("\\")) {
2165
+ invalidLocalTarget(book, to, "absolute URLs and paths are not allowed");
2166
+ }
2167
+ if (/[\u0000-\u001f\u007f]/.test(to)) {
2168
+ invalidLocalTarget(book, to, "control characters are not allowed");
2169
+ }
2170
+ const suffixIndex = [...[to.indexOf("?"), to.indexOf("#")]].filter((index) => index >= 0).reduce((minimum, index) => Math.min(minimum, index), to.length);
2171
+ const path = to.slice(0, suffixIndex);
2172
+ const suffix = to.slice(suffixIndex);
2173
+ if (path.length === 0) invalidLocalTarget(book, to, "query-only and fragment-only targets are not allowed");
2174
+ const segments = path.replace(/\/+$/, "").split("/");
2175
+ if (segments.some((segment) => segment.length === 0)) {
2176
+ invalidLocalTarget(book, to, "empty path segments are not allowed");
2177
+ }
2178
+ for (const segment of segments) {
2179
+ let decoded;
2180
+ try {
2181
+ decoded = decodeURIComponent(segment);
2182
+ } catch {
2183
+ invalidLocalTarget(book, to, "malformed percent encoding");
2184
+ }
2185
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
2186
+ invalidLocalTarget(book, to, "path traversal is not allowed");
2187
+ }
2188
+ }
2189
+ const base = normalizedBase(baseUrl);
2190
+ const local = segments.join("/");
2191
+ const destination = local === "chapters" ? `chapters/${book}` : local.startsWith("chapters/") ? `chapters/${book}/${local.slice("chapters/".length)}` : `${book}/${local}`;
2192
+ return `${base}${destination}/`.replace(/\/{2,}/g, "/") + suffix;
2193
+ }
1730
2194
  function entryUrl(entry) {
1731
2195
  if (typeof entry === "string") return entry;
1732
2196
  return entry?.url;
@@ -1922,7 +2386,7 @@ function buildExamManifest(entries) {
1922
2386
  options: (e.data.options ?? []).map((o) => ({ id: o.id, correct: o.correct === true }))
1923
2387
  }));
1924
2388
  }
1925
- function deriveDomainRouting(entries, baseUrl = "/") {
2389
+ function deriveDomainRouting(entries, baseUrl = "/", bookId) {
1926
2390
  const base = baseUrl.replace(/\/?$/, "/");
1927
2391
  const out = {};
1928
2392
  const seen = /* @__PURE__ */ new Set();
@@ -1933,7 +2397,7 @@ function deriveDomainRouting(entries, baseUrl = "/") {
1933
2397
  seen.add(key);
1934
2398
  (out[e.data.domain] ??= []).push({
1935
2399
  label,
1936
- href: typeof e.data.chapter === "string" ? `${base}chapters/${e.data.chapter}/` : null
2400
+ href: typeof e.data.chapter === "string" ? `${base}chapters/${bookId ? `${bookId}/` : ""}${e.data.chapter}/` : null
1937
2401
  });
1938
2402
  }
1939
2403
  return out;
@@ -1984,28 +2448,23 @@ function selectPartExercises(chapters, byChapter, part) {
1984
2448
  // src/styles/built-in.ts
1985
2449
  var academicStyle = defineStyle({
1986
2450
  name: "academic",
1987
- preset: "academic",
1988
- deploy: "workers"
2451
+ preset: "academic"
1989
2452
  });
1990
2453
  var toolsStyle = defineStyle({
1991
2454
  name: "tools",
1992
- preset: "tools",
1993
- deploy: "workers"
2455
+ preset: "tools"
1994
2456
  });
1995
2457
  var minimalStyle = defineStyle({
1996
2458
  name: "minimal",
1997
- preset: "minimal",
1998
- deploy: "workers"
2459
+ preset: "minimal"
1999
2460
  });
2000
2461
  var courseNotesStyle = defineStyle({
2001
2462
  name: "course-notes",
2002
- preset: "course-notes",
2003
- deploy: "pages"
2463
+ preset: "course-notes"
2004
2464
  });
2005
2465
  var researchPortfolioStyle = defineStyle({
2006
2466
  name: "research-portfolio",
2007
2467
  preset: "research-portfolio",
2008
- deploy: "pages",
2009
2468
  routes: { frontmatter: { enabled: true, prefix: "frontmatter" } }
2010
2469
  });
2011
2470
  var BUILTIN_STYLES = {
@@ -2027,9 +2486,12 @@ export {
2027
2486
  BRANDON_PORTFOLIO_DEFAULT,
2028
2487
  BUILTIN_STYLES,
2029
2488
  BookConfigError,
2489
+ CORPUS_APPARATUS_ROUTES,
2490
+ CORPUS_APPARATUS_TOGGLE_BY_ROUTE,
2030
2491
  DEFAULT_GITHUB_BRANCH,
2031
2492
  KIND_LABEL,
2032
2493
  NUMBER_STYLES,
2494
+ RESERVED_CORPUS_BOOK_IDS,
2033
2495
  THEOREM_KINDS,
2034
2496
  UNKNOWN_PART_ORDINAL,
2035
2497
  academicChapterSchema,
@@ -2040,6 +2502,7 @@ export {
2040
2502
  academicParts,
2041
2503
  academicStyle,
2042
2504
  apparatusHref,
2505
+ assertBookCorpus,
2043
2506
  assertEnumProp,
2044
2507
  assertKnownDomain,
2045
2508
  baseNoSlash,
@@ -2057,9 +2520,15 @@ export {
2057
2520
  chapterStatus,
2058
2521
  citationBackstops,
2059
2522
  composeStyles,
2523
+ corpusApparatusRoutesForBook,
2524
+ corpusBookHasApparatusRoute,
2525
+ corpusBookIdFromPath,
2526
+ corpusBookIdOf,
2527
+ corpusCollectionEntryId,
2060
2528
  courseNotesChapterSchema,
2061
2529
  courseNotesStyle,
2062
2530
  defineBookConfig,
2531
+ defineBookCorpus,
2063
2532
  defineMdxComponents,
2064
2533
  defineProfile,
2065
2534
  defineStyle,
@@ -2068,6 +2537,7 @@ export {
2068
2537
  deriveObjectiveMap,
2069
2538
  distinctChaptersSorted,
2070
2539
  fallbackChaptersRenderer,
2540
+ filterCorpusEntries,
2071
2541
  freshnessLabel,
2072
2542
  getFreshness,
2073
2543
  glossarySchema,
@@ -2075,6 +2545,7 @@ export {
2075
2545
  groupByDomain,
2076
2546
  isCurrentChapter,
2077
2547
  layoutModes,
2548
+ localCorpusEntryId,
2078
2549
  minimalChapterSchema,
2079
2550
  minimalStyle,
2080
2551
  normalizeBase,
@@ -2095,12 +2566,15 @@ export {
2095
2566
  researchPortfolioChaptersRenderer,
2096
2567
  researchPortfolioStyle,
2097
2568
  resolveBookHref,
2569
+ resolveCorpusBook,
2570
+ resolveCorpusBookHref,
2098
2571
  resolveGithubRepo,
2099
2572
  resolvePreset,
2100
2573
  resolveProfile,
2101
2574
  resolveTheoremNumber,
2102
2575
  sampleExam,
2103
2576
  scoreExam,
2577
+ selectBookArtifact,
2104
2578
  selectPartExercises,
2105
2579
  shuffle,
2106
2580
  slugOf,