@brandon_m_behring/book-scaffold-astro 4.15.0 → 4.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -100,10 +100,12 @@ Two callout families coexist. Authors import what they need.
100
100
 
101
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.
102
102
 
103
- **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
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`.)
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,32 @@
1
+ ---
2
+ /**
3
+ * BookLink — cross-book link to a sibling scaffold book (#96).
4
+ *
5
+ * Each scaffold book is a separate Astro app with its own `labels.json` and
6
+ * deploy origin, so `<XRef>` can't reach a sibling — a cross-book ref resolves
7
+ * against the wrong labels and dies. `<BookLink book="design" to="…">` resolves
8
+ * `book` against the consumer's `siblingBooks` registry
9
+ * (`defineBookConfig({ siblingBooks })`) — the single place to update when a
10
+ * sibling redeploys or extracts to its own repo. An unknown `book` throws at
11
+ * build (fail-loud, see `src/lib/book-link`), never a dead cross-origin link.
12
+ *
13
+ * `to` is a path within the sibling book (its router shape), e.g.
14
+ * `chapters/<slug>/#<id>`. Phase 1 builds the href; Phase 2 (deferred) will
15
+ * validate `to` against a vendored sibling `labels.json`.
16
+ *
17
+ * Usage:
18
+ * For design depth see
19
+ * <BookLink book="design" to="chapters/patterns/#layered">the layered pattern</BookLink>.
20
+ */
21
+ import bookConfig from 'virtual:book-scaffold/book-config';
22
+ import { resolveBookHref } from '../src/lib/book-link';
23
+
24
+ interface Props {
25
+ book: string;
26
+ to: string;
27
+ }
28
+
29
+ const { book, to } = Astro.props;
30
+ const href = resolveBookHref(bookConfig.siblingBooks, book, to);
31
+ ---
32
+ <a href={href} rel="external noopener" class="book-link"><slot /></a>
@@ -0,0 +1,113 @@
1
+ ---
2
+ /**
3
+ * <ObjectiveMap> — exam-domain → chapter coverage matrix (v4.17.0, Tier 3 #117).
4
+ *
5
+ * The reader-facing twin of a CompTIA/Cisco objective-coverage table. AUTO-
6
+ * DERIVED from getCollection('questions'): for each configured exam domain
7
+ * (defineBookConfig({ examDomains })), it shows which chapters carry ≥1 question
8
+ * in that domain. There is no separate coverage data file — the matrix is
9
+ * computed from the question bank, so it cannot drift from reality (and it can't
10
+ * render at all unless the per-book examDomains taxonomy is populated and
11
+ * consistent, which is the cheapest proof that taxonomy pays off).
12
+ *
13
+ * A domain with no questions renders an honest "—" gap rather than being
14
+ * omitted, so the table never implies coverage the bank doesn't have (same
15
+ * coverage-honesty discipline as /convergence).
16
+ *
17
+ * Drop into a front-matter / intro page. Any profile. Twin-gated: with no
18
+ * questions collection, renders an honest note instead of touching the
19
+ * collection (which would throw when unregistered).
20
+ */
21
+ import bookConfig from 'virtual:book-scaffold/book-config';
22
+ import { getCollection } from 'astro:content';
23
+ import { deriveObjectiveMap, distinctChaptersSorted } from '../src/lib/questions';
24
+ import { assertKnownDomain } from '../src/lib/exam-domains';
25
+
26
+ interface Props {
27
+ /** Heading above the table. Defaults to "Exam objective coverage". */
28
+ title?: string;
29
+ }
30
+ const { title = 'Exam objective coverage' } = Astro.props;
31
+
32
+ // Presence-gate: only touch the collection when the directory holds files.
33
+ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
34
+ query: '?raw',
35
+ import: 'default',
36
+ eager: true,
37
+ });
38
+ const hasQuestions = Object.keys(questionModules).length > 0;
39
+
40
+ const entries = hasQuestions
41
+ ? await getCollection('questions', (e) => !e.data.draft)
42
+ : [];
43
+ // Fail loud (like /practice-exam): a question with an unregistered / typo'd
44
+ // domain must throw at build, not silently vanish from the coverage matrix.
45
+ for (const e of entries) {
46
+ assertKnownDomain(bookConfig.examDomains, e.data.domain, { id: e.data.id });
47
+ }
48
+ const coverage = deriveObjectiveMap(entries);
49
+ const domains = bookConfig.examDomains ?? [];
50
+ // Column set: chapters that carry ≥1 question, in numeric-aware order
51
+ // (distinctChaptersSorted — a bare .sort() would order "10" before "2").
52
+ const chapters = distinctChaptersSorted(entries);
53
+ ---
54
+ {domains.length === 0 ? (
55
+ <p class="objective-map-empty">
56
+ No <code>examDomains</code> configured. Declare them in
57
+ <code>defineBookConfig({'{'} examDomains: [&hellip;] {'}'})</code> to render the coverage map.
58
+ </p>
59
+ ) : chapters.length === 0 ? (
60
+ <p class="objective-map-empty">
61
+ No questions found under <code>src/content/questions/</code> yet — the coverage map
62
+ populates as you add questions tagged with these domains.
63
+ </p>
64
+ ) : (
65
+ <figure class="objective-map">
66
+ <figcaption class="objective-map-title">{title}</figcaption>
67
+ <table class="objective-map-table">
68
+ <thead>
69
+ <tr>
70
+ <th scope="col">Domain</th>
71
+ {chapters.map((c) => <th scope="col">{c}</th>)}
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ {domains.map((domain) => {
76
+ const covered = coverage.get(domain) ?? new Set();
77
+ const isGap = covered.size === 0;
78
+ return (
79
+ <tr class={isGap ? 'objective-map-gap' : undefined}>
80
+ <th scope="row">{domain}</th>
81
+ {chapters.map((c) => (
82
+ <td aria-label={covered.has(c) ? `${domain} covered in ${c}` : undefined}>
83
+ {covered.has(c) ? '✓' : '—'}
84
+ </td>
85
+ ))}
86
+ </tr>
87
+ );
88
+ })}
89
+ </tbody>
90
+ </table>
91
+ </figure>
92
+ )}
93
+
94
+ <style>
95
+ .objective-map { margin-block: 1.5rem; overflow-x: auto; }
96
+ .objective-map-title { font-weight: 600; margin-block-end: 0.5rem; }
97
+ .objective-map-empty { color: var(--color-text-muted, #6b7280); }
98
+ .objective-map-table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
99
+ .objective-map-table th,
100
+ .objective-map-table td {
101
+ border: 1px solid var(--color-border, #e5e7eb);
102
+ padding: 0.35rem 0.55rem;
103
+ text-align: center;
104
+ }
105
+ .objective-map-table th[scope='row'] {
106
+ text-align: start;
107
+ text-transform: capitalize;
108
+ white-space: nowrap;
109
+ }
110
+ .objective-map-table td { color: var(--color-accent, #4f46e5); }
111
+ .objective-map-gap th[scope='row'] { color: var(--color-text-muted, #6b7280); }
112
+ .objective-map-gap td { color: var(--color-text-muted, #6b7280); }
113
+ </style>
@@ -0,0 +1,38 @@
1
+ ---
2
+ /**
3
+ * <Rationale> — collapsible answer/explanation for a study-guide question
4
+ * (v4.17.0, Tier 3). Renders its slot inside a <details> so the answer stays
5
+ * hidden until the reader chooses to reveal it (Bjork desirable-difficulties:
6
+ * delayed feedback aids retention). Authored in a question's MDX body; the
7
+ * answer-rationale back-appendix (#114) will later hoist these into an appendix
8
+ * using this same marker — so a rich, cited rationale has a stable home now.
9
+ *
10
+ * Any profile. Register it in src/mdx-components.ts (defineMdxComponents) to use
11
+ * inside src/content/questions/**.mdx. Slot freely: prose, math, <Cite>, code.
12
+ */
13
+ interface Props {
14
+ /** Summary label for the reveal. Defaults to "Answer & rationale". */
15
+ title?: string;
16
+ }
17
+ const { title = 'Answer & rationale' } = Astro.props;
18
+ ---
19
+ <details class="question-rationale">
20
+ <summary>{title}</summary>
21
+ <div class="question-rationale-body"><slot /></div>
22
+ </details>
23
+
24
+ <style>
25
+ .question-rationale {
26
+ margin-block: 0.75rem;
27
+ border-inline-start: 3px solid var(--color-accent, #4f46e5);
28
+ padding-inline-start: 0.75rem;
29
+ }
30
+ .question-rationale > summary {
31
+ cursor: pointer;
32
+ font-weight: 600;
33
+ color: var(--color-accent, #4f46e5);
34
+ }
35
+ .question-rationale-body {
36
+ margin-block-start: 0.5rem;
37
+ }
38
+ </style>
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, r as academicParts, o as Style } from './types-CjOseOSG.js';
3
- export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, m as Provenance, R as ResearchPortfolioChapter, n as RouteToggles, S as StatusBadge, p as StyleInput, T as ToolsChapter, V as VolatilityBadge, q as academicChapterSchema, s as changeKinds, t as changelogSchema, u as chapterStatus, v as citationBackstops, w as composeStyles, x as courseNotesChapterSchema, y as defineProfile, z as defineStyle, D as minimalChapterSchema, E as normalizeFrontmatterConfig, G as patternCategories, H as patternsSchema, I as provenanceObject, J as provenanceSchema, K as researchPortfolioChapterSchema, L as resolvePreset, N as resolveProfile, O as sourceTiers, Q as sourceTiersResearch, U as sourcesSchema, W as toolSlugs, X as toolsChapterSchema } from './types-CjOseOSG.js';
2
+ import { d as BookConfigOptions, g as BookScaffoldIntegrationOptions, a5 as volatilityLevels, i as ChaptersRenderer, t as academicParts, Q as Question, q as Style } from './types-CMPuyZGP.js';
3
+ export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BloomLevel, c as BookConfigError, e as BookPreset, f as BookProfile, h as BookSchemasOptions, C as ChapterFor, j as CourseNotesChapter, F as FreshnessAffordance, k as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, l as PartialRouteToggles, m as ProfileDefinition, n as Provenance, o as QuestionType, R as ResearchPortfolioChapter, p as RouteToggles, S as StatusBadge, r as StyleInput, T as ToolsChapter, V as VolatilityBadge, s as academicChapterSchema, u as bloomLevels, v as changeKinds, w as changelogSchema, x as chapterStatus, y as citationBackstops, z as composeStyles, D as courseNotesChapterSchema, E as defineProfile, G as defineStyle, H as minimalChapterSchema, I as normalizeFrontmatterConfig, J as patternCategories, K as patternsSchema, L as provenanceObject, N as provenanceSchema, O as questionDifficulties, U as questionSchema, W as questionTypes, X as refineQuestion, Y as refinedQuestionSchema, Z as researchPortfolioChapterSchema, _ as resolvePreset, $ as resolveProfile, a0 as sourceTiers, a1 as sourceTiersResearch, a2 as sourcesSchema, a3 as toolSlugs, a4 as toolsChapterSchema } from './types-CMPuyZGP.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
@@ -320,6 +320,89 @@ declare function assertEnumProp<T extends string>(value: unknown, allowed: reado
320
320
  prop: string;
321
321
  }): T;
322
322
 
323
+ /**
324
+ * book-link — resolve a cross-book `<BookLink>` href from the consumer's
325
+ * sibling-book registry (#96).
326
+ *
327
+ * Each scaffold book is a separate Astro app with its own `labels.json` and
328
+ * deploy origin, so `<XRef>` can't reach a sibling book — a cross-book ref
329
+ * resolves against the wrong labels and dies. `<BookLink book to>` instead
330
+ * resolves `book` against a per-consumer registry of sibling base URLs
331
+ * (`defineBookConfig({ siblingBooks })`) — the single place to update when a
332
+ * sibling redeploys or extracts to its own repo. An unknown `book` THROWS
333
+ * rather than emitting a dead cross-origin link (fail-loud, like #109).
334
+ *
335
+ * Phase 1 (this): registry-backed href + fail-loud on unknown book. Phase 2
336
+ * (deferred): validate the `to` id against a vendored sibling `labels.json`.
337
+ */
338
+ declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
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
+
323
406
  /**
324
407
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
325
408
  *
@@ -421,4 +504,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
421
504
  */
422
505
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
423
506
 
424
- 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, 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
@@ -1028,7 +1145,11 @@ function bookScaffoldIntegration(opts) {
1028
1145
  seo,
1029
1146
  // v4.15.0 (#109): optional GitHub repo/branch override for CodeRef/CodeBlock.
1030
1147
  githubRepo,
1031
- githubBranch
1148
+ githubBranch,
1149
+ // v4.16.0 (#96): sibling-book registry for cross-book <BookLink>.
1150
+ siblingBooks,
1151
+ // v4.17.0 (#112): exam-domain taxonomy for the questions collection.
1152
+ examDomains
1032
1153
  } = opts;
1033
1154
  const def = PROFILES[profile];
1034
1155
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -1088,7 +1209,9 @@ function bookScaffoldIntegration(opts) {
1088
1209
  twitterHandle: seo?.twitterHandle ?? null
1089
1210
  },
1090
1211
  githubRepo: resolvedGithubRepo,
1091
- githubBranch: resolvedGithubBranch
1212
+ githubBranch: resolvedGithubBranch,
1213
+ siblingBooks: siblingBooks ?? {},
1214
+ examDomains: examDomains ?? []
1092
1215
  })
1093
1216
  ],
1094
1217
  define: {
@@ -1224,7 +1347,11 @@ async function defineBookConfig(opts) {
1224
1347
  seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0,
1225
1348
  // v4.15.0 (#109): repo/branch override; integration auto-detects when undefined.
1226
1349
  githubRepo: opts.githubRepo,
1227
- githubBranch: opts.githubBranch
1350
+ githubBranch: opts.githubBranch,
1351
+ // v4.16.0 (#96): cross-book link registry.
1352
+ siblingBooks: opts.siblingBooks,
1353
+ // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1354
+ examDomains: opts.examDomains
1228
1355
  }),
1229
1356
  ...mergedExtraIntegrations
1230
1357
  ];
@@ -1269,6 +1396,10 @@ async function defineBookConfig(opts) {
1269
1396
  // v4.15.0: strip repo opts so they don't leak into AstroUserConfig.
1270
1397
  githubRepo: _githubRepo,
1271
1398
  githubBranch: _githubBranch,
1399
+ // v4.16.0: strip cross-book registry.
1400
+ siblingBooks: _siblingBooks,
1401
+ // v4.17.0: strip exam-domain taxonomy.
1402
+ examDomains: _examDomains,
1272
1403
  ...rest
1273
1404
  } = opts;
1274
1405
  void _styles;
@@ -1287,6 +1418,8 @@ async function defineBookConfig(opts) {
1287
1418
  void _seo;
1288
1419
  void _githubRepo;
1289
1420
  void _githubBranch;
1421
+ void _siblingBooks;
1422
+ void _examDomains;
1290
1423
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1291
1424
  const restVite = rest.vite ?? {};
1292
1425
  const restSsr = restVite.ssr ?? {};
@@ -1377,6 +1510,84 @@ function assertEnumProp(value, allowed, ctx) {
1377
1510
  );
1378
1511
  }
1379
1512
 
1513
+ // src/lib/book-link.ts
1514
+ function resolveBookHref(siblingBooks, book, to) {
1515
+ const base = siblingBooks?.[book];
1516
+ if (!base) {
1517
+ const known = siblingBooks ? Object.keys(siblingBooks) : [];
1518
+ throw new Error(
1519
+ `<BookLink book="${book}">: unknown sibling book. Register it in defineBookConfig({ siblingBooks: { "${book}": "https://\u2026" } })` + (known.length ? ` (known: ${known.join(", ")})` : "") + "."
1520
+ );
1521
+ }
1522
+ return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
1523
+ }
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
+
1380
1591
  // src/styles/built-in.ts
1381
1592
  var academicStyle = defineStyle({
1382
1593
  name: "academic",
@@ -1435,10 +1646,13 @@ export {
1435
1646
  academicParts,
1436
1647
  academicStyle,
1437
1648
  assertEnumProp,
1649
+ assertKnownDomain,
1650
+ bloomLevels,
1438
1651
  bookScaffoldIntegration,
1439
1652
  buildGithubUrl,
1440
1653
  changeKinds,
1441
1654
  changelogSchema,
1655
+ chapterLabel,
1442
1656
  chapterSortKey,
1443
1657
  chapterStatus,
1444
1658
  citationBackstops,
@@ -1450,9 +1664,13 @@ export {
1450
1664
  defineProfile,
1451
1665
  defineStyle,
1452
1666
  defineTips,
1667
+ deriveObjectiveMap,
1668
+ distinctChaptersSorted,
1453
1669
  fallbackChaptersRenderer,
1454
1670
  freshnessLabel,
1455
1671
  getFreshness,
1672
+ groupByChapter,
1673
+ groupByDomain,
1456
1674
  minimalChapterSchema,
1457
1675
  minimalStyle,
1458
1676
  normalizeFrontmatterConfig,
@@ -1462,11 +1680,18 @@ export {
1462
1680
  patternsSchema,
1463
1681
  provenanceObject,
1464
1682
  provenanceSchema,
1683
+ questionDifficulties,
1684
+ questionSchema,
1685
+ questionTypes,
1686
+ refineQuestion,
1687
+ refinedQuestionSchema,
1465
1688
  researchPortfolioChapterSchema,
1466
1689
  researchPortfolioStyle,
1690
+ resolveBookHref,
1467
1691
  resolveGithubRepo,
1468
1692
  resolvePreset,
1469
1693
  resolveProfile,
1694
+ sortQuestions,
1470
1695
  sourceTiers,
1471
1696
  sourceTiersResearch,
1472
1697
  sourcesSchema,