@brandon_m_behring/book-scaffold-astro 4.31.0 → 5.1.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 (86) hide show
  1. package/CLAUDE.md +61 -2
  2. package/MIGRATION-v4-to-v5.md +183 -0
  3. package/README.md +64 -1
  4. package/bin/book-scaffold.mjs +4 -0
  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/Flashcards.tsx +19 -11
  11. package/components/NavContent.astro +60 -11
  12. package/components/ObjectiveMap.astro +14 -2
  13. package/components/PartReview.astro +32 -4
  14. package/components/PatternTimeline.astro +6 -2
  15. package/components/Rationale.astro +20 -3
  16. package/components/Sidebar.astro +11 -3
  17. package/components/SourceArchive.astro +33 -3
  18. package/components/Term.astro +18 -1
  19. package/components/Theorem.astro +15 -5
  20. package/components/TipsCard.astro +40 -3
  21. package/components/XRef.astro +14 -2
  22. package/dist/components/ExamRunner.d.ts +1 -1
  23. package/dist/components/Flashcards.d.ts +6 -1
  24. package/dist/components/Flashcards.mjs +14 -11
  25. package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
  26. package/dist/index.d.ts +76 -8
  27. package/dist/index.mjs +522 -44
  28. package/dist/schemas.d.ts +1 -1
  29. package/dist/schemas.mjs +311 -32
  30. package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
  31. package/layouts/Base.astro +38 -9
  32. package/layouts/Chapter.astro +10 -2
  33. package/package.json +5 -2
  34. package/pages/answers.astro +25 -6
  35. package/pages/book.astro +75 -0
  36. package/pages/chapters/[...slug].astro +39 -7
  37. package/pages/chapters-book.astro +17 -0
  38. package/pages/chapters.astro +87 -9
  39. package/pages/convergence.astro +40 -10
  40. package/pages/corpus-apparatus/answers.astro +15 -0
  41. package/pages/corpus-apparatus/convergence.astro +15 -0
  42. package/pages/corpus-apparatus/exercises.astro +15 -0
  43. package/pages/corpus-apparatus/flashcards.astro +15 -0
  44. package/pages/corpus-apparatus/glossary.astro +15 -0
  45. package/pages/corpus-apparatus/practice-exam.astro +15 -0
  46. package/pages/corpus-apparatus/print.astro +15 -0
  47. package/pages/corpus-apparatus/references.astro +15 -0
  48. package/pages/corpus-apparatus/tips.astro +15 -0
  49. package/pages/exercises.astro +18 -5
  50. package/pages/flashcards.astro +26 -5
  51. package/pages/glossary.astro +20 -3
  52. package/pages/index.astro +54 -1
  53. package/pages/practice-exam.astro +8 -2
  54. package/pages/print.astro +7 -2
  55. package/pages/references.astro +26 -6
  56. package/pages/search.astro +65 -4
  57. package/pages/tips.astro +17 -4
  58. package/recipes/09-validation.md +1 -1
  59. package/recipes/15-defining-styles.md +6 -6
  60. package/recipes/20-anki-export.md +15 -8
  61. package/recipes/21-multi-guide-single-app.md +293 -44
  62. package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
  63. package/recipes/25-qa-readiness.md +197 -0
  64. package/recipes/README.md +3 -2
  65. package/scripts/build-bib.mjs +113 -35
  66. package/scripts/build-exercises.mjs +101 -24
  67. package/scripts/build-labels.mjs +199 -194
  68. package/scripts/build-tips.mjs +95 -23
  69. package/scripts/corpus-tooling.mjs +268 -0
  70. package/scripts/init-qa.mjs +209 -0
  71. package/scripts/qa-core.mjs +1511 -0
  72. package/scripts/qa.mjs +293 -0
  73. package/scripts/resolve-book-config.mjs +138 -1
  74. package/scripts/validate-core.mjs +1675 -0
  75. package/scripts/validate.mjs +18 -916
  76. package/scripts/validation-artifacts.mjs +23 -0
  77. package/scripts/walk-mdx.mjs +16 -4
  78. package/src/lib/book-link.ts +72 -0
  79. package/src/lib/chapters.ts +10 -4
  80. package/src/lib/corpus-collateral.ts +9 -0
  81. package/src/lib/corpus.ts +458 -0
  82. package/src/lib/define-style.ts +28 -15
  83. package/src/lib/exam-manifest.ts +4 -1
  84. package/src/lib/patterns.ts +15 -6
  85. package/src/lib/questions.ts +8 -3
  86. package/src/types.ts +603 -0
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,32 @@ function bookScaffoldIntegration(opts) {
1282
1623
  ),
