@lxpack/validators 0.2.0 → 0.2.2

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 — including flow, variables, and component lessons (v0.2.0).
8
+ Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.2).
9
9
 
10
10
  Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
11
 
@@ -94,7 +94,7 @@ isPathContained(courseDir, abs); // true if inside course root
94
94
  | `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view, keys, configs, feedback |
95
95
  | `toLearnerAssessment(assessment)` | Strip `correct` from choices; extract config and feedback maps |
96
96
  | `validateFlow(manifest)` | Flow rule and target validation |
97
- | `detectFlowCycles(flow)` | Cycle detection for branching graphs |
97
+ | `detectFlowCycles(manifest)` | Flow-jump cycle detection for branching graphs |
98
98
  | `collectActivityIds(manifest)` | Lesson and assessment IDs for flow targets |
99
99
  | `conditionSchema`, `flowRuleSchema` | Zod schemas for flow conditions and rules |
100
100
  | `BUILTIN_COMPONENT_IDS`, `isBuiltinComponentId` | Allowed built-in component lesson IDs |
package/dist/index.d.ts CHANGED
@@ -30,6 +30,8 @@ declare const flowRuleSchema: z.ZodObject<{
30
30
  }>;
31
31
  type FlowRule = z.infer<typeof flowRuleSchema>;
32
32
 
33
+ /** Safe for SCORM paths and manifest identifiers. */
34
+ declare const activityIdSchema: z.ZodString;
33
35
  declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
34
36
  id: z.ZodString;
35
37
  prompt: z.ZodString;
@@ -185,7 +187,7 @@ declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
185
187
  props?: Record<string, unknown> | undefined;
186
188
  }>]>;
187
189
  declare const showFeedbackSchema: z.ZodDefault<z.ZodEnum<["immediate", "end", "never"]>>;
188
- declare const assessmentSchema: z.ZodObject<{
190
+ declare const assessmentSchema: z.ZodEffects<z.ZodObject<{
189
191
  id: z.ZodString;
190
192
  title: z.ZodOptional<z.ZodString>;
191
193
  passingScore: z.ZodDefault<z.ZodNumber>;
@@ -280,6 +282,40 @@ declare const assessmentSchema: z.ZodObject<{
280
282
  maxAttempts?: number | undefined;
281
283
  shuffleChoices?: boolean | undefined;
282
284
  showFeedback?: "never" | "immediate" | "end" | undefined;
285
+ }>, {
286
+ id: string;
287
+ passingScore: number;
288
+ questions: {
289
+ id: string;
290
+ prompt: string;
291
+ choices: {
292
+ id: string;
293
+ text: string;
294
+ correct?: boolean | undefined;
295
+ }[];
296
+ explanation?: string | undefined;
297
+ }[];
298
+ title?: string | undefined;
299
+ maxAttempts?: number | undefined;
300
+ shuffleChoices?: boolean | undefined;
301
+ showFeedback?: "never" | "immediate" | "end" | undefined;
302
+ }, {
303
+ id: string;
304
+ questions: {
305
+ id: string;
306
+ prompt: string;
307
+ choices: {
308
+ id: string;
309
+ text: string;
310
+ correct?: boolean | undefined;
311
+ }[];
312
+ explanation?: string | undefined;
313
+ }[];
314
+ title?: string | undefined;
315
+ passingScore?: number | undefined;
316
+ maxAttempts?: number | undefined;
317
+ shuffleChoices?: boolean | undefined;
318
+ showFeedback?: "never" | "immediate" | "end" | undefined;
283
319
  }>;
284
320
  declare const assessmentRefSchema: z.ZodObject<{
285
321
  id: z.ZodString;
@@ -291,7 +327,7 @@ declare const assessmentRefSchema: z.ZodObject<{
291
327
  id: string;
292
328
  file: string;
293
329
  }>;
294
- declare const variableDefSchema: z.ZodObject<{
330
+ declare const variableDefSchema: z.ZodEffects<z.ZodObject<{
295
331
  default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
296
332
  type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
297
333
  }, "strict", z.ZodTypeAny, {
@@ -300,6 +336,12 @@ declare const variableDefSchema: z.ZodObject<{
300
336
  }, {
301
337
  default: string | number | boolean;
302
338
  type?: "string" | "number" | "boolean" | undefined;
339
+ }>, {
340
+ default: string | number | boolean;
341
+ type?: "string" | "number" | "boolean" | undefined;
342
+ }, {
343
+ default: string | number | boolean;
344
+ type?: "string" | "number" | "boolean" | undefined;
303
345
  }>;
304
346
  declare const trackingSchema: z.ZodOptional<z.ZodObject<{
305
347
  completion: z.ZodOptional<z.ZodObject<{
@@ -354,7 +396,7 @@ declare const courseManifestSchema: z.ZodObject<{
354
396
  threshold?: number | undefined;
355
397
  } | undefined;
356
398
  }>>;
357
- variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
399
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
358
400
  default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
359
401
  type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
360
402
  }, "strict", z.ZodTypeAny, {
@@ -363,6 +405,12 @@ declare const courseManifestSchema: z.ZodObject<{
363
405
  }, {
364
406
  default: string | number | boolean;
365
407
  type?: "string" | "number" | "boolean" | undefined;
408
+ }>, {
409
+ default: string | number | boolean;
410
+ type?: "string" | "number" | "boolean" | undefined;
411
+ }, {
412
+ default: string | number | boolean;
413
+ type?: "string" | "number" | "boolean" | undefined;
366
414
  }>>>;
367
415
  flow: z.ZodOptional<z.ZodArray<z.ZodObject<{
368
416
  when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
@@ -529,18 +577,6 @@ declare const BUILTIN_COMPONENT_IDS: readonly ["callout", "image-card", "checkli
529
577
  type BuiltinComponentId = (typeof BUILTIN_COMPONENT_IDS)[number];
530
578
  declare function isBuiltinComponentId(id: string): id is BuiltinComponentId;
531
579
 
532
- declare function formatErrorMessage(err: unknown): string;
533
- declare function formatIssuePath(path: PropertyKey[]): string;
534
- interface ValidationIssue {
535
- path: string;
536
- message: string;
537
- severity: "error" | "warning";
538
- }
539
- interface ValidationResult {
540
- valid: boolean;
541
- manifest?: CourseManifest;
542
- issues: ValidationIssue[];
543
- }
544
580
  declare function isPathContained(rootDir: string, candidatePath: string): boolean;
545
581
  declare function resolveCoursePath(courseDir: string, relativePath: string): {
546
582
  ok: true;
@@ -555,6 +591,21 @@ declare function assertResolvedPathContained(courseDir: string, resolvedPath: st
555
591
  ok: false;
556
592
  message: string;
557
593
  };
594
+
595
+ declare function formatErrorMessage(err: unknown): string;
596
+ declare function formatIssuePath(path: PropertyKey[]): string;
597
+ interface ValidationIssue {
598
+ path: string;
599
+ message: string;
600
+ severity: "error" | "warning";
601
+ }
602
+ interface ValidationResult {
603
+ valid: boolean;
604
+ manifest?: CourseManifest;
605
+ issues: ValidationIssue[];
606
+ /** Populated when assessment files parse successfully. */
607
+ parsedAssessments?: Map<string, Assessment>;
608
+ }
558
609
  declare function loadManifest(courseDir: string): Promise<{
559
610
  manifest: CourseManifest;
560
611
  raw: unknown;
@@ -562,9 +613,14 @@ declare function loadManifest(courseDir: string): Promise<{
562
613
  declare function validateCourse(courseDir: string): Promise<ValidationResult>;
563
614
 
564
615
  declare function collectActivityIds(manifest: CourseManifest): Set<string>;
616
+ declare function collectAssessmentIds(manifest: CourseManifest): Set<string>;
617
+ declare function collectInteractionIds(manifest: CourseManifest): Set<string>;
618
+ declare function buildActivityOrder(manifest: CourseManifest): string[];
565
619
  declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
566
- /** Detect simple goto cycles (same-target chains). */
567
- declare function detectFlowCycles(flow: FlowRule[]): string[];
620
+ /**
621
+ * Detect cycles reachable via flow jumps (first matching applicable rule).
622
+ */
623
+ declare function detectFlowCycles(manifest: CourseManifest): string[];
568
624
 
569
625
  interface LearnerChoice {
570
626
  id: string;
@@ -601,6 +657,22 @@ declare function toLearnerAssessment(assessment: Assessment): {
601
657
  config: AssessmentRuntimeConfig;
602
658
  feedback: QuestionFeedback;
603
659
  };
660
+
661
+ interface ParsedAssessmentsResult {
662
+ parsed: Map<string, Assessment>;
663
+ issues: ValidationIssue[];
664
+ }
665
+ declare function loadParsedAssessments(courseDir: string, manifest: CourseManifest): Promise<ParsedAssessmentsResult>;
666
+ declare function buildRuntimeAssessmentBundleFromParsed(parsed: Map<string, Assessment>): RuntimeAssessmentBundle;
604
667
  declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
605
668
 
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 };
669
+ interface CourseActivity {
670
+ id: string;
671
+ title: string;
672
+ kind: "lesson" | "assessment";
673
+ }
674
+ declare function enumerateActivities(manifest: CourseManifest): CourseActivity[];
675
+
676
+ declare function escapeHtml(text: string): string;
677
+
678
+ export { type Assessment, type AssessmentRef, type AssessmentRuntimeConfig, BUILTIN_COMPONENT_IDS, type BuiltinComponentId, type ComponentLesson, type Condition, type CourseActivity, type CourseManifest, type FlowRule, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type QuestionFeedback, type RuntimeAssessmentBundle, type ShowFeedback, type ValidationIssue, type ValidationResult, type VariableDef, activityIdSchema, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildActivityOrder, buildRuntimeAssessmentBundle, buildRuntimeAssessmentBundleFromParsed, collectActivityIds, collectAssessmentIds, collectInteractionIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, enumerateActivities, escapeHtml, flowRuleSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, loadParsedAssessments, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, variableDefSchema };
package/dist/index.js CHANGED
@@ -36,6 +36,10 @@ var flowRuleSchema = z.object({
36
36
  }).strict();
37
37
 
38
38
  // src/schemas.ts
39
+ var activityIdSchema = z2.string().min(1).regex(
40
+ /^[a-zA-Z][a-zA-Z0-9_-]*$/,
41
+ "ID must start with a letter and contain only letters, numbers, underscores, and hyphens"
42
+ );
39
43
  var choiceSchema = z2.object({
40
44
  id: z2.string().min(1),
41
45
  text: z2.string().min(1),
@@ -55,21 +59,33 @@ var assessmentQuestionSchema = z2.object({
55
59
  path: ["choices"]
56
60
  });
57
61
  }
62
+ const choiceIds = /* @__PURE__ */ new Set();
63
+ for (let i = 0; i < question.choices.length; i++) {
64
+ const choice = question.choices[i];
65
+ if (choiceIds.has(choice.id)) {
66
+ ctx.addIssue({
67
+ code: z2.ZodIssueCode.custom,
68
+ message: `Duplicate choice id: ${choice.id}`,
69
+ path: ["choices", i, "id"]
70
+ });
71
+ }
72
+ choiceIds.add(choice.id);
73
+ }
58
74
  });
59
75
  var markdownLessonSchema = z2.object({
60
- id: z2.string().min(1),
76
+ id: activityIdSchema,
61
77
  type: z2.literal("markdown"),
62
78
  file: z2.string().min(1),
63
79
  title: z2.string().optional()
64
80
  }).strict();
65
81
  var htmlLessonSchema = z2.object({
66
- id: z2.string().min(1),
82
+ id: activityIdSchema,
67
83
  type: z2.literal("html"),
68
84
  path: z2.string().min(1),
69
85
  title: z2.string().optional()
70
86
  }).strict();
71
87
  var componentLessonSchema = z2.object({
72
- id: z2.string().min(1),
88
+ id: activityIdSchema,
73
89
  type: z2.literal("component"),
74
90
  component: z2.string().min(1),
75
91
  props: z2.record(z2.unknown()).optional(),
@@ -82,22 +98,58 @@ var lessonSchema = z2.discriminatedUnion("type", [
82
98
  ]);
83
99
  var showFeedbackSchema = z2.enum(["immediate", "end", "never"]).default("never");
84
100
  var assessmentSchema = z2.object({
85
- id: z2.string().min(1),
101
+ id: activityIdSchema,
86
102
  title: z2.string().optional(),
87
103
  passingScore: z2.number().min(0).max(1).default(0.7),
88
104
  maxAttempts: z2.number().int().min(1).optional(),
89
105
  shuffleChoices: z2.boolean().optional(),
90
106
  showFeedback: showFeedbackSchema.optional(),
91
107
  questions: z2.array(assessmentQuestionSchema).min(1)
92
- }).strict();
108
+ }).strict().superRefine((assessment, ctx) => {
109
+ const questionIds = /* @__PURE__ */ new Set();
110
+ for (let i = 0; i < assessment.questions.length; i++) {
111
+ const q = assessment.questions[i];
112
+ if (questionIds.has(q.id)) {
113
+ ctx.addIssue({
114
+ code: z2.ZodIssueCode.custom,
115
+ message: `Duplicate question id: ${q.id}`,
116
+ path: ["questions", i, "id"]
117
+ });
118
+ }
119
+ questionIds.add(q.id);
120
+ }
121
+ });
93
122
  var assessmentRefSchema = z2.object({
94
- id: z2.string().min(1),
123
+ id: activityIdSchema,
95
124
  file: z2.string().min(1)
96
125
  }).strict();
97
126
  var variableDefSchema = z2.object({
98
127
  default: z2.union([z2.string(), z2.number(), z2.boolean()]),
99
128
  type: z2.enum(["string", "number", "boolean"]).optional()
100
- }).strict();
129
+ }).strict().superRefine((def, ctx) => {
130
+ const t = def.type;
131
+ if (t === "string" && typeof def.default !== "string") {
132
+ ctx.addIssue({
133
+ code: z2.ZodIssueCode.custom,
134
+ message: "Default must be a string when type is string",
135
+ path: ["default"]
136
+ });
137
+ }
138
+ if (t === "number" && typeof def.default !== "number") {
139
+ ctx.addIssue({
140
+ code: z2.ZodIssueCode.custom,
141
+ message: "Default must be a number when type is number",
142
+ path: ["default"]
143
+ });
144
+ }
145
+ if (t === "boolean" && typeof def.default !== "boolean") {
146
+ ctx.addIssue({
147
+ code: z2.ZodIssueCode.custom,
148
+ message: "Default must be a boolean when type is boolean",
149
+ path: ["default"]
150
+ });
151
+ }
152
+ });
101
153
  var trackingSchema = z2.object({
102
154
  completion: z2.object({
103
155
  threshold: z2.number().min(0).max(1).default(0.9)
@@ -139,6 +191,61 @@ function collectActivityIds(manifest) {
139
191
  }
140
192
  return ids;
141
193
  }
194
+ function collectAssessmentIds(manifest) {
195
+ const ids = /* @__PURE__ */ new Set();
196
+ for (const ref of manifest.assessments ?? []) {
197
+ ids.add(ref.id);
198
+ }
199
+ return ids;
200
+ }
201
+ function collectInteractionIds(manifest) {
202
+ const ids = /* @__PURE__ */ new Set();
203
+ for (const lesson of manifest.lessons) {
204
+ if (lesson.type === "html") {
205
+ ids.add(lesson.id);
206
+ }
207
+ }
208
+ return ids;
209
+ }
210
+ function buildActivityOrder(manifest) {
211
+ const ids = manifest.lessons.map((l) => l.id);
212
+ for (const ref of manifest.assessments ?? []) {
213
+ ids.push(ref.id);
214
+ }
215
+ return ids;
216
+ }
217
+ function validateConditionShape(condition, path) {
218
+ const issues = [];
219
+ if ("all" in condition && condition.all) {
220
+ if (condition.all.length === 0) {
221
+ issues.push({
222
+ path,
223
+ message: "Condition all: [] is always true at runtime; use a non-empty list",
224
+ severity: "error"
225
+ });
226
+ }
227
+ for (let i = 0; i < condition.all.length; i++) {
228
+ issues.push(
229
+ ...validateConditionShape(condition.all[i], `${path}.all[${i}]`)
230
+ );
231
+ }
232
+ }
233
+ if ("any" in condition && condition.any) {
234
+ if (condition.any.length === 0) {
235
+ issues.push({
236
+ path,
237
+ message: "Condition any: [] is always false at runtime; use a non-empty list",
238
+ severity: "error"
239
+ });
240
+ }
241
+ for (let i = 0; i < condition.any.length; i++) {
242
+ issues.push(
243
+ ...validateConditionShape(condition.any[i], `${path}.any[${i}]`)
244
+ );
245
+ }
246
+ }
247
+ return issues;
248
+ }
142
249
  function collectConditionRefs(condition, refs) {
143
250
  if ("variable" in condition && condition.variable?.eq) {
144
251
  refs.variables.add(condition.variable.eq[0]);
@@ -161,9 +268,12 @@ function validateFlow(manifest) {
161
268
  const flow = manifest.flow;
162
269
  if (!flow?.length) return issues;
163
270
  const activityIds = collectActivityIds(manifest);
271
+ const assessmentIds = collectAssessmentIds(manifest);
272
+ const interactionIds = collectInteractionIds(manifest);
164
273
  const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
165
274
  flow.forEach((rule, index) => {
166
275
  const path = `flow[${index}]`;
276
+ issues.push(...validateConditionShape(rule.when, `${path}.when`));
167
277
  if (!activityIds.has(rule.goto)) {
168
278
  issues.push({
169
279
  path: `${path}.goto`,
@@ -187,7 +297,7 @@ function validateFlow(manifest) {
187
297
  }
188
298
  }
189
299
  for (const a of refs.assessments) {
190
- if (!activityIds.has(a)) {
300
+ if (!assessmentIds.has(a)) {
191
301
  issues.push({
192
302
  path: `${path}.when`,
193
303
  message: `Unknown assessment in condition: ${a}`,
@@ -196,57 +306,92 @@ function validateFlow(manifest) {
196
306
  }
197
307
  }
198
308
  for (const i of refs.interactions) {
199
- if (!activityIds.has(i)) {
309
+ if (!interactionIds.has(i)) {
200
310
  issues.push({
201
311
  path: `${path}.when`,
202
- message: `Unknown interaction/lesson id in condition: ${i}`,
203
- severity: "warning"
312
+ message: `Unknown interaction id in condition (expected html lesson): ${i}`,
313
+ severity: "error"
204
314
  });
205
315
  }
206
316
  }
207
317
  });
208
318
  return issues;
209
319
  }
210
- function detectFlowCycles(flow) {
211
- const gotoOf = /* @__PURE__ */ new Map();
212
- flow.forEach((rule, i) => gotoOf.set(i, rule.goto));
320
+ function conditionCouldApplyAt(condition, currentActivityId, interactionIds) {
321
+ if ("variable" in condition) return true;
322
+ if ("assessment" in condition && condition.assessment?.passed) {
323
+ return currentActivityId === condition.assessment.passed;
324
+ }
325
+ if ("interaction" in condition && condition.interaction?.done) {
326
+ return interactionIds.has(currentActivityId);
327
+ }
328
+ if ("all" in condition && condition.all) {
329
+ return condition.all.length > 0 && condition.all.every(
330
+ (c) => conditionCouldApplyAt(c, currentActivityId, interactionIds)
331
+ );
332
+ }
333
+ if ("any" in condition && condition.any) {
334
+ return condition.any.length > 0 && condition.any.some(
335
+ (c) => conditionCouldApplyAt(c, currentActivityId, interactionIds)
336
+ );
337
+ }
338
+ return false;
339
+ }
340
+ function detectFlowCycles(manifest) {
341
+ const flow = manifest.flow;
342
+ if (!flow?.length) return [];
343
+ const activityIds = collectActivityIds(manifest);
344
+ const interactionIds = collectInteractionIds(manifest);
345
+ const flowJumpFrom = (current) => {
346
+ for (const rule of flow) {
347
+ if (rule.goto !== current && activityIds.has(rule.goto) && conditionCouldApplyAt(rule.when, current, interactionIds)) {
348
+ return rule.goto;
349
+ }
350
+ }
351
+ return null;
352
+ };
213
353
  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;
354
+ const reported = /* @__PURE__ */ new Set();
355
+ const dfs = (node, stack, onStack) => {
356
+ const jump = flowJumpFrom(node);
357
+ if (!jump) return;
358
+ if (onStack.has(jump)) {
359
+ const cycleStart = stack.indexOf(jump);
360
+ const cycle = [...stack.slice(cycleStart), jump];
361
+ const key = cycle.join("->");
362
+ if (!reported.has(key)) {
363
+ reported.add(key);
364
+ errors.push(`Flow cycle: ${cycle.join(" -> ")}`);
224
365
  }
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;
366
+ return;
233
367
  }
368
+ if (stack.length >= activityIds.size) return;
369
+ onStack.add(jump);
370
+ stack.push(jump);
371
+ dfs(jump, stack, onStack);
372
+ stack.pop();
373
+ onStack.delete(jump);
374
+ };
375
+ for (const id of activityIds) {
376
+ dfs(id, [id], /* @__PURE__ */ new Set([id]));
234
377
  }
235
378
  return errors;
236
379
  }
237
380
 
238
381
  // src/validate.ts
239
- import { existsSync, realpathSync, statSync } from "fs";
240
- import { isAbsolute, join, relative, resolve } from "path";
241
- import { parse as parseYaml } from "yaml";
382
+ import { existsSync as existsSync5 } from "fs";
383
+ import { readFile as readFile2 } from "fs/promises";
384
+ import { join as join3, resolve as resolve2 } from "path";
385
+ import { parse as parseYaml2 } from "yaml";
386
+
387
+ // src/course-assessments.ts
388
+ import { existsSync, statSync } from "fs";
242
389
  import { readFile } from "fs/promises";
243
- function formatErrorMessage(err) {
244
- return err instanceof Error ? err.message : String(err);
245
- }
246
- function formatIssuePath(path) {
247
- const joined = path.map(String).join(".");
248
- return joined || "course.yaml";
249
- }
390
+ import { parse as parseYaml } from "yaml";
391
+
392
+ // src/course-paths.ts
393
+ import { realpathSync } from "fs";
394
+ import { isAbsolute, relative, resolve } from "path";
250
395
  function isPathContained(rootDir, candidatePath) {
251
396
  const root = resolve(rootDir);
252
397
  const candidate = resolve(candidatePath);
@@ -277,10 +422,360 @@ function assertResolvedPathContained(courseDir, resolvedPath) {
277
422
  return { ok: false, message: "Path could not be resolved" };
278
423
  }
279
424
  }
425
+
426
+ // src/assessments.ts
427
+ function toLearnerAssessment(assessment) {
428
+ const answerKey = {};
429
+ const feedback = {};
430
+ const questions = assessment.questions.map((q) => {
431
+ const correct = q.choices.find((c) => c.correct === true);
432
+ if (correct) {
433
+ answerKey[q.id] = correct.id;
434
+ }
435
+ if (q.explanation) {
436
+ feedback[q.id] = q.explanation;
437
+ }
438
+ return {
439
+ id: q.id,
440
+ prompt: q.prompt,
441
+ choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
442
+ };
443
+ });
444
+ return {
445
+ learner: {
446
+ id: assessment.id,
447
+ title: assessment.title,
448
+ passingScore: assessment.passingScore,
449
+ questions
450
+ },
451
+ answerKey,
452
+ config: {
453
+ maxAttempts: assessment.maxAttempts ?? 1,
454
+ shuffleChoices: assessment.shuffleChoices ?? false,
455
+ showFeedback: assessment.showFeedback ?? "never"
456
+ },
457
+ feedback
458
+ };
459
+ }
460
+
461
+ // src/course-assessments.ts
462
+ async function loadParsedAssessments(courseDir, manifest) {
463
+ const resolvedDir = courseDir;
464
+ const issues = [];
465
+ const parsed = /* @__PURE__ */ new Map();
466
+ const assessmentIds = /* @__PURE__ */ new Set();
467
+ for (const ref of manifest.assessments ?? []) {
468
+ if (assessmentIds.has(ref.id)) {
469
+ issues.push({
470
+ path: "assessments",
471
+ message: `Duplicate assessment ID: ${ref.id}`,
472
+ severity: "error"
473
+ });
474
+ continue;
475
+ }
476
+ assessmentIds.add(ref.id);
477
+ const resolved = resolveCoursePath(resolvedDir, ref.file);
478
+ if (!resolved.ok) {
479
+ issues.push({
480
+ path: `assessments.${ref.id}.file`,
481
+ message: resolved.message,
482
+ severity: "error"
483
+ });
484
+ continue;
485
+ }
486
+ if (!existsSync(resolved.path)) {
487
+ issues.push({
488
+ path: `assessments.${ref.id}.file`,
489
+ message: `Assessment file not found: ${ref.file}`,
490
+ severity: "error"
491
+ });
492
+ continue;
493
+ }
494
+ const contained = assertResolvedPathContained(resolvedDir, resolved.path);
495
+ if (!contained.ok) {
496
+ issues.push({
497
+ path: `assessments.${ref.id}.file`,
498
+ message: contained.message,
499
+ severity: "error"
500
+ });
501
+ continue;
502
+ }
503
+ const assessmentStat = statSync(resolved.path);
504
+ if (!assessmentStat.isFile()) {
505
+ issues.push({
506
+ path: `assessments.${ref.id}.file`,
507
+ message: `Assessment path is not a file: ${ref.file}`,
508
+ severity: "error"
509
+ });
510
+ continue;
511
+ }
512
+ try {
513
+ const content = await readFile(resolved.path, "utf-8");
514
+ const raw = parseYaml(content);
515
+ const result = assessmentSchema.safeParse(raw);
516
+ if (!result.success) {
517
+ for (const issue of result.error.issues) {
518
+ const subPath = issue.path.length ? issue.path.join(".") : "root";
519
+ issues.push({
520
+ path: `${ref.file}:${subPath}`,
521
+ message: issue.message,
522
+ severity: "error"
523
+ });
524
+ }
525
+ continue;
526
+ }
527
+ if (result.data.id !== ref.id) {
528
+ issues.push({
529
+ path: `assessments.${ref.id}`,
530
+ message: `Assessment file id "${result.data.id}" does not match manifest ref id "${ref.id}"`,
531
+ severity: "error"
532
+ });
533
+ continue;
534
+ }
535
+ parsed.set(ref.id, result.data);
536
+ } catch (err) {
537
+ issues.push({
538
+ path: ref.file,
539
+ message: `Failed to parse assessment: ${formatErrorMessage(err)}`,
540
+ severity: "error"
541
+ });
542
+ }
543
+ }
544
+ return { parsed, issues };
545
+ }
546
+ function buildRuntimeAssessmentBundleFromParsed(parsed) {
547
+ const assessments = {};
548
+ const answerKeys = {};
549
+ const configs = {};
550
+ const feedback = {};
551
+ for (const [id, assessment] of parsed) {
552
+ const built = toLearnerAssessment(assessment);
553
+ assessments[id] = built.learner;
554
+ answerKeys[id] = built.answerKey;
555
+ configs[id] = built.config;
556
+ feedback[id] = built.feedback;
557
+ }
558
+ return { assessments, answerKeys, configs, feedback };
559
+ }
560
+ async function buildRuntimeAssessmentBundle(courseDir, manifest) {
561
+ const { parsed, issues } = await loadParsedAssessments(courseDir, manifest);
562
+ const errors = issues.filter((i) => i.severity === "error");
563
+ if (errors.length > 0) {
564
+ throw new Error(
565
+ errors.map((i) => `${i.path}: ${i.message}`).join("; ")
566
+ );
567
+ }
568
+ return buildRuntimeAssessmentBundleFromParsed(parsed);
569
+ }
570
+
571
+ // src/validate/lesson-markdown.ts
572
+ import { existsSync as existsSync2, statSync as statSync2 } from "fs";
573
+ function validateMarkdownLesson(courseDir, lesson) {
574
+ const issues = [];
575
+ const resolved = resolveCoursePath(courseDir, lesson.file);
576
+ if (!resolved.ok) {
577
+ issues.push({
578
+ path: `lessons.${lesson.id}.file`,
579
+ message: resolved.message,
580
+ severity: "error"
581
+ });
582
+ return issues;
583
+ }
584
+ if (!existsSync2(resolved.path)) {
585
+ issues.push({
586
+ path: `lessons.${lesson.id}.file`,
587
+ message: `Lesson file not found: ${lesson.file}`,
588
+ severity: "error"
589
+ });
590
+ return issues;
591
+ }
592
+ const contained = assertResolvedPathContained(courseDir, resolved.path);
593
+ if (!contained.ok) {
594
+ issues.push({
595
+ path: `lessons.${lesson.id}.file`,
596
+ message: contained.message,
597
+ severity: "error"
598
+ });
599
+ return issues;
600
+ }
601
+ const stat = statSync2(resolved.path);
602
+ if (!stat.isFile()) {
603
+ issues.push({
604
+ path: `lessons.${lesson.id}.file`,
605
+ message: `Lesson path is not a file: ${lesson.file}`,
606
+ severity: "error"
607
+ });
608
+ }
609
+ return issues;
610
+ }
611
+
612
+ // src/validate/lesson-html.ts
613
+ import { existsSync as existsSync3, statSync as statSync3 } from "fs";
614
+ import { join } from "path";
615
+ function validateHtmlLesson(courseDir, lesson) {
616
+ const issues = [];
617
+ const resolved = resolveCoursePath(courseDir, lesson.path);
618
+ if (!resolved.ok) {
619
+ issues.push({
620
+ path: `lessons.${lesson.id}.path`,
621
+ message: resolved.message,
622
+ severity: "error"
623
+ });
624
+ return issues;
625
+ }
626
+ if (!existsSync3(resolved.path)) {
627
+ issues.push({
628
+ path: `lessons.${lesson.id}.path`,
629
+ message: `HTML interaction directory not found: ${lesson.path}`,
630
+ severity: "error"
631
+ });
632
+ return issues;
633
+ }
634
+ const contained = assertResolvedPathContained(courseDir, resolved.path);
635
+ if (!contained.ok) {
636
+ issues.push({
637
+ path: `lessons.${lesson.id}.path`,
638
+ message: contained.message,
639
+ severity: "error"
640
+ });
641
+ return issues;
642
+ }
643
+ const stat = statSync3(resolved.path);
644
+ if (!stat.isDirectory()) {
645
+ issues.push({
646
+ path: `lessons.${lesson.id}.path`,
647
+ message: `HTML interaction path is not a directory: ${lesson.path}`,
648
+ severity: "error"
649
+ });
650
+ return issues;
651
+ }
652
+ const indexPath = join(resolved.path, "index.html");
653
+ if (!existsSync3(indexPath)) {
654
+ issues.push({
655
+ path: `lessons.${lesson.id}.path`,
656
+ message: `HTML interaction missing index.html: ${lesson.path}`,
657
+ severity: "error"
658
+ });
659
+ return issues;
660
+ }
661
+ const indexContained = assertResolvedPathContained(courseDir, indexPath);
662
+ if (!indexContained.ok) {
663
+ issues.push({
664
+ path: `lessons.${lesson.id}.path`,
665
+ message: indexContained.message,
666
+ severity: "error"
667
+ });
668
+ return issues;
669
+ }
670
+ if (!statSync3(indexPath).isFile()) {
671
+ issues.push({
672
+ path: `lessons.${lesson.id}.path`,
673
+ message: `index.html is not a file: ${lesson.path}/index.html`,
674
+ severity: "error"
675
+ });
676
+ }
677
+ return issues;
678
+ }
679
+
680
+ // src/validate/lesson-component.ts
681
+ import { existsSync as existsSync4, statSync as statSync4 } from "fs";
682
+ import { join as join2 } from "path";
683
+ function validateComponentLesson(courseDir, lesson) {
684
+ const issues = [];
685
+ if (isBuiltinComponentId(lesson.component)) {
686
+ return issues;
687
+ }
688
+ const resolved = resolveCoursePath(
689
+ courseDir,
690
+ join2("components", lesson.component)
691
+ );
692
+ if (!resolved.ok) {
693
+ issues.push({
694
+ path: `lessons.${lesson.id}.component`,
695
+ message: resolved.message,
696
+ severity: "error"
697
+ });
698
+ return issues;
699
+ }
700
+ if (!existsSync4(resolved.path)) {
701
+ issues.push({
702
+ path: `lessons.${lesson.id}.component`,
703
+ message: `Unknown component "${lesson.component}" and no override at components/${lesson.component}`,
704
+ severity: "error"
705
+ });
706
+ return issues;
707
+ }
708
+ const contained = assertResolvedPathContained(courseDir, resolved.path);
709
+ if (!contained.ok) {
710
+ issues.push({
711
+ path: `lessons.${lesson.id}.component`,
712
+ message: contained.message,
713
+ severity: "error"
714
+ });
715
+ return issues;
716
+ }
717
+ const componentStat = statSync4(resolved.path);
718
+ if (!componentStat.isFile()) {
719
+ issues.push({
720
+ path: `lessons.${lesson.id}.component`,
721
+ message: `Component override path is not a file: components/${lesson.component}`,
722
+ severity: "error"
723
+ });
724
+ }
725
+ return issues;
726
+ }
727
+
728
+ // src/validate/registry.ts
729
+ var lessonValidators = {
730
+ markdown: (courseDir, lesson) => validateMarkdownLesson(courseDir, lesson),
731
+ html: (courseDir, lesson) => validateHtmlLesson(courseDir, lesson),
732
+ component: (courseDir, lesson) => validateComponentLesson(courseDir, lesson)
733
+ };
734
+
735
+ // src/validate/ids.ts
736
+ function validateActivityIds(manifest) {
737
+ const issues = [];
738
+ const lessonIdCounts = /* @__PURE__ */ new Map();
739
+ for (const lesson of manifest.lessons) {
740
+ lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
741
+ }
742
+ for (const [id, count] of lessonIdCounts) {
743
+ if (count > 1) {
744
+ issues.push({
745
+ path: "lessons",
746
+ message: `Duplicate lesson ID: ${id}`,
747
+ severity: "error"
748
+ });
749
+ }
750
+ }
751
+ const assessmentIdSet = /* @__PURE__ */ new Set();
752
+ for (const ref of manifest.assessments ?? []) {
753
+ assessmentIdSet.add(ref.id);
754
+ }
755
+ for (const lesson of manifest.lessons) {
756
+ if (assessmentIdSet.has(lesson.id)) {
757
+ issues.push({
758
+ path: "lessons",
759
+ message: `Lesson ID "${lesson.id}" conflicts with an assessment ID`,
760
+ severity: "error"
761
+ });
762
+ }
763
+ }
764
+ return issues;
765
+ }
766
+
767
+ // src/validate.ts
768
+ function formatErrorMessage(err) {
769
+ return err instanceof Error ? err.message : String(err);
770
+ }
771
+ function formatIssuePath(path) {
772
+ const joined = path.map(String).join(".");
773
+ return joined || "course.yaml";
774
+ }
280
775
  async function loadManifest(courseDir) {
281
- const resolvedDir = resolve(courseDir);
282
- const manifestPath = join(resolvedDir, "course.yaml");
283
- if (!existsSync(manifestPath)) {
776
+ const resolvedDir = resolve2(courseDir);
777
+ const manifestPath = join3(resolvedDir, "course.yaml");
778
+ if (!existsSync5(manifestPath)) {
284
779
  return [
285
780
  {
286
781
  path: "course.yaml",
@@ -291,8 +786,8 @@ async function loadManifest(courseDir) {
291
786
  }
292
787
  let raw;
293
788
  try {
294
- const content = await readFile(manifestPath, "utf-8");
295
- raw = parseYaml(content);
789
+ const content = await readFile2(manifestPath, "utf-8");
790
+ raw = parseYaml2(content);
296
791
  } catch (err) {
297
792
  return [
298
793
  {
@@ -314,346 +809,76 @@ async function loadManifest(courseDir) {
314
809
  }
315
810
  async function validateCourse(courseDir) {
316
811
  const issues = [];
317
- const resolvedDir = resolve(courseDir);
812
+ const resolvedDir = resolve2(courseDir);
318
813
  const loaded = await loadManifest(resolvedDir);
319
814
  if (Array.isArray(loaded)) {
320
815
  return { valid: false, issues: loaded };
321
816
  }
322
817
  const { manifest } = loaded;
323
818
  for (const lesson of manifest.lessons) {
324
- if (lesson.type === "markdown") {
325
- const resolved = resolveCoursePath(resolvedDir, lesson.file);
326
- if (!resolved.ok) {
327
- issues.push({
328
- path: `lessons.${lesson.id}.file`,
329
- message: resolved.message,
330
- severity: "error"
331
- });
332
- continue;
333
- }
334
- if (!existsSync(resolved.path)) {
335
- issues.push({
336
- path: `lessons.${lesson.id}.file`,
337
- message: `Lesson file not found: ${lesson.file}`,
338
- severity: "error"
339
- });
340
- continue;
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
- }
351
- const stat = statSync(resolved.path);
352
- if (!stat.isFile()) {
353
- issues.push({
354
- path: `lessons.${lesson.id}.file`,
355
- message: `Lesson path is not a file: ${lesson.file}`,
356
- severity: "error"
357
- });
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
- }
390
- } else if (lesson.type === "html") {
391
- const resolved = resolveCoursePath(resolvedDir, lesson.path);
392
- if (!resolved.ok) {
393
- issues.push({
394
- path: `lessons.${lesson.id}.path`,
395
- message: resolved.message,
396
- severity: "error"
397
- });
398
- continue;
399
- }
400
- if (!existsSync(resolved.path)) {
401
- issues.push({
402
- path: `lessons.${lesson.id}.path`,
403
- message: `HTML interaction directory not found: ${lesson.path}`,
404
- severity: "error"
405
- });
406
- continue;
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
- }
417
- const stat = statSync(resolved.path);
418
- if (!stat.isDirectory()) {
419
- issues.push({
420
- path: `lessons.${lesson.id}.path`,
421
- message: `HTML interaction path is not a directory: ${lesson.path}`,
422
- severity: "error"
423
- });
424
- continue;
425
- }
426
- const indexPath = join(resolved.path, "index.html");
427
- if (!existsSync(indexPath)) {
428
- issues.push({
429
- path: `lessons.${lesson.id}.path`,
430
- message: `HTML interaction missing index.html: ${lesson.path}`,
431
- severity: "error"
432
- });
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
- }
451
- }
452
- }
453
- }
454
- if (manifest.assessments) {
455
- const assessmentIds = /* @__PURE__ */ new Set();
456
- for (const ref of manifest.assessments) {
457
- if (assessmentIds.has(ref.id)) {
458
- issues.push({
459
- path: "assessments",
460
- message: `Duplicate assessment ID: ${ref.id}`,
461
- severity: "error"
462
- });
463
- }
464
- assessmentIds.add(ref.id);
465
- const resolved = resolveCoursePath(resolvedDir, ref.file);
466
- if (!resolved.ok) {
467
- issues.push({
468
- path: `assessments.${ref.id}.file`,
469
- message: resolved.message,
470
- severity: "error"
471
- });
472
- continue;
473
- }
474
- if (!existsSync(resolved.path)) {
475
- issues.push({
476
- path: `assessments.${ref.id}.file`,
477
- message: `Assessment file not found: ${ref.file}`,
478
- severity: "error"
479
- });
480
- continue;
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
- }
500
- try {
501
- const content = await readFile(resolved.path, "utf-8");
502
- const raw = parseYaml(content);
503
- const parsed = assessmentSchema.safeParse(raw);
504
- if (!parsed.success) {
505
- for (const issue of parsed.error.issues) {
506
- const subPath = issue.path.length ? issue.path.join(".") : "root";
507
- issues.push({
508
- path: `${ref.file}:${subPath}`,
509
- message: issue.message,
510
- severity: "error"
511
- });
512
- }
513
- continue;
514
- }
515
- if (parsed.data.id !== ref.id) {
516
- issues.push({
517
- path: `assessments.${ref.id}`,
518
- message: `Assessment file id "${parsed.data.id}" does not match manifest ref id "${ref.id}"`,
519
- severity: "error"
520
- });
521
- }
522
- } catch (err) {
523
- issues.push({
524
- path: ref.file,
525
- message: `Failed to parse assessment: ${formatErrorMessage(err)}`,
526
- severity: "error"
527
- });
528
- }
529
- }
819
+ issues.push(...lessonValidators[lesson.type](resolvedDir, lesson));
530
820
  }
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) {
821
+ const assessmentLoad = await loadParsedAssessments(resolvedDir, manifest);
822
+ issues.push(...assessmentLoad.issues);
823
+ issues.push(...validateActivityIds(manifest));
824
+ issues.push(...validateFlow(manifest));
825
+ if (manifest.flow?.length) {
826
+ for (const message of detectFlowCycles(manifest)) {
537
827
  issues.push({
538
- path: "lessons",
539
- message: `Duplicate lesson ID: ${id}`,
828
+ path: "flow",
829
+ message,
540
830
  severity: "error"
541
831
  });
542
832
  }
543
833
  }
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
- }
571
834
  return {
572
835
  valid: issues.filter((i) => i.severity === "error").length === 0,
573
836
  manifest,
574
- issues
837
+ issues,
838
+ parsedAssessments: assessmentLoad.parsed
575
839
  };
576
840
  }
577
841
 
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 = {};
842
+ // src/activities.ts
843
+ function enumerateActivities(manifest) {
844
+ const activities = manifest.lessons.map((lesson) => ({
845
+ id: lesson.id,
846
+ title: lesson.title ?? lesson.id,
847
+ kind: "lesson"
848
+ }));
619
849
  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;
850
+ activities.push({
851
+ id: ref.id,
852
+ title: ref.id.replace(/_/g, " "),
853
+ kind: "assessment"
854
+ });
642
855
  }
643
- return { assessments, answerKeys, configs, feedback };
856
+ return activities;
857
+ }
858
+
859
+ // src/html.ts
860
+ function escapeHtml(text) {
861
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
644
862
  }
645
863
  export {
646
864
  BUILTIN_COMPONENT_IDS,
865
+ activityIdSchema,
647
866
  assertResolvedPathContained,
648
867
  assessmentQuestionSchema,
649
868
  assessmentRefSchema,
650
869
  assessmentSchema,
870
+ buildActivityOrder,
651
871
  buildRuntimeAssessmentBundle,
872
+ buildRuntimeAssessmentBundleFromParsed,
652
873
  collectActivityIds,
874
+ collectAssessmentIds,
875
+ collectInteractionIds,
653
876
  componentLessonSchema,
654
877
  conditionSchema,
655
878
  courseManifestSchema,
656
879
  detectFlowCycles,
880
+ enumerateActivities,
881
+ escapeHtml,
657
882
  flowRuleSchema,
658
883
  formatErrorMessage,
659
884
  formatIssuePath,
@@ -662,6 +887,7 @@ export {
662
887
  isPathContained,
663
888
  lessonSchema,
664
889
  loadManifest,
890
+ loadParsedAssessments,
665
891
  markdownLessonSchema,
666
892
  resolveCoursePath,
667
893
  runtimeConfigSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/validators",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {