@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/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-DgSlAew3.js';
2
+ import { i as BookSchemasOptions } from './types-D1QZgKMO.js';
3
3
  import 'astro';
4
4
  import './schemas-CKipJ5Ie.js';
5
5
  import 'astro/zod';
package/dist/schemas.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/schemas-entry.ts
2
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
2
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
3
3
  import { defineCollection } from "astro:content";
4
4
  import { glob, file } from "astro/loaders";
5
5
 
@@ -790,6 +790,17 @@ var BOOK_PROFILES = Object.keys(PROFILES);
790
790
  // src/types.ts
791
791
  import { existsSync, readFileSync } from "fs";
792
792
  var BOOK_PRESETS = BOOK_PROFILES;
793
+ var CORPUS_APPARATUS_ROUTES = [
794
+ "references",
795
+ "print",
796
+ "convergence",
797
+ "tips",
798
+ "exercises",
799
+ "practice-exam",
800
+ "glossary",
801
+ "flashcards",
802
+ "answers"
803
+ ];
793
804
  var BookConfigError = class extends Error {
794
805
  constructor(message) {
795
806
  super(message);
@@ -814,36 +825,230 @@ function readEnvFile(path = ".env") {
814
825
  return {};
815
826
  }
816
827
  }
817
- var PRESET_FALLBACK_WARNING = /* @__PURE__ */ Symbol.for("book-scaffold-astro:preset-fallback-warning");
818
828
  function resolvePreset(explicitPreset, explicitProfile) {
819
829
  let candidate = explicitPreset ?? explicitProfile ?? process.env.BOOK_PRESET ?? process.env.BOOK_PROFILE;
820
- let source = "default";
821
- if (explicitPreset || explicitProfile) source = "param";
822
- else if (process.env.BOOK_PRESET || process.env.BOOK_PROFILE) source = "env";
823
830
  if (!candidate) {
824
831
  const env = readEnvFile();
825
832
  const fromFile = env.BOOK_PRESET ?? env.BOOK_PROFILE;
826
833
  if (fromFile) {
827
834
  candidate = fromFile;
828
- source = "dotenv";
829
835
  }
830
836
  }
831
- candidate = candidate ?? "minimal";
837
+ if (!candidate) {
838
+ throw new BookConfigError(
839
+ `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.`
840
+ );
841
+ }
832
842
  if (!BOOK_PRESETS.includes(candidate)) {
833
843
  throw new BookConfigError(
834
844
  `preset must be one of ${BOOK_PRESETS.join(" | ")} (got ${JSON.stringify(candidate)})`
835
845
  );
836
846
  }
837
- if (source === "default") {
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
- );
847
+ return candidate;
848
+ }
849
+
850
+ // src/lib/corpus.ts
851
+ var BOOK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
852
+ var BOOK_CORPUS_BRAND = /* @__PURE__ */ Symbol.for(
853
+ "@brandon_m_behring/book-scaffold-astro/BookCorpus/v1"
854
+ );
855
+ var RESERVED_CORPUS_BOOK_IDS = Object.freeze([
856
+ "assets",
857
+ "chapters",
858
+ "search",
859
+ "questions",
860
+ "glossary",
861
+ "frontmatter",
862
+ "_astro",
863
+ "_og",
864
+ "pagefind"
865
+ ]);
866
+ var CORPUS_OWNED_ROUTE_FIELDS = Object.freeze([
867
+ "chapterRoute",
868
+ "bookField",
869
+ "apparatusRoute",
870
+ "apparatusRoutes"
871
+ ]);
872
+ var CORPUS_APPARATUS_TOGGLE_BY_ROUTE = Object.freeze({
873
+ references: "references",
874
+ print: "print",
875
+ convergence: "convergence",
876
+ tips: "tips",
877
+ exercises: "exercises",
878
+ "practice-exam": "practiceExam",
879
+ glossary: "glossary",
880
+ flashcards: "flashcards",
881
+ answers: "answers"
882
+ });
883
+ var RESERVED = new Set(RESERVED_CORPUS_BOOK_IDS);
884
+ var APPARATUS = new Set(CORPUS_APPARATUS_ROUTES);
885
+ var RESERVED_CONTENT_ROOTS = Object.freeze({
886
+ questions: "src/content/questions/ is the dedicated questions collection root",
887
+ glossary: "src/content/glossary/ is the dedicated glossary collection root",
888
+ frontmatter: "src/content/frontmatter/ is the shared frontmatter collection root"
889
+ });
890
+ var BRANDED_CORPUS_KEYS = /* @__PURE__ */ new Set(["__bookCorpusVersion", "preset", "books"]);
891
+ var BOOK_KEYS = /* @__PURE__ */ new Set([
892
+ "id",
893
+ "title",
894
+ "subtitle",
895
+ "description",
896
+ "author",
897
+ "image",
898
+ "apparatus"
899
+ ]);
900
+ function objectRecord(value, label) {
901
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
902
+ throw new BookConfigError(`${label} must be an object.`);
903
+ }
904
+ return value;
905
+ }
906
+ function rejectUnknownKeys(value, allowed, label) {
907
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
908
+ if (unknown.length > 0) {
909
+ throw new BookConfigError(
910
+ `${label} has unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`
911
+ );
912
+ }
913
+ }
914
+ function optionalNonBlankString(value, label) {
915
+ if (value === void 0) return void 0;
916
+ if (typeof value !== "string" || value.trim().length === 0) {
917
+ throw new BookConfigError(`${label} must be a non-blank string when provided.`);
918
+ }
919
+ return value;
920
+ }
921
+ function validateBook(value, index) {
922
+ const label = `defineBookCorpus books[${index}]`;
923
+ const input = objectRecord(value, label);
924
+ rejectUnknownKeys(input, BOOK_KEYS, label);
925
+ const id = optionalNonBlankString(input.id, `${label}.id`);
926
+ if (id === void 0 || !BOOK_ID.test(id)) {
927
+ throw new BookConfigError(
928
+ `${label}.id must match [a-z0-9]+(?:-[a-z0-9]+)* (got ${JSON.stringify(input.id)}).`
929
+ );
930
+ }
931
+ if (RESERVED.has(id)) {
932
+ const reason = RESERVED_CONTENT_ROOTS[id] ?? "the scaffold owns that route or asset namespace";
933
+ throw new BookConfigError(
934
+ `${label}.id ${JSON.stringify(id)} is reserved because ${reason}; choose a book-specific id.`
935
+ );
936
+ }
937
+ const title = optionalNonBlankString(input.title, `${label}.title`);
938
+ if (title === void 0) {
939
+ throw new BookConfigError(`${label}.title must be a non-blank string.`);
940
+ }
941
+ let apparatus;
942
+ if (input.apparatus !== void 0) {
943
+ if (!Array.isArray(input.apparatus)) {
944
+ throw new BookConfigError(`${label}.apparatus must be an array when provided.`);
945
+ }
946
+ const seen = /* @__PURE__ */ new Set();
947
+ const routes = [];
948
+ for (const route of input.apparatus) {
949
+ if (typeof route !== "string" || !APPARATUS.has(route)) {
950
+ throw new BookConfigError(
951
+ `${label}.apparatus contains ${JSON.stringify(route)}; expected a subset of ` + CORPUS_APPARATUS_ROUTES.join(" | ") + "."
952
+ );
953
+ }
954
+ if (seen.has(route)) {
955
+ throw new BookConfigError(`${label}.apparatus contains duplicate route ${JSON.stringify(route)}.`);
956
+ }
957
+ seen.add(route);
958
+ routes.push(route);
844
959
  }
960
+ apparatus = Object.freeze(routes);
961
+ }
962
+ const book = {
963
+ id,
964
+ title,
965
+ ...optionalField("subtitle", optionalNonBlankString(input.subtitle, `${label}.subtitle`)),
966
+ ...optionalField("description", optionalNonBlankString(input.description, `${label}.description`)),
967
+ ...optionalField("author", optionalNonBlankString(input.author, `${label}.author`)),
968
+ ...optionalField("image", optionalNonBlankString(input.image, `${label}.image`)),
969
+ ...apparatus === void 0 ? {} : { apparatus }
970
+ };
971
+ return Object.freeze(book);
972
+ }
973
+ function optionalField(key, value) {
974
+ return value === void 0 ? {} : { [key]: value };
975
+ }
976
+ function assertBookCorpus(value) {
977
+ const invalid = () => new BookConfigError(
978
+ "corpus must be created by defineBookCorpus() from this book-scaffold major."
979
+ );
980
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
981
+ throw invalid();
982
+ }
983
+ const input = value;
984
+ if (input[BOOK_CORPUS_BRAND] !== true || input.__bookCorpusVersion !== 1 || !Object.isFrozen(value) || !Object.isFrozen(input.books)) {
985
+ throw invalid();
986
+ }
987
+ try {
988
+ rejectUnknownKeys(input, BRANDED_CORPUS_KEYS, "corpus");
989
+ if (typeof input.preset !== "string" || !BOOK_PRESETS.includes(input.preset)) {
990
+ throw invalid();
991
+ }
992
+ if (!Array.isArray(input.books) || input.books.length === 0) throw invalid();
993
+ const seen = /* @__PURE__ */ new Set();
994
+ for (const [index, rawBook] of input.books.entries()) {
995
+ if (!Object.isFrozen(rawBook)) throw invalid();
996
+ const book = validateBook(rawBook, index);
997
+ if (seen.has(book.id)) throw invalid();
998
+ seen.add(book.id);
999
+ if (book.apparatus !== void 0) {
1000
+ const original = rawBook.apparatus;
1001
+ if (!Object.isFrozen(original)) throw invalid();
1002
+ }
1003
+ }
1004
+ } catch (error) {
1005
+ if (error instanceof BookConfigError && error.message.startsWith("corpus must be created")) {
1006
+ throw error;
1007
+ }
1008
+ throw invalid();
845
1009
  }
846
- return candidate;
1010
+ }
1011
+ function corpusCollectionEntryId(corpus, entry, data, options = {}) {
1012
+ const label = options.label ?? "Content entry";
1013
+ const normalized = entry.replaceAll("\\", "/").replace(/^\.\//, "");
1014
+ const [bookId, ...localParts] = normalized.split("/");
1015
+ if (!bookId || !corpus.books.some((book) => book.id === bookId)) {
1016
+ throw new Error(`${label} ${JSON.stringify(entry)} is outside the registered corpus books.`);
1017
+ }
1018
+ if (Object.prototype.hasOwnProperty.call(data, "book") && data.book !== bookId) {
1019
+ throw new Error(
1020
+ `${label} ${JSON.stringify(entry)} has frontmatter book ${JSON.stringify(data.book)}, but its registered path owner is ${JSON.stringify(bookId)}.`
1021
+ );
1022
+ }
1023
+ const fileId = localParts.join("/").replace(/\.(?:md|mdx)$/i, "");
1024
+ const configured = options.slugField ? data[options.slugField] : void 0;
1025
+ const localId = typeof configured === "string" ? configured : fileId;
1026
+ const localSegments = localId.split("/");
1027
+ let hasInvalidEncodedSegment = false;
1028
+ for (const segment of localSegments) {
1029
+ try {
1030
+ const decoded = decodeURIComponent(segment);
1031
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {
1032
+ hasInvalidEncodedSegment = true;
1033
+ }
1034
+ } catch {
1035
+ hasInvalidEncodedSegment = true;
1036
+ }
1037
+ }
1038
+ if (localId.length === 0 || localId.startsWith("/") || localId.endsWith("/") || localId.includes("\\") || localSegments.some((part) => part === "." || part === ".." || part.length === 0) || hasInvalidEncodedSegment) {
1039
+ throw new Error(
1040
+ `${label} ${JSON.stringify(entry)} resolved invalid corpus id ${JSON.stringify(localId)}.`
1041
+ );
1042
+ }
1043
+ return `${bookId}/${localId}`;
1044
+ }
1045
+
1046
+ // src/lib/corpus-collateral.ts
1047
+ function corpusPatternsCollection(bookId) {
1048
+ return `corpus-patterns-${bookId}`;
1049
+ }
1050
+ function corpusChangelogCollection(bookId) {
1051
+ return `corpus-changelog-${bookId}`;
847
1052
  }
848
1053
 
849
1054
  // src/schemas-entry.ts
@@ -856,6 +1061,30 @@ function isYamlEmpty(path) {
856
1061
  return false;
857
1062
  }
858
1063
  }