1283
1624
  frontmatter: fmEnabled
1284
1625
  };
1626
+ const enabledRouteNames = Object.freeze(
1627
+ Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name)
1628
+ );
1629
+ if (corpus) {
1630
+ for (const book of corpus.books) {
1631
+ for (const route of book.apparatus ?? []) {
1632
+ const toggle = CORPUS_APPARATUS_TOGGLE_BY_ROUTE[route];
1633
+ if (!enabledRoutes[toggle]) {
1634
+ throw new BookConfigError(
1635
+ `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.`
1636
+ );
1637
+ }
1638
+ }
1639
+ }
1640
+ }
1641
+ const inheritedCorpusApparatusRoutes = corpus ? CORPUS_APPARATUS_ROUTES.filter(
1642
+ (route) => enabledRoutes[CORPUS_APPARATUS_TOGGLE_BY_ROUTE[route]]
1643
+ ) : [];
1644
+ const corpusApparatusRoutes = corpus ? CORPUS_APPARATUS_ROUTES.filter(
1645
+ (route) => corpus.books.some(
1646
+ (book) => (book.apparatus ?? inheritedCorpusApparatusRoutes).includes(route)
1647
+ )
1648
+ ) : [];
1649
+ const corpusApparatusToggleKeys = new Set(
1650
+ Object.values(CORPUS_APPARATUS_TOGGLE_BY_ROUTE)
1651
+ );
1285
1652
  const integration = {
1286
1653
  name: "book-scaffold-astro",
1287
1654
  hooks: {
@@ -1296,24 +1663,39 @@ function bookScaffoldIntegration(opts) {
1296
1663
  const routesToInject = [];
1297
1664
  for (const [name, on] of Object.entries(enabledRoutes)) {
1298
1665
  if (!on) continue;
1666
+ if (corpus && corpusApparatusToggleKeys.has(name)) continue;
1299
1667
  routesToInject.push(name);
1300
- if (name === "chapters") routesToInject.push("chaptersSlug");
1668
+ if (name === "chapters") {
1669
+ routesToInject.push("chaptersSlug");
1670
+ if (corpus) routesToInject.push("corpusChapters");
1671
+ }
1672
+ if (name === "landing" && corpus) routesToInject.push("corpusBook");
1301
1673
  }
1302
1674
  for (const name of routesToInject) {
1303
1675
  const route = ROUTE_REGISTRY[name];
1304
1676
  if (!route) continue;
1305
- const pattern = name === "frontmatter" ? frontmatterPatternFromPrefix(fmPrefix) : route.pattern;
1677
+ let pattern = name === "frontmatter" ? frontmatterPatternFromPrefix(fmPrefix) : route.pattern;
1678
+ if (corpus && name === "chaptersSlug") {
1679
+ pattern = "/chapters/[book]/[...slug]";
1680
+ }
1306
1681
  injectRoute({
1307
1682
  pattern,
1308
1683
  entrypoint: resolvePage(route.file)
1309
1684
  });
1310
1685
  }
1686
+ if (corpus) {
1687
+ for (const route of corpusApparatusRoutes) {
1688
+ injectRoute({
1689
+ pattern: `/[book]/${route}`,
1690
+ entrypoint: resolvePage(`corpus-apparatus/${route}.astro`)
1691
+ });
1692
+ }
1693
+ }
1311
1694
  const consumerRoot = fileURLToPath(config.root);
1312
1695
  const resolvedMdxPath = resolveMdxComponentsPath(consumerRoot, mdxComponentsModule);
1313
1696
  const resolvedGithubRepo = resolveBookGithubRepo(githubRepo, consumerRoot);
1314
1697
  const resolvedGithubBranch = githubBranch ?? DEFAULT_GITHUB_BRANCH;
1315
1698
  const presetLiteral = JSON.stringify(profile);
1316
- const enabledRouteNames = Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name);
1317
1699
  updateConfig({
1318
1700
  vite: {
1319
1701
  plugins: [
@@ -1339,8 +1721,9 @@ function bookScaffoldIntegration(opts) {
1339
1721
  // reproduce the single-book `/chapters/<id>/` behavior exactly.
1340
1722
  chapterRoute: chapterRoute ?? "/chapters/:id/",
1341
1723
  bookField: bookField ?? "book",
1342
- apparatusRoute: apparatusRoute ?? "/:route/",
1343
- apparatusRoutes: apparatusRoutes ?? []
1724
+ apparatusRoute: corpus ? "/:book/:route/" : apparatusRoute ?? "/:route/",
1725
+ apparatusRoutes: corpus ? inheritedCorpusApparatusRoutes : apparatusRoutes ?? [],
1726
+ corpus: corpus ?? null
1344
1727
  })
1345
1728
  ],
1346
1729
  define: {
@@ -1383,8 +1766,13 @@ function bookScaffoldIntegration(opts) {
1383
1766
  preset: profile,
1384
1767
  numberStyle,
1385
1768
  siblingBooks: siblingBooks ?? {},
1386
- chapterRoute: chapterRoute ?? "/chapters/:id/",
1387
- bookField: bookField ?? "book"
1769
+ corpus: corpus ?? null,
1770
+ chapterRoute: corpus ? "/chapters/:id/" : chapterRoute ?? "/chapters/:id/",
1771
+ bookField: bookField ?? "book",
1772
+ apparatusRoute: corpus ? "/:book/:route/" : apparatusRoute ?? "/:route/",
1773
+ apparatusRoutes: corpus ? inheritedCorpusApparatusRoutes : apparatusRoutes ?? [],
1774
+ enabledRoutes: enabledRouteNames,
1775
+ frontmatterRoute: frontmatterPatternFromPrefix(fmPrefix)
1388
1776
  }),
1389
1777
  enumerable: false,
1390
1778
  configurable: false,
@@ -1428,12 +1816,38 @@ async function defineBookConfig(opts) {
1428
1816
  throw v3MigrationError(opts);
1429
1817
  }
1430
1818
  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)."
1819
+ throw new BookConfigError(
1820
+ "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
1821
  );
1434
1822
  }
1435
1823
  const composed = composeStyles(opts.styles ?? []);
1436
- const profile = resolvePreset(composed.preset);
1824
+ const corpus = opts.corpus;
1825
+ if (corpus !== void 0) {
1826
+ assertBookCorpus(corpus);
1827
+ }
1828
+ const profile = resolvePreset(composed.preset ?? corpus?.preset);
1829
+ if (corpus !== void 0) {
1830
+ if (corpus.preset !== profile) {
1831
+ throw new BookConfigError(
1832
+ `defineBookConfig corpus preset ${JSON.stringify(corpus.preset)} does not match the composed Style preset ${JSON.stringify(profile)}. One preset applies to the whole corpus.`
1833
+ );
1834
+ }
1835
+ const incompatible = CORPUS_OWNED_ROUTE_FIELDS.filter(
1836
+ (field) => Object.prototype.hasOwnProperty.call(opts, field)
1837
+ );
1838
+ if (incompatible.length > 0) {
1839
+ throw new BookConfigError(
1840
+ `Corpus mode owns ${incompatible.join(", ")}; remove the explicit ${incompatible.length === 1 ? "override" : "overrides"} so injected routes and navigation agree.`
1841
+ );
1842
+ }
1843
+ const siblingKeys = new Set(Object.keys(opts.siblingBooks ?? {}));
1844
+ const duplicateOwner = corpus.books.find((book) => siblingKeys.has(book.id));
1845
+ if (duplicateOwner) {
1846
+ throw new BookConfigError(
1847
+ `Book id ${JSON.stringify(duplicateOwner.id)} exists in both corpus.books and siblingBooks. A key must be either local or externally deployed, never both.`
1848
+ );
1849
+ }
1850
+ }
1437
1851
  const numberStyle = opts.numberStyle ?? composed.numberStyle ?? "shared";
1438
1852
  if (!NUMBER_STYLES.includes(numberStyle)) {
1439
1853
  throw new BookConfigError(
@@ -1507,6 +1921,7 @@ async function defineBookConfig(opts) {
1507
1921
  sitemap(sitemapOptions),
1508
1922
  bookScaffoldIntegration({
1509
1923
  profile,
1924
+ corpus,
1510
1925
  numberStyle,
1511
1926
  routes: mergedRoutes,
1512
1927
  mdxComponentsModule,
@@ -1538,11 +1953,16 @@ async function defineBookConfig(opts) {
1538
1953
  siblingBooks: opts.siblingBooks,
1539
1954
  // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1540
1955
  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
1956
+ // v4.26.0 (#80): book-aware nav route patterns (undefined → single-book
1957
+ // defaults). Corpus mode owns these fields, so do not manufacture own
1958
+ // properties with undefined values when handing the validated manifest
1959
+ // to the independently exported integration.
1960
+ ...corpus ? {} : {
1961
+ chapterRoute: opts.chapterRoute,
1962
+ bookField: opts.bookField,
1963
+ apparatusRoute: opts.apparatusRoute,
1964
+ apparatusRoutes: opts.apparatusRoutes
1965
+ }
1546
1966
  }),
1547
1967
  ...mergedExtraIntegrations
1548
1968
  ];
@@ -1569,10 +1989,10 @@ async function defineBookConfig(opts) {
1569
1989
  };
1570
1990
  const {
1571
1991
  styles: _styles,
1992
+ corpus: _corpus,
1572
1993
  numberStyle: _numberStyle,
1573
1994
  site: _site,
1574
1995
  routes: _routes,
1575
- deploy: _deploy,
1576
1996
  mdxComponentsModule: _mdxComponentsModule,
1577
1997
  extraIntegrations: _extraIntegrations,
1578
1998
  extraStyles: _extraStyles,
@@ -1605,10 +2025,10 @@ async function defineBookConfig(opts) {
1605
2025
  ...rest
1606
2026
  } = opts;
1607
2027
  void _styles;
2028
+ void _corpus;
1608
2029
  void _numberStyle;
1609
2030
  void _site;
1610
2031
  void _routes;
1611
- void _deploy;
1612
2032
  void _mdxComponentsModule;
1613
2033
  void _extraIntegrations;
1614
2034
  void _extraStyles;
@@ -1727,6 +2147,54 @@ function assertEnumProp(value, allowed, ctx) {
1727
2147
  init_katex_macros();
1728
2148
 
1729
2149
  // src/lib/book-link.ts
2150
+ var CORPUS_BOOK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2151
+ function normalizedBase(baseUrl) {
2152
+ const inner = baseUrl.replace(/^\/+|\/+$/g, "");
2153
+ return inner.length === 0 ? "/" : `/${inner}/`;
2154
+ }
2155
+ function invalidLocalTarget(book, to, reason) {
2156
+ throw new Error(
2157
+ `<BookLink book="${book}" to=${JSON.stringify(to)}>: invalid local corpus target (${reason}).`
2158
+ );
2159
+ }
2160
+ function resolveCorpusBookHref(book, to, baseUrl = "/") {
2161
+ if (!CORPUS_BOOK_ID.test(book)) {
2162
+ throw new Error(`<BookLink book=${JSON.stringify(book)}>: invalid corpus book id.`);
2163
+ }
2164
+ if (typeof to !== "string" || to.trim().length === 0) {
2165
+ invalidLocalTarget(book, String(to), "target must be non-empty");
2166
+ }
2167
+ if (to !== to.trim()) invalidLocalTarget(book, to, "surrounding whitespace is not allowed");
2168
+ if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith("/") || to.includes("\\")) {
2169
+ invalidLocalTarget(book, to, "absolute URLs and paths are not allowed");
2170
+ }
2171
+ if (/[\u0000-\u001f\u007f]/.test(to)) {
2172
+ invalidLocalTarget(book, to, "control characters are not allowed");
2173
+ }
2174
+ const suffixIndex = [...[to.indexOf("?"), to.indexOf("#")]].filter((index) => index >= 0).reduce((minimum, index) => Math.min(minimum, index), to.length);
2175
+ const path = to.slice(0, suffixIndex);
2176
+ const suffix = to.slice(suffixIndex);
2177
+ if (path.length === 0) invalidLocalTarget(book, to, "query-only and fragment-only targets are not allowed");
2178
+ const segments = path.replace(/\/+$/, "").split("/");
2179
+ if (segments.some((segment) => segment.length === 0)) {
2180
+ invalidLocalTarget(book, to, "empty path segments are not allowed");
2181
+ }
2182
+ for (const segment of segments) {
2183
+ let decoded;
2184
+ try {
2185
+ decoded = decodeURIComponent(segment);
2186
+ } catch {
2187
+ invalidLocalTarget(book, to, "malformed percent encoding");
2188
+ }
2189
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
2190
+ invalidLocalTarget(book, to, "path traversal is not allowed");
2191
+ }
2192
+ }
2193
+ const base = normalizedBase(baseUrl);
2194
+ const local = segments.join("/");
2195
+ const destination = local === "chapters" ? `chapters/${book}` : local.startsWith("chapters/") ? `chapters/${book}/${local.slice("chapters/".length)}` : `${book}/${local}`;
2196
+ return `${base}${destination}/`.replace(/\/{2,}/g, "/") + suffix;
2197
+ }
1730
2198
  function entryUrl(entry) {
1731
2199
  if (typeof entry === "string") return entry;
1732
2200
  return entry?.url;
@@ -1922,7 +2390,7 @@ function buildExamManifest(entries) {
1922
2390
  options: (e.data.options ?? []).map((o) => ({ id: o.id, correct: o.correct === true }))
1923
2391
  }));
1924
2392
  }
1925
- function deriveDomainRouting(entries, baseUrl = "/") {
2393
+ function deriveDomainRouting(entries, baseUrl = "/", bookId) {
1926
2394
  const base = baseUrl.replace(/\/?$/, "/");
1927
2395
  const out = {};
1928
2396
  const seen = /* @__PURE__ */ new Set();
@@ -1933,7 +2401,7 @@ function deriveDomainRouting(entries, baseUrl = "/") {
1933
2401
  seen.add(key);
1934
2402
  (out[e.data.domain] ??= []).push({
1935
2403
  label,
1936
- href: typeof e.data.chapter === "string" ? `${base}chapters/${e.data.chapter}/` : null
2404
+ href: typeof e.data.chapter === "string" ? `${base}chapters/${bookId ? `${bookId}/` : ""}${e.data.chapter}/` : null
1937
2405
  });
1938
2406
  }
1939
2407
  return out;
@@ -1984,28 +2452,23 @@ function selectPartExercises(chapters, byChapter, part) {
1984
2452
  // src/styles/built-in.ts
1985
2453
  var academicStyle = defineStyle({
1986
2454
  name: "academic",
1987
- preset: "academic",
1988
- deploy: "workers"
2455
+ preset: "academic"
1989
2456
  });
1990
2457
  var toolsStyle = defineStyle({
1991
2458
  name: "tools",
1992
- preset: "tools",
1993
- deploy: "workers"
2459
+ preset: "tools"
1994
2460
  });
1995
2461
  var minimalStyle = defineStyle({
1996
2462
  name: "minimal",
1997
- preset: "minimal",
1998
- deploy: "workers"
2463
+ preset: "minimal"
1999
2464
  });
2000
2465
  var courseNotesStyle = defineStyle({
2001
2466
  name: "course-notes",
2002
- preset: "course-notes",
2003
- deploy: "pages"
2467
+ preset: "course-notes"
2004
2468
  });
2005
2469
  var researchPortfolioStyle = defineStyle({
2006
2470
  name: "research-portfolio",
2007
2471
  preset: "research-portfolio",
2008
- deploy: "pages",
2009
2472
  routes: { frontmatter: { enabled: true, prefix: "frontmatter" } }
2010
2473
  });
2011
2474
  var BUILTIN_STYLES = {
@@ -2027,9 +2490,12 @@ export {
2027
2490
  BRANDON_PORTFOLIO_DEFAULT,
2028
2491
  BUILTIN_STYLES,
2029
2492
  BookConfigError,
2493
+ CORPUS_APPARATUS_ROUTES,
2494
+ CORPUS_APPARATUS_TOGGLE_BY_ROUTE,
2030
2495
  DEFAULT_GITHUB_BRANCH,
2031
2496
  KIND_LABEL,
2032
2497
  NUMBER_STYLES,
2498
+ RESERVED_CORPUS_BOOK_IDS,
2033
2499
  THEOREM_KINDS,
2034
2500
  UNKNOWN_PART_ORDINAL,
2035
2501
  academicChapterSchema,
@@ -2040,6 +2506,7 @@ export {
2040
2506
  academicParts,
2041
2507
  academicStyle,
2042
2508
  apparatusHref,
2509
+ assertBookCorpus,
2043
2510
  assertEnumProp,
2044
2511
  assertKnownDomain,
2045
2512
  baseNoSlash,
@@ -2057,9 +2524,15 @@ export {
2057
2524
  chapterStatus,
2058
2525
  citationBackstops,
2059
2526
  composeStyles,
2527
+ corpusApparatusRoutesForBook,
2528
+ corpusBookHasApparatusRoute,
2529
+ corpusBookIdFromPath,
2530
+ corpusBookIdOf,
2531
+ corpusCollectionEntryId,
2060
2532
  courseNotesChapterSchema,
2061
2533
  courseNotesStyle,
2062
2534
  defineBookConfig,
2535
+ defineBookCorpus,
2063
2536
  defineMdxComponents,
2064
2537
  defineProfile,
2065
2538
  defineStyle,
@@ -2068,6 +2541,7 @@ export {
2068
2541
  deriveObjectiveMap,
2069
2542
  distinctChaptersSorted,
2070
2543
  fallbackChaptersRenderer,
2544
+ filterCorpusEntries,
2071
2545
  freshnessLabel,
2072
2546
  getFreshness,
2073
2547
  glossarySchema,
@@ -2075,6 +2549,7 @@ export {
2075
2549
  groupByDomain,
2076
2550
  isCurrentChapter,
2077
2551
  layoutModes,
2552
+ localCorpusEntryId,
2078
2553
  minimalChapterSchema,
2079
2554
  minimalStyle,
2080
2555
  normalizeBase,
@@ -2095,12 +2570,15 @@ export {
2095
2570
  researchPortfolioChaptersRenderer,
2096
2571
  researchPortfolioStyle,
2097
2572
  resolveBookHref,
2573
+ resolveCorpusBook,
2574
+ resolveCorpusBookHref,
2098
2575
  resolveGithubRepo,
2099
2576
  resolvePreset,
2100
2577
  resolveProfile,
2101
2578
  resolveTheoremNumber,
2102
2579
  sampleExam,
2103
2580
  scoreExam,
2581
+ selectBookArtifact,
2104
2582
  selectPartExercises,
2105
2583
  shuffle,
2106
2584
  slugOf,