@lxpack/validators 0.1.1 → 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 CHANGED
@@ -5,7 +5,7 @@
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
7
 
8
- Zod schemas and filesystem validation for LXPack course manifests.
8
+ Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.0).
9
9
 
10
10
  Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
11
 
@@ -14,6 +14,7 @@ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learni
14
14
  | CLI | [`@lxpack/cli`](../cli/README.md) |
15
15
  | Packaging | [`@lxpack/scorm`](../scorm/README.md) |
16
16
  | Runtime | [`@lxpack/runtime`](../runtime/README.md) |
17
+ | Components | [`@lxpack/components`](../components/README.md) |
17
18
 
18
19
  ## Install
19
20
 
@@ -44,7 +45,9 @@ if (!result.valid) {
44
45
  const { manifest } = result;
45
46
  const bundle = await buildRuntimeAssessmentBundle("/path/to/my-course", manifest);
46
47
  // bundle.assessments — learner-facing questions (no correct flags)
47
- // bundle.answerKeys — scoring keys for the runtime (embedded at build time)
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
48
51
  }
49
52
  ```
50
53
 
@@ -65,6 +68,14 @@ if (Array.isArray(loaded)) {
65
68
  const parsed = courseManifestSchema.safeParse(manifestObject);
66
69
  ```
67
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
+
68
79
  ### Safe path resolution
69
80
 
70
81
  ```ts
@@ -78,34 +89,43 @@ isPathContained(courseDir, abs); // true if inside course root
78
89
 
79
90
  | Export | Description |
80
91
  |--------|-------------|
81
- | `validateCourse(dir)` | Parse `course.yaml`, validate schema, check files, symlink containment |
92
+ | `validateCourse(dir)` | Parse `course.yaml`, validate schema, flow, files, symlink containment |
82
93
  | `loadManifest(courseDir)` | Load and parse `course.yaml` |
83
- | `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view vs answer keys |
84
- | `toLearnerAssessment(assessment)` | Strip `correct` / `explanation` from one assessment |
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 |
85
101
  | `resolveCoursePath(dir, relativePath)` | Resolve a path safely inside the course directory |
86
102
  | `isPathContained(root, target)` | Whether `target` stays under `root` |
87
103
  | `courseManifestSchema` | Zod schema for the full course manifest |
88
- | `lessonSchema`, `assessmentSchema`, … | Strict sub-schemas for manifest sections |
89
- | `CourseManifest`, `Lesson`, `Assessment`, `LearnerAssessment`, `RuntimeAssessmentBundle` | TypeScript types |
104
+ | `lessonSchema`, `assessmentSchema`, `variableDefSchema`, … | Strict sub-schemas |
105
+ | `CourseManifest`, `Lesson`, `Assessment`, `FlowRule`, `VariableDef`, `RuntimeAssessmentBundle` | TypeScript types |
90
106
 
91
107
  ## What gets validated
92
108
 
93
- - Manifest shape (lessons, assessments, tracking rules)
94
- - Lesson types: `markdown` (`file`) and `html` (`path`)
95
- - Assessment YAML: strict MCQ schemas (`correct` on exactly one choice per question)
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
96
114
  - Duplicate lesson IDs
97
115
  - Path containment — referenced files must stay inside the course directory (including via symlinks)
98
- - On-disk assets: files exist and assessments paths are regular files
116
+ - On-disk assets: files exist and assessment paths are regular files
99
117
 
100
118
  ## Assessment packaging
101
119
 
102
120
  Author assessments live as YAML under `assessments/` in the course repo. At build/preview time:
103
121
 
104
122
  1. `buildRuntimeAssessmentBundle()` reads each assessment file.
105
- 2. **Learner payload** — questions and choices without `correct` or `explanation`.
123
+ 2. **Learner payload** — questions and choices without `correct` flags.
106
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).
107
127
 
108
- The CLI and [`@lxpack/scorm`](../scorm/README.md) embed both in the HTML config JSON. Exported ZIPs **do not** include `assessments/` files, so answer keys are not fetchable as static assets.
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.
109
129
 
110
130
  ## Development
111
131
 
@@ -120,6 +140,8 @@ pnpm --filter @lxpack/validators typecheck
120
140
  ## Links
121
141
 
122
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)
123
145
  - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
124
146
 
125
147
  ## License
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;
@@ -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,29 +106,48 @@ 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;
87
117
  title?: string | undefined;
88
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;
135
+ title?: string | undefined;
136
+ props?: Record<string, unknown> | undefined;
137
+ }>;
89
138
  declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
90
139
  id: z.ZodString;
91
140
  type: z.ZodLiteral<"markdown">;
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;
118
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;
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;
@@ -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;
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;
298
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;
322
448
  title?: string | undefined;
449
+ } | {
450
+ type: "component";
451
+ id: string;
452
+ component: string;
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;
489
+ title?: string | undefined;
490
+ } | {
491
+ type: "component";
492
+ id: string;
493
+ component: string;
349
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;
@@ -399,6 +561,11 @@ declare function loadManifest(courseDir: string): Promise<{
399
561
  } | ValidationIssue[]>;
400
562
  declare function validateCourse(courseDir: string): Promise<ValidationResult>;
401
563
 
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
+
402
569
  interface LearnerChoice {
403
570
  id: string;
404
571
  text: string;
@@ -414,14 +581,26 @@ interface LearnerAssessment {
414
581
  passingScore: number;
415
582
  questions: LearnerQuestion[];
416
583
  }
584
+ interface AssessmentRuntimeConfig {
585
+ maxAttempts: number;
586
+ shuffleChoices: boolean;
587
+ showFeedback: ShowFeedback;
588
+ }
589
+ interface QuestionFeedback {
590
+ [questionId: string]: string | undefined;
591
+ }
417
592
  interface RuntimeAssessmentBundle {
418
593
  assessments: Record<string, LearnerAssessment>;
419
594
  answerKeys: Record<string, Record<string, string>>;
595
+ configs: Record<string, AssessmentRuntimeConfig>;
596
+ feedback: Record<string, QuestionFeedback>;
420
597
  }
421
598
  declare function toLearnerAssessment(assessment: Assessment): {
422
599
  learner: LearnerAssessment;
423
600
  answerKey: Record<string, string>;
601
+ config: AssessmentRuntimeConfig;
602
+ feedback: QuestionFeedback;
424
603
  };
425
604
  declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
426
605
 
427
- export { type Assessment, type AssessmentRef, type CourseManifest, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type RuntimeAssessmentBundle, type ValidationIssue, type ValidationResult, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, courseManifestSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isPathContained, lessonSchema, loadManifest, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, toLearnerAssessment, trackingSchema, validateCourse };
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,69 +1,240 @@
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()
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()
7
43
  }).strict();
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()
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()
13
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()
64
+ }).strict();
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()
28
70
  }).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()
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()
34
77
  }).strict();
35
- var lessonSchema = z.discriminatedUnion("type", [
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)
92
+ }).strict();
93
+ var assessmentRefSchema = z2.object({
94
+ id: z2.string().min(1),
95
+ file: z2.string().min(1)
44
96
  }).strict();
