@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 +2 -2
- package/components/Diagnostic.astro +56 -0
- package/components/PartReview.astro +81 -0
- package/components/Term.astro +33 -0
- package/dist/index.d.ts +108 -3
- package/dist/index.mjs +101 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +31 -0
- package/dist/types-DJgLQGP9.d.ts +1248 -0
- package/package.json +7 -3
- package/pages/glossary.astro +103 -0
- package/recipes/04-component-library.md +4 -1
- package/src/lib/exam-engine.ts +155 -0
- package/src/lib/part-review.ts +56 -0
- package/src/profile-kit.ts +8 -0
- package/src/profiles/academic.ts +1 -0
- package/src/profiles/course-notes.ts +1 -0
- package/src/profiles/minimal.ts +1 -0
- package/src/profiles/research-portfolio.ts +1 -0
- package/src/profiles/tools.ts +1 -0
- package/src/schemas.ts +22 -0
- package/styles/callouts.css +84 -0
- package/styles/tokens.css +1 -0
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,
|
|
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. (
|
|
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,
|
|
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,
|
|
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
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 {
|