@lxpack/validators 0.2.0 → 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.
Files changed (3) hide show
  1. package/dist/index.d.ts +47 -15
  2. package/dist/index.js +435 -325
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -291,7 +291,7 @@ declare const assessmentRefSchema: z.ZodObject<{
291
291
  id: string;
292
292
  file: string;
293
293
  }>;
294
- declare const variableDefSchema: z.ZodObject<{
294
+ declare const variableDefSchema: z.ZodEffects<z.ZodObject<{
295
295
  default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
296
296
  type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
297
297
  }, "strict", z.ZodTypeAny, {
@@ -300,6 +300,12 @@ declare const variableDefSchema: z.ZodObject<{
300
300
  }, {
301
301
  default: string | number | boolean;
302
302
  type?: "string" | "number" | "boolean" | undefined;
303
+ }>, {
304
+ default: string | number | boolean;
305
+ type?: "string" | "number" | "boolean" | undefined;
306
+ }, {
307
+ default: string | number | boolean;
308
+ type?: "string" | "number" | "boolean" | undefined;
303
309
  }>;
304
310
  declare const trackingSchema: z.ZodOptional<z.ZodObject<{
305
311
  completion: z.ZodOptional<z.ZodObject<{
@@ -354,7 +360,7 @@ declare const courseManifestSchema: z.ZodObject<{
354
360
  threshold?: number | undefined;
355
361
  } | undefined;
356
362
  }>>;
357
- variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
363
+ variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
358
364
  default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
359
365
  type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
360
366
  }, "strict", z.ZodTypeAny, {
@@ -363,6 +369,12 @@ declare const courseManifestSchema: z.ZodObject<{
363
369
  }, {
364
370
  default: string | number | boolean;
365
371
  type?: "string" | "number" | "boolean" | undefined;
372
+ }>, {
373
+ default: string | number | boolean;
374
+ type?: "string" | "number" | "boolean" | undefined;
375
+ }, {
376
+ default: string | number | boolean;
377
+ type?: "string" | "number" | "boolean" | undefined;
366
378
  }>>>;
367
379
  flow: z.ZodOptional<z.ZodArray<z.ZodObject<{
368
380
  when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
@@ -529,18 +541,6 @@ declare const BUILTIN_COMPONENT_IDS: readonly ["callout", "image-card", "checkli
529
541
  type BuiltinComponentId = (typeof BUILTIN_COMPONENT_IDS)[number];
530
542
  declare function isBuiltinComponentId(id: string): id is BuiltinComponentId;
531
543
 
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
544
  declare function isPathContained(rootDir: string, candidatePath: string): boolean;
545
545
  declare function resolveCoursePath(courseDir: string, relativePath: string): {
546
546
  ok: true;
@@ -555,6 +555,21 @@ declare function assertResolvedPathContained(courseDir: string, resolvedPath: st
555
555
  ok: false;
556
556
  message: string;
557
557
  };
558
+
559
+ declare function formatErrorMessage(err: unknown): string;
560
+ declare function formatIssuePath(path: PropertyKey[]): string;
561
+ interface ValidationIssue {
562
+ path: string;
563
+ message: string;
564
+ severity: "error" | "warning";
565
+ }
566
+ interface ValidationResult {
567
+ valid: boolean;
568
+ manifest?: CourseManifest;
569
+ issues: ValidationIssue[];
570
+ /** Populated when assessment files parse successfully. */
571
+ parsedAssessments?: Map<string, Assessment>;
572
+ }
558
573
  declare function loadManifest(courseDir: string): Promise<{
559
574
  manifest: CourseManifest;
560
575
  raw: unknown;
@@ -562,6 +577,7 @@ declare function loadManifest(courseDir: string): Promise<{
562
577
  declare function validateCourse(courseDir: string): Promise<ValidationResult>;
563
578
 
564
579
  declare function collectActivityIds(manifest: CourseManifest): Set<string>;
580
+ declare function collectAssessmentIds(manifest: CourseManifest): Set<string>;
565
581
  declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
566
582
  /** Detect simple goto cycles (same-target chains). */
567
583
  declare function detectFlowCycles(flow: FlowRule[]): string[];
@@ -601,6 +617,22 @@ declare function toLearnerAssessment(assessment: Assessment): {
601
617
  config: AssessmentRuntimeConfig;
602
618
  feedback: QuestionFeedback;
603
619
  };
620
+
621
+ interface ParsedAssessmentsResult {
622
+ parsed: Map<string, Assessment>;
623
+ issues: ValidationIssue[];
624
+ }
625
+ declare function loadParsedAssessments(courseDir: string, manifest: CourseManifest): Promise<ParsedAssessmentsResult>;
626
+ declare function buildRuntimeAssessmentBundleFromParsed(parsed: Map<string, Assessment>): RuntimeAssessmentBundle;
604
627
  declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
605
628
 
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 };
629
+ interface CourseActivity {
630
+ id: string;
631
+ title: string;
632
+ kind: "lesson" | "assessment";
633
+ }
634
+ declare function enumerateActivities(manifest: CourseManifest): CourseActivity[];
635
+
636
+ declare function escapeHtml(text: string): string;
637
+
638
+ 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, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, buildRuntimeAssessmentBundleFromParsed, collectActivityIds, collectAssessmentIds, 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
@@ -97,7 +97,30 @@ var assessmentRefSchema = z2.object({
97
97
  var variableDefSchema = z2.object({
98
98
  default: z2.union([z2.string(), z2.number(), z2.boolean()]),
99
99
  type: z2.enum(["string", "number", "boolean"]).optional()
100
- }).strict();
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
+ });
101
124
  var trackingSchema = z2.object({
102
125
  completion: z2.object({
103
126
  threshold: z2.number().min(0).max(1).default(0.9)
@@ -139,6 +162,13 @@ function collectActivityIds(manifest) {
139
162
  }
140
163
  return ids;
141
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
+ }
142
172
  function collectConditionRefs(condition, refs) {
143
173
  if ("variable" in condition && condition.variable?.eq) {
144
174
  refs.variables.add(condition.variable.eq[0]);
@@ -161,6 +191,7 @@ function validateFlow(manifest) {
161
191
  const flow = manifest.flow;
162
192
  if (!flow?.length) return issues;
163
193
  const activityIds = collectActivityIds(manifest);
194
+ const assessmentIds = collectAssessmentIds(manifest);
164
195
  const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
165
196
  flow.forEach((rule, index) => {
166
197
  const path = `flow[${index}]`;
@@ -187,7 +218,7 @@ function validateFlow(manifest) {
187
218
  }
188
219
  }
189
220
  for (const a of refs.assessments) {
190
- if (!activityIds.has(a)) {
221
+ if (!assessmentIds.has(a)) {
191
222
  issues.push({
192
223
  path: `${path}.when`,
193
224
  message: `Unknown assessment in condition: ${a}`,
@@ -200,7 +231,7 @@ function validateFlow(manifest) {
200
231
  issues.push({
201
232
  path: `${path}.when`,
202
233
  message: `Unknown interaction/lesson id in condition: ${i}`,
203
- severity: "warning"
234
+ severity: "error"
204
235
  });
205
236
  }
206
237
  }
@@ -236,17 +267,19 @@ function detectFlowCycles(flow) {
236
267
  }
237
268
 
238
269
  // 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";
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";
242
277
  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
- }
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";
250
283
  function isPathContained(rootDir, candidatePath) {
251
284
  const root = resolve(rootDir);
252
285
  const candidate = resolve(candidatePath);
@@ -277,10 +310,359 @@ function assertResolvedPathContained(courseDir, resolvedPath) {
277
310
  return { ok: false, message: "Path could not be resolved" };
278
311
  }
279
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
+ }
280
662
  async function loadManifest(courseDir) {
281
- const resolvedDir = resolve(courseDir);
282
- const manifestPath = join(resolvedDir, "course.yaml");
283
- if (!existsSync(manifestPath)) {
663
+ const resolvedDir = resolve2(courseDir);
664
+ const manifestPath = join3(resolvedDir, "course.yaml");
665
+ if (!existsSync5(manifestPath)) {
284
666
  return [
285
667
  {
286
668
  path: "course.yaml",
@@ -291,8 +673,8 @@ async function loadManifest(courseDir) {
291
673
  }
292
674
  let raw;
293
675
  try {
294
- const content = await readFile(manifestPath, "utf-8");
295
- raw = parseYaml(content);
676
+ const content = await readFile2(manifestPath, "utf-8");
677
+ raw = parseYaml2(content);
296
678
  } catch (err) {
297
679
  return [
298
680
  {
@@ -314,333 +696,56 @@ async function loadManifest(courseDir) {
314
696
  }
315
697
  async function validateCourse(courseDir) {
316
698
  const issues = [];
317
- const resolvedDir = resolve(courseDir);
699
+ const resolvedDir = resolve2(courseDir);
318
700
  const loaded = await loadManifest(resolvedDir);
319
701
  if (Array.isArray(loaded)) {
320
702
  return { valid: false, issues: loaded };
321
703
  }
322
704
  const { manifest } = loaded;
323
705
  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
- }
706
+ issues.push(...lessonValidators[lesson.type](resolvedDir, lesson));
453
707
  }
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
- }
530
- }
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) {
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)) {
537
714
  issues.push({
538
- path: "lessons",
539
- message: `Duplicate lesson ID: ${id}`,
715
+ path: "flow",
716
+ message,
540
717
  severity: "error"
541
718
  });
542
719
  }
543
720
  }
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
721
  return {
572
722
  valid: issues.filter((i) => i.severity === "error").length === 0,
573
723
  manifest,
574
- issues
724
+ issues,
725
+ parsedAssessments: assessmentLoad.parsed
575
726
  };
576
727
  }
577
728
 
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 = {};
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
+ }));
619
736
  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;
737
+ activities.push({
738
+ id: ref.id,
739
+ title: ref.id.replace(/_/g, " "),
740
+ kind: "assessment"
741
+ });
642
742
  }
643
- return { assessments, answerKeys, configs, feedback };
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;");
644
749
  }
645
750
  export {
646
751
  BUILTIN_COMPONENT_IDS,
@@ -649,11 +754,15 @@ export {
649
754
  assessmentRefSchema,
650
755
  assessmentSchema,
651
756
  buildRuntimeAssessmentBundle,
757
+ buildRuntimeAssessmentBundleFromParsed,
652
758
  collectActivityIds,
759
+ collectAssessmentIds,
653
760
  componentLessonSchema,
654
761
  conditionSchema,
655
762
  courseManifestSchema,
656
763
  detectFlowCycles,
764
+ enumerateActivities,
765
+ escapeHtml,
657
766
  flowRuleSchema,
658
767
  formatErrorMessage,
659
768
  formatIssuePath,
@@ -662,6 +771,7 @@ export {
662
771
  isPathContained,
663
772
  lessonSchema,
664
773
  loadManifest,
774
+ loadParsedAssessments,
665
775
  markdownLessonSchema,
666
776
  resolveCoursePath,
667
777
  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.1",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {