@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 +2 -2
- package/dist/index.d.ts +90 -18
- package/dist/index.js +580 -354
- 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, and component lessons (v0.2.
|
|
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(
|
|
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
|
-
/**
|
|
567
|
-
|
|
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
|
-
|
|
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:
|
|
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,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:
|
|
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({
|
|
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 (!
|
|
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 (!
|
|
309
|
+
if (!interactionIds.has(i)) {
|
|
200
310
|
issues.push({
|
|
201
311
|
path: `${path}.when`,
|
|
202
|
-
message: `Unknown interaction
|
|
203
|
-
severity: "
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
import {
|
|
241
|
-
import {
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 =
|
|
282
|
-
const manifestPath =
|
|
283
|
-
if (!
|
|
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
|
|
295
|
-
raw =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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: "
|
|
539
|
-
message
|
|
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/
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
856
|
+
return activities;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/html.ts
|
|
860
|
+
function escapeHtml(text) {
|
|
861
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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,
|