1064
+ function assertCorpusConvergenceLayout(books) {
1065
+ const registered = new Set(books.map((book) => book.id));
1066
+ const legacy = existsSync2("./changelog/patterns.yaml") ? ["./changelog/patterns.yaml"] : [];
1067
+ if (existsSync2("./changelog/tools") && readdirSync("./changelog/tools", { withFileTypes: true }).some(
1068
+ (entry) => entry.isFile() && entry.name.endsWith(".yaml") && !(registered.has("tools") && entry.name === "patterns.yaml")
1069
+ )) {
1070
+ legacy.push("./changelog/tools/*.yaml");
1071
+ }
1072
+ if (legacy.length > 0) {
1073
+ throw new Error(
1074
+ `Corpus convergence collateral must be book-owned; move ${legacy.join(" and ")} to changelog/<book>/patterns.yaml and changelog/<book>/tools/.`
1075
+ );
1076
+ }
1077
+ if (!existsSync2("./changelog")) return;
1078
+ for (const entry of readdirSync("./changelog", { withFileTypes: true })) {
1079
+ if (!entry.isDirectory() || registered.has(entry.name)) continue;
1080
+ const base = `./changelog/${entry.name}`;
1081
+ if (existsSync2(`${base}/patterns.yaml`) || existsSync2(`${base}/tools`)) {
1082
+ throw new Error(
1083
+ `Corpus convergence collateral owner ${JSON.stringify(entry.name)} is not a registered book. Known books: ${books.map((book) => book.id).join(", ")}.`
1084
+ );
1085
+ }
1086
+ }
1087
+ }
859
1088
  function frontmatterCollection(schema, base = "./src/content/frontmatter") {
860
1089
  return defineCollection({
861
1090
  loader: glob({
@@ -866,14 +1095,33 @@ function frontmatterCollection(schema, base = "./src/content/frontmatter") {
866
1095
  });
867
1096
  }
868
1097
  function defineBookSchemas(opts = {}) {
869
- const profile = resolvePreset(opts.preset, opts.profile);
870
- const chaptersBase = opts.chaptersBase ?? "./src/content/chapters";
1098
+ const corpus = opts.corpus;
1099
+ if (corpus !== void 0) assertBookCorpus(corpus);
1100
+ const explicitProfile = opts.preset ?? opts.profile;
1101
+ if (corpus && explicitProfile !== void 0 && explicitProfile !== corpus.preset) {
1102
+ throw new Error(
1103
+ `defineBookSchemas preset ${JSON.stringify(explicitProfile)} does not match corpus preset ${JSON.stringify(corpus.preset)}.`
1104
+ );
1105
+ }
1106
+ const profile = corpus?.preset ?? resolvePreset(opts.preset, opts.profile);
1107
+ const chaptersBase = opts.chaptersBase ?? (corpus ? "./src/content" : "./src/content/chapters");
871
1108
  const schemaForProfile = profile === "academic" ? academicChapterSchema : profile === "course-notes" ? courseNotesChapterSchema : profile === "research-portfolio" ? researchPortfolioChapterSchema : profile === "minimal" ? minimalChapterSchema : toolsChapterSchema;
872
1109
  const chapters = defineCollection({
873
1110
  loader: glob({
874
1111
  // Exclude underscore-prefixed files (standard "hidden" convention).
875
- pattern: ["**/*.{md,mdx}", "!**/_*"],
876
- base: chaptersBase
1112
+ pattern: corpus ? [
1113
+ ...corpus.books.map((book) => `${book.id}/**/*.{md,mdx}`),
1114
+ "!**/_*"
1115
+ ] : ["**/*.{md,mdx}", "!**/_*"],
1116
+ base: chaptersBase,
1117
+ ...corpus ? {
1118
+ generateId: ({ entry, data }) => {
1119
+ return corpusCollectionEntryId(corpus, entry, data, {
1120
+ label: "Chapter",
1121
+ slugField: "slug"
1122
+ });
1123
+ }
1124
+ } : {}
877
1125
  }),
878
1126
  schema: schemaForProfile
879
1127
  });
@@ -886,23 +1134,49 @@ function defineBookSchemas(opts = {}) {
886
1134
  schema: sourcesSchema
887
1135
  });
888
1136
  }
889
- if (existsSync2("./changelog/tools")) {
890
- collections.changelog = defineCollection({
891
- loader: glob({ pattern: "*.yaml", base: "./changelog/tools" }),
892
- schema: changelogSchema
893
- });
894
- }
895
- if (existsSync2("./changelog/patterns.yaml")) {
896
- collections.patterns = defineCollection({
897
- loader: file("changelog/patterns.yaml"),
898
- schema: patternsSchema
899
- });
1137
+ if (corpus) {
1138
+ assertCorpusConvergenceLayout(corpus.books);
1139
+ for (const book of corpus.books) {
1140
+ const changelogBase = `./changelog/${book.id}/tools`;
1141
+ if (existsSync2(changelogBase)) {
1142
+ collections[corpusChangelogCollection(book.id)] = defineCollection({
1143
+ loader: glob({ pattern: "*.yaml", base: changelogBase }),
1144
+ schema: changelogSchema
1145
+ });
1146
+ }
1147
+ const patternsPath = `changelog/${book.id}/patterns.yaml`;
1148
+ if (existsSync2(`./${patternsPath}`)) {
1149
+ collections[corpusPatternsCollection(book.id)] = defineCollection({
1150
+ loader: file(patternsPath),
1151
+ schema: patternsSchema
1152
+ });
1153
+ }
1154
+ }
1155
+ } else {
1156
+ if (existsSync2("./changelog/tools")) {
1157
+ collections.changelog = defineCollection({
1158
+ loader: glob({ pattern: "*.yaml", base: "./changelog/tools" }),
1159
+ schema: changelogSchema
1160
+ });
1161
+ }
1162
+ if (existsSync2("./changelog/patterns.yaml")) {
1163
+ collections.patterns = defineCollection({
1164
+ loader: file("changelog/patterns.yaml"),
1165
+ schema: patternsSchema
1166
+ });
1167
+ }
900
1168
  }
901
1169
  if (existsSync2("./src/content/questions")) {
902
1170
  collections.questions = defineCollection({
903
1171
  loader: glob({
1172
+ // In corpus mode this intentionally scans the entire dedicated root.
1173
+ // `generateId` is the ownership boundary: a flat file or an unknown
1174
+ // first directory must fail instead of disappearing from the loader.
904
1175
  pattern: ["**/*.{md,mdx}", "!**/_*"],
905
- base: "./src/content/questions"
1176
+ base: "./src/content/questions",
1177
+ ...corpus ? {
1178
+ generateId: ({ entry, data }) => corpusCollectionEntryId(corpus, entry, data, { label: "Question" })
1179
+ } : {}
906
1180
  }),
907
1181
  schema: refinedQuestionSchema
908
1182
  });
@@ -910,8 +1184,13 @@ function defineBookSchemas(opts = {}) {
910
1184
  if (existsSync2("./src/content/glossary")) {
911
1185
  collections.glossary = defineCollection({
912
1186
  loader: glob({
1187
+ // As with questions, discover all non-hidden entries so malformed
1188
+ // corpus ownership is rejected rather than silently ignored.
913
1189
  pattern: ["**/*.{md,mdx}", "!**/_*"],
914
- base: "./src/content/glossary"
1190
+ base: "./src/content/glossary",
1191
+ ...corpus ? {
1192
+ generateId: ({ entry, data }) => corpusCollectionEntryId(corpus, entry, data, { label: "Glossary term" })
1193
+ } : {}
915
1194
  }),
916
1195
  schema: glossarySchema
917
1196
  });
@@ -302,8 +302,8 @@ type ChapterFor<P extends BookProfile> = P extends 'academic' ? AcademicChapter
302
302
  * - Version marker (`__styleVersion`) — future API-shape changes can
303
303
  * be detected at composition time without breaking existing styles.
304
304
  *
305
- * See `recipes/15-defining-styles.md` for usage patterns + MIGRATION-v3-to-v4.md
306
- * for migration from the v3 `preset:` shorthand.
305
+ * See `recipes/15-defining-styles.md` for usage patterns and the migration
306
+ * guides for version-specific breaking changes.
307
307
  */
308
308
 
309
309
  /** Internal brand symbol — prevents confusion between Style and plain config objects.
@@ -376,11 +376,6 @@ interface Style {
376
376
  * `remarkPlugins` and `rehypePlugins` arrays concat across the chain;
377
377
  * scalar fields override. */
378
378
  readonly markdown?: AstroUserConfig['markdown'];
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. */
383
- readonly deploy?: 'pages' | 'workers';
384
379
  /**
385
380
  * v4.26.2 (#149; style inheritance fixed in v4.26.3): release-state
386
381
  * banner. Shallow override (last defined wins); `false` suppresses a
@@ -417,7 +412,6 @@ type StyleInput = Omit<Style, typeof StyleBrand | '__styleVersion'>;
417
412
  * preset: 'research-portfolio',
418
413
  * site: 'https://guides.brandon-behring.dev/',
419
414
  * routes: { frontmatter: { enabled: true, prefix: '' } },
420
- * deploy: 'pages',
421
415
  * });
422
416
  *
423
417
  * // guides/foo/astro.config.mjs
@@ -447,7 +441,7 @@ declare function defineStyle(opts: StyleInput): Style;
447
441
  * - Top-level `defineBookConfig` fields beat any style (handled in config.ts)
448
442
  *
449
443
  * Per-key merge strategy:
450
- * - `preset`, `numberStyle`, `site`, `deploy`, `mdxComponentsModule`, `name`, `releaseStatus`
444
+ * - `preset`, `numberStyle`, `site`, `mdxComponentsModule`, `name`, `releaseStatus`
451
445
  * → shallow override (last defined wins; `releaseStatus: false` suppresses)
452
446
  * - `routes` → per-route spread (each route key independently overridable)
453
447
  * - `katexMacros` → per-macro spread (each macro key independently overridable)
@@ -487,6 +481,44 @@ declare const BOOK_PRESETS: readonly ("academic" | "tools" | "minimal" | "course
487
481
  /** Theorem-family numbering strategy used by build-labels and validate. */
488
482
  type NumberStyle = 'shared' | 'per-kind';
489
483
  declare const NUMBER_STYLES: readonly ["shared", "per-kind"];
484
+ /** Scaffold-owned book routes that may be enabled per corpus book (#80). */
485
+ declare const CORPUS_APPARATUS_ROUTES: readonly ["references", "print", "convergence", "tips", "exercises", "practice-exam", "glossary", "flashcards", "answers"];
486
+ type CorpusApparatusRoute = (typeof CORPUS_APPARATUS_ROUTES)[number];
487
+ /** Consumer-authored metadata for one book in a corpus manifest. */
488
+ interface CorpusBookInput {
489
+ id: string;
490
+ title: string;
491
+ subtitle?: string;
492
+ description?: string;
493
+ author?: string;
494
+ image?: string;
495
+ apparatus?: readonly CorpusApparatusRoute[];
496
+ }
497
+ /** Frozen, validated book metadata returned by `defineBookCorpus`. */
498
+ interface CorpusBook extends Omit<CorpusBookInput, 'apparatus'> {
499
+ readonly id: string;
500
+ readonly title: string;
501
+ readonly subtitle?: string;
502
+ readonly description?: string;
503
+ readonly author?: string;
504
+ readonly image?: string;
505
+ readonly apparatus?: readonly CorpusApparatusRoute[];
506
+ }
507
+ /** Input accepted by `defineBookCorpus`. One preset applies to the whole app. */
508
+ interface BookCorpusInput {
509
+ preset: BookPreset;
510
+ books: readonly CorpusBookInput[];
511
+ }
512
+ /**
513
+ * Branded, deeply frozen corpus registry shared by Astro and content config.
514
+ * The string version marker is intentionally serializable through Vite's
515
+ * virtual module and inspectable by plain-Node CLI tooling.
516
+ */
517
+ interface BookCorpus {
518
+ readonly __bookCorpusVersion: 1;
519
+ readonly preset: BookPreset;
520
+ readonly books: readonly CorpusBook[];
521
+ }
490
522
  /**
491
523
  * A sibling book with an optional vendored label index for cross-book
492
524
  * `<BookLink>` validation (#147).
@@ -573,6 +605,12 @@ interface BookConfigOptions {
573
605
  * });
574
606
  */
575
607
  styles?: readonly Style[];
608
+ /**
609
+ * v5.0.0 (#80): opt into one-app/one-build corpus mode. Define the value
610
+ * once with `defineBookCorpus` and pass the same object to
611
+ * `defineBookSchemas({ corpus })`.
612
+ */
613
+ corpus?: BookCorpus;
576
614
  /**
577
615
  * v4.27.0 (#175): theorem-family counter strategy. `shared` preserves the
578
616
  * amsthm-style sequence used through v4.26; `per-kind` gives theorem,
@@ -593,16 +631,6 @@ interface BookConfigOptions {
593
631
  * Closes #3 (v3.3.0).
594
632
  */
595
633
  routes?: PartialRouteToggles;
596
- /**
597
- * v4.0.0 (#50): RESERVED — accepted and style-chain-merged, but currently
598
- * has NO runtime effect (#180). The wrangler.toml shape is decided once, at
599
- * scaffold time, by `create-book` from the profile name (academic/tools/
600
- * minimal → Workers; course-notes/research-portfolio → Pages); setting this
601
- * field changes nothing afterward. Wire-or-remove is a v5 decision.
602
- * @deprecated Inert in v4; remove this option before v5. Deployment shape
603
- * is chosen by create-book or by the consumer's own deployment files.
604
- */
605
- deploy?: 'pages' | 'workers';
606
634
  /**
607
635
  * Optional explicit path to the consumer's MDX-components map (relative
608
636
  * to project root). When omitted, the toolkit auto-detects one of
@@ -819,12 +847,16 @@ interface BookSchemasOptions {
819
847
  preset?: BookPreset;
820
848
  /** Backward-compat alias for `preset`. */
821
849
  profile?: BookProfile;
822
- /** Defaults to `'./src/content/chapters'`. */
850
+ /** Defaults to `'./src/content/chapters'`, or `'./src/content'` in corpus mode. */
823
851
  chaptersBase?: string;
852
+ /** v5.0.0 (#80): the same branded manifest passed to `defineBookConfig`. */
853
+ corpus?: BookCorpus;
824
854
  }
825
855
  /** Options for the internal `bookScaffoldIntegration`. See PACKAGE_DESIGN.md §6. */
826
856
  interface BookScaffoldIntegrationOptions {
827
857
  profile: BookProfile;
858
+ /** v5.0.0 (#80): resolved one-app corpus manifest. */
859
+ corpus?: BookCorpus;
828
860
  /** Resolved theorem-family counter strategy exposed to package CLI tooling. */
829
861
  numberStyle?: NumberStyle;
830
862
  /**
@@ -903,6 +935,26 @@ interface BookScaffoldIntegrationOptions {
903
935
  declare class BookConfigError extends Error {
904
936
  constructor(message: string);
905
937
  }
938
+ /**
939
+ * Resolve a required preset from explicit args → env → .env.
940
+ * Throws when the selected value is invalid or every source is absent.
941
+ *
942
+ * v3.4.0 (closes #9): canonical resolver. Accepts both `preset` and `profile`
943
+ * (back-compat) explicit args; reads both `BOOK_PRESET` (preferred) and
944
+ * `BOOK_PROFILE` (alias) env vars; same for .env file lookups.
945
+ *
946
+ * Resolution order:
947
+ * 1. explicitPreset (from defineBookConfig({ preset: ... }))
948
+ * 2. explicitProfile (from defineBookConfig({ profile: ... }))
949
+ * 3. process.env.BOOK_PRESET
950
+ * 4. process.env.BOOK_PROFILE
951
+ * 5. .env BOOK_PRESET
952
+ * 6. .env BOOK_PROFILE
953
+ *
954
+ * v5.0.0 (#212): there is deliberately no implicit `minimal` fallback. A
955
+ * missing preset can otherwise make CI validate and render against a schema
956
+ * that the author never selected.
957
+ */
906
958
  declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: BookProfile): BookPreset;
907
959
  /**
908
960
  * Backward-compat alias. New code should use `resolvePreset()`.
@@ -911,4 +963,4 @@ declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: Bo
911
963
  */
912
964
  declare function resolveProfile(explicit?: BookProfile): BookProfile;
913
965
 
914
- 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 SiblingBookDescriptor as n, type SiblingBookEntry as o, type SiblingBooks as p, type StatusBadge as q, type Style as r, type StyleInput as s, composeStyles as t, defineProfile as u, defineStyle as v, normalizeFrontmatterConfig as w, resolvePreset as x, resolveProfile as y };
966
+ export { defineProfile as A, BOOK_PRESETS as B, CORPUS_APPARATUS_ROUTES as C, defineStyle as D, normalizeFrontmatterConfig as E, type FreshnessAffordance as F, resolvePreset as G, resolveProfile as H, 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 BookCorpus as d, type BookCorpusInput as e, type BookPreset as f, type BookProfile as g, type BookScaffoldIntegrationOptions as h, type BookSchemasOptions as i, type ChapterFor as j, type ChaptersRenderer as k, type CorpusApparatusRoute as l, type CorpusBook as m, type CorpusBookInput as n, type FrontmatterRouteConfig as o, type NumberStyle as p, type PartialRouteToggles as q, type ProfileDefinition as r, type RouteToggles as s, type SiblingBookDescriptor as t, type SiblingBookEntry as u, type SiblingBooks as v, type StatusBadge as w, type Style as x, type StyleInput as y, composeStyles as z };