@brandon_m_behring/book-scaffold-astro 4.16.0 → 4.17.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.
package/CLAUDE.md CHANGED
@@ -104,6 +104,8 @@ Two callout families coexist. Authors import what they need.
104
104
 
105
105
  **Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
106
106
 
107
+ **Study-guide (v4.17.0+, #112; opt-in).** A schema-validated `questions` content collection drives an exam-prep "question bank". Author questions under `src/content/questions/**.{md,mdx}` — frontmatter `id` (unique, required) / `type` (`mcq`|`free`|`cloze`) / `chapter` / `domain` (+ optional `part`/`bloom_level`/`objective_id`/`difficulty`), MDX body = the stem. MCQ carries `options: [{ id, text, correct }]` (exactly one `correct: true`); free-response carries an `answer` (model answer). An MCQ must **not** set `answer` — its answer is the option marked `correct`, and explanations for any type go in a `<Rationale>` body block. Declare the per-book domain taxonomy in `defineBookConfig({ examDomains: ['…'] })` — a question whose `domain` isn't registered **throws at build** (fail-loud, like `<BookLink>`'s `siblingBooks`). Enable the static `/practice-exam` route with `defineBookConfig({ routes: { practiceExam: true } })` (renders the bank grouped by domain with answers behind a `<details>` reveal; `cloze` is reserved/render-deferred). `<ObjectiveMap />` renders the exam-domain → chapter coverage matrix auto-derived from the collection (no separate data file). `<Rationale>` is a collapsible answer/explanation marker for a question's MDX body. (Scoring + diagnostics + appendix + flashcards are later increments — see `docs/plans/active/study-guide-epic_*.md`.)
108
+
107
109
  Full reference in `recipes/04-component-library.md`.
108
110
 
109
111
  ### Theme-change event (v4.14.2)
@@ -168,6 +170,7 @@ For monorepo Astro projects (Astro project in subdir), prefix build + deploy com
168
170
  - Unknown `<XRef id>` — id not in `labels.json` (XRef silently renders `[?label]` otherwise)
169
171
  - Missing `<Figure src>` files under `public/`
170
172
  - Internal markdown links that don't resolve
173
+ - Study-guide questions (v4.17.0, #112) — a question whose frontmatter `domain` isn't in `examDomains`, and duplicate question `id`s
171
174
 
172
175
  See `recipes/09-validation.md` to extend.
173
176
 
@@ -0,0 +1,113 @@
1
+ ---
2
+ /**
3
+ * <ObjectiveMap> — exam-domain → chapter coverage matrix (v4.17.0, Tier 3 #117).
4
+ *
5
+ * The reader-facing twin of a CompTIA/Cisco objective-coverage table. AUTO-
6
+ * DERIVED from getCollection('questions'): for each configured exam domain
7
+ * (defineBookConfig({ examDomains })), it shows which chapters carry ≥1 question
8
+ * in that domain. There is no separate coverage data file — the matrix is
9
+ * computed from the question bank, so it cannot drift from reality (and it can't
10
+ * render at all unless the per-book examDomains taxonomy is populated and
11
+ * consistent, which is the cheapest proof that taxonomy pays off).
12
+ *
13
+ * A domain with no questions renders an honest "—" gap rather than being
14
+ * omitted, so the table never implies coverage the bank doesn't have (same
15
+ * coverage-honesty discipline as /convergence).
16
+ *
17
+ * Drop into a front-matter / intro page. Any profile. Twin-gated: with no
18
+ * questions collection, renders an honest note instead of touching the
19
+ * collection (which would throw when unregistered).
20
+ */
21
+ import bookConfig from 'virtual:book-scaffold/book-config';
22
+ import { getCollection } from 'astro:content';
23
+ import { deriveObjectiveMap, distinctChaptersSorted } from '../src/lib/questions';
24
+ import { assertKnownDomain } from '../src/lib/exam-domains';
25
+
26
+ interface Props {
27
+ /** Heading above the table. Defaults to "Exam objective coverage". */
28
+ title?: string;
29
+ }
30
+ const { title = 'Exam objective coverage' } = Astro.props;
31
+
32
+ // Presence-gate: only touch the collection when the directory holds files.
33
+ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
34
+ query: '?raw',
35
+ import: 'default',
36
+ eager: true,
37
+ });
38
+ const hasQuestions = Object.keys(questionModules).length > 0;
39
+
40
+ const entries = hasQuestions
41
+ ? await getCollection('questions', (e) => !e.data.draft)
42
+ : [];
43
+ // Fail loud (like /practice-exam): a question with an unregistered / typo'd
44
+ // domain must throw at build, not silently vanish from the coverage matrix.
45
+ for (const e of entries) {
46
+ assertKnownDomain(bookConfig.examDomains, e.data.domain, { id: e.data.id });
47
+ }
48
+ const coverage = deriveObjectiveMap(entries);
49
+ const domains = bookConfig.examDomains ?? [];
50
+ // Column set: chapters that carry ≥1 question, in numeric-aware order
51
+ // (distinctChaptersSorted — a bare .sort() would order "10" before "2").
52
+ const chapters = distinctChaptersSorted(entries);
53
+ ---
54
+ {domains.length === 0 ? (
55
+ <p class="objective-map-empty">
56
+ No <code>examDomains</code> configured. Declare them in
57
+ <code>defineBookConfig({'{'} examDomains: [&hellip;] {'}'})</code> to render the coverage map.
58
+ </p>
59
+ ) : chapters.length === 0 ? (
60
+ <p class="objective-map-empty">
61
+ No questions found under <code>src/content/questions/</code> yet — the coverage map
62
+ populates as you add questions tagged with these domains.
63
+ </p>
64
+ ) : (
65
+ <figure class="objective-map">
66
+ <figcaption class="objective-map-title">{title}</figcaption>
67
+ <table class="objective-map-table">
68
+ <thead>
69
+ <tr>
70
+ <th scope="col">Domain</th>
71
+ {chapters.map((c) => <th scope="col">{c}</th>)}
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ {domains.map((domain) => {
76
+ const covered = coverage.get(domain) ?? new Set();
77
+ const isGap = covered.size === 0;
78
+ return (
79
+ <tr class={isGap ? 'objective-map-gap' : undefined}>
80
+ <th scope="row">{domain}</th>
81
+ {chapters.map((c) => (
82
+ <td aria-label={covered.has(c) ? `${domain} covered in ${c}` : undefined}>
83
+ {covered.has(c) ? '✓' : '—'}
84
+ </td>
85
+ ))}
86
+ </tr>
87
+ );
88
+ })}
89
+ </tbody>
90
+ </table>
91
+ </figure>
92
+ )}
93
+
94
+ <style>
95
+ .objective-map { margin-block: 1.5rem; overflow-x: auto; }
96
+ .objective-map-title { font-weight: 600; margin-block-end: 0.5rem; }
97
+ .objective-map-empty { color: var(--color-text-muted, #6b7280); }
98
+ .objective-map-table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
99
+ .objective-map-table th,
100
+ .objective-map-table td {
101
+ border: 1px solid var(--color-border, #e5e7eb);
102
+ padding: 0.35rem 0.55rem;
103
+ text-align: center;
104
+ }
105
+ .objective-map-table th[scope='row'] {
106
+ text-align: start;
107
+ text-transform: capitalize;
108
+ white-space: nowrap;
109
+ }
110
+ .objective-map-table td { color: var(--color-accent, #4f46e5); }
111
+ .objective-map-gap th[scope='row'] { color: var(--color-text-muted, #6b7280); }
112
+ .objective-map-gap td { color: var(--color-text-muted, #6b7280); }
113
+ </style>
@@ -0,0 +1,38 @@
1
+ ---
2
+ /**
3
+ * <Rationale> — collapsible answer/explanation for a study-guide question
4
+ * (v4.17.0, Tier 3). Renders its slot inside a <details> so the answer stays
5
+ * hidden until the reader chooses to reveal it (Bjork desirable-difficulties:
6
+ * delayed feedback aids retention). Authored in a question's MDX body; the
7
+ * answer-rationale back-appendix (#114) will later hoist these into an appendix
8
+ * using this same marker — so a rich, cited rationale has a stable home now.
9
+ *
10
+ * Any profile. Register it in src/mdx-components.ts (defineMdxComponents) to use
11
+ * inside src/content/questions/**.mdx. Slot freely: prose, math, <Cite>, code.
12
+ */
13
+ interface Props {
14
+ /** Summary label for the reveal. Defaults to "Answer & rationale". */
15
+ title?: string;
16
+ }
17
+ const { title = 'Answer & rationale' } = Astro.props;
18
+ ---
19
+ <details class="question-rationale">
20
+ <summary>{title}</summary>
21
+ <div class="question-rationale-body"><slot /></div>
22
+ </details>
23
+
24
+ <style>
25
+ .question-rationale {
26
+ margin-block: 0.75rem;
27
+ border-inline-start: 3px solid var(--color-accent, #4f46e5);
28
+ padding-inline-start: 0.75rem;
29
+ }
30
+ .question-rationale > summary {
31
+ cursor: pointer;
32
+ font-weight: 600;
33
+ color: var(--color-accent, #4f46e5);
34
+ }
35
+ .question-rationale-body {
36
+ margin-block-start: 0.5rem;
37
+ }
38
+ </style>
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, r as academicParts, o as Style } from './types-BmBIV5qD.js';
3
- export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, m as Provenance, R as ResearchPortfolioChapter, n as RouteToggles, S as StatusBadge, p as StyleInput, T as ToolsChapter, V as VolatilityBadge, q as academicChapterSchema, s as changeKinds, t as changelogSchema, u as chapterStatus, v as citationBackstops, w as composeStyles, x as courseNotesChapterSchema, y as defineProfile, z as defineStyle, D as minimalChapterSchema, E as normalizeFrontmatterConfig, G as patternCategories, H as patternsSchema, I as provenanceObject, J as provenanceSchema, K as researchPortfolioChapterSchema, L as resolvePreset, N as resolveProfile, O as sourceTiers, Q as sourceTiersResearch, U as sourcesSchema, W as toolSlugs, X as toolsChapterSchema } from './types-BmBIV5qD.js';
2
+ import { d as BookConfigOptions, g as BookScaffoldIntegrationOptions, a5 as volatilityLevels, i as ChaptersRenderer, t as academicParts, Q as Question, q as Style } from './types-CMPuyZGP.js';
3
+ export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BloomLevel, c as BookConfigError, e as BookPreset, f as BookProfile, h as BookSchemasOptions, C as ChapterFor, j as CourseNotesChapter, F as FreshnessAffordance, k as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, l as PartialRouteToggles, m as ProfileDefinition, n as Provenance, o as QuestionType, R as ResearchPortfolioChapter, p as RouteToggles, S as StatusBadge, r as StyleInput, T as ToolsChapter, V as VolatilityBadge, s as academicChapterSchema, u as bloomLevels, v as changeKinds, w as changelogSchema, x as chapterStatus, y as citationBackstops, z as composeStyles, D as courseNotesChapterSchema, E as defineProfile, G as defineStyle, H as minimalChapterSchema, I as normalizeFrontmatterConfig, J as patternCategories, K as patternsSchema, L as provenanceObject, N as provenanceSchema, O as questionDifficulties, U as questionSchema, W as questionTypes, X as refineQuestion, Y as refinedQuestionSchema, Z as researchPortfolioChapterSchema, _ as resolvePreset, $ as resolveProfile, a0 as sourceTiers, a1 as sourceTiersResearch, a2 as sourcesSchema, a3 as toolSlugs, a4 as toolsChapterSchema } from './types-CMPuyZGP.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
@@ -337,6 +337,72 @@ declare function assertEnumProp<T extends string>(value: unknown, allowed: reado
337
337
  */
338
338
  declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
339
339
 
340
+ /**
341
+ * exam-domains — validate a question's `domain` against the consumer's closed
342
+ * `examDomains` taxonomy (Tier 3, #112).
343
+ *
344
+ * Exam domains are PER-BOOK (Cisco security domains ≠ CompTIA objectives ≠ a
345
+ * math syllabus's topics), so they can't be a hardcoded `z.enum` in the schema.
346
+ * The consumer declares them once — `defineBookConfig({ examDomains: [...] })`,
347
+ * threaded through the `virtual:book-scaffold/book-config` module — and a
348
+ * question whose `domain` is not in that list THROWS at build rather than
349
+ * silently mis-weighting a blueprint or dropping a row from the objective-map.
350
+ *
351
+ * Membership can't be a Zod invariant: `questionSchema` is constructed at
352
+ * package-load time, outside any consumer context, so it can't see
353
+ * `examDomains` (the same constraint that put `siblingBooks` validation in
354
+ * lib/book-link.ts rather than the schema). So Zod checks `domain` is a
355
+ * non-empty string; THIS runs at the route/build layer where the resolved
356
+ * config is available. Mirrors `resolveBookHref` (lib/book-link.ts).
357
+ */
358
+ declare function assertKnownDomain(examDomains: readonly string[] | null | undefined, domain: string, ctx: {
359
+ id: string;
360
+ }): string;
361
+
362
+ /**
363
+ * src/lib/questions-derive.ts — PURE grouping + objective-map derivation for the
364
+ * study-guide `questions` collection (Tier 3, #112).
365
+ *
366
+ * No `astro:content` import, so this is safe in the Node-loaded main entry and
367
+ * unit-tested directly from dist/ (tests/questions.test.mjs). The Astro-context
368
+ * `getCollection` wrapper lives in questions.ts — same split as
369
+ * chapter-sort.ts (pure) / chapters.ts (Astro wrapper).
370
+ */
371
+
372
+ /** Stable string label for a chapter ref (number or academic-style string). */
373
+ declare function chapterLabel(chapter: number | string): string;
374
+ /** Sort questions by chapter then id (stable, deterministic). Pure. */
375
+ declare function sortQuestions<T extends {
376
+ data: Pick<Question, 'chapter' | 'id'>;
377
+ }>(entries: readonly T[]): T[];
378
+ /** Group entries by `domain`, preserving input order within each bucket. */
379
+ declare function groupByDomain<T extends {
380
+ data: Pick<Question, 'domain'>;
381
+ }>(entries: readonly T[]): Map<string, T[]>;
382
+ /** Group entries by `chapter` (string label), preserving input order. */
383
+ declare function groupByChapter<T extends {
384
+ data: Pick<Question, 'chapter'>;
385
+ }>(entries: readonly T[]): Map<string, T[]>;
386
+ /**
387
+ * Domain → set of chapter labels that have ≥1 question in that domain. The data
388
+ * backbone of `<ObjectiveMap>` (#117): the coverage matrix is *derived* from the
389
+ * question bank, so it can't drift from a separately-maintained table — the
390
+ * cheapest proof the per-book `examDomains` taxonomy is populated + consistent.
391
+ */
392
+ declare function deriveObjectiveMap<T extends {
393
+ data: Pick<Question, 'domain' | 'chapter'>;
394
+ }>(entries: readonly T[]): Map<string, Set<string>>;
395
+ /**
396
+ * Distinct chapter labels across the entries, in numeric-aware order (numeric
397
+ * chapters before string slugs, each ordered) — the column set for
398
+ * `<ObjectiveMap>`. Dedupes by label, sorts the underlying chapter values via
399
+ * the same `chapterOrder` as `sortQuestions`, then labels. A bare `.sort()` on
400
+ * the labels would put "10" before "2" (the chapter-column sort bug).
401
+ */
402
+ declare function distinctChaptersSorted<T extends {
403
+ data: Pick<Question, 'chapter'>;
404
+ }>(entries: readonly T[]): string[];
405
+
340
406
  /**
341
407
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
342
408
  *
@@ -438,4 +504,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
438
504
  */
439
505
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
440
506
 
441
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, KIND_LABEL, type ResolvedTheoremLabel, Style, THEOREM_KINDS, type TheoremKind, type TheoremLabelProps, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, assertEnumProp, bookScaffoldIntegration, buildGithubUrl, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, originUrlFromGitConfig, parseRepoSlug, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, theoremLabel, toolsChaptersRenderer, toolsStyle, volatilityLevels };
507
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, KIND_LABEL, Question, type ResolvedTheoremLabel, Style, THEOREM_KINDS, type TheoremKind, type TheoremLabelProps, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, assertEnumProp, assertKnownDomain, bookScaffoldIntegration, buildGithubUrl, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, minimalStyle, originUrlFromGitConfig, parseRepoSlug, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, sortQuestions, theoremLabel, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -358,6 +358,110 @@ var patternsSchema = z.object({
358
358
  category: z.enum(patternCategories).optional(),
359
359
  convergence_date: z.date().nullable().optional()
360
360
  });
361
+ var questionTypes = ["mcq", "free", "cloze"];
362
+ var bloomLevels = [
363
+ "remember",
364
+ "understand",
365
+ "apply",
366
+ "analyze",
367
+ "evaluate",
368
+ "create"
369
+ ];
370
+ var questionDifficulties = ["1", "2", "3", "4"];
371
+ var CHAPTER_SLUG = /^[a-z0-9][a-z0-9-]*$/;
372
+ var mcqOptionObject = z.object({
373
+ id: z.string().min(1),
374
+ // stable per-option key (e.g. 'a') — anchors + future scoring
375
+ correct: z.boolean().default(false),
376
+ text: z.string().optional()
377
+ // short option prose inline; long ones via body
378
+ }).strict();
379
+ var questionSchema = z.object({
380
+ // ----- identity -----
381
+ id: z.string().trim().min(1),
382
+ // EXPLICIT cross-ref key (#114 appendix / #116 cards); distinct from file-derived entry.id. trim → whitespace-only fails loud.
383
+ type: z.enum(questionTypes),
384
+ // ----- placement (every surface keys on these) -----
385
+ chapter: z.union([z.number().int().min(0).max(99), z.string().regex(CHAPTER_SLUG)]),
386
+ // number OR a kebab-slug string
387
+ part: z.union([z.number().int().min(0).max(20), z.string().regex(CHAPTER_SLUG)]).optional(),
388
+ domain: z.string().trim().min(1),
389
+ // value validated at route/build (assertKnownDomain), NOT here; trim → whitespace-only fails loud
390
+ // ----- pedagogy metadata (used-when-present) -----
391
+ bloom_level: z.enum(bloomLevels).optional(),
392
+ // #112/#113/#116
393
+ objective_id: z.string().min(1).optional(),
394
+ // #117 objective-map rows + #116 cards
395
+ difficulty: z.enum(questionDifficulties).optional(),
396
+ // #112 blueprint, #113 routing
397
+ // ----- type-specific payloads (validated per-type in refineQuestion) -----
398
+ options: z.array(mcqOptionObject).optional(),
399
+ // MCQ only
400
+ answer: z.string().optional(),
401
+ // free-response model answer (prose)
402
+ // ----- lifecycle (mirrors chapter schemas) -----
403
+ draft: z.boolean().default(false),
404
+ tags: z.array(z.string()).default([])
405
+ }).strict();
406
+ function refineQuestion(q, ctx) {
407
+ if (q.type === "mcq") {
408
+ if (q.answer !== void 0) {
409
+ ctx.addIssue({
410
+ code: "custom",
411
+ path: ["answer"],
412
+ message: `MCQ "${q.id}": the answer is the option marked correct: true \u2014 remove \`answer\` and put any explanation in a <Rationale> body block.`
413
+ });
414
+ }
415
+ if (!q.options || q.options.length < 2) {
416
+ ctx.addIssue({
417
+ code: "custom",
418
+ path: ["options"],
419
+ message: `MCQ "${q.id}" needs \u22652 options (got ${q.options?.length ?? 0}).`
420
+ });
421
+ return;
422
+ }
423
+ const correct = q.options.filter((o) => o.correct).length;
424
+ if (correct !== 1) {
425
+ ctx.addIssue({
426
+ code: "custom",
427
+ path: ["options"],
428
+ message: `MCQ "${q.id}" must have EXACTLY ONE option with correct: true (got ${correct}).`
429
+ });
430
+ }
431
+ const ids = q.options.map((o) => o.id);
432
+ if (new Set(ids).size !== ids.length) {
433
+ ctx.addIssue({
434
+ code: "custom",
435
+ path: ["options"],
436
+ message: `MCQ "${q.id}" has duplicate option ids (${ids.join(", ")}).`
437
+ });
438
+ }
439
+ } else if (q.type === "free") {
440
+ if (q.options) {
441
+ ctx.addIssue({
442
+ code: "custom",
443
+ path: ["options"],
444
+ message: `Free-response "${q.id}" must not define MCQ options.`
445
+ });
446
+ }
447
+ if (!q.answer || q.answer.trim() === "") {
448
+ ctx.addIssue({
449
+ code: "custom",
450
+ path: ["answer"],
451
+ message: `Free-response "${q.id}" needs an "answer" (model answer) for the appendix.`
452
+ });
453
+ }
454
+ } else if (q.type === "cloze") {
455
+ if (q.options) {
456
+ ctx.addIssue({
457
+ code: "custom",
458
+ path: ["options"],
459
+ message: `Cloze "${q.id}" must not define MCQ options.`
460
+ });
461
+ }
462
+ }
463
+ }
464
+ var refinedQuestionSchema = questionSchema.superRefine(refineQuestion);
361
465
 
362
466
  // src/lib/academic-parts.ts
363
467
  var ACADEMIC_PART_NAMES = {
@@ -449,6 +553,8 @@ var academicProfile = defineProfile({
449
553
  // v4.3.0 #70: opt-in per book; requires build-tips
450
554
  exercises: false,
451
555
  // v4.4.0: opt-in per book; requires build-exercises
556
+ practiceExam: false,
557
+ // v4.17.0 #112: opt-in per book; requires src/content/questions/
452
558
  landing: true
453
559
  // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
454
560
  },
@@ -572,6 +678,8 @@ var toolsProfile = defineProfile({
572
678
  // v4.3.0 #70: opt-in per book
573
679
  exercises: false,
574
680
  // v4.4.0: opt-in per book
681
+ practiceExam: false,
682
+ // v4.17.0 #112: opt-in per book; requires src/content/questions/
575
683
  landing: true
576
684
  // v4.5.0: auto-inject minimal root landing
577
685
  },
@@ -657,6 +765,8 @@ var minimalProfile = defineProfile({
657
765
  // v4.3.0 #70: opt-in per book
658
766
  exercises: false,
659
767
  // v4.4.0: opt-in per book
768
+ practiceExam: false,
769
+ // v4.17.0 #112: opt-in per book; requires src/content/questions/
660
770
  landing: true
661
771
  // v4.5.0: auto-inject minimal root landing
662
772
  },
@@ -682,6 +792,8 @@ var courseNotesProfile = defineProfile({
682
792
  // v4.3.0 #70: opt-in per book
683
793
  exercises: false,
684
794
  // v4.4.0: opt-in per book
795
+ practiceExam: false,
796
+ // v4.17.0 #112: opt-in per book; requires src/content/questions/
685
797
  landing: true
686
798
  // v4.5.0: auto-inject minimal root landing
687
799
  },
@@ -711,6 +823,8 @@ var researchPortfolioProfile = defineProfile({
711
823
  // v4.3.0 #70: opt-in per book
712
824
  exercises: false,
713
825
  // v4.4.0: opt-in per book
826
+ practiceExam: false,
827
+ // v4.17.0 #112: opt-in per book; requires src/content/questions/
714
828
  landing: true
715
829
  // v4.5.0: auto-inject minimal root landing
716
830
  },
@@ -991,6 +1105,9 @@ var ROUTE_REGISTRY = {
991
1105
  // v4.4.0: exercises index by chapter. Opt-in via routes.exercises: true;
992
1106
  // pairs with build-exercises script + <ExerciseSolutions auto /> mode.
993
1107
  exercises: { pattern: "/exercises", file: "exercises.astro" },
1108
+ // v4.17.0 (Tier 3, #112): static practice question-bank. Opt-in via
1109
+ // routes.practiceExam: true; reads the `questions` collection + examDomains.
1110
+ practiceExam: { pattern: "/practice-exam", file: "practice-exam.astro" },
994
1111
  // v4.5.0: minimal root landing page. Reads title/description/portfolio/routes
995
1112
  // from vite.define-injected import.meta.env vars. Default-on per profile;
996
1113
  // consumers with their own src/pages/index.astro override (file-system route
@@ -1030,7 +1147,9 @@ function bookScaffoldIntegration(opts) {
1030
1147
  githubRepo,
1031
1148
  githubBranch,
1032
1149
  // v4.16.0 (#96): sibling-book registry for cross-book <BookLink>.
1033
- siblingBooks
1150
+ siblingBooks,
1151
+ // v4.17.0 (#112): exam-domain taxonomy for the questions collection.
1152
+ examDomains
1034
1153
  } = opts;
1035
1154
  const def = PROFILES[profile];
1036
1155
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -1091,7 +1210,8 @@ function bookScaffoldIntegration(opts) {
1091
1210
  },
1092
1211
  githubRepo: resolvedGithubRepo,
1093
1212
  githubBranch: resolvedGithubBranch,
1094
- siblingBooks: siblingBooks ?? {}
1213
+ siblingBooks: siblingBooks ?? {},
1214
+ examDomains: examDomains ?? []
1095
1215
  })
1096
1216
  ],
1097
1217
  define: {
@@ -1229,7 +1349,9 @@ async function defineBookConfig(opts) {
1229
1349
  githubRepo: opts.githubRepo,
1230
1350
  githubBranch: opts.githubBranch,
1231
1351
  // v4.16.0 (#96): cross-book link registry.
1232
- siblingBooks: opts.siblingBooks
1352
+ siblingBooks: opts.siblingBooks,
1353
+ // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1354
+ examDomains: opts.examDomains
1233
1355
  }),
1234
1356
  ...mergedExtraIntegrations
1235
1357
  ];
@@ -1276,6 +1398,8 @@ async function defineBookConfig(opts) {
1276
1398
  githubBranch: _githubBranch,
1277
1399
  // v4.16.0: strip cross-book registry.
1278
1400
  siblingBooks: _siblingBooks,
1401
+ // v4.17.0: strip exam-domain taxonomy.
1402
+ examDomains: _examDomains,
1279
1403
  ...rest
1280
1404
  } = opts;
1281
1405
  void _styles;
@@ -1295,6 +1419,7 @@ async function defineBookConfig(opts) {
1295
1419
  void _githubRepo;
1296
1420
  void _githubBranch;
1297
1421
  void _siblingBooks;
1422
+ void _examDomains;
1298
1423
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1299
1424
  const restVite = rest.vite ?? {};
1300
1425
  const restSsr = restVite.ssr ?? {};
@@ -1397,6 +1522,72 @@ function resolveBookHref(siblingBooks, book, to) {
1397
1522
  return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
1398
1523
  }
1399
1524
 
1525
+ // src/lib/exam-domains.ts
1526
+ function assertKnownDomain(examDomains, domain, ctx) {
1527
+ if (!examDomains || !examDomains.includes(domain)) {
1528
+ const known = examDomains ?? [];
1529
+ throw new Error(
1530
+ `question "${ctx.id}": unknown exam domain "${domain}". Register it in defineBookConfig({ examDomains: [${JSON.stringify(domain)}, \u2026] })` + (known.length ? ` (known: ${known.join(", ")})` : " (no examDomains configured)") + "."
1531
+ );
1532
+ }
1533
+ return domain;
1534
+ }
1535
+
1536
+ // src/lib/questions-derive.ts
1537
+ function chapterLabel(chapter) {
1538
+ return String(chapter);
1539
+ }
1540
+ function chapterOrder(chapter) {
1541
+ return typeof chapter === "number" ? [0, String(chapter).padStart(6, "0")] : [1, chapter];
1542
+ }
1543
+ function sortQuestions(entries) {
1544
+ return [...entries].sort((a, b) => {
1545
+ const [ar, as] = chapterOrder(a.data.chapter);
1546
+ const [br, bs] = chapterOrder(b.data.chapter);
1547
+ if (ar !== br) return ar - br;
1548
+ if (as !== bs) return as < bs ? -1 : 1;
1549
+ return a.data.id.localeCompare(b.data.id);
1550
+ });
1551
+ }
1552
+ function groupByDomain(entries) {
1553
+ const out = /* @__PURE__ */ new Map();
1554
+ for (const e of entries) {
1555
+ const bucket = out.get(e.data.domain) ?? [];
1556
+ bucket.push(e);
1557
+ out.set(e.data.domain, bucket);
1558
+ }
1559
+ return out;
1560
+ }
1561
+ function groupByChapter(entries) {
1562
+ const out = /* @__PURE__ */ new Map();
1563
+ for (const e of entries) {
1564
+ const key = chapterLabel(e.data.chapter);
1565
+ const bucket = out.get(key) ?? [];
1566
+ bucket.push(e);
1567
+ out.set(key, bucket);
1568
+ }
1569
+ return out;
1570
+ }
1571
+ function deriveObjectiveMap(entries) {
1572
+ const out = /* @__PURE__ */ new Map();
1573
+ for (const e of entries) {
1574
+ const set = out.get(e.data.domain) ?? /* @__PURE__ */ new Set();
1575
+ set.add(chapterLabel(e.data.chapter));
1576
+ out.set(e.data.domain, set);
1577
+ }
1578
+ return out;
1579
+ }
1580
+ function distinctChaptersSorted(entries) {
1581
+ const byLabel = /* @__PURE__ */ new Map();
1582
+ for (const e of entries) byLabel.set(chapterLabel(e.data.chapter), e.data.chapter);
1583
+ return [...byLabel.values()].sort((a, b) => {
1584
+ const [ar, as] = chapterOrder(a);
1585
+ const [br, bs] = chapterOrder(b);
1586
+ if (ar !== br) return ar - br;
1587
+ return as < bs ? -1 : as > bs ? 1 : 0;
1588
+ }).map(chapterLabel);
1589
+ }
1590
+
1400
1591
  // src/styles/built-in.ts
1401
1592
  var academicStyle = defineStyle({
1402
1593
  name: "academic",
@@ -1455,10 +1646,13 @@ export {
1455
1646
  academicParts,
1456
1647
  academicStyle,
1457
1648
  assertEnumProp,
1649
+ assertKnownDomain,
1650
+ bloomLevels,
1458
1651
  bookScaffoldIntegration,
1459
1652
  buildGithubUrl,
1460
1653
  changeKinds,
1461
1654
  changelogSchema,
1655
+ chapterLabel,
1462
1656
  chapterSortKey,
1463
1657
  chapterStatus,
1464
1658
  citationBackstops,
@@ -1470,9 +1664,13 @@ export {
1470
1664
  defineProfile,
1471
1665
  defineStyle,
1472
1666
  defineTips,
1667
+ deriveObjectiveMap,
1668
+ distinctChaptersSorted,
1473
1669
  fallbackChaptersRenderer,
1474
1670
  freshnessLabel,
1475
1671
  getFreshness,
1672
+ groupByChapter,
1673
+ groupByDomain,
1476
1674
  minimalChapterSchema,
1477
1675
  minimalStyle,
1478
1676
  normalizeFrontmatterConfig,
@@ -1482,12 +1680,18 @@ export {
1482
1680
  patternsSchema,
1483
1681
  provenanceObject,
1484
1682
  provenanceSchema,
1683
+ questionDifficulties,
1684
+ questionSchema,
1685
+ questionTypes,
1686
+ refineQuestion,
1687
+ refinedQuestionSchema,
1485
1688
  researchPortfolioChapterSchema,
1486
1689
  researchPortfolioStyle,
1487
1690
  resolveBookHref,
1488
1691
  resolveGithubRepo,
1489
1692
  resolvePreset,
1490
1693
  resolveProfile,
1694
+ sortQuestions,
1491
1695
  sourceTiers,
1492
1696
  sourceTiersResearch,
1493
1697
  sourcesSchema,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-BmBIV5qD.js';
2
+ import { h as BookSchemasOptions } from './types-CMPuyZGP.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5