@lxpack/validators 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # @lxpack/validators
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@lxpack/validators)](https://www.npmjs.com/package/@lxpack/validators)
4
+ [![CI](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
+
8
+ Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.0).
9
+
10
+ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
+
12
+ | Related | Package |
13
+ |---------|---------|
14
+ | CLI | [`@lxpack/cli`](../cli/README.md) |
15
+ | Packaging | [`@lxpack/scorm`](../scorm/README.md) |
16
+ | Runtime | [`@lxpack/runtime`](../runtime/README.md) |
17
+ | Components | [`@lxpack/components`](../components/README.md) |
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @lxpack/validators
23
+ ```
24
+
25
+ Requires Node.js 20+.
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ import {
31
+ validateCourse,
32
+ loadManifest,
33
+ buildRuntimeAssessmentBundle,
34
+ courseManifestSchema,
35
+ type ValidationResult,
36
+ } from "@lxpack/validators";
37
+
38
+ const result: ValidationResult = await validateCourse("/path/to/my-course");
39
+
40
+ if (!result.valid) {
41
+ for (const issue of result.issues) {
42
+ console.error(`${issue.path}: ${issue.message}`);
43
+ }
44
+ } else {
45
+ const { manifest } = result;
46
+ const bundle = await buildRuntimeAssessmentBundle("/path/to/my-course", manifest);
47
+ // bundle.assessments — learner-facing questions (no correct flags)
48
+ // bundle.answerKeys — scoring keys for the runtime
49
+ // bundle.configs — maxAttempts, shuffleChoices, showFeedback per assessment
50
+ // bundle.feedback — questionId → explanation text for feedback modes
51
+ }
52
+ ```
53
+
54
+ ### Load and parse only
55
+
56
+ ```ts
57
+ const loaded = await loadManifest("/path/to/my-course");
58
+ if (Array.isArray(loaded)) {
59
+ // validation issues
60
+ } else {
61
+ const { manifest } = loaded;
62
+ }
63
+ ```
64
+
65
+ ### Schema-only validation
66
+
67
+ ```ts
68
+ const parsed = courseManifestSchema.safeParse(manifestObject);
69
+ ```
70
+
71
+ ### Flow validation
72
+
73
+ ```ts
74
+ import { validateFlow, detectFlowCycles, collectActivityIds } from "@lxpack/validators";
75
+ ```
76
+
77
+ `validateCourse` runs flow checks when `manifest.flow` is present: valid `goto` targets, known condition shapes, and cycle detection.
78
+
79
+ ### Safe path resolution
80
+
81
+ ```ts
82
+ import { resolveCoursePath, isPathContained } from "@lxpack/validators";
83
+
84
+ const abs = resolveCoursePath(courseDir, "lessons/intro.md");
85
+ isPathContained(courseDir, abs); // true if inside course root
86
+ ```
87
+
88
+ ## Exports
89
+
90
+ | Export | Description |
91
+ |--------|-------------|
92
+ | `validateCourse(dir)` | Parse `course.yaml`, validate schema, flow, files, symlink containment |
93
+ | `loadManifest(courseDir)` | Load and parse `course.yaml` |
94
+ | `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view, keys, configs, feedback |
95
+ | `toLearnerAssessment(assessment)` | Strip `correct` from choices; extract config and feedback maps |
96
+ | `validateFlow(manifest)` | Flow rule and target validation |
97
+ | `detectFlowCycles(flow)` | Cycle detection for branching graphs |
98
+ | `collectActivityIds(manifest)` | Lesson and assessment IDs for flow targets |
99
+ | `conditionSchema`, `flowRuleSchema` | Zod schemas for flow conditions and rules |
100
+ | `BUILTIN_COMPONENT_IDS`, `isBuiltinComponentId` | Allowed built-in component lesson IDs |
101
+ | `resolveCoursePath(dir, relativePath)` | Resolve a path safely inside the course directory |
102
+ | `isPathContained(root, target)` | Whether `target` stays under `root` |
103
+ | `courseManifestSchema` | Zod schema for the full course manifest |
104
+ | `lessonSchema`, `assessmentSchema`, `variableDefSchema`, … | Strict sub-schemas |
105
+ | `CourseManifest`, `Lesson`, `Assessment`, `FlowRule`, `VariableDef`, `RuntimeAssessmentBundle` | TypeScript types |
106
+
107
+ ## What gets validated
108
+
109
+ - Manifest shape: lessons, assessments, optional `variables` and `flow`, tracking rules
110
+ - Lesson types: `markdown` (`file`), `html` (`path`), `component` (`component` + optional `props`)
111
+ - Component IDs: built-in IDs or course overrides under `components/<id>/`
112
+ - Flow rules: condition grammar, `goto` targets that reference known activity IDs, acyclic flow (warnings for cycles)
113
+ - Assessment YAML: strict MCQ schemas; optional `maxAttempts`, `shuffleChoices`, `showFeedback`; `explanation` per question
114
+ - Duplicate lesson IDs
115
+ - Path containment — referenced files must stay inside the course directory (including via symlinks)
116
+ - On-disk assets: files exist and assessment paths are regular files
117
+
118
+ ## Assessment packaging
119
+
120
+ Author assessments live as YAML under `assessments/` in the course repo. At build/preview time:
121
+
122
+ 1. `buildRuntimeAssessmentBundle()` reads each assessment file.
123
+ 2. **Learner payload** — questions and choices without `correct` flags.
124
+ 3. **Answer keys** — `questionId → choiceId` map for scoring.
125
+ 4. **Configs** — per-assessment quiz behavior (`maxAttempts`, `shuffleChoices`, `showFeedback`).
126
+ 5. **Feedback** — `questionId → explanation` for immediate/end feedback (not shipped as separate files).
127
+
128
+ The CLI and [`@lxpack/scorm`](../scorm/README.md) embed all of this in the HTML config JSON. Exported ZIPs **do not** include `assessments/` files, so answer keys are not fetchable as static assets.
129
+
130
+ ## Development
131
+
132
+ From the monorepo root:
133
+
134
+ ```bash
135
+ pnpm --filter @lxpack/validators build
136
+ pnpm --filter @lxpack/validators test
137
+ pnpm --filter @lxpack/validators typecheck
138
+ ```
139
+
140
+ ## Links
141
+
142
+ - [LXPack repository](https://github.com/eddiethedean/lxpack)
143
+ - [Documentation index](https://github.com/eddiethedean/lxpack/blob/main/docs/README.md)
144
+ - [Technical specification](https://github.com/eddiethedean/lxpack/blob/main/docs/SPEC.md)
145
+ - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
146
+
147
+ ## License
148
+
149
+ Apache-2.0
package/dist/index.d.ts CHANGED
@@ -1,5 +1,35 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ type Condition = {
4
+ variable: {
5
+ eq: [string, string | number | boolean];
6
+ };
7
+ } | {
8
+ assessment: {
9
+ passed: string;
10
+ };
11
+ } | {
12
+ interaction: {
13
+ done: string;
14
+ };
15
+ } | {
16
+ all: Condition[];
17
+ } | {
18
+ any: Condition[];
19
+ };
20
+ declare const conditionSchema: z.ZodType<Condition>;
21
+ declare const flowRuleSchema: z.ZodObject<{
22
+ when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
23
+ goto: z.ZodString;
24
+ }, "strict", z.ZodTypeAny, {
25
+ when: Condition;
26
+ goto: string;
27
+ }, {
28
+ when: Condition;
29
+ goto: string;
30
+ }>;
31
+ type FlowRule = z.infer<typeof flowRuleSchema>;
32
+
3
33
  declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
4
34
  id: z.ZodString;
5
35
  prompt: z.ZodString;
@@ -7,7 +37,7 @@ declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
7
37
  id: z.ZodString;
8
38
  text: z.ZodString;
9
39
  correct: z.ZodOptional<z.ZodBoolean>;
10
- }, "strip", z.ZodTypeAny, {
40
+ }, "strict", z.ZodTypeAny, {
11
41
  id: string;
12
42
  text: string;
13
43
  correct?: boolean | undefined;
@@ -17,7 +47,7 @@ declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
17
47
  correct?: boolean | undefined;
18
48
  }>, "many">;
19
49
  explanation: z.ZodOptional<z.ZodString>;
20
- }, "strip", z.ZodTypeAny, {
50
+ }, "strict", z.ZodTypeAny, {
21
51
  id: string;
22
52
  prompt: string;
23
53
  choices: {
@@ -60,13 +90,13 @@ declare const markdownLessonSchema: z.ZodObject<{
60
90
  file: z.ZodString;
61
91
  title: z.ZodOptional<z.ZodString>;
62
92
  }, "strict", z.ZodTypeAny, {
63
- id: string;
64
93
  type: "markdown";
94
+ id: string;
65
95
  file: string;
66
96
  title?: string | undefined;
67
97
  }, {
68
- id: string;
69
98
  type: "markdown";
99
+ id: string;
70
100
  file: string;
71
101
  title?: string | undefined;
72
102
  }>;
@@ -76,15 +106,34 @@ declare const htmlLessonSchema: z.ZodObject<{
76
106
  path: z.ZodString;
77
107
  title: z.ZodOptional<z.ZodString>;
78
108
  }, "strict", z.ZodTypeAny, {
79
- id: string;
80
109
  path: string;
81
110
  type: "html";
111
+ id: string;
82
112
  title?: string | undefined;
83
113
  }, {
84
- id: string;
85
114
  path: string;
86
115
  type: "html";
116
+ id: string;
117
+ title?: string | undefined;
118
+ }>;
119
+ declare const componentLessonSchema: z.ZodObject<{
120
+ id: z.ZodString;
121
+ type: z.ZodLiteral<"component">;
122
+ component: z.ZodString;
123
+ props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
124
+ title: z.ZodOptional<z.ZodString>;
125
+ }, "strict", z.ZodTypeAny, {
126
+ type: "component";
127
+ id: string;
128
+ component: string;
129
+ title?: string | undefined;
130
+ props?: Record<string, unknown> | undefined;
131
+ }, {
132
+ type: "component";
133
+ id: string;
134
+ component: string;
87
135
  title?: string | undefined;
136
+ props?: Record<string, unknown> | undefined;
88
137
  }>;
89
138
  declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
90
139
  id: z.ZodString;
@@ -92,13 +141,13 @@ declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
92
141
  file: z.ZodString;
93
142
  title: z.ZodOptional<z.ZodString>;
94
143
  }, "strict", z.ZodTypeAny, {
95
- id: string;
96
144
  type: "markdown";
145
+ id: string;
97
146
  file: string;
98
147
  title?: string | undefined;
99
148
  }, {
100
- id: string;
101
149
  type: "markdown";
150
+ id: string;
102
151
  file: string;
103
152
  title?: string | undefined;
104
153
  }>, z.ZodObject<{
@@ -107,20 +156,42 @@ declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
107
156
  path: z.ZodString;
108
157
  title: z.ZodOptional<z.ZodString>;
109
158
  }, "strict", z.ZodTypeAny, {
110
- id: string;
111
159
  path: string;
112
160
  type: "html";
161
+ id: string;
113
162
  title?: string | undefined;
114
163
  }, {
115
- id: string;
116
164
  path: string;
117
165
  type: "html";
166
+ id: string;
167
+ title?: string | undefined;
168
+ }>, z.ZodObject<{
169
+ id: z.ZodString;
170
+ type: z.ZodLiteral<"component">;
171
+ component: z.ZodString;
172
+ props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
173
+ title: z.ZodOptional<z.ZodString>;
174
+ }, "strict", z.ZodTypeAny, {
175
+ type: "component";
176
+ id: string;
177
+ component: string;
118
178
  title?: string | undefined;
179
+ props?: Record<string, unknown> | undefined;
180
+ }, {
181
+ type: "component";
182
+ id: string;
183
+ component: string;
184
+ title?: string | undefined;
185
+ props?: Record<string, unknown> | undefined;
119
186
  }>]>;
187
+ declare const showFeedbackSchema: z.ZodDefault<z.ZodEnum<["immediate", "end", "never"]>>;
120
188
  declare const assessmentSchema: z.ZodObject<{
121
189
  id: z.ZodString;
122
190
  title: z.ZodOptional<z.ZodString>;
123
191
  passingScore: z.ZodDefault<z.ZodNumber>;
192
+ maxAttempts: z.ZodOptional<z.ZodNumber>;
193
+ shuffleChoices: z.ZodOptional<z.ZodBoolean>;
194
+ showFeedback: z.ZodOptional<z.ZodDefault<z.ZodEnum<["immediate", "end", "never"]>>>;
124
195
  questions: z.ZodArray<z.ZodEffects<z.ZodObject<{
125
196
  id: z.ZodString;
126
197
  prompt: z.ZodString;
@@ -128,7 +199,7 @@ declare const assessmentSchema: z.ZodObject<{
128
199
  id: z.ZodString;
129
200
  text: z.ZodString;
130
201
  correct: z.ZodOptional<z.ZodBoolean>;
131
- }, "strip", z.ZodTypeAny, {
202
+ }, "strict", z.ZodTypeAny, {
132
203
  id: string;
133
204
  text: string;
134
205
  correct?: boolean | undefined;
@@ -138,7 +209,7 @@ declare const assessmentSchema: z.ZodObject<{
138
209
  correct?: boolean | undefined;
139
210
  }>, "many">;
140
211
  explanation: z.ZodOptional<z.ZodString>;
141
- }, "strip", z.ZodTypeAny, {
212
+ }, "strict", z.ZodTypeAny, {
142
213
  id: string;
143
214
  prompt: string;
144
215
  choices: {
@@ -189,6 +260,9 @@ declare const assessmentSchema: z.ZodObject<{
189
260
  explanation?: string | undefined;
190
261
  }[];
191
262
  title?: string | undefined;
263
+ maxAttempts?: number | undefined;
264
+ shuffleChoices?: boolean | undefined;
265
+ showFeedback?: "never" | "immediate" | "end" | undefined;
192
266
  }, {
193
267
  id: string;
194
268
  questions: {
@@ -203,6 +277,9 @@ declare const assessmentSchema: z.ZodObject<{
203
277
  }[];
204
278
  title?: string | undefined;
205
279
  passingScore?: number | undefined;
280
+ maxAttempts?: number | undefined;
281
+ shuffleChoices?: boolean | undefined;
282
+ showFeedback?: "never" | "immediate" | "end" | undefined;
206
283
  }>;
207
284
  declare const assessmentRefSchema: z.ZodObject<{
208
285
  id: z.ZodString;
@@ -214,6 +291,16 @@ declare const assessmentRefSchema: z.ZodObject<{
214
291
  id: string;
215
292
  file: string;
216
293
  }>;
294
+ declare const variableDefSchema: z.ZodObject<{
295
+ default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
296
+ type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
297
+ }, "strict", z.ZodTypeAny, {
298
+ default: string | number | boolean;
299
+ type?: "string" | "number" | "boolean" | undefined;
300
+ }, {
301
+ default: string | number | boolean;
302
+ type?: "string" | "number" | "boolean" | undefined;
303
+ }>;
217
304
  declare const trackingSchema: z.ZodOptional<z.ZodObject<{
218
305
  completion: z.ZodOptional<z.ZodObject<{
219
306
  threshold: z.ZodDefault<z.ZodNumber>;
@@ -238,6 +325,7 @@ declare const runtimeConfigSchema: z.ZodOptional<z.ZodObject<{
238
325
  }, {
239
326
  theme?: string | undefined;
240
327
  }>>;
328
+
241
329
  declare const courseManifestSchema: z.ZodObject<{
242
330
  title: z.ZodString;
243
331
  version: z.ZodString;
@@ -266,19 +354,39 @@ declare const courseManifestSchema: z.ZodObject<{
266
354
  threshold?: number | undefined;
267
355
  } | undefined;
268
356
  }>>;
357
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
358
+ default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
359
+ type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
360
+ }, "strict", z.ZodTypeAny, {
361
+ default: string | number | boolean;
362
+ type?: "string" | "number" | "boolean" | undefined;
363
+ }, {
364
+ default: string | number | boolean;
365
+ type?: "string" | "number" | "boolean" | undefined;
366
+ }>>>;
367
+ flow: z.ZodOptional<z.ZodArray<z.ZodObject<{
368
+ when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
369
+ goto: z.ZodString;
370
+ }, "strict", z.ZodTypeAny, {
371
+ when: Condition;
372
+ goto: string;
373
+ }, {
374
+ when: Condition;
375
+ goto: string;
376
+ }>, "many">>;
269
377
  lessons: z.ZodArray<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
270
378
  id: z.ZodString;
271
379
  type: z.ZodLiteral<"markdown">;
272
380
  file: z.ZodString;
273
381
  title: z.ZodOptional<z.ZodString>;
274
382
  }, "strict", z.ZodTypeAny, {
275
- id: string;
276
383
  type: "markdown";
384
+ id: string;
277
385
  file: string;
278
386
  title?: string | undefined;
279
387
  }, {
280
- id: string;
281
388
  type: "markdown";
389
+ id: string;
282
390
  file: string;
283
391
  title?: string | undefined;
284
392
  }>, z.ZodObject<{
@@ -287,15 +395,33 @@ declare const courseManifestSchema: z.ZodObject<{
287
395
  path: z.ZodString;
288
396
  title: z.ZodOptional<z.ZodString>;
289
397
  }, "strict", z.ZodTypeAny, {
290
- id: string;
291
398
  path: string;
292
399
  type: "html";
400
+ id: string;
293
401
  title?: string | undefined;
294
402
  }, {
295
- id: string;
296
403
  path: string;
297
404
  type: "html";
405
+ id: string;
298
406
  title?: string | undefined;
407
+ }>, z.ZodObject<{
408
+ id: z.ZodString;
409
+ type: z.ZodLiteral<"component">;
410
+ component: z.ZodString;
411
+ props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
412
+ title: z.ZodOptional<z.ZodString>;
413
+ }, "strict", z.ZodTypeAny, {
414
+ type: "component";
415
+ id: string;
416
+ component: string;
417
+ title?: string | undefined;
418
+ props?: Record<string, unknown> | undefined;
419
+ }, {
420
+ type: "component";
421
+ id: string;
422
+ component: string;
423
+ title?: string | undefined;
424
+ props?: Record<string, unknown> | undefined;
299
425
  }>]>, "many">;
300
426
  assessments: z.ZodOptional<z.ZodArray<z.ZodObject<{
301
427
  id: z.ZodString;
@@ -311,15 +437,21 @@ declare const courseManifestSchema: z.ZodObject<{
311
437
  title: string;
312
438
  version: string;
313
439
  lessons: ({
314
- id: string;
315
440
  type: "markdown";
441
+ id: string;
316
442
  file: string;
317
443
  title?: string | undefined;
318
444
  } | {
319
- id: string;
320
445
  path: string;
321
446
  type: "html";
447
+ id: string;
448
+ title?: string | undefined;
449
+ } | {
450
+ type: "component";
451
+ id: string;
452
+ component: string;
322
453
  title?: string | undefined;
454
+ props?: Record<string, unknown> | undefined;
323
455
  })[];
324
456
  description?: string | undefined;
325
457
  runtime?: {
@@ -330,6 +462,14 @@ declare const courseManifestSchema: z.ZodObject<{
330
462
  threshold: number;
331
463
  } | undefined;
332
464
  } | undefined;
465
+ variables?: Record<string, {
466
+ default: string | number | boolean;
467
+ type?: "string" | "number" | "boolean" | undefined;
468
+ }> | undefined;
469
+ flow?: {
470
+ when: Condition;
471
+ goto: string;
472
+ }[] | undefined;
333
473
  assessments?: {
334
474
  id: string;
335
475
  file: string;
@@ -338,15 +478,21 @@ declare const courseManifestSchema: z.ZodObject<{
338
478
  title: string;
339
479
  version: string;
340
480
  lessons: ({
341
- id: string;
342
481
  type: "markdown";
482
+ id: string;
343
483
  file: string;
344
484
  title?: string | undefined;
345
485
  } | {
346
- id: string;
347
486
  path: string;
348
487
  type: "html";
488
+ id: string;
349
489
  title?: string | undefined;
490
+ } | {
491
+ type: "component";
492
+ id: string;
493
+ component: string;
494
+ title?: string | undefined;
495
+ props?: Record<string, unknown> | undefined;
350
496
  })[];
351
497
  description?: string | undefined;
352
498
  runtime?: {
@@ -357,6 +503,14 @@ declare const courseManifestSchema: z.ZodObject<{
357
503
  threshold?: number | undefined;
358
504
  } | undefined;
359
505
  } | undefined;
506
+ variables?: Record<string, {
507
+ default: string | number | boolean;
508
+ type?: "string" | "number" | "boolean" | undefined;
509
+ }> | undefined;
510
+ flow?: {
511
+ when: Condition;
512
+ goto: string;
513
+ }[] | undefined;
360
514
  assessments?: {
361
515
  id: string;
362
516
  file: string;
@@ -366,6 +520,14 @@ type CourseManifest = z.infer<typeof courseManifestSchema>;
366
520
  type Lesson = z.infer<typeof lessonSchema>;
367
521
  type Assessment = z.infer<typeof assessmentSchema>;
368
522
  type AssessmentRef = z.infer<typeof assessmentRefSchema>;
523
+ type VariableDef = z.infer<typeof variableDefSchema>;
524
+ type ComponentLesson = z.infer<typeof componentLessonSchema>;
525
+ type ShowFeedback = z.infer<typeof showFeedbackSchema>;
526
+
527
+ /** Built-in component ids shipped in @lxpack/components */
528
+ declare const BUILTIN_COMPONENT_IDS: readonly ["callout", "image-card", "checklist"];
529
+ type BuiltinComponentId = (typeof BUILTIN_COMPONENT_IDS)[number];
530
+ declare function isBuiltinComponentId(id: string): id is BuiltinComponentId;
369
531
 
370
532
  declare function formatErrorMessage(err: unknown): string;
371
533
  declare function formatIssuePath(path: PropertyKey[]): string;
@@ -379,6 +541,7 @@ interface ValidationResult {
379
541
  manifest?: CourseManifest;
380
542
  issues: ValidationIssue[];
381
543
  }
544
+ declare function isPathContained(rootDir: string, candidatePath: string): boolean;
382
545
  declare function resolveCoursePath(courseDir: string, relativePath: string): {
383
546
  ok: true;
384
547
  path: string;
@@ -386,10 +549,58 @@ declare function resolveCoursePath(courseDir: string, relativePath: string): {
386
549
  ok: false;
387
550
  message: string;
388
551
  };
552
+ declare function assertResolvedPathContained(courseDir: string, resolvedPath: string): {
553
+ ok: true;
554
+ } | {
555
+ ok: false;
556
+ message: string;
557
+ };
389
558
  declare function loadManifest(courseDir: string): Promise<{
390
559
  manifest: CourseManifest;
391
560
  raw: unknown;
392
561
  } | ValidationIssue[]>;
393
562
  declare function validateCourse(courseDir: string): Promise<ValidationResult>;
394
563
 
395
- export { type Assessment, type AssessmentRef, type CourseManifest, type Lesson, type ValidationIssue, type ValidationResult, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, courseManifestSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, lessonSchema, loadManifest, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, trackingSchema, validateCourse };
564
+ declare function collectActivityIds(manifest: CourseManifest): Set<string>;
565
+ declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
566
+ /** Detect simple goto cycles (same-target chains). */
567
+ declare function detectFlowCycles(flow: FlowRule[]): string[];
568
+
569
+ interface LearnerChoice {
570
+ id: string;
571
+ text: string;
572
+ }
573
+ interface LearnerQuestion {
574
+ id: string;
575
+ prompt: string;
576
+ choices: LearnerChoice[];
577
+ }
578
+ interface LearnerAssessment {
579
+ id: string;
580
+ title?: string;
581
+ passingScore: number;
582
+ questions: LearnerQuestion[];
583
+ }
584
+ interface AssessmentRuntimeConfig {
585
+ maxAttempts: number;
586
+ shuffleChoices: boolean;
587
+ showFeedback: ShowFeedback;
588
+ }
589
+ interface QuestionFeedback {
590
+ [questionId: string]: string | undefined;
591
+ }
592
+ interface RuntimeAssessmentBundle {
593
+ assessments: Record<string, LearnerAssessment>;
594
+ answerKeys: Record<string, Record<string, string>>;
595
+ configs: Record<string, AssessmentRuntimeConfig>;
596
+ feedback: Record<string, QuestionFeedback>;
597
+ }
598
+ declare function toLearnerAssessment(assessment: Assessment): {
599
+ learner: LearnerAssessment;
600
+ answerKey: Record<string, string>;
601
+ config: AssessmentRuntimeConfig;
602
+ feedback: QuestionFeedback;
603
+ };
604
+ declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
605
+
606
+ export { type Assessment, type AssessmentRef, type AssessmentRuntimeConfig, BUILTIN_COMPONENT_IDS, type BuiltinComponentId, type ComponentLesson, type Condition, type CourseManifest, type FlowRule, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type QuestionFeedback, type RuntimeAssessmentBundle, type ShowFeedback, type ValidationIssue, type ValidationResult, type VariableDef, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, collectActivityIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, flowRuleSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, variableDefSchema };
package/dist/index.js CHANGED
@@ -1,72 +1,243 @@
1
1
  // src/schemas.ts
2
+ import { z as z2 } from "zod";
3
+
4
+ // src/conditions.ts
2
5
  import { z } from "zod";
3
- var choiceSchema = z.object({
4
- id: z.string().min(1),
5
- text: z.string().min(1),
6
- correct: z.boolean().optional()
7
- });
8
- var assessmentQuestionSchema = z.object({
9
- id: z.string().min(1),
10
- prompt: z.string().min(1),
11
- choices: z.array(choiceSchema).min(1),
12
- explanation: z.string().optional()
13
- }).superRefine((question, ctx) => {
6
+ var baseConditionSchema = z.union([
7
+ z.object({
8
+ variable: z.object({
9
+ eq: z.tuple([
10
+ z.string().min(1),
11
+ z.union([z.string(), z.number(), z.boolean()])
12
+ ])
13
+ })
14
+ }).strict(),
15
+ z.object({
16
+ assessment: z.object({
17
+ passed: z.string().min(1)
18
+ })
19
+ }).strict(),
20
+ z.object({
21
+ interaction: z.object({
22
+ done: z.string().min(1)
23
+ })
24
+ }).strict()
25
+ ]);
26
+ var conditionSchema = z.lazy(
27
+ () => z.union([
28
+ ...baseConditionSchema.options,
29
+ z.object({ all: z.array(conditionSchema) }).strict(),
30
+ z.object({ any: z.array(conditionSchema) }).strict()
31
+ ])
32
+ );
33
+ var flowRuleSchema = z.object({
34
+ when: conditionSchema,
35
+ goto: z.string().min(1)
36
+ }).strict();
37
+
38
+ // src/schemas.ts
39
+ var choiceSchema = z2.object({
40
+ id: z2.string().min(1),
41
+ text: z2.string().min(1),
42
+ correct: z2.boolean().optional()
43
+ }).strict();
44
+ var assessmentQuestionSchema = z2.object({
45
+ id: z2.string().min(1),
46
+ prompt: z2.string().min(1),
47
+ choices: z2.array(choiceSchema).min(1),
48
+ explanation: z2.string().optional()
49
+ }).strict().superRefine((question, ctx) => {
14
50
  const correctCount = question.choices.filter((c) => c.correct === true).length;
15
51
  if (correctCount !== 1) {
16
52
  ctx.addIssue({
17
- code: z.ZodIssueCode.custom,
53
+ code: z2.ZodIssueCode.custom,
18
54
  message: "Each question must have exactly one correct choice",
19
55
  path: ["choices"]
20
56
  });
21
57
  }
22
58
  });
23
- var markdownLessonSchema = z.object({
24
- id: z.string().min(1),
25
- type: z.literal("markdown"),
26
- file: z.string().min(1),
27
- title: z.string().optional()
59
+ var markdownLessonSchema = z2.object({
60
+ id: z2.string().min(1),
61
+ type: z2.literal("markdown"),
62
+ file: z2.string().min(1),
63
+ title: z2.string().optional()
28
64
  }).strict();
29
- var htmlLessonSchema = z.object({
30
- id: z.string().min(1),
31
- type: z.literal("html"),
32
- path: z.string().min(1),
33
- title: z.string().optional()
65
+ var htmlLessonSchema = z2.object({
66
+ id: z2.string().min(1),
67
+ type: z2.literal("html"),
68
+ path: z2.string().min(1),
69
+ title: z2.string().optional()
34
70
  }).strict();
35
- var lessonSchema = z.discriminatedUnion("type", [
71
+ var componentLessonSchema = z2.object({
72
+ id: z2.string().min(1),
73
+ type: z2.literal("component"),
74
+ component: z2.string().min(1),
75
+ props: z2.record(z2.unknown()).optional(),
76
+ title: z2.string().optional()
77
+ }).strict();
78
+ var lessonSchema = z2.discriminatedUnion("type", [
36
79
  markdownLessonSchema,
37
- htmlLessonSchema
80
+ htmlLessonSchema,
81
+ componentLessonSchema
38
82
  ]);
39
- var assessmentSchema = z.object({
40
- id: z.string().min(1),
41
- title: z.string().optional(),
42
- passingScore: z.number().min(0).max(1).default(0.7),
43
- questions: z.array(assessmentQuestionSchema).min(1)
83
+ var showFeedbackSchema = z2.enum(["immediate", "end", "never"]).default("never");
84
+ var assessmentSchema = z2.object({
85
+ id: z2.string().min(1),
86
+ title: z2.string().optional(),
87
+ passingScore: z2.number().min(0).max(1).default(0.7),
88
+ maxAttempts: z2.number().int().min(1).optional(),
89
+ shuffleChoices: z2.boolean().optional(),
90
+ showFeedback: showFeedbackSchema.optional(),
91
+ questions: z2.array(assessmentQuestionSchema).min(1)
44
92
  }).strict();
45
- var assessmentRefSchema = z.object({
46
- id: z.string().min(1),
47
- file: z.string().min(1)
93
+ var assessmentRefSchema = z2.object({
94
+ id: z2.string().min(1),
95
+ file: z2.string().min(1)
48
96
  }).strict();
49
- var trackingSchema = z.object({
50
- completion: z.object({
51
- threshold: z.number().min(0).max(1).default(0.9)
97
+ var variableDefSchema = z2.object({
98
+ default: z2.union([z2.string(), z2.number(), z2.boolean()]),
99
+ type: z2.enum(["string", "number", "boolean"]).optional()
100
+ }).strict();
101
+ var trackingSchema = z2.object({
102
+ completion: z2.object({
103
+ threshold: z2.number().min(0).max(1).default(0.9)
52
104
  }).strict().optional()
53
105
  }).strict().optional();
54
- var runtimeConfigSchema = z.object({
55
- theme: z.string().default("modern")
106
+ var runtimeConfigSchema = z2.object({
107
+ theme: z2.string().default("modern")
56
108
  }).strict().optional();
57
- var courseManifestSchema = z.object({
58
- title: z.string().min(1),
59
- version: z.string().min(1),
60
- description: z.string().optional(),
109
+ var courseManifestSchema = z2.object({
110
+ title: z2.string().min(1),
111
+ version: z2.string().min(1),
112
+ description: z2.string().optional(),
61
113
  runtime: runtimeConfigSchema,
62
114
  tracking: trackingSchema,
63
- lessons: z.array(lessonSchema).min(1),
64
- assessments: z.array(assessmentRefSchema).optional()
115
+ variables: z2.record(variableDefSchema).optional(),
116
+ flow: z2.array(flowRuleSchema).optional(),
117
+ lessons: z2.array(lessonSchema).min(1),
118
+ assessments: z2.array(assessmentRefSchema).optional()
65
119
  }).strict();
66
120
 
121
+ // src/components.ts
122
+ var BUILTIN_COMPONENT_IDS = [
123
+ "callout",
124
+ "image-card",
125
+ "checklist"
126
+ ];
127
+ function isBuiltinComponentId(id) {
128
+ return BUILTIN_COMPONENT_IDS.includes(id);
129
+ }
130
+
131
+ // src/flow-validate.ts
132
+ function collectActivityIds(manifest) {
133
+ const ids = /* @__PURE__ */ new Set();
134
+ for (const lesson of manifest.lessons) {
135
+ ids.add(lesson.id);
136
+ }
137
+ for (const ref of manifest.assessments ?? []) {
138
+ ids.add(ref.id);
139
+ }
140
+ return ids;
141
+ }
142
+ function collectConditionRefs(condition, refs) {
143
+ if ("variable" in condition && condition.variable?.eq) {
144
+ refs.variables.add(condition.variable.eq[0]);
145
+ }
146
+ if ("assessment" in condition && condition.assessment?.passed) {
147
+ refs.assessments.add(condition.assessment.passed);
148
+ }
149
+ if ("interaction" in condition && condition.interaction?.done) {
150
+ refs.interactions.add(condition.interaction.done);
151
+ }
152
+ if ("all" in condition && condition.all) {
153
+ for (const c of condition.all) collectConditionRefs(c, refs);
154
+ }
155
+ if ("any" in condition && condition.any) {
156
+ for (const c of condition.any) collectConditionRefs(c, refs);
157
+ }
158
+ }
159
+ function validateFlow(manifest) {
160
+ const issues = [];
161
+ const flow = manifest.flow;
162
+ if (!flow?.length) return issues;
163
+ const activityIds = collectActivityIds(manifest);
164
+ const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
165
+ flow.forEach((rule, index) => {
166
+ const path = `flow[${index}]`;
167
+ if (!activityIds.has(rule.goto)) {
168
+ issues.push({
169
+ path: `${path}.goto`,
170
+ message: `Unknown activity id: ${rule.goto}`,
171
+ severity: "error"
172
+ });
173
+ }
174
+ const refs = {
175
+ variables: /* @__PURE__ */ new Set(),
176
+ assessments: /* @__PURE__ */ new Set(),
177
+ interactions: /* @__PURE__ */ new Set()
178
+ };
179
+ collectConditionRefs(rule.when, refs);
180
+ for (const v of refs.variables) {
181
+ if (!manifestVars.has(v)) {
182
+ issues.push({
183
+ path: `${path}.when`,
184
+ message: `Unknown variable in condition: ${v}`,
185
+ severity: "error"
186
+ });
187
+ }
188
+ }
189
+ for (const a of refs.assessments) {
190
+ if (!activityIds.has(a)) {
191
+ issues.push({
192
+ path: `${path}.when`,
193
+ message: `Unknown assessment in condition: ${a}`,
194
+ severity: "error"
195
+ });
196
+ }
197
+ }
198
+ for (const i of refs.interactions) {
199
+ if (!activityIds.has(i)) {
200
+ issues.push({
201
+ path: `${path}.when`,
202
+ message: `Unknown interaction/lesson id in condition: ${i}`,
203
+ severity: "warning"
204
+ });
205
+ }
206
+ }
207
+ });
208
+ return issues;
209
+ }
210
+ function detectFlowCycles(flow) {
211
+ const gotoOf = /* @__PURE__ */ new Map();
212
+ flow.forEach((rule, i) => gotoOf.set(i, rule.goto));
213
+ const errors = [];
214
+ const visited = /* @__PURE__ */ new Set();
215
+ for (let start = 0; start < flow.length; start++) {
216
+ if (visited.has(start)) continue;
217
+ const chain = /* @__PURE__ */ new Set();
218
+ let i = start;
219
+ while (i !== void 0 && i < flow.length) {
220
+ const ruleIndex = i;
221
+ if (chain.has(ruleIndex)) {
222
+ errors.push(`Flow rule cycle detected involving flow[${ruleIndex}]`);
223
+ break;
224
+ }
225
+ if (visited.has(ruleIndex)) break;
226
+ chain.add(ruleIndex);
227
+ visited.add(ruleIndex);
228
+ const target = gotoOf.get(ruleIndex);
229
+ const nextIdx = flow.findIndex(
230
+ (_, idx) => idx > ruleIndex && flow[idx].goto === target
231
+ );
232
+ i = nextIdx >= 0 ? nextIdx : void 0;
233
+ }
234
+ }
235
+ return errors;
236
+ }
237
+
67
238
  // src/validate.ts
68
- import { existsSync, statSync } from "fs";
69
- import { join, resolve, sep } from "path";
239
+ import { existsSync, realpathSync, statSync } from "fs";
240
+ import { isAbsolute, join, relative, resolve } from "path";
70
241
  import { parse as parseYaml } from "yaml";
71
242
  import { readFile } from "fs/promises";
72
243
  function formatErrorMessage(err) {
@@ -76,20 +247,39 @@ function formatIssuePath(path) {
76
247
  const joined = path.map(String).join(".");
77
248
  return joined || "course.yaml";
78
249
  }
250
+ function isPathContained(rootDir, candidatePath) {
251
+ const root = resolve(rootDir);
252
+ const candidate = resolve(candidatePath);
253
+ const rel = relative(root, candidate);
254
+ if (rel === "") return true;
255
+ return !rel.startsWith("..") && !isAbsolute(rel);
256
+ }
79
257
  function resolveCoursePath(courseDir, relativePath) {
80
258
  if (relativePath.startsWith("/") || /^[a-zA-Z]:\\/.test(relativePath)) {
81
259
  return { ok: false, message: "Absolute paths are not allowed" };
82
260
  }
83
261
  const resolvedDir = resolve(courseDir);
84
262
  const resolvedPath = resolve(resolvedDir, relativePath);
85
- const prefix = `${resolvedDir}${sep}`;
86
- if (!resolvedPath.startsWith(prefix)) {
263
+ if (!isPathContained(resolvedDir, resolvedPath)) {
87
264
  return { ok: false, message: "Path escapes course directory" };
88
265
  }
89
266
  return { ok: true, path: resolvedPath };
90
267
  }
268
+ function assertResolvedPathContained(courseDir, resolvedPath) {
269
+ try {
270
+ const root = realpathSync(resolve(courseDir));
271
+ const target = realpathSync(resolvedPath);
272
+ if (!isPathContained(root, target)) {
273
+ return { ok: false, message: "Path escapes course directory" };
274
+ }
275
+ return { ok: true };
276
+ } catch {
277
+ return { ok: false, message: "Path could not be resolved" };
278
+ }
279
+ }
91
280
  async function loadManifest(courseDir) {
92
- const manifestPath = join(courseDir, "course.yaml");
281
+ const resolvedDir = resolve(courseDir);
282
+ const manifestPath = join(resolvedDir, "course.yaml");
93
283
  if (!existsSync(manifestPath)) {
94
284
  return [
95
285
  {
@@ -149,6 +339,15 @@ async function validateCourse(courseDir) {
149
339
  });
150
340
  continue;
151
341
  }
342
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
343
+ if (!contained.ok) {
344
+ issues.push({
345
+ path: `lessons.${lesson.id}.file`,
346
+ message: contained.message,
347
+ severity: "error"
348
+ });
349
+ continue;
350
+ }
152
351
  const stat = statSync(resolved.path);
153
352
  if (!stat.isFile()) {
154
353
  issues.push({
@@ -157,6 +356,37 @@ async function validateCourse(courseDir) {
157
356
  severity: "error"
158
357
  });
159
358
  }
359
+ } else if (lesson.type === "component") {
360
+ if (!isBuiltinComponentId(lesson.component)) {
361
+ const resolved = resolveCoursePath(
362
+ resolvedDir,
363
+ join("components", lesson.component)
364
+ );
365
+ if (!resolved.ok) {
366
+ issues.push({
367
+ path: `lessons.${lesson.id}.component`,
368
+ message: resolved.message,
369
+ severity: "error"
370
+ });
371
+ continue;
372
+ }
373
+ if (!existsSync(resolved.path)) {
374
+ issues.push({
375
+ path: `lessons.${lesson.id}.component`,
376
+ message: `Unknown component "${lesson.component}" and no override at components/${lesson.component}`,
377
+ severity: "error"
378
+ });
379
+ continue;
380
+ }
381
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
382
+ if (!contained.ok) {
383
+ issues.push({
384
+ path: `lessons.${lesson.id}.component`,
385
+ message: contained.message,
386
+ severity: "error"
387
+ });
388
+ }
389
+ }
160
390
  } else if (lesson.type === "html") {
161
391
  const resolved = resolveCoursePath(resolvedDir, lesson.path);
162
392
  if (!resolved.ok) {
@@ -175,6 +405,15 @@ async function validateCourse(courseDir) {
175
405
  });
176
406
  continue;
177
407
  }
408
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
409
+ if (!contained.ok) {
410
+ issues.push({
411
+ path: `lessons.${lesson.id}.path`,
412
+ message: contained.message,
413
+ severity: "error"
414
+ });
415
+ continue;
416
+ }
178
417
  const stat = statSync(resolved.path);
179
418
  if (!stat.isDirectory()) {
180
419
  issues.push({
@@ -191,12 +430,24 @@ async function validateCourse(courseDir) {
191
430
  message: `HTML interaction missing index.html: ${lesson.path}`,
192
431
  severity: "error"
193
432
  });
194
- } else if (!statSync(indexPath).isFile()) {
195
- issues.push({
196
- path: `lessons.${lesson.id}.path`,
197
- message: `index.html is not a file: ${lesson.path}/index.html`,
198
- severity: "error"
199
- });
433
+ } else {
434
+ const indexContained = assertResolvedPathContained(
435
+ resolvedDir,
436
+ indexPath
437
+ );
438
+ if (!indexContained.ok) {
439
+ issues.push({
440
+ path: `lessons.${lesson.id}.path`,
441
+ message: indexContained.message,
442
+ severity: "error"
443
+ });
444
+ } else if (!statSync(indexPath).isFile()) {
445
+ issues.push({
446
+ path: `lessons.${lesson.id}.path`,
447
+ message: `index.html is not a file: ${lesson.path}/index.html`,
448
+ severity: "error"
449
+ });
450
+ }
200
451
  }
201
452
  }
202
453
  }
@@ -228,6 +479,24 @@ async function validateCourse(courseDir) {
228
479
  });
229
480
  continue;
230
481
  }
482
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
483
+ if (!contained.ok) {
484
+ issues.push({
485
+ path: `assessments.${ref.id}.file`,
486
+ message: contained.message,
487
+ severity: "error"
488
+ });
489
+ continue;
490
+ }
491
+ const assessmentStat = statSync(resolved.path);
492
+ if (!assessmentStat.isFile()) {
493
+ issues.push({
494
+ path: `assessments.${ref.id}.file`,
495
+ message: `Assessment path is not a file: ${ref.file}`,
496
+ severity: "error"
497
+ });
498
+ continue;
499
+ }
231
500
  try {
232
501
  const content = await readFile(resolved.path, "utf-8");
233
502
  const raw = parseYaml(content);
@@ -259,13 +528,45 @@ async function validateCourse(courseDir) {
259
528
  }
260
529
  }
261
530
  }
262
- const lessonIds = new Set(manifest.lessons.map((l) => l.id));
263
- if (lessonIds.size !== manifest.lessons.length) {
264
- issues.push({
265
- path: "lessons",
266
- message: "Duplicate lesson IDs detected",
267
- severity: "error"
268
- });
531
+ const lessonIdCounts = /* @__PURE__ */ new Map();
532
+ for (const lesson of manifest.lessons) {
533
+ lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
534
+ }
535
+ for (const [id, count] of lessonIdCounts) {
536
+ if (count > 1) {
537
+ issues.push({
538
+ path: "lessons",
539
+ message: `Duplicate lesson ID: ${id}`,
540
+ severity: "error"
541
+ });
542
+ }
543
+ }
544
+ issues.push(...validateFlow(manifest));
545
+ if (manifest.variables) {
546
+ for (const [name, def] of Object.entries(manifest.variables)) {
547
+ const t = def.type;
548
+ if (t === "string" && typeof def.default !== "string") {
549
+ issues.push({
550
+ path: `variables.${name}.default`,
551
+ message: "Default must be a string when type is string",
552
+ severity: "error"
553
+ });
554
+ }
555
+ if (t === "number" && typeof def.default !== "number") {
556
+ issues.push({
557
+ path: `variables.${name}.default`,
558
+ message: "Default must be a number when type is number",
559
+ severity: "error"
560
+ });
561
+ }
562
+ if (t === "boolean" && typeof def.default !== "boolean") {
563
+ issues.push({
564
+ path: `variables.${name}.default`,
565
+ message: "Default must be a boolean when type is boolean",
566
+ severity: "error"
567
+ });
568
+ }
569
+ }
269
570
  }
270
571
  return {
271
572
  valid: issues.filter((i) => i.severity === "error").length === 0,
@@ -273,19 +574,101 @@ async function validateCourse(courseDir) {
273
574
  issues
274
575
  };
275
576
  }
577
+
578
+ // src/assessments.ts
579
+ import { readFile as readFile2 } from "fs/promises";
580
+ import { parse as parseYaml2 } from "yaml";
581
+ function toLearnerAssessment(assessment) {
582
+ const answerKey = {};
583
+ const feedback = {};
584
+ const questions = assessment.questions.map((q) => {
585
+ const correct = q.choices.find((c) => c.correct === true);
586
+ if (correct) {
587
+ answerKey[q.id] = correct.id;
588
+ }
589
+ if (q.explanation) {
590
+ feedback[q.id] = q.explanation;
591
+ }
592
+ return {
593
+ id: q.id,
594
+ prompt: q.prompt,
595
+ choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
596
+ };
597
+ });
598
+ return {
599
+ learner: {
600
+ id: assessment.id,
601
+ title: assessment.title,
602
+ passingScore: assessment.passingScore,
603
+ questions
604
+ },
605
+ answerKey,
606
+ config: {
607
+ maxAttempts: assessment.maxAttempts ?? 1,
608
+ shuffleChoices: assessment.shuffleChoices ?? false,
609
+ showFeedback: assessment.showFeedback ?? "never"
610
+ },
611
+ feedback
612
+ };
613
+ }
614
+ async function buildRuntimeAssessmentBundle(courseDir, manifest) {
615
+ const assessments = {};
616
+ const answerKeys = {};
617
+ const configs = {};
618
+ const feedback = {};
619
+ for (const ref of manifest.assessments ?? []) {
620
+ const resolved = resolveCoursePath(courseDir, ref.file);
621
+ if (!resolved.ok) {
622
+ throw new Error(resolved.message);
623
+ }
624
+ const content = await readFile2(resolved.path, "utf-8");
625
+ const raw = parseYaml2(content);
626
+ const parsed = assessmentSchema.safeParse(raw);
627
+ if (!parsed.success) {
628
+ throw new Error(
629
+ `Invalid assessment ${ref.file}: ${parsed.error.issues.map((i) => i.message).join("; ")}`
630
+ );
631
+ }
632
+ if (parsed.data.id !== ref.id) {
633
+ throw new Error(
634
+ `Assessment file id "${parsed.data.id}" does not match manifest ref "${ref.id}"`
635
+ );
636
+ }
637
+ const built = toLearnerAssessment(parsed.data);
638
+ assessments[ref.id] = built.learner;
639
+ answerKeys[ref.id] = built.answerKey;
640
+ configs[ref.id] = built.config;
641
+ feedback[ref.id] = built.feedback;
642
+ }
643
+ return { assessments, answerKeys, configs, feedback };
644
+ }
276
645
  export {
646
+ BUILTIN_COMPONENT_IDS,
647
+ assertResolvedPathContained,
277
648
  assessmentQuestionSchema,
278
649
  assessmentRefSchema,
279
650
  assessmentSchema,
651
+ buildRuntimeAssessmentBundle,
652
+ collectActivityIds,
653
+ componentLessonSchema,
654
+ conditionSchema,
280
655
  courseManifestSchema,
656
+ detectFlowCycles,
657
+ flowRuleSchema,
281
658
  formatErrorMessage,
282
659
  formatIssuePath,
283
660
  htmlLessonSchema,
661
+ isBuiltinComponentId,
662
+ isPathContained,
284
663
  lessonSchema,
285
664
  loadManifest,
286
665
  markdownLessonSchema,
287
666
  resolveCoursePath,
288
667
  runtimeConfigSchema,
668
+ showFeedbackSchema,
669
+ toLearnerAssessment,
289
670
  trackingSchema,
290
- validateCourse
671
+ validateCourse,
672
+ validateFlow,
673
+ variableDefSchema
291
674
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/validators",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {