@lxpack/validators 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
7
 
8
- Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.0).
8
+ Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, component lessons, and xAPI tracking (v0.3.0).
9
9
 
10
10
  Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
11
 
@@ -90,11 +90,13 @@ isPathContained(courseDir, abs); // true if inside course root
90
90
  | Export | Description |
91
91
  |--------|-------------|
92
92
  | `validateCourse(dir)` | Parse `course.yaml`, validate schema, flow, files, symlink containment |
93
+ | `validateXapiTracking(manifest)` | Require HTTPS `tracking.xapi.activityIri` for xapi/cmi5 exports |
94
+ | `getCourseActivityIri(manifest)` | Read course activity IRI from manifest |
93
95
  | `loadManifest(courseDir)` | Load and parse `course.yaml` |
94
96
  | `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view, keys, configs, feedback |
95
97
  | `toLearnerAssessment(assessment)` | Strip `correct` from choices; extract config and feedback maps |
96
98
  | `validateFlow(manifest)` | Flow rule and target validation |
97
- | `detectFlowCycles(flow)` | Cycle detection for branching graphs |
99
+ | `detectFlowCycles(manifest)` | Flow-jump cycle detection for branching graphs |
98
100
  | `collectActivityIds(manifest)` | Lesson and assessment IDs for flow targets |
99
101
  | `conditionSchema`, `flowRuleSchema` | Zod schemas for flow conditions and rules |
100
102
  | `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;
@@ -307,6 +343,16 @@ declare const variableDefSchema: z.ZodEffects<z.ZodObject<{
307
343
  default: string | number | boolean;
308
344
  type?: "string" | "number" | "boolean" | undefined;
309
345
  }>;
