@brandon_m_behring/book-scaffold-astro 4.18.0 → 4.19.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
@@ -98,13 +98,13 @@ Two callout families coexist. Authors import what they need.
98
98
 
99
99
  **Academic family** (`src/components/callouts/`, 10 components): `NoteBox`, `ExampleBox`, `DynConnect`, `InsightBox`, `WarnBox`, `CounterBox`, `TipBox`, `OpenQuestion`, `PaperBox`, `ResultBox`. Plus `Theorem` (unified for theorem/proposition/lemma/corollary/definition/example/exercise/remark/proof). **Props (v4.14.3, #121):** `kind=` is canonical; `type=` is accepted as a legacy alias (likewise `title=`/`label=` alias `name=`). An absent or unknown kind **throws at build** (via `src/lib/theorem-label`) rather than rendering an empty label; `book-scaffold validate` flags a `<Theorem>` with neither `kind=` nor `type=` even earlier. **Numbering (v4.18.0, #126):** a theorem with an `id` auto-numbers from `labels.json` — the same index `<XRef>` reads — so the heading number equals every cross-reference to it by construction; explicit `n=` is a fallback for un-id'd theorems. `build-labels` indexes the kind-accurate word (`Proposition 8.1`, not a kind-blind `Theorem 8.1`) and throws on an unknown kind.
100
100
 
101
- **Pedagogy family** (v4.1.0+, any profile, 3 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop). Slot bullets/code freely; render at any preset.
101
+ **Pedagogy family** (v4.1.0+, any profile, 4 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop), `Diagnostic` (v4.19.0, #110; teal; pre-reading "Do I Know This Already?" DIKTA self-check — a slotted question list + a skip/skim/read routing rubric via `skimTo`, plus an optional collapsible answer key via `slot="answers"`). Slot bullets/code freely; render at any preset.
102
102
 
103
103
  **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
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`.)
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. `<Diagnostic>` (v4.19.0, #110) renders a per-chapter pre-reading "Do I Know This Already?" self-check (pedagogy family above; static `<details>` answer reveal). `<PartReview part={N} />` (v4.19.0, #111) aggregates a Part's `<Exercise>` items for interleaved review — reusing the `build-exercises` index + the chapters' `part` field (run `book-scaffold build-exercises` first; presence-gated otherwise). A **searchable glossary** (v4.19.0, #115): author terms under `src/content/glossary/**` (frontmatter `term` + an MDX-body definition), enable the static `/glossary` route via `defineBookConfig({ routes: { glossary: true } })`, and link inline with `<Term id="…">…</Term>` (→ `/glossary#term-<id>`). (Scoring, the rationale appendix, and flashcards are later increments — see `docs/plans/active/study-guide-epic_*.md`.)
108
108
 
109
109
  Full reference in `recipes/04-component-library.md`.
110
110
 
@@ -0,0 +1,56 @@
1
+ ---
2
+ /**
3
+ * Diagnostic — pre-reading "Do I Know This Already?" self-check (#110).
4
+ *
5
+ * Pearson / Cisco-Press DIKTA pattern: a short retrieval list the reader
6
+ * attempts BEFORE reading, plus a skip/skim/read routing rubric — visually
7
+ * distinct from the post-chapter <Exercise>/<Practice>. Static: the optional
8
+ * answer key reveals via native <details> (no JS; the scored engine is the
9
+ * separate #112 increment). Pedagogically this is pre-testing (Bjork): a
10
+ * retrieval attempt before study strengthens later recall even when the guess
11
+ * is wrong, so the reader should try before peeking.
12
+ *
13
+ * Slots:
14
+ * default — the numbered question list (the retrieval items)
15
+ * "answers" — OPTIONAL collapsible answer key (rendered only when provided)
16
+ *
17
+ * Family: teal (--callout-diagnostic, a blue+green blend) — a cool hue held
18
+ * distinct from the warm pedagogy callouts (Pitfall crimson, WorkedExample
19
+ * plum, YouWillLearn gold) and the plum Exercise/Practice.
20
+ */
21
+ interface Props {
22
+ /** Heading. Default "Do I know this already?". */
23
+ title?: string;
24
+ /** Section to skim ahead to when confident. Default "Exam essentials". */
25
+ skimTo?: string;
26
+ /** Full override of the routing sentence (else built from skimTo). */
27
+ routing?: string;
28
+ }
29
+ const {
30
+ title = 'Do I know this already?',
31
+ skimTo = 'Exam essentials',
32
+ routing,
33
+ } = Astro.props;
34
+ const hasAnswers = Astro.slots.has('answers');
35
+ ---
36
+ <aside class="callout callout-diagnostic" role="note">
37
+ <div class="callout-diagnostic-header">
38
+ <strong class="callout-title">{title}</strong>
39
+ <span class="callout-chip">Diagnostic</span>
40
+ </div>
41
+ <p class="callout-routing">
42
+ {routing ? routing : (
43
+ <Fragment>
44
+ Answer these confidently and you can skim ahead to <strong>{skimTo}</strong>;
45
+ if any is shaky, read closely — each is developed below.
46
+ </Fragment>
47
+ )}
48
+ </p>
49
+ <div class="callout-body"><slot /></div>
50
+ {hasAnswers && (
51
+ <details class="callout-diagnostic-answers">
52
+ <summary>Check your answers</summary>
53
+ <div class="callout-body"><slot name="answers" /></div>
54
+ </details>
55
+ )}
56
+ </aside>
@@ -0,0 +1,81 @@
1
+ ---
2
+ /**
3
+ * PartReview — aggregate a Part's <Exercise> items for interleaved review (#111).
4
+ *
5
+ * Cisco-Press / Pearson "Part Review": after a group of chapters (a Part — e.g.
6
+ * a cert domain) the reader gets that part's exercises collected in one place
7
+ * for spaced, interleaved practice. Reuses `src/data/exercises.json` (emitted by
8
+ * `book-scaffold build-exercises`) plus the chapters collection's `part` field —
9
+ * no new build step. Drop `<PartReview part={N} />` at the end of a part's last
10
+ * chapter or on a part-summary page.
11
+ *
12
+ * `part` accepts a number (tools/minimal/course-notes/research-portfolio
13
+ * profiles, where `part` is numeric) or a string (academic `part` enum).
14
+ *
15
+ * Presence-gated (twin-gate, like /exercises): with no exercises.json it renders
16
+ * a build hint rather than failing; with the index but no items in this part it
17
+ * says so honestly. `<Practice>` aggregation + a book-level `/review` route are
18
+ * later increments (see docs/plans/active/study-guide-epic_*.md).
19
+ */
20
+ import { getCollection } from 'astro:content';
21
+ import { selectPartExercises } from '../src/lib/part-review';
22
+
23
+ interface Props {
24
+ /** The part to aggregate. Number (tools/minimal/…) or string (academic enum). */
25
+ part: number | string;
26
+ /** Heading override. Default "Part {part} Review". */
27
+ title?: string;
28
+ }
29
+ const { part, title } = Astro.props;
30
+
31
+ // Reuse the build-exercises index (keyed by chapter slug). Project-root-relative
32
+ // glob (the tips.astro / exercises.astro lesson) so it resolves across consumers.
33
+ const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
34
+ '/src/data/exercises.json',
35
+ { eager: true },
36
+ );
37
+ const exEntry = exModules['/src/data/exercises.json'];
38
+ const byChapter = exEntry?.default ?? {};
39
+ const hasIndex = Boolean(exEntry);
40
+
41
+ // Filter this part's chapters, sort to book order, and join the index — pure +
42
+ // unit-tested in src/lib/part-review.ts (`part` is String-coerced, so a
43
+ // number-vs-string prop can't silently miss). This component just renders it.
44
+ const chapters = await getCollection('chapters', (e) => !e.data.draft);
45
+ const { groups, total } = selectPartExercises(chapters, byChapter, part);
46
+ const heading = title ?? `Part ${part} Review`;
47
+ ---
48
+ <aside class="part-review" aria-label={heading}>
49
+ <h2 class="part-review-title">{heading}</h2>
50
+
51
+ {!hasIndex && (
52
+ <p class="part-review-empty">
53
+ No <code>src/data/exercises.json</code> — run <code>npx book-scaffold build-exercises</code> to populate the review.
54
+ </p>
55
+ )}
56
+
57
+ {hasIndex && total === 0 && (
58
+ <p class="part-review-empty">No exercises found in this part's chapters yet.</p>
59
+ )}
60
+
61
+ {total > 0 && (
62
+ <Fragment>
63
+ <p class="part-review-summary">
64
+ {total} exercise{total === 1 ? '' : 's'} across {groups.length} chapter{groups.length === 1 ? '' : 's'} — interleaved review.
65
+ </p>
66
+ {groups.map((g) => (
67
+ <section class="part-review-chapter">
68
+ <h3 class="part-review-chapter-title"><a href={`/chapters/${g.chapter}/`}>{g.chapter}</a></h3>
69
+ <ol class="part-review-list">
70
+ {g.exercises.map((ex) => (
71
+ <li id={`review-${ex.id}`} class="part-review-item">
72
+ <span class="part-review-item-id">{ex.id}</span>
73
+ <span class="part-review-item-problem">{ex.problem}</span>
74
+ </li>
75
+ ))}
76
+ </ol>
77
+ </section>
78
+ ))}
79
+ </Fragment>
80
+ )}
81
+ </aside>
@@ -0,0 +1,33 @@
1
+ ---
2
+ /**
3
+ * Term — inline glossary cross-link (v4.19.0, #115).
4
+ *
5
+ * <Term id="agentic-loop">agent loop</Term> renders the slot text as a link to
6
+ * the glossary entry's anchor (/glossary#term-<id>). `id` is the glossary file's
7
+ * entry id (filename slug, e.g. `agentic-loop` for agentic-loop.mdx). Static
8
+ * link — no tooltip/definition preview in v1. Pairs with the `glossary`
9
+ * collection + the /glossary route (defineBookConfig({ routes: { glossary: true } })).
10
+ *
11
+ * Like <XRef>, this does not validate the target at render — a dangling id
12
+ * produces a link to a missing anchor, caught by review (a future `validate`
13
+ * check can resolve <Term id> against the glossary collection).
14
+ */
15
+ interface Props {
16
+ /** Glossary entry id (filename slug) — the /glossary#term-<id> anchor target. */
17
+ id: string;
18
+ }
19
+ const { id } = Astro.props;
20
+ ---
21
+ <a href={`/glossary#term-${id}`} class="term-link"><slot /></a>
22
+
23
+ <style>
24
+ .term-link {
25
+ color: inherit;
26
+ text-decoration: underline dotted;
27
+ text-decoration-color: var(--callout-info, #3B6FA0);
28
+ text-underline-offset: 0.15em;
29
+ }
30
+ .term-link:hover {
31
+ text-decoration-style: solid;
32
+ }
33
+ </style>
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
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';
2
+ import { d as BookConfigOptions, g as BookScaffoldIntegrationOptions, a7 as volatilityLevels, i as ChaptersRenderer, t as academicParts, Q as Question, q as Style } from './types-DJgLQGP9.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, G as GlossaryTerm, 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, H as defineStyle, I as glossarySchema, J as minimalChapterSchema, K as normalizeFrontmatterConfig, L as patternCategories, N as patternsSchema, O as provenanceObject, U as provenanceSchema, W as questionDifficulties, X as questionSchema, Y as questionTypes, Z as refineQuestion, _ as refinedQuestionSchema, $ as researchPortfolioChapterSchema, a0 as resolvePreset, a1 as resolveProfile, a2 as sourceTiers, a3 as sourceTiersResearch, a4 as sourcesSchema, a5 as toolSlugs, a6 as toolsChapterSchema } from './types-DJgLQGP9.js';
4
4
  export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
5
5
  import 'astro/zod';
6
6
 
@@ -356,6 +356,111 @@ declare function distinctChaptersSorted<T extends {
356
356
  data: Pick<Question, 'chapter'>;
357
357
  }>(entries: readonly T[]): string[];
358
358
 
359
+ /**
360
+ * exam-engine.ts — PURE sampling + scoring for the interactive practice exam
361
+ * (#112) and the cross-domain assessment test (#113).
362
+ *
363
+ * No DOM, no Preact: this is the testable core both the PracticeExam island and
364
+ * the AssessmentTest reuse, exercised in tests/exam-engine.test.mjs (node:test,
365
+ * no browser). The islands are thin UI over these functions — selection state +
366
+ * rendering only — so the *logic* (which questions, what score, which weak
367
+ * domains) is verified without a headless browser.
368
+ *
369
+ * Randomness is injected (`rng`, default Math.random) so the sampler is
370
+ * deterministic under test (pass a seeded rng) and varied in the browser.
371
+ */
372
+ /** The minimum a question contributes to sampling + scoring. The stem (MDX
373
+ * body) is rendered server-side, NOT carried here. */
374
+ interface ExamQuestion {
375
+ id: string;
376
+ domain: string;
377
+ /** MCQ options; exactly one carries `correct: true` (enforced by the schema). */
378
+ options: ReadonlyArray<{
379
+ id: string;
380
+ correct?: boolean;
381
+ }>;
382
+ }
383
+ interface ExamBlueprint {
384
+ /** Total questions to sample (clamped to pool size). */
385
+ count: number;
386
+ /** Optional per-domain quota (a Bloom/weight blueprint). Filled first, then
387
+ * topped up from the rest of the pool to reach `count`. */
388
+ perDomain?: Readonly<Record<string, number>>;
389
+ }
390
+ /**
391
+ * Fisher–Yates shuffle with an injectable rng (default Math.random). Pure: it
392
+ * copies the input rather than mutating it.
393
+ *
394
+ * `rng` MUST return a value in `[0, 1)` (Math.random's contract). `j` is
395
+ * additionally clamped to `i` so a defective rng returning exactly `1.0` can't
396
+ * index out of bounds (`Math.floor(1.0 * (i+1)) === i+1`), which would otherwise
397
+ * punch a hole into `a` and grow its length.
398
+ */
399
+ declare function shuffle<T>(arr: readonly T[], rng?: () => number): T[];
400
+ /**
401
+ * Sample a question form from the pool. With a `perDomain` blueprint, each
402
+ * domain's quota is filled first (clamped to availability), then the form is
403
+ * topped up from the remaining pool to reach `count`. Never returns duplicates;
404
+ * never more than the pool holds. The result is shuffled so domain-grouped
405
+ * picks aren't clustered.
406
+ *
407
+ * Caller contracts: `rng` returns `[0, 1)` (passed through to `shuffle`); and
408
+ * `Σ perDomain ≤ count` — quotas summing past `count` are honored in
409
+ * `Object.entries` order until the budget is spent, so later-listed domains are
410
+ * silently starved. Validating/clamping the blueprint sum is the caller's job;
411
+ * a guard can graduate here once a real consumer needs it.
412
+ */
413
+ declare function sampleExam(pool: readonly ExamQuestion[], blueprint: ExamBlueprint, rng?: () => number): ExamQuestion[];
414
+ interface DomainScore {
415
+ domain: string;
416
+ correct: number;
417
+ total: number;
418
+ }
419
+ interface ExamResult {
420
+ correct: number;
421
+ total: number;
422
+ /** Whole-percent score, 0–100 (0 when no questions). */
423
+ pct: number;
424
+ /** Per-domain rollup, in first-seen order. */
425
+ byDomain: DomainScore[];
426
+ /** Domains scoring below `passMark` — what the assessment routes the reader to. */
427
+ weakDomains: string[];
428
+ }
429
+ /**
430
+ * Score a set of answered questions. `answers` maps question id → chosen option
431
+ * id; a question is correct when the chosen option is the one flagged
432
+ * `correct`. Unanswered or wrongly-answered questions count as incorrect.
433
+ * Rolls up per domain and flags domains below `passMark` (default 0.7) as weak.
434
+ */
435
+ declare function scoreExam(questions: readonly ExamQuestion[], answers: Readonly<Record<string, string>>, passMark?: number): ExamResult;
436
+
437
+ /** One exercise as emitted by `book-scaffold build-exercises` (src/data/exercises.json). */
438
+ interface ReviewExercise {
439
+ id: string;
440
+ problem: string;
441
+ }
442
+ /** A chapter entry — structurally what `getCollection('chapters')` yields. */
443
+ interface ReviewChapter {
444
+ id: string;
445
+ data: Record<string, unknown>;
446
+ }
447
+ interface PartReviewGroup {
448
+ chapter: string;
449
+ exercises: ReviewExercise[];
450
+ }
451
+ interface PartReviewSelection {
452
+ groups: PartReviewGroup[];
453
+ total: number;
454
+ }
455
+ /**
456
+ * Select a Part's exercises, in book order, joining `chapters` (filtered by
457
+ * `part`) against the `byChapter` index (keyed by chapter id). `part` is matched
458
+ * with `String()` coercion so `<PartReview part="2" />` finds numeric-`part`
459
+ * chapters and vice versa (the fail-silent trap a strict `===` left). Chapters
460
+ * with no exercises in the index are skipped; `total` sums the rest.
461
+ */
462
+ declare function selectPartExercises(chapters: readonly ReviewChapter[], byChapter: Readonly<Record<string, ReviewExercise[]>>, part: number | string): PartReviewSelection;
463
+
359
464
  /**
360
465
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
361
466
  *
@@ -457,4 +562,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
457
562
  */
458
563
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
459
564
 
460
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, Question, Style, 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, toolsChaptersRenderer, toolsStyle, volatilityLevels };
565
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type DomainScore, type ExamBlueprint, type ExamQuestion, type ExamResult, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, Style, 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, sampleExam, scoreExam, selectPartExercises, shuffle, sortQuestions, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -462,6 +462,18 @@ function refineQuestion(q, ctx) {
462
462
  }
463
463
  }
464
464
  var refinedQuestionSchema = questionSchema.superRefine(refineQuestion);
465
+ var glossarySchema = z.object({
466
+ term: z.string().trim().min(1),
467
+ // display term (required)
468
+ aliases: z.array(z.string()).default([]),
469
+ // synonyms / alternate spellings (searchable)
470
+ domain: z.string().optional(),
471
+ // optional grouping (e.g. an exam domain)
472
+ see: z.array(z.string()).default([]),
473
+ // related glossary entry ids (cross-links)
474
+ tags: z.array(z.string()).default([]),
475
+ draft: z.boolean().default(false)
476
+ }).strict();
465
477
 
466
478
  // src/lib/academic-parts.ts
467
479
  var ACADEMIC_PART_NAMES = {
@@ -555,6 +567,8 @@ var academicProfile = defineProfile({
555
567
  // v4.4.0: opt-in per book; requires build-exercises
556
568
  practiceExam: false,
557
569
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
570
+ glossary: false,
571
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
558
572
  landing: true
559
573
  // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
560
574
  },
@@ -680,6 +694,8 @@ var toolsProfile = defineProfile({
680
694
  // v4.4.0: opt-in per book
681
695
  practiceExam: false,
682
696
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
697
+ glossary: false,
698
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
683
699
  landing: true
684
700
  // v4.5.0: auto-inject minimal root landing
685
701
  },
@@ -767,6 +783,8 @@ var minimalProfile = defineProfile({
767
783
  // v4.4.0: opt-in per book
768
784
  practiceExam: false,
769
785
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
786
+ glossary: false,
787
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
770
788
  landing: true
771
789
  // v4.5.0: auto-inject minimal root landing
772
790
  },
@@ -794,6 +812,8 @@ var courseNotesProfile = defineProfile({
794
812
  // v4.4.0: opt-in per book
795
813
  practiceExam: false,
796
814
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
815
+ glossary: false,
816
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
797
817
  landing: true
798
818
  // v4.5.0: auto-inject minimal root landing
799
819
  },
@@ -825,6 +845,8 @@ var researchPortfolioProfile = defineProfile({
825
845
  // v4.4.0: opt-in per book
826
846
  practiceExam: false,
827
847
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
848
+ glossary: false,
849
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
828
850
  landing: true
829
851
  // v4.5.0: auto-inject minimal root landing
830
852
  },
@@ -1108,6 +1130,9 @@ var ROUTE_REGISTRY = {
1108
1130
  // v4.17.0 (Tier 3, #112): static practice question-bank. Opt-in via
1109
1131
  // routes.practiceExam: true; reads the `questions` collection + examDomains.
1110
1132
  practiceExam: { pattern: "/practice-exam", file: "practice-exam.astro" },
1133
+ // v4.19.0 (#115): searchable key-terms glossary. Opt-in via routes.glossary:
1134
+ // true; reads the `glossary` collection (src/content/glossary/).
1135
+ glossary: { pattern: "/glossary", file: "glossary.astro" },
1111
1136
  // v4.5.0: minimal root landing page. Reads title/description/portfolio/routes
1112
1137
  // from vite.define-injected import.meta.env vars. Default-on per profile;
1113
1138
  // consumers with their own src/pages/index.astro override (file-system route
@@ -1591,6 +1616,77 @@ function distinctChaptersSorted(entries) {
1591
1616
  }).map(chapterLabel);
1592
1617
  }
1593
1618
 
1619
+ // src/lib/exam-engine.ts
1620
+ function shuffle(arr, rng = Math.random) {
1621
+ const a = arr.slice();
1622
+ for (let i = a.length - 1; i > 0; i--) {
1623
+ const j = Math.min(i, Math.floor(rng() * (i + 1)));
1624
+ const ai = a[i];
1625
+ a[i] = a[j];
1626
+ a[j] = ai;
1627
+ }
1628
+ return a;
1629
+ }
1630
+ function sampleExam(pool, blueprint, rng = Math.random) {
1631
+ const count = Math.max(0, Math.min(blueprint.count, pool.length));
1632
+ const picked = [];
1633
+ const used = /* @__PURE__ */ new Set();
1634
+ if (blueprint.perDomain) {
1635
+ for (const [domain, quota] of Object.entries(blueprint.perDomain)) {
1636
+ const inDomain = shuffle(
1637
+ pool.filter((q) => q.domain === domain && !used.has(q.id)),
1638
+ rng
1639
+ );
1640
+ for (const q of inDomain.slice(0, Math.max(0, quota))) {
1641
+ if (picked.length >= count) break;
1642
+ picked.push(q);
1643
+ used.add(q.id);
1644
+ }
1645
+ }
1646
+ }
1647
+ for (const q of shuffle(pool.filter((q2) => !used.has(q2.id)), rng)) {
1648
+ if (picked.length >= count) break;
1649
+ picked.push(q);
1650
+ used.add(q.id);
1651
+ }
1652
+ return shuffle(picked, rng);
1653
+ }
1654
+ function scoreExam(questions, answers, passMark = 0.7) {
1655
+ const order = [];
1656
+ const tally = /* @__PURE__ */ new Map();
1657
+ let correct = 0;
1658
+ for (const q of questions) {
1659
+ const chosen = answers[q.id];
1660
+ const right = chosen !== void 0 && q.options.some((o) => o.correct === true && o.id === chosen);
1661
+ if (right) correct++;
1662
+ if (!tally.has(q.domain)) {
1663
+ tally.set(q.domain, { correct: 0, total: 0 });
1664
+ order.push(q.domain);
1665
+ }
1666
+ const t = tally.get(q.domain);
1667
+ t.total++;
1668
+ if (right) t.correct++;
1669
+ }
1670
+ const total = questions.length;
1671
+ const byDomain = order.map((domain) => ({ domain, ...tally.get(domain) }));
1672
+ const weakDomains = byDomain.filter((d) => d.total > 0 && d.correct / d.total < passMark).map((d) => d.domain);
1673
+ return {
1674
+ correct,
1675
+ total,
1676
+ pct: total === 0 ? 0 : Math.round(correct / total * 100),
1677
+ byDomain,
1678
+ weakDomains
1679
+ };
1680
+ }
1681
+
1682
+ // src/lib/part-review.ts
1683
+ function selectPartExercises(chapters, byChapter, part) {
1684
+ const want = String(part);
1685
+ const groups = chapters.filter((c) => String(c.data.part ?? "") === want).sort((a, b) => chapterSortKey(a.data) - chapterSortKey(b.data)).map((c) => ({ chapter: c.id, exercises: byChapter[c.id] ?? [] })).filter((g) => g.exercises.length > 0);
1686
+ const total = groups.reduce((n, g) => n + g.exercises.length, 0);
1687
+ return { groups, total };
1688
+ }
1689
+
1594
1690
  // src/styles/built-in.ts
1595
1691
  var academicStyle = defineStyle({
1596
1692
  name: "academic",
@@ -1672,6 +1768,7 @@ export {
1672
1768
  fallbackChaptersRenderer,
1673
1769
  freshnessLabel,
1674
1770
  getFreshness,
1771
+ glossarySchema,
1675
1772
  groupByChapter,
1676
1773
  groupByDomain,
1677
1774
  minimalChapterSchema,
@@ -1695,6 +1792,10 @@ export {
1695
1792
  resolvePreset,
1696
1793
  resolveProfile,
1697
1794
  resolveTheoremNumber,
1795
+ sampleExam,
1796
+ scoreExam,
1797
+ selectPartExercises,
1798
+ shuffle,
1698
1799
  sortQuestions,
1699
1800
  sourceTiers,
1700
1801
  sourceTiersResearch,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { h as BookSchemasOptions } from './types-CMPuyZGP.js';
2
+ import { h as BookSchemasOptions } from './types-DJgLQGP9.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
package/dist/schemas.mjs CHANGED
@@ -346,6 +346,18 @@ function refineQuestion(q, ctx) {
346
346
  }
347
347
  }
348
348
  var refinedQuestionSchema = questionSchema.superRefine(refineQuestion);
349
+ var glossarySchema = z.object({
350
+ term: z.string().trim().min(1),
351
+ // display term (required)
352
+ aliases: z.array(z.string()).default([]),
353
+ // synonyms / alternate spellings (searchable)
354
+ domain: z.string().optional(),
355
+ // optional grouping (e.g. an exam domain)
356
+ see: z.array(z.string()).default([]),
357
+ // related glossary entry ids (cross-links)
358
+ tags: z.array(z.string()).default([]),
359
+ draft: z.boolean().default(false)
360
+ }).strict();
349
361
 
350
362
  // src/lib/academic-parts.ts
351
363
  var ACADEMIC_PART_NAMES = {
@@ -433,6 +445,8 @@ var academicProfile = defineProfile({
433
445
  // v4.4.0: opt-in per book; requires build-exercises
434
446
  practiceExam: false,
435
447
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
448
+ glossary: false,
449
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
436
450
  landing: true
437
451
  // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
438
452
  },
@@ -558,6 +572,8 @@ var toolsProfile = defineProfile({
558
572
  // v4.4.0: opt-in per book
559
573
  practiceExam: false,
560
574
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
575
+ glossary: false,
576
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
561
577
  landing: true
562
578
  // v4.5.0: auto-inject minimal root landing
563
579
  },
@@ -645,6 +661,8 @@ var minimalProfile = defineProfile({
645
661
  // v4.4.0: opt-in per book
646
662
  practiceExam: false,
647
663
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
664
+ glossary: false,
665
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
648
666
  landing: true
649
667
  // v4.5.0: auto-inject minimal root landing
650
668
  },
@@ -672,6 +690,8 @@ var courseNotesProfile = defineProfile({
672
690
  // v4.4.0: opt-in per book
673
691
  practiceExam: false,
674
692
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
693
+ glossary: false,
694
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
675
695
  landing: true
676
696
  // v4.5.0: auto-inject minimal root landing
677
697
  },
@@ -703,6 +723,8 @@ var researchPortfolioProfile = defineProfile({
703
723
  // v4.4.0: opt-in per book
704
724
  practiceExam: false,
705
725
  // v4.17.0 #112: opt-in per book; requires src/content/questions/
726
+ glossary: false,
727
+ // v4.19.0 #115: opt-in per book; requires src/content/glossary/
706
728
  landing: true
707
729
  // v4.5.0: auto-inject minimal root landing
708
730
  },
@@ -836,6 +858,15 @@ function defineBookSchemas(opts = {}) {
836
858
  schema: refinedQuestionSchema
837
859
  });
838
860
  }
861
+ if (existsSync2("./src/content/glossary")) {
862
+ collections.glossary = defineCollection({
863
+ loader: glob({
864
+ pattern: ["**/*.{md,mdx}", "!**/_*"],
865
+ base: "./src/content/glossary"
866
+ }),
867
+ schema: glossarySchema
868
+ });
869
+ }
839
870
  return { collections };
840
871
  }
841
872
  export {