@lxpack/validators 0.1.1 → 0.2.1

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/dist/index.js CHANGED
@@ -1,81 +1,285 @@
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)
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().superRefine((def, ctx) => {
101
+ const t = def.type;
102
+ if (t === "string" && typeof def.default !== "string") {
103
+ ctx.addIssue({
104
+ code: z2.ZodIssueCode.custom,
105
+ message: "Default must be a string when type is string",
106
+ path: ["default"]
107
+ });
108
+ }
109
+ if (t === "number" && typeof def.default !== "number") {
110
+ ctx.addIssue({
111
+ code: z2.ZodIssueCode.custom,
112
+ message: "Default must be a number when type is number",
113
+ path: ["default"]
114
+ });
115
+ }
116
+ if (t === "boolean" && typeof def.default !== "boolean") {
117
+ ctx.addIssue({
118
+ code: z2.ZodIssueCode.custom,
119
+ message: "Default must be a boolean when type is boolean",
120
+ path: ["default"]
121
+ });
122
+ }
123
+ });
124
+ var trackingSchema = z2.object({
125
+ completion: z2.object({
126
+ threshold: z2.number().min(0).max(1).default(0.9)
52
127
  }).strict().optional()
53
128
  }).strict().optional();
54
- var runtimeConfigSchema = z.object({
55
- theme: z.string().default("modern")
129
+ var runtimeConfigSchema = z2.object({
130
+ theme: z2.string().default("modern")
56
131
  }).strict().optional();
57
- var courseManifestSchema = z.object({
58
- title: z.string().min(1),
59
- version: z.string().min(1),
60
- description: z.string().optional(),
132
+ var courseManifestSchema = z2.object({
133
+ title: z2.string().min(1),
134
+ version: z2.string().min(1),
135
+ description: z2.string().optional(),
61
136
  runtime: runtimeConfigSchema,
62
137
  tracking: trackingSchema,
63
- lessons: z.array(lessonSchema).min(1),
64
- assessments: z.array(assessmentRefSchema).optional()
138
+ variables: z2.record(variableDefSchema).optional(),
139
+ flow: z2.array(flowRuleSchema).optional(),
140
+ lessons: z2.array(lessonSchema).min(1),
141
+ assessments: z2.array(assessmentRefSchema).optional()
65
142
  }).strict();
66
143
 
67
- // src/validate.ts
68
- import { existsSync, realpathSync, statSync } from "fs";
69
- import { isAbsolute, join, relative, resolve } from "path";
70
- import { parse as parseYaml } from "yaml";
71
- import { readFile } from "fs/promises";
72
- function formatErrorMessage(err) {
73
- return err instanceof Error ? err.message : String(err);
144
+ // src/components.ts
145
+ var BUILTIN_COMPONENT_IDS = [
146
+ "callout",
147
+ "image-card",
148
+ "checklist"
149
+ ];
150
+ function isBuiltinComponentId(id) {
151
+ return BUILTIN_COMPONENT_IDS.includes(id);
74
152
  }
75
- function formatIssuePath(path) {
76
- const joined = path.map(String).join(".");
77
- return joined || "course.yaml";
153
+
154
+ // src/flow-validate.ts
155
+ function collectActivityIds(manifest) {
156
+ const ids = /* @__PURE__ */ new Set();
157
+ for (const lesson of manifest.lessons) {
158
+ ids.add(lesson.id);
159
+ }
160
+ for (const ref of manifest.assessments ?? []) {
161
+ ids.add(ref.id);
162
+ }
163
+ return ids;
164
+ }
165
+ function collectAssessmentIds(manifest) {
166
+ const ids = /* @__PURE__ */ new Set();
167
+ for (const ref of manifest.assessments ?? []) {
168
+ ids.add(ref.id);
169
+ }
170
+ return ids;
171
+ }
172
+ function collectConditionRefs(condition, refs) {
173
+ if ("variable" in condition && condition.variable?.eq) {
174
+ refs.variables.add(condition.variable.eq[0]);
175
+ }
176
+ if ("assessment" in condition && condition.assessment?.passed) {
177
+ refs.assessments.add(condition.assessment.passed);
178
+ }
179
+ if ("interaction" in condition && condition.interaction?.done) {
180
+ refs.interactions.add(condition.interaction.done);
181
+ }
182
+ if ("all" in condition && condition.all) {
183
+ for (const c of condition.all) collectConditionRefs(c, refs);
184
+ }
185
+ if ("any" in condition && condition.any) {
186
+ for (const c of condition.any) collectConditionRefs(c, refs);
187
+ }
188
+ }
189
+ function validateFlow(manifest) {
190
+ const issues = [];
191
+ const flow = manifest.flow;
192
+ if (!flow?.length) return issues;
193
+ const activityIds = collectActivityIds(manifest);
194
+ const assessmentIds = collectAssessmentIds(manifest);
195
+ const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
196
+ flow.forEach((rule, index) => {
197
+ const path = `flow[${index}]`;
198
+ if (!activityIds.has(rule.goto)) {
199
+ issues.push({
200
+ path: `${path}.goto`,
201
+ message: `Unknown activity id: ${rule.goto}`,
202
+ severity: "error"
203
+ });
204
+ }
205
+ const refs = {
206
+ variables: /* @__PURE__ */ new Set(),
207
+ assessments: /* @__PURE__ */ new Set(),
208
+ interactions: /* @__PURE__ */ new Set()
209
+ };
210
+ collectConditionRefs(rule.when, refs);
211
+ for (const v of refs.variables) {
212
+ if (!manifestVars.has(v)) {
213
+ issues.push({
214
+ path: `${path}.when`,
215
+ message: `Unknown variable in condition: ${v}`,
216
+ severity: "error"
217
+ });
218
+ }
219
+ }
220
+ for (const a of refs.assessments) {
221
+ if (!assessmentIds.has(a)) {
222
+ issues.push({
223
+ path: `${path}.when`,
224
+ message: `Unknown assessment in condition: ${a}`,
225
+ severity: "error"
226
+ });
227
+ }
228
+ }
229
+ for (const i of refs.interactions) {
230
+ if (!activityIds.has(i)) {
231
+ issues.push({
232
+ path: `${path}.when`,
233
+ message: `Unknown interaction/lesson id in condition: ${i}`,
234
+ severity: "error"
235
+ });
236
+ }
237
+ }
238
+ });
239
+ return issues;
240
+ }
241
+ function detectFlowCycles(flow) {
242
+ const gotoOf = /* @__PURE__ */ new Map();
243
+ flow.forEach((rule, i) => gotoOf.set(i, rule.goto));
244
+ const errors = [];
245
+ const visited = /* @__PURE__ */ new Set();
246
+ for (let start = 0; start < flow.length; start++) {
247
+ if (visited.has(start)) continue;
248
+ const chain = /* @__PURE__ */ new Set();
249
+ let i = start;
250
+ while (i !== void 0 && i < flow.length) {
251
+ const ruleIndex = i;
252
+ if (chain.has(ruleIndex)) {
253
+ errors.push(`Flow rule cycle detected involving flow[${ruleIndex}]`);
254
+ break;
255
+ }
256
+ if (visited.has(ruleIndex)) break;
257
+ chain.add(ruleIndex);
258
+ visited.add(ruleIndex);
259
+ const target = gotoOf.get(ruleIndex);
260
+ const nextIdx = flow.findIndex(
261
+ (_, idx) => idx > ruleIndex && flow[idx].goto === target
262
+ );
263
+ i = nextIdx >= 0 ? nextIdx : void 0;
264
+ }
265
+ }
266
+ return errors;
78
267
  }
268
+
269
+ // src/validate.ts
270
+ import { existsSync as existsSync5 } from "fs";
271
+ import { readFile as readFile2 } from "fs/promises";
272
+ import { join as join3, resolve as resolve2 } from "path";
273
+ import { parse as parseYaml2 } from "yaml";
274
+
275
+ // src/course-assessments.ts
276
+ import { existsSync, statSync } from "fs";
277
+ import { readFile } from "fs/promises";
278
+ import { parse as parseYaml } from "yaml";
279
+
280
+ // src/course-paths.ts
281
+ import { realpathSync } from "fs";
282
+ import { isAbsolute, relative, resolve } from "path";
79
283
  function isPathContained(rootDir, candidatePath) {
80
284
  const root = resolve(rootDir);
81
285
  const candidate = resolve(candidatePath);
@@ -106,10 +310,359 @@ function assertResolvedPathContained(courseDir, resolvedPath) {
106
310
  return { ok: false, message: "Path could not be resolved" };
107
311
  }
108
312
  }
313
+
314
+ // src/assessments.ts
315
+ function toLearnerAssessment(assessment) {
316
+ const answerKey = {};
317
+ const feedback = {};
318
+ const questions = assessment.questions.map((q) => {
319
+ const correct = q.choices.find((c) => c.correct === true);
320
+ if (correct) {
321
+ answerKey[q.id] = correct.id;
322
+ }
323
+ if (q.explanation) {
324
+ feedback[q.id] = q.explanation;
325
+ }
326
+ return {
327
+ id: q.id,
328
+ prompt: q.prompt,
329
+ choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
330
+ };
331
+ });
332
+ return {
333
+ learner: {
334
+ id: assessment.id,
335
+ title: assessment.title,
336
+ passingScore: assessment.passingScore,
337
+ questions
338
+ },
339
+ answerKey,
340
+ config: {
341
+ maxAttempts: assessment.maxAttempts ?? 1,
342
+ shuffleChoices: assessment.shuffleChoices ?? false,
343
+ showFeedback: assessment.showFeedback ?? "never"
344
+ },
345
+ feedback
346
+ };
347
+ }
348
+
349
+ // src/course-assessments.ts
350
+ async function loadParsedAssessments(courseDir, manifest) {
351
+ const resolvedDir = courseDir;
352
+ const issues = [];
353
+ const parsed = /* @__PURE__ */ new Map();
354
+ const assessmentIds = /* @__PURE__ */ new Set();
355
+ for (const ref of manifest.assessments ?? []) {
356
+ if (assessmentIds.has(ref.id)) {
357
+ issues.push({
358
+ path: "assessments",
359
+ message: `Duplicate assessment ID: ${ref.id}`,
360
+ severity: "error"
361
+ });
362
+ }
363
+ assessmentIds.add(ref.id);
364
+ const resolved = resolveCoursePath(resolvedDir, ref.file);
365
+ if (!resolved.ok) {
366
+ issues.push({
367
+ path: `assessments.${ref.id}.file`,
368
+ message: resolved.message,
369
+ severity: "error"
370
+ });
371
+ continue;
372
+ }
373
+ if (!existsSync(resolved.path)) {
374
+ issues.push({
375
+ path: `assessments.${ref.id}.file`,
376
+ message: `Assessment file not found: ${ref.file}`,
377
+ severity: "error"
378
+ });
379
+ continue;
380
+ }
381
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
382
+ if (!contained.ok) {
383
+ issues.push({
384
+ path: `assessments.${ref.id}.file`,
385
+ message: contained.message,
386
+ severity: "error"
387
+ });
388
+ continue;
389
+ }
390
+ const assessmentStat = statSync(resolved.path);
391
+ if (!assessmentStat.isFile()) {
392
+ issues.push({
393
+ path: `assessments.${ref.id}.file`,
394
+ message: `Assessment path is not a file: ${ref.file}`,
395
+ severity: "error"
396
+ });
397
+ continue;
398
+ }
399
+ try {
400
+ const content = await readFile(resolved.path, "utf-8");
401
+ const raw = parseYaml(content);
402
+ const result = assessmentSchema.safeParse(raw);
403
+ if (!result.success) {
404
+ for (const issue of result.error.issues) {
405
+ const subPath = issue.path.length ? issue.path.join(".") : "root";
406
+ issues.push({
407
+ path: `${ref.file}:${subPath}`,
408
+ message: issue.message,
409
+ severity: "error"
410
+ });
411
+ }
412
+ continue;
413
+ }
414
+ if (result.data.id !== ref.id) {
415
+ issues.push({
416
+ path: `assessments.${ref.id}`,
417
+ message: `Assessment file id "${result.data.id}" does not match manifest ref id "${ref.id}"`,
418
+ severity: "error"
419
+ });
420
+ continue;
421
+ }
422
+ parsed.set(ref.id, result.data);
423
+ } catch (err) {
424
+ issues.push({
425
+ path: ref.file,
426
+ message: `Failed to parse assessment: ${formatErrorMessage(err)}`,
427
+ severity: "error"
428
+ });
429
+ }
430
+ }
431
+ return { parsed, issues };
432
+ }
433
+ function buildRuntimeAssessmentBundleFromParsed(parsed) {
434
+ const assessments = {};
435
+ const answerKeys = {};
436
+ const configs = {};
437
+ const feedback = {};
438
+ for (const [id, assessment] of parsed) {
439
+ const built = toLearnerAssessment(assessment);
440
+ assessments[id] = built.learner;
441
+ answerKeys[id] = built.answerKey;
442
+ configs[id] = built.config;
443
+ feedback[id] = built.feedback;
444
+ }
445
+ return { assessments, answerKeys, configs, feedback };
446
+ }
447
+ async function buildRuntimeAssessmentBundle(courseDir, manifest) {
448
+ const { parsed, issues } = await loadParsedAssessments(courseDir, manifest);
449
+ const errors = issues.filter((i) => i.severity === "error");
450
+ if (errors.length > 0) {
451
+ throw new Error(
452
+ errors.map((i) => `${i.path}: ${i.message}`).join("; ")
453
+ );
454
+ }
455
+ return buildRuntimeAssessmentBundleFromParsed(parsed);
456
+ }
457
+
458
+ // src/validate/lesson-markdown.ts
459
+ import { existsSync as existsSync2, statSync as statSync2 } from "fs";
460
+ function validateMarkdownLesson(courseDir, lesson) {
461
+ const issues = [];
462
+ const resolved = resolveCoursePath(courseDir, lesson.file);
463
+ if (!resolved.ok) {
464
+ issues.push({
465
+ path: `lessons.${lesson.id}.file`,
466
+ message: resolved.message,
467
+ severity: "error"
468
+ });
469
+ return issues;
470
+ }
471
+ if (!existsSync2(resolved.path)) {
472
+ issues.push({
473
+ path: `lessons.${lesson.id}.file`,
474
+ message: `Lesson file not found: ${lesson.file}`,
475
+ severity: "error"
476
+ });
477
+ return issues;
478
+ }
479
+ const contained = assertResolvedPathContained(courseDir, resolved.path);
480
+ if (!contained.ok) {
481
+ issues.push({
482
+ path: `lessons.${lesson.id}.file`,
483
+ message: contained.message,
484
+ severity: "error"
485
+ });
486
+ return issues;
487
+ }
488
+ const stat = statSync2(resolved.path);
489
+ if (!stat.isFile()) {
490
+ issues.push({
491
+ path: `lessons.${lesson.id}.file`,
492
+ message: `Lesson path is not a file: ${lesson.file}`,
493
+ severity: "error"
494
+ });
495
+ }
496
+ return issues;
497
+ }
498
+
499
+ // src/validate/lesson-html.ts
500
+ import { existsSync as existsSync3, statSync as statSync3 } from "fs";
501
+ import { join } from "path";
502
+ function validateHtmlLesson(courseDir, lesson) {
503
+ const issues = [];
504
+ const resolved = resolveCoursePath(courseDir, lesson.path);
505
+ if (!resolved.ok) {
506
+ issues.push({
507
+ path: `lessons.${lesson.id}.path`,
508
+ message: resolved.message,
509
+ severity: "error"
510
+ });
511
+ return issues;
512
+ }
513
+ if (!existsSync3(resolved.path)) {
514
+ issues.push({
515
+ path: `lessons.${lesson.id}.path`,
516
+ message: `HTML interaction directory not found: ${lesson.path}`,
517
+ severity: "error"
518
+ });
519
+ return issues;
520
+ }
521
+ const contained = assertResolvedPathContained(courseDir, resolved.path);
522
+ if (!contained.ok) {
523
+ issues.push({
524
+ path: `lessons.${lesson.id}.path`,
525
+ message: contained.message,
526
+ severity: "error"
527
+ });
528
+ return issues;
529
+ }
530
+ const stat = statSync3(resolved.path);
531
+ if (!stat.isDirectory()) {
532
+ issues.push({
533
+ path: `lessons.${lesson.id}.path`,
534
+ message: `HTML interaction path is not a directory: ${lesson.path}`,
535
+ severity: "error"
536
+ });
537
+ return issues;
538
+ }
539
+ const indexPath = join(resolved.path, "index.html");
540
+ if (!existsSync3(indexPath)) {
541
+ issues.push({
542
+ path: `lessons.${lesson.id}.path`,
543
+ message: `HTML interaction missing index.html: ${lesson.path}`,
544
+ severity: "error"
545
+ });
546
+ return issues;
547
+ }
548
+ const indexContained = assertResolvedPathContained(courseDir, indexPath);
549
+ if (!indexContained.ok) {
550
+ issues.push({
551
+ path: `lessons.${lesson.id}.path`,
552
+ message: indexContained.message,
553
+ severity: "error"
554
+ });
555
+ return issues;
556
+ }
557
+ if (!statSync3(indexPath).isFile()) {
558
+ issues.push({
559
+ path: `lessons.${lesson.id}.path`,
560
+ message: `index.html is not a file: ${lesson.path}/index.html`,
561
+ severity: "error"
562
+ });
563
+ }
564
+ return issues;
565
+ }
566
+
567
+ // src/validate/lesson-component.ts
568
+ import { existsSync as existsSync4, statSync as statSync4 } from "fs";
569
+ import { join as join2 } from "path";
570
+ function validateComponentLesson(courseDir, lesson) {
571
+ const issues = [];
572
+ if (isBuiltinComponentId(lesson.component)) {
573
+ return issues;
574
+ }
575
+ const resolved = resolveCoursePath(
576
+ courseDir,
577
+ join2("components", lesson.component)
578
+ );
579
+ if (!resolved.ok) {
580
+ issues.push({
581
+ path: `lessons.${lesson.id}.component`,
582
+ message: resolved.message,
583
+ severity: "error"
584
+ });
585
+ return issues;
586
+ }
587
+ if (!existsSync4(resolved.path)) {
588
+ issues.push({
589
+ path: `lessons.${lesson.id}.component`,
590
+ message: `Unknown component "${lesson.component}" and no override at components/${lesson.component}`,
591
+ severity: "error"
592
+ });
593
+ return issues;
594
+ }
595
+ const contained = assertResolvedPathContained(courseDir, resolved.path);
596
+ if (!contained.ok) {
597
+ issues.push({
598
+ path: `lessons.${lesson.id}.component`,
599
+ message: contained.message,
600
+ severity: "error"
601
+ });
602
+ return issues;
603
+ }
604
+ const componentStat = statSync4(resolved.path);
605
+ if (!componentStat.isFile()) {
606
+ issues.push({
607
+ path: `lessons.${lesson.id}.component`,
608
+ message: `Component override path is not a file: components/${lesson.component}`,
609
+ severity: "error"
610
+ });
611
+ }
612
+ return issues;
613
+ }
614
+
615
+ // src/validate/registry.ts
616
+ var lessonValidators = {
617
+ markdown: (courseDir, lesson) => validateMarkdownLesson(courseDir, lesson),
618
+ html: (courseDir, lesson) => validateHtmlLesson(courseDir, lesson),
619
+ component: (courseDir, lesson) => validateComponentLesson(courseDir, lesson)
620
+ };
621
+
622
+ // src/validate/ids.ts
623
+ function validateActivityIds(manifest) {
624
+ const issues = [];
625
+ const lessonIdCounts = /* @__PURE__ */ new Map();
626
+ for (const lesson of manifest.lessons) {
627
+ lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
628
+ }
629
+ for (const [id, count] of lessonIdCounts) {
630
+ if (count > 1) {
631
+ issues.push({
632
+ path: "lessons",
633
+ message: `Duplicate lesson ID: ${id}`,
634
+ severity: "error"
635
+ });
636
+ }
637
+ }
638
+ const assessmentIdSet = /* @__PURE__ */ new Set();
639
+ for (const ref of manifest.assessments ?? []) {
640
+ assessmentIdSet.add(ref.id);
641
+ }
642
+ for (const lesson of manifest.lessons) {
643
+ if (assessmentIdSet.has(lesson.id)) {
644
+ issues.push({
645
+ path: "lessons",
646
+ message: `Lesson ID "${lesson.id}" conflicts with an assessment ID`,
647
+ severity: "error"
648
+ });
649
+ }
650
+ }
651
+ return issues;
652
+ }
653
+
654
+ // src/validate.ts
655
+ function formatErrorMessage(err) {
656
+ return err instanceof Error ? err.message : String(err);
657
+ }
658
+ function formatIssuePath(path) {
659
+ const joined = path.map(String).join(".");
660
+ return joined || "course.yaml";
661
+ }
109
662
  async function loadManifest(courseDir) {
110
- const resolvedDir = resolve(courseDir);
111
- const manifestPath = join(resolvedDir, "course.yaml");
112
- if (!existsSync(manifestPath)) {
663
+ const resolvedDir = resolve2(courseDir);
664
+ const manifestPath = join3(resolvedDir, "course.yaml");
665
+ if (!existsSync5(manifestPath)) {
113
666
  return [
114
667
  {
115
668
  path: "course.yaml",
@@ -120,8 +673,8 @@ async function loadManifest(courseDir) {
120
673
  }
121
674
  let raw;
122
675
  try {
123
- const content = await readFile(manifestPath, "utf-8");
124
- raw = parseYaml(content);
676
+ const content = await readFile2(manifestPath, "utf-8");
677
+ raw = parseYaml2(content);
125
678
  } catch (err) {
126
679
  return [
127
680
  {
@@ -143,198 +696,24 @@ async function loadManifest(courseDir) {
143
696
  }
144
697
  async function validateCourse(courseDir) {
145
698
  const issues = [];
146
- const resolvedDir = resolve(courseDir);
699
+ const resolvedDir = resolve2(courseDir);
147
700
  const loaded = await loadManifest(resolvedDir);
148
701
  if (Array.isArray(loaded)) {
149
702
  return { valid: false, issues: loaded };
150
703
  }
151
704
  const { manifest } = loaded;
152
705
  for (const lesson of manifest.lessons) {
153
- if (lesson.type === "markdown") {
154
- const resolved = resolveCoursePath(resolvedDir, lesson.file);
155
- if (!resolved.ok) {
156
- issues.push({
157
- path: `lessons.${lesson.id}.file`,
158
- message: resolved.message,
159
- severity: "error"
160
- });
161
- continue;
162
- }
163
- if (!existsSync(resolved.path)) {
164
- issues.push({
165
- path: `lessons.${lesson.id}.file`,
166
- message: `Lesson file not found: ${lesson.file}`,
167
- severity: "error"
168
- });
169
- continue;
170
- }
171
- const contained = assertResolvedPathContained(resolvedDir, resolved.path);
172
- if (!contained.ok) {
173
- issues.push({
174
- path: `lessons.${lesson.id}.file`,
175
- message: contained.message,
176
- severity: "error"
177
- });
178
- continue;
179
- }
180
- const stat = statSync(resolved.path);
181
- if (!stat.isFile()) {
182
- issues.push({
183
- path: `lessons.${lesson.id}.file`,
184
- message: `Lesson path is not a file: ${lesson.file}`,
185
- severity: "error"
186
- });
187
- }
188
- } else if (lesson.type === "html") {
189
- const resolved = resolveCoursePath(resolvedDir, lesson.path);
190
- if (!resolved.ok) {
191
- issues.push({
192
- path: `lessons.${lesson.id}.path`,
193
- message: resolved.message,
194
- severity: "error"
195
- });
196
- continue;
197
- }
198
- if (!existsSync(resolved.path)) {
199
- issues.push({
200
- path: `lessons.${lesson.id}.path`,
201
- message: `HTML interaction directory not found: ${lesson.path}`,
202
- severity: "error"
203
- });
204
- continue;
205
- }
206
- const contained = assertResolvedPathContained(resolvedDir, resolved.path);
207
- if (!contained.ok) {
208
- issues.push({
209
- path: `lessons.${lesson.id}.path`,
210
- message: contained.message,
211
- severity: "error"
212
- });
213
- continue;
214
- }
215
- const stat = statSync(resolved.path);
216
- if (!stat.isDirectory()) {
217
- issues.push({
218
- path: `lessons.${lesson.id}.path`,
219
- message: `HTML interaction path is not a directory: ${lesson.path}`,
220
- severity: "error"
221
- });
222
- continue;
223
- }
224
- const indexPath = join(resolved.path, "index.html");
225
- if (!existsSync(indexPath)) {
226
- issues.push({
227
- path: `lessons.${lesson.id}.path`,
228
- message: `HTML interaction missing index.html: ${lesson.path}`,
229
- severity: "error"
230
- });
231
- } else {
232
- const indexContained = assertResolvedPathContained(
233
- resolvedDir,
234
- indexPath
235
- );
236
- if (!indexContained.ok) {
237
- issues.push({
238
- path: `lessons.${lesson.id}.path`,
239
- message: indexContained.message,
240
- severity: "error"
241
- });
242
- } else if (!statSync(indexPath).isFile()) {
243
- issues.push({
244
- path: `lessons.${lesson.id}.path`,
245
- message: `index.html is not a file: ${lesson.path}/index.html`,
246
- severity: "error"
247
- });
248
- }
249
- }
250
- }
251
- }
252
- if (manifest.assessments) {
253
- const assessmentIds = /* @__PURE__ */ new Set();
254
- for (const ref of manifest.assessments) {
255
- if (assessmentIds.has(ref.id)) {
256
- issues.push({
257
- path: "assessments",
258
- message: `Duplicate assessment ID: ${ref.id}`,
259
- severity: "error"
260
- });
261
- }
262
- assessmentIds.add(ref.id);
263
- const resolved = resolveCoursePath(resolvedDir, ref.file);
264
- if (!resolved.ok) {
265
- issues.push({
266
- path: `assessments.${ref.id}.file`,
267
- message: resolved.message,
268
- severity: "error"
269
- });
270
- continue;
271
- }
272
- if (!existsSync(resolved.path)) {
273
- issues.push({
274
- path: `assessments.${ref.id}.file`,
275
- message: `Assessment file not found: ${ref.file}`,
276
- severity: "error"
277
- });
278
- continue;
279
- }
280
- const contained = assertResolvedPathContained(resolvedDir, resolved.path);
281
- if (!contained.ok) {
282
- issues.push({
283
- path: `assessments.${ref.id}.file`,
284
- message: contained.message,
285
- severity: "error"
286
- });
287
- continue;
288
- }
289
- const assessmentStat = statSync(resolved.path);
290
- if (!assessmentStat.isFile()) {
291
- issues.push({
292
- path: `assessments.${ref.id}.file`,
293
- message: `Assessment path is not a file: ${ref.file}`,
294
- severity: "error"
295
- });
296
- continue;
297
- }
298
- try {
299
- const content = await readFile(resolved.path, "utf-8");
300
- const raw = parseYaml(content);
301
- const parsed = assessmentSchema.safeParse(raw);
302
- if (!parsed.success) {
303
- for (const issue of parsed.error.issues) {
304
- const subPath = issue.path.length ? issue.path.join(".") : "root";
305
- issues.push({
306
- path: `${ref.file}:${subPath}`,
307
- message: issue.message,
308
- severity: "error"
309
- });
310
- }
311
- continue;
312
- }
313
- if (parsed.data.id !== ref.id) {
314
- issues.push({
315
- path: `assessments.${ref.id}`,
316
- message: `Assessment file id "${parsed.data.id}" does not match manifest ref id "${ref.id}"`,
317
- severity: "error"
318
- });
319
- }
320
- } catch (err) {
321
- issues.push({
322
- path: ref.file,
323
- message: `Failed to parse assessment: ${formatErrorMessage(err)}`,
324
- severity: "error"
325
- });
326
- }
327
- }
328
- }
329
- const lessonIdCounts = /* @__PURE__ */ new Map();
330
- for (const lesson of manifest.lessons) {
331
- lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
706
+ issues.push(...lessonValidators[lesson.type](resolvedDir, lesson));
332
707
  }
333
- for (const [id, count] of lessonIdCounts) {
334
- if (count > 1) {
708
+ const assessmentLoad = await loadParsedAssessments(resolvedDir, manifest);
709
+ issues.push(...assessmentLoad.issues);
710
+ issues.push(...validateActivityIds(manifest));
711
+ issues.push(...validateFlow(manifest));
712
+ if (manifest.flow?.length) {
713
+ for (const message of detectFlowCycles(manifest.flow)) {
335
714
  issues.push({
336
- path: "lessons",
337
- message: `Duplicate lesson ID: ${id}`,
715
+ path: "flow",
716
+ message,
338
717
  severity: "error"
339
718
  });
340
719
  }
@@ -342,80 +721,64 @@ async function validateCourse(courseDir) {
342
721
  return {
343
722
  valid: issues.filter((i) => i.severity === "error").length === 0,
344
723
  manifest,
345
- issues
724
+ issues,
725
+ parsedAssessments: assessmentLoad.parsed
346
726
  };
347
727
  }
348
728
 
349
- // src/assessments.ts
350
- import { readFile as readFile2 } from "fs/promises";
351
- import { parse as parseYaml2 } from "yaml";
352
- function toLearnerAssessment(assessment) {
353
- const answerKey = {};
354
- const questions = assessment.questions.map((q) => {
355
- const correct = q.choices.find((c) => c.correct === true);
356
- if (correct) {
357
- answerKey[q.id] = correct.id;
358
- }
359
- return {
360
- id: q.id,
361
- prompt: q.prompt,
362
- choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
363
- };
364
- });
365
- return {
366
- learner: {
367
- id: assessment.id,
368
- title: assessment.title,
369
- passingScore: assessment.passingScore,
370
- questions
371
- },
372
- answerKey
373
- };
374
- }
375
- async function buildRuntimeAssessmentBundle(courseDir, manifest) {
376
- const assessments = {};
377
- const answerKeys = {};
729
+ // src/activities.ts
730
+ function enumerateActivities(manifest) {
731
+ const activities = manifest.lessons.map((lesson) => ({
732
+ id: lesson.id,
733
+ title: lesson.title ?? lesson.id,
734
+ kind: "lesson"
735
+ }));
378
736
  for (const ref of manifest.assessments ?? []) {
379
- const resolved = resolveCoursePath(courseDir, ref.file);
380
- if (!resolved.ok) {
381
- throw new Error(resolved.message);
382
- }
383
- const content = await readFile2(resolved.path, "utf-8");
384
- const raw = parseYaml2(content);
385
- const parsed = assessmentSchema.safeParse(raw);
386
- if (!parsed.success) {
387
- throw new Error(
388
- `Invalid assessment ${ref.file}: ${parsed.error.issues.map((i) => i.message).join("; ")}`
389
- );
390
- }
391
- if (parsed.data.id !== ref.id) {
392
- throw new Error(
393
- `Assessment file id "${parsed.data.id}" does not match manifest ref "${ref.id}"`
394
- );
395
- }
396
- const { learner, answerKey } = toLearnerAssessment(parsed.data);
397
- assessments[ref.id] = learner;
398
- answerKeys[ref.id] = answerKey;
737
+ activities.push({
738
+ id: ref.id,
739
+ title: ref.id.replace(/_/g, " "),
740
+ kind: "assessment"
741
+ });
399
742
  }
400
- return { assessments, answerKeys };
743
+ return activities;
744
+ }
745
+
746
+ // src/html.ts
747
+ function escapeHtml(text) {
748
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
401
749
  }
402
750
  export {
751
+ BUILTIN_COMPONENT_IDS,
403
752
  assertResolvedPathContained,
404
753
  assessmentQuestionSchema,
405
754
  assessmentRefSchema,
406
755
  assessmentSchema,
407
756
  buildRuntimeAssessmentBundle,
757
+ buildRuntimeAssessmentBundleFromParsed,
758
+ collectActivityIds,
759
+ collectAssessmentIds,
760
+ componentLessonSchema,
761
+ conditionSchema,
408
762
  courseManifestSchema,
763
+ detectFlowCycles,
764
+ enumerateActivities,
765
+ escapeHtml,
766
+ flowRuleSchema,
409
767
  formatErrorMessage,
410
768
  formatIssuePath,
411
769
  htmlLessonSchema,
770
+ isBuiltinComponentId,
412
771
  isPathContained,
413
772
  lessonSchema,
414
773
  loadManifest,
774
+ loadParsedAssessments,
415
775
  markdownLessonSchema,
416
776
  resolveCoursePath,
417
777
  runtimeConfigSchema,
778
+ showFeedbackSchema,
418
779
  toLearnerAssessment,
419
780
  trackingSchema,
420
- validateCourse
781
+ validateCourse,
782
+ validateFlow,
783
+ variableDefSchema
421
784
  };