@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 +4 -2
- package/dist/index.d.ts +102 -4
- package/dist/index.js +214 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
|
|
8
|
-
Zod schemas and filesystem validation for LXPack course manifests — including flow, variables,
|
|
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(
|
|
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
|
-
/**
|
|
583
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 (!
|
|
316
|
+
if (!interactionIds.has(i)) {
|
|
231
317
|
issues.push({
|
|
232
318
|
path: `${path}.when`,
|
|
233
|
-
message: `Unknown interaction
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
963
|
+
validateXapiTracking,
|
|
964
|
+
variableDefSchema,
|
|
965
|
+
xapiTrackingSchema
|
|
784
966
|
};
|