@brandon_m_behring/book-scaffold-astro 4.17.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
@@ -96,15 +96,15 @@ Two callout families coexist. Authors import what they need.
96
96
 
97
97
  **Tools family** (`src/components/callouts/`, 8 components): `SkillBox`, `CaseStudy`, `ConceptBox`, `KeyIdea`, `TryThis`, `Recovery`, `Convergence`, `Divergence`.
98
98
 
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.
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>
@@ -10,29 +10,54 @@
10
10
  * (ssm-foundations ch1–11 + the LaTeX-env mental model pass `type=`). `name`
11
11
  * is canonical with `title=`/`label=` accepted. An absent or unknown kind
12
12
  * THROWS at build (see `src/lib/theorem-label`) — it never renders an empty
13
- * label. Pass `n` to number the environment.
13
+ * label.
14
14
  *
15
- * Auto-numbering note: the LaTeX preamble shares a counter between
16
- * theorem/proposition/lemma/corollary and gives definition/example/exercise/
17
- * remark their own; in MDX the author passes `n` explicitly when it matters.
15
+ * Auto-numbering (#126): when the theorem has an `id`, its number is read from
16
+ * `src/data/labels.json` the same map <XRef> resolves — so the heading number
17
+ * EQUALS every cross-reference to it by construction. No hand-passed `n=`, no
18
+ * drift; inserting a theorem renumbers the chapter from one counter
19
+ * (`book-scaffold build-labels`). Explicit `n=` stays a fallback for an un-id'd
20
+ * theorem (or before labels.json is built); when an id resolves, the labels.json
21
+ * number wins so the two surfaces can never disagree.
18
22
  *
19
23
  * Usage:
20
- * <Theorem kind="theorem" n="4.2" name="Stable continuous-time eigenvalues" id="thm-w4-stability">
21
- * For …
24
+ * <Theorem kind="theorem" name="Stable continuous-time eigenvalues" id="thm-w4-stability">
25
+ * For … // number auto-resolved from labels.json
22
26
  * </Theorem>
23
27
  *
24
28
  * <Theorem kind="definition" n="4.1" name="HiPPO-LegS">
25
- * The HiPPO-LegS state matrix …
29
+ * The HiPPO-LegS state matrix … // explicit n= (un-cross-referenced)
26
30
  * </Theorem>
27
31
  */
28
- import { theoremLabel, type TheoremLabelProps } from '../src/lib/theorem-label';
32
+ import { theoremLabel, resolveTheoremNumber, type TheoremLabelProps } from '../src/lib/theorem-label';
29
33
 
30
34
  interface Props extends TheoremLabelProps {
31
35
  id?: string;
32
36
  }
33
37
 
38
+ // #126: resolve THIS theorem's number from the same labels.json that <XRef>
39
+ // reads, so the heading number == the cross-reference display by construction.
40
+ // build-labels.mjs writes { href, display, number } keyed by id. Mirror XRef's
41
+ // project-root glob (Vite resolves `/` to the consumer root, not the package);
42
+ // a missing file → empty map → fall back to explicit n= (the same soft-degrade
43
+ // path as XRef — the validator catches unknown ids at CI).
44
+ type LabelEntry = { href: string; display: string; number?: string | null };
45
+ const labelsModules = import.meta.glob<{ default: Record<string, LabelEntry> }>(
46
+ '/src/data/labels.json',
47
+ { eager: true },
48
+ );
49
+ const labelsMap = (labelsModules['/src/data/labels.json']?.default ?? {}) as Record<
50
+ string,
51
+ LabelEntry
52
+ >;
53
+
34
54
  const { id } = Astro.props;
35
- const { kind, fullLabel } = theoremLabel(Astro.props);
55
+ // labels.json (by id) is the number source; explicit n= is the fallback when no
56
+ // id resolves. When both exist the shared source wins — that is what guarantees
57
+ // heading == xref (a stale n= can't reintroduce drift).
58
+ const entry = id ? labelsMap[id] : undefined;
59
+ const resolvedN = resolveTheoremNumber(entry, Astro.props.n);
60
+ const { kind, fullLabel } = theoremLabel({ ...Astro.props, n: resolvedN });
36
61
  ---
37
62
  <div class="theorem" data-kind={kind} id={id}>
38
63
  <span class="theorem-label">{fullLabel}.</span>
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
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
+ export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
4
5
  import 'astro/zod';
5
6
 
6
7
  /**
@@ -204,54 +205,6 @@ declare function academicPartOrdinal(part: string): number;
204
205
  */
205
206
  declare function academicPartHeading(part: string): string;
206
207
 
207
- /**
208
- * theorem-label — resolve a `<Theorem>` component's label from its props,
209
- * failing loud instead of rendering a silent empty label (#121).
210
- *
211
- * Vocabulary: `kind` is canonical; `type` is an accepted **legacy alias** (many
212
- * consumer books — ssm-foundations ch1–11 — and the LaTeX `\begin{<env>}`
213
- * mental model pass `type=`). Likewise `name` is canonical with `title`/`label`
214
- * accepted as aliases. Aliases keep existing content valid; they are synonyms,
215
- * not deprecations, so they neither warn nor throw.
216
- *
217
- * Failure mode: an absent or unrecognized kind THROWS a build-failing,
218
- * actionable error rather than computing `KIND_LABEL[undefined]` → a bare "."
219
- * (the v4.8–4.14 silent defect, live across 32+ ssm-foundations theorems). A
220
- * typo'd kind — silent before — now stops the build with the offending value.
221
- *
222
- * Extracted from the `Theorem.astro` frontmatter so the contract is unit-tested
223
- * in the pure `node --test` suite (see tests/theorem-label.test.mjs) — the same
224
- * single-source-of-truth move as `academic-parts.ts` (#95).
225
- */
226
- declare const THEOREM_KINDS: readonly ["theorem", "proposition", "lemma", "corollary", "definition", "example", "exercise", "remark", "proof"];
227
- type TheoremKind = (typeof THEOREM_KINDS)[number];
228
- declare const KIND_LABEL: Record<TheoremKind, string>;
229
- interface TheoremLabelProps {
230
- /** Canonical environment selector. */
231
- kind?: string;
232
- /** Legacy alias for `kind` (LaTeX-env mental model; ssm-foundations ch1–11). */
233
- type?: string;
234
- /** Explicit number, e.g. "4.2"; omit for an unnumbered environment. */
235
- n?: string;
236
- /** Canonical display name shown in parentheses. */
237
- name?: string;
238
- /** Legacy aliases for `name`. */
239
- title?: string;
240
- label?: string;
241
- }
242
- interface ResolvedTheoremLabel {
243
- /** The validated, canonical kind (for `data-kind`). */
244
- kind: TheoremKind;
245
- /** The composed "Kind N (Name)" label (never empty). */
246
- fullLabel: string;
247
- }
248
- /**
249
- * Resolve a `<Theorem>`'s `kind` (canonical or legacy `type=`) and compose its
250
- * full label. Throws — never returns an empty label — when the kind is absent
251
- * or not one of {@link THEOREM_KINDS}.
252
- */
253
- declare function theoremLabel(props: TheoremLabelProps): ResolvedTheoremLabel;
254
-
255
208
  /**
256
209
  * repo-url — resolve and build GitHub source links for CodeRef / CodeBlock.
257
210
  *
@@ -403,6 +356,111 @@ declare function distinctChaptersSorted<T extends {
403
356
  data: Pick<Question, 'chapter'>;
404
357
  }>(entries: readonly T[]): string[];
405
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
+
406
464
  /**
407
465
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
408
466
  *
@@ -504,4 +562,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
504
562
  */
505
563
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
506
564
 
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 };
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
@@ -1498,6 +1523,9 @@ function theoremLabel(props) {
1498
1523
  const fullLabel = name ? `${numbered} (${name})` : numbered;
1499
1524
  return { kind: raw, fullLabel };
1500
1525
  }
1526
+ function resolveTheoremNumber(entry, n) {
1527
+ return entry?.number ?? n;
1528
+ }
1501
1529
 
1502
1530
  // src/lib/assert-prop.ts
1503
1531
  function assertEnumProp(value, allowed, ctx) {
@@ -1588,6 +1616,77 @@ function distinctChaptersSorted(entries) {
1588
1616
  }).map(chapterLabel);
1589
1617
  }
1590
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
+
1591
1690
  // src/styles/built-in.ts
1592
1691
  var academicStyle = defineStyle({
1593
1692
  name: "academic",
@@ -1669,6 +1768,7 @@ export {
1669
1768
  fallbackChaptersRenderer,
1670
1769
  freshnessLabel,
1671
1770
  getFreshness,
1771
+ glossarySchema,
1672
1772
  groupByChapter,
1673
1773
  groupByDomain,
1674
1774
  minimalChapterSchema,
@@ -1691,6 +1791,11 @@ export {
1691
1791
  resolveGithubRepo,
1692
1792
  resolvePreset,
1693
1793
  resolveProfile,
1794
+ resolveTheoremNumber,
1795
+ sampleExam,
1796
+ scoreExam,
1797
+ selectPartExercises,
1798
+ shuffle,
1694
1799
  sortQuestions,
1695
1800
  sourceTiers,
1696
1801
  sourceTiersResearch,