346
+ declare const xapiTrackingSchema: z.ZodObject<{
347
+ activityIri: z.ZodEffects<z.ZodString, string, string>;
348
+ displayName: z.ZodOptional<z.ZodString>;
349
+ }, "strict", z.ZodTypeAny, {
350
+ activityIri: string;
351
+ displayName?: string | undefined;
352
+ }, {
353
+ activityIri: string;
354
+ displayName?: string | undefined;
355
+ }>;
310
356
  declare const trackingSchema: z.ZodOptional<z.ZodObject<{
311
357
  completion: z.ZodOptional<z.ZodObject<{
312
358
  threshold: z.ZodDefault<z.ZodNumber>;
@@ -315,15 +361,34 @@ declare const trackingSchema: z.ZodOptional<z.ZodObject<{
315
361
  }, {
316
362
  threshold?: number | undefined;
317
363
  }>>;
364
+ xapi: z.ZodOptional<z.ZodObject<{
365
+ activityIri: z.ZodEffects<z.ZodString, string, string>;
366
+ displayName: z.ZodOptional<z.ZodString>;
367
+ }, "strict", z.ZodTypeAny, {
368
+ activityIri: string;
369
+ displayName?: string | undefined;
370
+ }, {
371
+ activityIri: string;
372
+ displayName?: string | undefined;
373
+ }>>;
318
374
  }, "strict", z.ZodTypeAny, {
319
375
  completion?: {
320
376
  threshold: number;
321
377
  } | undefined;
378
+ xapi?: {
379
+ activityIri: string;
380
+ displayName?: string | undefined;
381
+ } | undefined;
322
382
  }, {
323
383
  completion?: {
324
384
  threshold?: number | undefined;
325
385
  } | undefined;
386
+ xapi?: {
387
+ activityIri: string;
388
+ displayName?: string | undefined;
389
+ } | undefined;
326
390
  }>>;
391
+ type XapiTrackingConfig = z.infer<typeof xapiTrackingSchema>;
327
392
  declare const runtimeConfigSchema: z.ZodOptional<z.ZodObject<{
328
393
  theme: z.ZodDefault<z.ZodString>;
329
394
  }, "strict", z.ZodTypeAny, {
@@ -351,14 +416,32 @@ declare const courseManifestSchema: z.ZodObject<{
351
416
  }, {
352
417
  threshold?: number | undefined;
353
418
  }>>;
419
+ xapi: z.ZodOptional<z.ZodObject<{
420
+ activityIri: z.ZodEffects<z.ZodString, string, string>;
421
+ displayName: z.ZodOptional<z.ZodString>;
422
+ }, "strict", z.ZodTypeAny, {
423
+ activityIri: string;
424
+ displayName?: string | undefined;
425
+ }, {
426
+ activityIri: string;
427
+ displayName?: string | undefined;
428
+ }>>;
354
429
  }, "strict", z.ZodTypeAny, {
355
430
  completion?: {
356
431
  threshold: number;
357
432
  } | undefined;
433
+ xapi?: {
434
+ activityIri: string;
435
+ displayName?: string | undefined;
436
+ } | undefined;
358
437
  }, {
359
438
  completion?: {
360
439
  threshold?: number | undefined;
361
440
  } | undefined;
441
+ xapi?: {
442
+ activityIri: string;
443
+ displayName?: string | undefined;
444
+ } | undefined;
362
445
  }>>;
363
446
  variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
364
447
  default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
@@ -473,6 +556,10 @@ declare const courseManifestSchema: z.ZodObject<{
473
556
  completion?: {
474
557
  threshold: number;
475
558
  } | undefined;
559
+ xapi?: {
560
+ activityIri: string;
561
+ displayName?: string | undefined;
562
+ } | undefined;
476
563
  } | undefined;
477
564
  variables?: Record<string, {
478
565
  default: string | number | boolean;
@@ -514,6 +601,10 @@ declare const courseManifestSchema: z.ZodObject<{
514
601
  completion?: {
515
602
  threshold?: number | undefined;
516
603
  } | undefined;
604
+ xapi?: {
605
+ activityIri: string;
606
+ displayName?: string | undefined;
607
+ } | undefined;
517
608
  } | undefined;
518
609
  variables?: Record<string, {
519
610
  default: string | number | boolean;
@@ -578,9 +669,13 @@ declare function validateCourse(courseDir: string): Promise<ValidationResult>;
578
669
 
579
670
  declare function collectActivityIds(manifest: CourseManifest): Set<string>;
580
671
  declare function collectAssessmentIds(manifest: CourseManifest): Set<string>;
672
+ declare function collectInteractionIds(manifest: CourseManifest): Set<string>;
673
+ declare function buildActivityOrder(manifest: CourseManifest): string[];
581
674
  declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
582
- /** Detect simple goto cycles (same-target chains). */
583
- declare function detectFlowCycles(flow: FlowRule[]): string[];
675
+ /**
676
+ * Detect cycles reachable via flow jumps (first matching applicable rule).
677
+ */
678
+ declare function detectFlowCycles(manifest: CourseManifest): string[];
584
679
 
585
680
  interface LearnerChoice {
586
681
  id: string;
@@ -635,4 +730,7 @@ declare function enumerateActivities(manifest: CourseManifest): CourseActivity[]
635
730
 
636
731
  declare function escapeHtml(text: string): string;
637
732
 
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 };
733
+ declare function validateXapiTracking(manifest: CourseManifest): ValidationIssue[];
734
+ declare function getCourseActivityIri(manifest: CourseManifest): string | undefined;
735
+
736
+ 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, type XapiTrackingConfig, activityIdSchema, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildActivityOrder, buildRuntimeAssessmentBundle, buildRuntimeAssessmentBundleFromParsed, collectActivityIds, collectAssessmentIds, collectInteractionIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, enumerateActivities, escapeHtml, flowRuleSchema, formatErrorMessage, formatIssuePath, getCourseActivityIri, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, loadParsedAssessments, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, validateXapiTracking, variableDefSchema, xapiTrackingSchema };
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,16 +98,29 @@ 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({
@@ -121,10 +150,17 @@ var variableDefSchema = z2.object({
121
150
  });
122
151
  }
123
152
  });
153
+ var xapiTrackingSchema = z2.object({
154
+ activityIri: z2.string().url().refine((u) => u.startsWith("https://"), {
155
+ message: "activityIri must be an https URL"
156
+ }),
157
+ displayName: z2.string().min(1).optional()
158
+ }).strict();
124
159
  var trackingSchema = z2.object({
125
160
  completion: z2.object({
126
161
  threshold: z2.number().min(0).max(1).default(0.9)
127
- }).strict().optional()
162
+ }).strict().optional(),
163
+ xapi: xapiTrackingSchema.optional()
128
164
  }).strict().optional();