45
- var assessmentRefSchema = z.object({
46
- id: z.string().min(1),
47
- file: z.string().min(1)
97
+ var variableDefSchema = z2.object({
98
+ default: z2.union([z2.string(), z2.number(), z2.boolean()]),
99
+ type: z2.enum(["string", "number", "boolean"]).optional()
48
100
  }).strict();
49
- var trackingSchema = z.object({
50
- completion: z.object({
51
- threshold: z.number().min(0).max(1).default(0.9)
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
239
  import { existsSync, realpathSync, statSync } from "fs";
69
240
  import { isAbsolute, join, relative, resolve } from "path";
@@ -185,6 +356,37 @@ async function validateCourse(courseDir) {
185
356
  severity: "error"
186
357
  });
187
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
+ }
188
390
  } else if (lesson.type === "html") {
189
391
  const resolved = resolveCoursePath(resolvedDir, lesson.path);
190
392
  if (!resolved.ok) {
@@ -339,6 +541,33 @@ async function validateCourse(courseDir) {
339
541
  });
340
542
  }
341
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
+ }
570
+ }
342
571
  return {
343
572
  valid: issues.filter((i) => i.severity === "error").length === 0,
344
573
  manifest,
@@ -351,11 +580,15 @@ import { readFile as readFile2 } from "fs/promises";
351
580
  import { parse as parseYaml2 } from "yaml";
352
581
  function toLearnerAssessment(assessment) {
353
582
  const answerKey = {};
583
+ const feedback = {};
354
584
  const questions = assessment.questions.map((q) => {
355
585
  const correct = q.choices.find((c) => c.correct === true);
356
586
  if (correct) {
357
587
  answerKey[q.id] = correct.id;
358
588
  }
589
+ if (q.explanation) {
590
+ feedback[q.id] = q.explanation;
591
+ }
359
592
  return {
360
593
  id: q.id,
361
594
  prompt: q.prompt,
@@ -369,12 +602,20 @@ function toLearnerAssessment(assessment) {
369
602
  passingScore: assessment.passingScore,
370
603
  questions
371
604
  },
372
- answerKey
605
+ answerKey,
606
+ config: {
607
+ maxAttempts: assessment.maxAttempts ?? 1,
608
+ shuffleChoices: assessment.shuffleChoices ?? false,
609
+ showFeedback: assessment.showFeedback ?? "never"
610
+ },
611
+ feedback
373
612
  };
374
613
  }
375
614
  async function buildRuntimeAssessmentBundle(courseDir, manifest) {
376
615
  const assessments = {};
377
616
  const answerKeys = {};
617
+ const configs = {};
618
+ const feedback = {};
378
619
  for (const ref of manifest.assessments ?? []) {
379
620
  const resolved = resolveCoursePath(courseDir, ref.file);
380
621
  if (!resolved.ok) {
@@ -393,29 +634,41 @@ async function buildRuntimeAssessmentBundle(courseDir, manifest) {
393
634
  `Assessment file id "${parsed.data.id}" does not match manifest ref "${ref.id}"`
394
635
  );
395
636
  }
396
- const { learner, answerKey } = toLearnerAssessment(parsed.data);
397
- assessments[ref.id] = learner;
398
- answerKeys[ref.id] = answerKey;
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;
399
642
  }
400
- return { assessments, answerKeys };
643
+ return { assessments, answerKeys, configs, feedback };
401
644
  }
402
645
  export {
646
+ BUILTIN_COMPONENT_IDS,
403
647
  assertResolvedPathContained,
404
648
  assessmentQuestionSchema,
405
649
  assessmentRefSchema,
406
650
  assessmentSchema,
407
651
  buildRuntimeAssessmentBundle,
652
+ collectActivityIds,
653
+ componentLessonSchema,
654
+ conditionSchema,
408
655
  courseManifestSchema,
656
+ detectFlowCycles,
657
+ flowRuleSchema,
409
658
  formatErrorMessage,
410
659
  formatIssuePath,
411
660
  htmlLessonSchema,
661
+ isBuiltinComponentId,
412
662
  isPathContained,
413
663
  lessonSchema,
414
664
  loadManifest,
415
665
  markdownLessonSchema,
416
666
  resolveCoursePath,
417
667
  runtimeConfigSchema,
668
+ showFeedbackSchema,
418
669
  toLearnerAssessment,
419
670
  trackingSchema,
420
- validateCourse
671
+ validateCourse,
672
+ validateFlow,
673
+ variableDefSchema
421
674
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/validators",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {