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