129
165
  var runtimeConfigSchema = z2.object({
130
166
  theme: z2.string().default("modern")
@@ -169,6 +205,54 @@ function collectAssessmentIds(manifest) {
169
205
  }
170
206
  return ids;
171
207
  }
208
+ function collectInteractionIds(manifest) {
209
+ const ids = /* @__PURE__ */ new Set();
210
+ for (const lesson of manifest.lessons) {
211
+ if (lesson.type === "html") {
212
+ ids.add(lesson.id);
213
+ }
214
+ }
215
+ return ids;
216
+ }
217
+ function buildActivityOrder(manifest) {
218
+ const ids = manifest.lessons.map((l) => l.id);
219
+ for (const ref of manifest.assessments ?? []) {
220
+ ids.push(ref.id);
221
+ }
222
+ return ids;
223
+ }
224
+ function validateConditionShape(condition, path) {
225
+ const issues = [];
226
+ if ("all" in condition && condition.all) {
227
+ if (condition.all.length === 0) {
228
+ issues.push({
229
+ path,
230
+ message: "Condition all: [] is always true at runtime; use a non-empty list",
231
+ severity: "error"
232
+ });
233
+ }
234
+ for (let i = 0; i < condition.all.length; i++) {
235
+ issues.push(
236
+ ...validateConditionShape(condition.all[i], `${path}.all[${i}]`)
237
+ );
238
+ }
239
+ }
240
+ if ("any" in condition && condition.any) {
241
+ if (condition.any.length === 0) {
242
+ issues.push({
243
+ path,
244
+ message: "Condition any: [] is always false at runtime; use a non-empty list",
245
+ severity: "error"
246
+ });
247
+ }
248
+ for (let i = 0; i < condition.any.length; i++) {
249
+ issues.push(
250
+ ...validateConditionShape(condition.any[i], `${path}.any[${i}]`)
251
+ );
252
+ }
253
+ }
254
+ return issues;
255
+ }
172
256
  function collectConditionRefs(condition, refs) {
173
257
  if ("variable" in condition && condition.variable?.eq) {
174
258
  refs.variables.add(condition.variable.eq[0]);
@@ -192,9 +276,11 @@ function validateFlow(manifest) {
192
276
  if (!flow?.length) return issues;
193
277
  const activityIds = collectActivityIds(manifest);
194
278
  const assessmentIds = collectAssessmentIds(manifest);
279
+ const interactionIds = collectInteractionIds(manifest);
195
280
  const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
196
281
  flow.forEach((rule, index) => {
197
282
  const path = `flow[${index}]`;
283
+ issues.push(...validateConditionShape(rule.when, `${path}.when`));
198
284
  if (!activityIds.has(rule.goto)) {
199
285
  issues.push({
200
286
  path: `${path}.goto`,
@@ -227,10 +313,10 @@ function validateFlow(manifest) {
227
313
  }
228
314
  }
229
315
  for (const i of refs.interactions) {
230
- if (!activityIds.has(i)) {
316
+ if (!interactionIds.has(i)) {
231
317
  issues.push({
232
318
  path: `${path}.when`,
233
- message: `Unknown interaction/lesson id in condition: ${i}`,
319
+ message: `Unknown interaction id in condition (expected html lesson): ${i}`,
234
320
  severity: "error"
235
321
  });
236
322
  }
@@ -238,30 +324,63 @@ function validateFlow(manifest) {
238
324
  });
239
325
  return issues;
240
326
  }
241
- function detectFlowCycles(flow) {
242
- const gotoOf = /* @__PURE__ */ new Map();
243
- flow.forEach((rule, i) => gotoOf.set(i, rule.goto));
327
+ function conditionCouldApplyAt(condition, currentActivityId, interactionIds) {
328
+ if ("variable" in condition) return true;
329
+ if ("assessment" in condition && condition.assessment?.passed) {
330
+ return currentActivityId === condition.assessment.passed;
331
+ }
332
+ if ("interaction" in condition && condition.interaction?.done) {
333
+ return interactionIds.has(currentActivityId);
334
+ }
335
+ if ("all" in condition && condition.all) {
336
+ return condition.all.length > 0 && condition.all.every(
337
+ (c) => conditionCouldApplyAt(c, currentActivityId, interactionIds)
338
+ );
339
+ }
340
+ if ("any" in condition && condition.any) {
341
+ return condition.any.length > 0 && condition.any.some(
342
+ (c) => conditionCouldApplyAt(c, currentActivityId, interactionIds)
343
+ );
344
+ }
345
+ return false;
346
+ }
347
+ function detectFlowCycles(manifest) {
348
+ const flow = manifest.flow;
349
+ if (!flow?.length) return [];
350
+ const activityIds = collectActivityIds(manifest);
351
+ const interactionIds = collectInteractionIds(manifest);
352
+ const flowJumpFrom = (current) => {
353
+ for (const rule of flow) {
354
+ if (rule.goto !== current && activityIds.has(rule.goto) && conditionCouldApplyAt(rule.when, current, interactionIds)) {
355
+ return rule.goto;
356
+ }
357
+ }
358
+ return null;
359
+ };
244
360
  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;
361
+ const reported = /* @__PURE__ */ new Set();
362
+ const dfs = (node, stack, onStack) => {
363
+ const jump = flowJumpFrom(node);
364
+ if (!jump) return;
365
+ if (onStack.has(jump)) {
366
+ const cycleStart = stack.indexOf(jump);
367
+ const cycle = [...stack.slice(cycleStart), jump];
368
+ const key = cycle.join("->");
369
+ if (!reported.has(key)) {
370
+ reported.add(key);
371
+ errors.push(`Flow cycle: ${cycle.join(" -> ")}`);
255
372
  }
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;
373
+ return;
264
374
  }
375
+ if (stack.length >= activityIds.size) return;
376
+ onStack.add(jump);
377
+ stack.push(jump);
378
+ dfs(jump, stack, onStack);
379
+ stack.pop();
380
+ onStack.delete(jump);
381
+ };
382
+ for (const id of activityIds) {
383
+ dfs(id, [id], /* @__PURE__ */ new Set([id]));
265
384
  }
266
385
  return errors;
267
386
  }
@@ -359,6 +478,7 @@ async function loadParsedAssessments(courseDir, manifest) {
359
478
  message: `Duplicate assessment ID: ${ref.id}`,
360
479
  severity: "error"
361
480
  });
481
+ continue;
362
482
  }
363
483
  assessmentIds.add(ref.id);
364
484
  const resolved = resolveCoursePath(resolvedDir, ref.file);
@@ -499,8 +619,30 @@ function validateMarkdownLesson(courseDir, lesson) {
499
619
  // src/validate/lesson-html.ts
500
620
  import { existsSync as existsSync3, statSync as statSync3 } from "fs";
501
621
  import { join } from "path";
622
+ var HTML_LESSON_PATH_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_./-]*$/;
623
+ function validateHtmlLessonPath(path) {
624
+ if (/["'<>]/.test(path) || /\s/.test(path)) {
625
+ return "HTML interaction path contains invalid characters (quotes, angle brackets, or whitespace)";
626
+ }
627
+ if (path.includes("..")) {
628
+ return "HTML interaction path must not contain '..' segments";
629
+ }
630
+ if (!HTML_LESSON_PATH_PATTERN.test(path)) {
631
+ return "HTML interaction path must start with a letter and use only letters, numbers, /, _, ., and -";
632
+ }
633
+ return null;
634
+ }
502
635
  function validateHtmlLesson(courseDir, lesson) {
503
636
  const issues = [];
637
+ const pathError = validateHtmlLessonPath(lesson.path);
638
+ if (pathError) {
639
+ issues.push({
640
+ path: `lessons.${lesson.id}.path`,
641
+ message: pathError,
642
+ severity: "error"
643
+ });
644
+ return issues;
645
+ }
504
646
  const resolved = resolveCoursePath(courseDir, lesson.path);
505
647
  if (!resolved.ok) {
506
648
  issues.push({
@@ -710,7 +852,7 @@ async function validateCourse(courseDir) {
710
852
  issues.push(...validateActivityIds(manifest));
711
853
  issues.push(...validateFlow(manifest));
712
854
  if (manifest.flow?.length) {
713
- for (const message of detectFlowCycles(manifest.flow)) {
855
+ for (const message of detectFlowCycles(manifest)) {
714
856
  issues.push({
715
857
  path: "flow",
716
858
  message,
@@ -747,16 +889,53 @@ function enumerateActivities(manifest) {
747
889
  function escapeHtml(text) {
748
890
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
749
891
  }
892
+
893
+ // src/xapi-validate.ts
894
+ function validateXapiTracking(manifest) {
895
+ const issues = [];
896
+ const xapi = manifest.tracking?.xapi;
897
+ if (!xapi) {
898
+ issues.push({
899
+ path: "tracking.xapi",
900
+ message: "tracking.xapi.activityIri is required for xapi/cmi5 export targets",
901
+ severity: "error"
902
+ });
903
+ return issues;
904
+ }
905
+ try {
906
+ const url = new URL(xapi.activityIri);
907
+ if (url.protocol !== "https:") {
908
+ issues.push({
909
+ path: "tracking.xapi.activityIri",
910
+ message: "activityIri must use https",
911
+ severity: "error"
912
+ });
913
+ }
914
+ } catch {
915
+ issues.push({
916
+ path: "tracking.xapi.activityIri",
917
+ message: "activityIri must be a valid URL",
918
+ severity: "error"
919
+ });
920
+ }
921
+ return issues;
922
+ }
923
+ function getCourseActivityIri(manifest) {
924
+ return manifest.tracking?.xapi?.activityIri;
925
+ }
750
926
  export {
751
927
  BUILTIN_COMPONENT_IDS,
928
+ activityIdSchema,
752
929
  assertResolvedPathContained,
753
930
  assessmentQuestionSchema,
754
931
  assessmentRefSchema,
755
932
  assessmentSchema,
933
+ buildActivityOrder,
756
934
  buildRuntimeAssessmentBundle,
757
935
  buildRuntimeAssessmentBundleFromParsed,
758
936
  collectActivityIds,
759
937
  collectAssessmentIds,
938
+ collectInteractionIds,
760
939
  componentLessonSchema,
761
940
  conditionSchema,
762
941
  courseManifestSchema,
@@ -766,6 +945,7 @@ export {
766
945
  flowRuleSchema,
767
946
  formatErrorMessage,
768
947
  formatIssuePath,
948
+ getCourseActivityIri,
769
949
  htmlLessonSchema,
770
950
  isBuiltinComponentId,
771
951
  isPathContained,
@@ -780,5 +960,7 @@ export {
780
960
  trackingSchema,
781
961
  validateCourse,
782
962
  validateFlow,
783
- variableDefSchema
963
+ validateXapiTracking,
964
+ variableDefSchema,
965
+ xapiTrackingSchema
784
966
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/validators",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {