@lxpack/validators 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +47 -15
- package/dist/index.js +435 -325
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -291,7 +291,7 @@ declare const assessmentRefSchema: z.ZodObject<{
|
|
|
291
291
|
id: string;
|
|
292
292
|
file: string;
|
|
293
293
|
}>;
|
|
294
|
-
declare const variableDefSchema: z.ZodObject<{
|
|
294
|
+
declare const variableDefSchema: z.ZodEffects<z.ZodObject<{
|
|
295
295
|
default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
296
296
|
type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
|
|
297
297
|
}, "strict", z.ZodTypeAny, {
|
|
@@ -300,6 +300,12 @@ declare const variableDefSchema: z.ZodObject<{
|
|
|
300
300
|
}, {
|
|
301
301
|
default: string | number | boolean;
|
|
302
302
|
type?: "string" | "number" | "boolean" | undefined;
|
|
303
|
+
}>, {
|
|
304
|
+
default: string | number | boolean;
|
|
305
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
306
|
+
}, {
|
|
307
|
+
default: string | number | boolean;
|
|
308
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
303
309
|
}>;
|
|
304
310
|
declare const trackingSchema: z.ZodOptional<z.ZodObject<{
|
|
305
311
|
completion: z.ZodOptional<z.ZodObject<{
|
|
@@ -354,7 +360,7 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
354
360
|
threshold?: number | undefined;
|
|
355
361
|
} | undefined;
|
|
356
362
|
}>>;
|
|
357
|
-
variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
363
|
+
variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
|
|
358
364
|
default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
359
365
|
type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
|
|
360
366
|
}, "strict", z.ZodTypeAny, {
|
|
@@ -363,6 +369,12 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
363
369
|
}, {
|
|
364
370
|
default: string | number | boolean;
|
|
365
371
|
type?: "string" | "number" | "boolean" | undefined;
|
|
372
|
+
}>, {
|
|
373
|
+
default: string | number | boolean;
|
|
374
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
375
|
+
}, {
|
|
376
|
+
default: string | number | boolean;
|
|
377
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
366
378
|
}>>>;
|
|
367
379
|
flow: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
368
380
|
when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
|
|
@@ -529,18 +541,6 @@ declare const BUILTIN_COMPONENT_IDS: readonly ["callout", "image-card", "checkli
|
|
|
529
541
|
type BuiltinComponentId = (typeof BUILTIN_COMPONENT_IDS)[number];
|
|
530
542
|
declare function isBuiltinComponentId(id: string): id is BuiltinComponentId;
|
|
531
543
|
|
|
532
|
-
declare function formatErrorMessage(err: unknown): string;
|
|
533
|
-
declare function formatIssuePath(path: PropertyKey[]): string;
|
|
534
|
-
interface ValidationIssue {
|
|
535
|
-
path: string;
|
|
536
|
-
message: string;
|
|
537
|
-
severity: "error" | "warning";
|
|
538
|
-
}
|
|
539
|
-
interface ValidationResult {
|
|
540
|
-
valid: boolean;
|
|
541
|
-
manifest?: CourseManifest;
|
|
542
|
-
issues: ValidationIssue[];
|
|
543
|
-
}
|
|
544
544
|
declare function isPathContained(rootDir: string, candidatePath: string): boolean;
|
|
545
545
|
declare function resolveCoursePath(courseDir: string, relativePath: string): {
|
|
546
546
|
ok: true;
|
|
@@ -555,6 +555,21 @@ declare function assertResolvedPathContained(courseDir: string, resolvedPath: st
|
|
|
555
555
|
ok: false;
|
|
556
556
|
message: string;
|
|
557
557
|
};
|
|
558
|
+
|
|
559
|
+
declare function formatErrorMessage(err: unknown): string;
|
|
560
|
+
declare function formatIssuePath(path: PropertyKey[]): string;
|
|
561
|
+
interface ValidationIssue {
|
|
562
|
+
path: string;
|
|
563
|
+
message: string;
|
|
564
|
+
severity: "error" | "warning";
|
|
565
|
+
}
|
|
566
|
+
interface ValidationResult {
|
|
567
|
+
valid: boolean;
|
|
568
|
+
manifest?: CourseManifest;
|
|
569
|
+
issues: ValidationIssue[];
|
|
570
|
+
/** Populated when assessment files parse successfully. */
|
|
571
|
+
parsedAssessments?: Map<string, Assessment>;
|
|
572
|
+
}
|
|
558
573
|
declare function loadManifest(courseDir: string): Promise<{
|
|
559
574
|
manifest: CourseManifest;
|
|
560
575
|
raw: unknown;
|
|
@@ -562,6 +577,7 @@ declare function loadManifest(courseDir: string): Promise<{
|
|
|
562
577
|
declare function validateCourse(courseDir: string): Promise<ValidationResult>;
|
|
563
578
|
|
|
564
579
|
declare function collectActivityIds(manifest: CourseManifest): Set<string>;
|
|
580
|
+
declare function collectAssessmentIds(manifest: CourseManifest): Set<string>;
|
|
565
581
|
declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
|
|
566
582
|
/** Detect simple goto cycles (same-target chains). */
|
|
567
583
|
declare function detectFlowCycles(flow: FlowRule[]): string[];
|
|
@@ -601,6 +617,22 @@ declare function toLearnerAssessment(assessment: Assessment): {
|
|
|
601
617
|
config: AssessmentRuntimeConfig;
|
|
602
618
|
feedback: QuestionFeedback;
|
|
603
619
|
};
|
|
620
|
+
|
|
621
|
+
interface ParsedAssessmentsResult {
|
|
622
|
+
parsed: Map<string, Assessment>;
|
|
623
|
+
issues: ValidationIssue[];
|
|
624
|
+
}
|
|
625
|
+
declare function loadParsedAssessments(courseDir: string, manifest: CourseManifest): Promise<ParsedAssessmentsResult>;
|
|
626
|
+
declare function buildRuntimeAssessmentBundleFromParsed(parsed: Map<string, Assessment>): RuntimeAssessmentBundle;
|
|
604
627
|
declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
|
|
605
628
|
|
|
606
|
-
|
|
629
|
+
interface CourseActivity {
|
|
630
|
+
id: string;
|
|
631
|
+
title: string;
|
|
632
|
+
kind: "lesson" | "assessment";
|
|
633
|
+
}
|
|
634
|
+
declare function enumerateActivities(manifest: CourseManifest): CourseActivity[];
|
|
635
|
+
|
|
636
|
+
declare function escapeHtml(text: string): string;
|
|
637
|
+
|
|
638
|
+
export { type Assessment, type AssessmentRef, type AssessmentRuntimeConfig, BUILTIN_COMPONENT_IDS, type BuiltinComponentId, type ComponentLesson, type Condition, type CourseActivity, type CourseManifest, type FlowRule, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type QuestionFeedback, type RuntimeAssessmentBundle, type ShowFeedback, type ValidationIssue, type ValidationResult, type VariableDef, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, buildRuntimeAssessmentBundleFromParsed, collectActivityIds, collectAssessmentIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, enumerateActivities, escapeHtml, flowRuleSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, loadParsedAssessments, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, variableDefSchema };
|
package/dist/index.js
CHANGED
|
@@ -97,7 +97,30 @@ var assessmentRefSchema = z2.object({
|
|
|
97
97
|
var variableDefSchema = z2.object({
|
|
98
98
|
default: z2.union([z2.string(), z2.number(), z2.boolean()]),
|
|
99
99
|
type: z2.enum(["string", "number", "boolean"]).optional()
|
|
100
|
-
}).strict()
|
|
100
|
+
}).strict().superRefine((def, ctx) => {
|
|
101
|
+
const t = def.type;
|
|
102
|
+
if (t === "string" && typeof def.default !== "string") {
|
|
103
|
+
ctx.addIssue({
|
|
104
|
+
code: z2.ZodIssueCode.custom,
|
|
105
|
+
message: "Default must be a string when type is string",
|
|
106
|
+
path: ["default"]
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (t === "number" && typeof def.default !== "number") {
|
|
110
|
+
ctx.addIssue({
|
|
111
|
+
code: z2.ZodIssueCode.custom,
|
|
112
|
+
message: "Default must be a number when type is number",
|
|
113
|
+
path: ["default"]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (t === "boolean" && typeof def.default !== "boolean") {
|
|
117
|
+
ctx.addIssue({
|
|
118
|
+
code: z2.ZodIssueCode.custom,
|
|
119
|
+
message: "Default must be a boolean when type is boolean",
|
|
120
|
+
path: ["default"]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
101
124
|
var trackingSchema = z2.object({
|
|
102
125
|
completion: z2.object({
|
|
103
126
|
threshold: z2.number().min(0).max(1).default(0.9)
|
|
@@ -139,6 +162,13 @@ function collectActivityIds(manifest) {
|
|
|
139
162
|
}
|
|
140
163
|
return ids;
|
|
141
164
|
}
|
|
165
|
+
function collectAssessmentIds(manifest) {
|
|
166
|
+
const ids = /* @__PURE__ */ new Set();
|
|
167
|
+
for (const ref of manifest.assessments ?? []) {
|
|
168
|
+
ids.add(ref.id);
|
|
169
|
+
}
|
|
170
|
+
return ids;
|
|
171
|
+
}
|
|
142
172
|
function collectConditionRefs(condition, refs) {
|
|
143
173
|
if ("variable" in condition && condition.variable?.eq) {
|
|
144
174
|
refs.variables.add(condition.variable.eq[0]);
|
|
@@ -161,6 +191,7 @@ function validateFlow(manifest) {
|
|
|
161
191
|
const flow = manifest.flow;
|
|
162
192
|
if (!flow?.length) return issues;
|
|
163
193
|
const activityIds = collectActivityIds(manifest);
|
|
194
|
+
const assessmentIds = collectAssessmentIds(manifest);
|
|
164
195
|
const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
|
|
165
196
|
flow.forEach((rule, index) => {
|
|
166
197
|
const path = `flow[${index}]`;
|
|
@@ -187,7 +218,7 @@ function validateFlow(manifest) {
|
|
|
187
218
|
}
|
|
188
219
|
}
|
|
189
220
|
for (const a of refs.assessments) {
|
|
190
|
-
if (!
|
|
221
|
+
if (!assessmentIds.has(a)) {
|
|
191
222
|
issues.push({
|
|
192
223
|
path: `${path}.when`,
|
|
193
224
|
message: `Unknown assessment in condition: ${a}`,
|
|
@@ -200,7 +231,7 @@ function validateFlow(manifest) {
|
|
|
200
231
|
issues.push({
|
|
201
232
|
path: `${path}.when`,
|
|
202
233
|
message: `Unknown interaction/lesson id in condition: ${i}`,
|
|
203
|
-
severity: "
|
|
234
|
+
severity: "error"
|
|
204
235
|
});
|
|
205
236
|
}
|
|
206
237
|
}
|
|
@@ -236,17 +267,19 @@ function detectFlowCycles(flow) {
|
|
|
236
267
|
}
|
|
237
268
|
|
|
238
269
|
// src/validate.ts
|
|
239
|
-
import { existsSync
|
|
240
|
-
import {
|
|
241
|
-
import {
|
|
270
|
+
import { existsSync as existsSync5 } from "fs";
|
|
271
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
272
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
273
|
+
import { parse as parseYaml2 } from "yaml";
|
|
274
|
+
|
|
275
|
+
// src/course-assessments.ts
|
|
276
|
+
import { existsSync, statSync } from "fs";
|
|
242
277
|
import { readFile } from "fs/promises";
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return joined || "course.yaml";
|
|
249
|
-
}
|
|
278
|
+
import { parse as parseYaml } from "yaml";
|
|
279
|
+
|
|
280
|
+
// src/course-paths.ts
|
|
281
|
+
import { realpathSync } from "fs";
|
|
282
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
250
283
|
function isPathContained(rootDir, candidatePath) {
|
|
251
284
|
const root = resolve(rootDir);
|
|
252
285
|
const candidate = resolve(candidatePath);
|
|
@@ -277,10 +310,359 @@ function assertResolvedPathContained(courseDir, resolvedPath) {
|
|
|
277
310
|
return { ok: false, message: "Path could not be resolved" };
|
|
278
311
|
}
|
|
279
312
|
}
|
|
313
|
+
|
|
314
|
+
// src/assessments.ts
|
|
315
|
+
function toLearnerAssessment(assessment) {
|
|
316
|
+
const answerKey = {};
|
|
317
|
+
const feedback = {};
|
|
318
|
+
const questions = assessment.questions.map((q) => {
|
|
319
|
+
const correct = q.choices.find((c) => c.correct === true);
|
|
320
|
+
if (correct) {
|
|
321
|
+
answerKey[q.id] = correct.id;
|
|
322
|
+
}
|
|
323
|
+
if (q.explanation) {
|
|
324
|
+
feedback[q.id] = q.explanation;
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
id: q.id,
|
|
328
|
+
prompt: q.prompt,
|
|
329
|
+
choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
learner: {
|
|
334
|
+
id: assessment.id,
|
|
335
|
+
title: assessment.title,
|
|
336
|
+
passingScore: assessment.passingScore,
|
|
337
|
+
questions
|
|
338
|
+
},
|
|
339
|
+
answerKey,
|
|
340
|
+
config: {
|
|
341
|
+
maxAttempts: assessment.maxAttempts ?? 1,
|
|
342
|
+
shuffleChoices: assessment.shuffleChoices ?? false,
|
|
343
|
+
showFeedback: assessment.showFeedback ?? "never"
|
|
344
|
+
},
|
|
345
|
+
feedback
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/course-assessments.ts
|
|
350
|
+
async function loadParsedAssessments(courseDir, manifest) {
|
|
351
|
+
const resolvedDir = courseDir;
|
|
352
|
+
const issues = [];
|
|
353
|
+
const parsed = /* @__PURE__ */ new Map();
|
|
354
|
+
const assessmentIds = /* @__PURE__ */ new Set();
|
|
355
|
+
for (const ref of manifest.assessments ?? []) {
|
|
356
|
+
if (assessmentIds.has(ref.id)) {
|
|
357
|
+
issues.push({
|
|
358
|
+
path: "assessments",
|
|
359
|
+
message: `Duplicate assessment ID: ${ref.id}`,
|
|
360
|
+
severity: "error"
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
assessmentIds.add(ref.id);
|
|
364
|
+
const resolved = resolveCoursePath(resolvedDir, ref.file);
|
|
365
|
+
if (!resolved.ok) {
|
|
366
|
+
issues.push({
|
|
367
|
+
path: `assessments.${ref.id}.file`,
|
|
368
|
+
message: resolved.message,
|
|
369
|
+
severity: "error"
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (!existsSync(resolved.path)) {
|
|
374
|
+
issues.push({
|
|
375
|
+
path: `assessments.${ref.id}.file`,
|
|
376
|
+
message: `Assessment file not found: ${ref.file}`,
|
|
377
|
+
severity: "error"
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
382
|
+
if (!contained.ok) {
|
|
383
|
+
issues.push({
|
|
384
|
+
path: `assessments.${ref.id}.file`,
|
|
385
|
+
message: contained.message,
|
|
386
|
+
severity: "error"
|
|
387
|
+
});
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const assessmentStat = statSync(resolved.path);
|
|
391
|
+
if (!assessmentStat.isFile()) {
|
|
392
|
+
issues.push({
|
|
393
|
+
path: `assessments.${ref.id}.file`,
|
|
394
|
+
message: `Assessment path is not a file: ${ref.file}`,
|
|
395
|
+
severity: "error"
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const content = await readFile(resolved.path, "utf-8");
|
|
401
|
+
const raw = parseYaml(content);
|
|
402
|
+
const result = assessmentSchema.safeParse(raw);
|
|
403
|
+
if (!result.success) {
|
|
404
|
+
for (const issue of result.error.issues) {
|
|
405
|
+
const subPath = issue.path.length ? issue.path.join(".") : "root";
|
|
406
|
+
issues.push({
|
|
407
|
+
path: `${ref.file}:${subPath}`,
|
|
408
|
+
message: issue.message,
|
|
409
|
+
severity: "error"
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (result.data.id !== ref.id) {
|
|
415
|
+
issues.push({
|
|
416
|
+
path: `assessments.${ref.id}`,
|
|
417
|
+
message: `Assessment file id "${result.data.id}" does not match manifest ref id "${ref.id}"`,
|
|
418
|
+
severity: "error"
|
|
419
|
+
});
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
parsed.set(ref.id, result.data);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
issues.push({
|
|
425
|
+
path: ref.file,
|
|
426
|
+
message: `Failed to parse assessment: ${formatErrorMessage(err)}`,
|
|
427
|
+
severity: "error"
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { parsed, issues };
|
|
432
|
+
}
|
|
433
|
+
function buildRuntimeAssessmentBundleFromParsed(parsed) {
|
|
434
|
+
const assessments = {};
|
|
435
|
+
const answerKeys = {};
|
|
436
|
+
const configs = {};
|
|
437
|
+
const feedback = {};
|
|
438
|
+
for (const [id, assessment] of parsed) {
|
|
439
|
+
const built = toLearnerAssessment(assessment);
|
|
440
|
+
assessments[id] = built.learner;
|
|
441
|
+
answerKeys[id] = built.answerKey;
|
|
442
|
+
configs[id] = built.config;
|
|
443
|
+
feedback[id] = built.feedback;
|
|
444
|
+
}
|
|
445
|
+
return { assessments, answerKeys, configs, feedback };
|
|
446
|
+
}
|
|
447
|
+
async function buildRuntimeAssessmentBundle(courseDir, manifest) {
|
|
448
|
+
const { parsed, issues } = await loadParsedAssessments(courseDir, manifest);
|
|
449
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
450
|
+
if (errors.length > 0) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
errors.map((i) => `${i.path}: ${i.message}`).join("; ")
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return buildRuntimeAssessmentBundleFromParsed(parsed);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/validate/lesson-markdown.ts
|
|
459
|
+
import { existsSync as existsSync2, statSync as statSync2 } from "fs";
|
|
460
|
+
function validateMarkdownLesson(courseDir, lesson) {
|
|
461
|
+
const issues = [];
|
|
462
|
+
const resolved = resolveCoursePath(courseDir, lesson.file);
|
|
463
|
+
if (!resolved.ok) {
|
|
464
|
+
issues.push({
|
|
465
|
+
path: `lessons.${lesson.id}.file`,
|
|
466
|
+
message: resolved.message,
|
|
467
|
+
severity: "error"
|
|
468
|
+
});
|
|
469
|
+
return issues;
|
|
470
|
+
}
|
|
471
|
+
if (!existsSync2(resolved.path)) {
|
|
472
|
+
issues.push({
|
|
473
|
+
path: `lessons.${lesson.id}.file`,
|
|
474
|
+
message: `Lesson file not found: ${lesson.file}`,
|
|
475
|
+
severity: "error"
|
|
476
|
+
});
|
|
477
|
+
return issues;
|
|
478
|
+
}
|
|
479
|
+
const contained = assertResolvedPathContained(courseDir, resolved.path);
|
|
480
|
+
if (!contained.ok) {
|
|
481
|
+
issues.push({
|
|
482
|
+
path: `lessons.${lesson.id}.file`,
|
|
483
|
+
message: contained.message,
|
|
484
|
+
severity: "error"
|
|
485
|
+
});
|
|
486
|
+
return issues;
|
|
487
|
+
}
|
|
488
|
+
const stat = statSync2(resolved.path);
|
|
489
|
+
if (!stat.isFile()) {
|
|
490
|
+
issues.push({
|
|
491
|
+
path: `lessons.${lesson.id}.file`,
|
|
492
|
+
message: `Lesson path is not a file: ${lesson.file}`,
|
|
493
|
+
severity: "error"
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return issues;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/validate/lesson-html.ts
|
|
500
|
+
import { existsSync as existsSync3, statSync as statSync3 } from "fs";
|
|
501
|
+
import { join } from "path";
|
|
502
|
+
function validateHtmlLesson(courseDir, lesson) {
|
|
503
|
+
const issues = [];
|
|
504
|
+
const resolved = resolveCoursePath(courseDir, lesson.path);
|
|
505
|
+
if (!resolved.ok) {
|
|
506
|
+
issues.push({
|
|
507
|
+
path: `lessons.${lesson.id}.path`,
|
|
508
|
+
message: resolved.message,
|
|
509
|
+
severity: "error"
|
|
510
|
+
});
|
|
511
|
+
return issues;
|
|
512
|
+
}
|
|
513
|
+
if (!existsSync3(resolved.path)) {
|
|
514
|
+
issues.push({
|
|
515
|
+
path: `lessons.${lesson.id}.path`,
|
|
516
|
+
message: `HTML interaction directory not found: ${lesson.path}`,
|
|
517
|
+
severity: "error"
|
|
518
|
+
});
|
|
519
|
+
return issues;
|
|
520
|
+
}
|
|
521
|
+
const contained = assertResolvedPathContained(courseDir, resolved.path);
|
|
522
|
+
if (!contained.ok) {
|
|
523
|
+
issues.push({
|
|
524
|
+
path: `lessons.${lesson.id}.path`,
|
|
525
|
+
message: contained.message,
|
|
526
|
+
severity: "error"
|
|
527
|
+
});
|
|
528
|
+
return issues;
|
|
529
|
+
}
|
|
530
|
+
const stat = statSync3(resolved.path);
|
|
531
|
+
if (!stat.isDirectory()) {
|
|
532
|
+
issues.push({
|
|
533
|
+
path: `lessons.${lesson.id}.path`,
|
|
534
|
+
message: `HTML interaction path is not a directory: ${lesson.path}`,
|
|
535
|
+
severity: "error"
|
|
536
|
+
});
|
|
537
|
+
return issues;
|
|
538
|
+
}
|
|
539
|
+
const indexPath = join(resolved.path, "index.html");
|
|
540
|
+
if (!existsSync3(indexPath)) {
|
|
541
|
+
issues.push({
|
|
542
|
+
path: `lessons.${lesson.id}.path`,
|
|
543
|
+
message: `HTML interaction missing index.html: ${lesson.path}`,
|
|
544
|
+
severity: "error"
|
|
545
|
+
});
|
|
546
|
+
return issues;
|
|
547
|
+
}
|
|
548
|
+
const indexContained = assertResolvedPathContained(courseDir, indexPath);
|
|
549
|
+
if (!indexContained.ok) {
|
|
550
|
+
issues.push({
|
|
551
|
+
path: `lessons.${lesson.id}.path`,
|
|
552
|
+
message: indexContained.message,
|
|
553
|
+
severity: "error"
|
|
554
|
+
});
|
|
555
|
+
return issues;
|
|
556
|
+
}
|
|
557
|
+
if (!statSync3(indexPath).isFile()) {
|
|
558
|
+
issues.push({
|
|
559
|
+
path: `lessons.${lesson.id}.path`,
|
|
560
|
+
message: `index.html is not a file: ${lesson.path}/index.html`,
|
|
561
|
+
severity: "error"
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return issues;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/validate/lesson-component.ts
|
|
568
|
+
import { existsSync as existsSync4, statSync as statSync4 } from "fs";
|
|
569
|
+
import { join as join2 } from "path";
|
|
570
|
+
function validateComponentLesson(courseDir, lesson) {
|
|
571
|
+
const issues = [];
|
|
572
|
+
if (isBuiltinComponentId(lesson.component)) {
|
|
573
|
+
return issues;
|
|
574
|
+
}
|
|
575
|
+
const resolved = resolveCoursePath(
|
|
576
|
+
courseDir,
|
|
577
|
+
join2("components", lesson.component)
|
|
578
|
+
);
|
|
579
|
+
if (!resolved.ok) {
|
|
580
|
+
issues.push({
|
|
581
|
+
path: `lessons.${lesson.id}.component`,
|
|
582
|
+
message: resolved.message,
|
|
583
|
+
severity: "error"
|
|
584
|
+
});
|
|
585
|
+
return issues;
|
|
586
|
+
}
|
|
587
|
+
if (!existsSync4(resolved.path)) {
|
|
588
|
+
issues.push({
|
|
589
|
+
path: `lessons.${lesson.id}.component`,
|
|
590
|
+
message: `Unknown component "${lesson.component}" and no override at components/${lesson.component}`,
|
|
591
|
+
severity: "error"
|
|
592
|
+
});
|
|
593
|
+
return issues;
|
|
594
|
+
}
|
|
595
|
+
const contained = assertResolvedPathContained(courseDir, resolved.path);
|
|
596
|
+
if (!contained.ok) {
|
|
597
|
+
issues.push({
|
|
598
|
+
path: `lessons.${lesson.id}.component`,
|
|
599
|
+
message: contained.message,
|
|
600
|
+
severity: "error"
|
|
601
|
+
});
|
|
602
|
+
return issues;
|
|
603
|
+
}
|
|
604
|
+
const componentStat = statSync4(resolved.path);
|
|
605
|
+
if (!componentStat.isFile()) {
|
|
606
|
+
issues.push({
|
|
607
|
+
path: `lessons.${lesson.id}.component`,
|
|
608
|
+
message: `Component override path is not a file: components/${lesson.component}`,
|
|
609
|
+
severity: "error"
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
return issues;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/validate/registry.ts
|
|
616
|
+
var lessonValidators = {
|
|
617
|
+
markdown: (courseDir, lesson) => validateMarkdownLesson(courseDir, lesson),
|
|
618
|
+
html: (courseDir, lesson) => validateHtmlLesson(courseDir, lesson),
|
|
619
|
+
component: (courseDir, lesson) => validateComponentLesson(courseDir, lesson)
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// src/validate/ids.ts
|
|
623
|
+
function validateActivityIds(manifest) {
|
|
624
|
+
const issues = [];
|
|
625
|
+
const lessonIdCounts = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const lesson of manifest.lessons) {
|
|
627
|
+
lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
|
|
628
|
+
}
|
|
629
|
+
for (const [id, count] of lessonIdCounts) {
|
|
630
|
+
if (count > 1) {
|
|
631
|
+
issues.push({
|
|
632
|
+
path: "lessons",
|
|
633
|
+
message: `Duplicate lesson ID: ${id}`,
|
|
634
|
+
severity: "error"
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const assessmentIdSet = /* @__PURE__ */ new Set();
|
|
639
|
+
for (const ref of manifest.assessments ?? []) {
|
|
640
|
+
assessmentIdSet.add(ref.id);
|
|
641
|
+
}
|
|
642
|
+
for (const lesson of manifest.lessons) {
|
|
643
|
+
if (assessmentIdSet.has(lesson.id)) {
|
|
644
|
+
issues.push({
|
|
645
|
+
path: "lessons",
|
|
646
|
+
message: `Lesson ID "${lesson.id}" conflicts with an assessment ID`,
|
|
647
|
+
severity: "error"
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return issues;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/validate.ts
|
|
655
|
+
function formatErrorMessage(err) {
|
|
656
|
+
return err instanceof Error ? err.message : String(err);
|
|
657
|
+
}
|
|
658
|
+
function formatIssuePath(path) {
|
|
659
|
+
const joined = path.map(String).join(".");
|
|
660
|
+
return joined || "course.yaml";
|
|
661
|
+
}
|
|
280
662
|
async function loadManifest(courseDir) {
|
|
281
|
-
const resolvedDir =
|
|
282
|
-
const manifestPath =
|
|
283
|
-
if (!
|
|
663
|
+
const resolvedDir = resolve2(courseDir);
|
|
664
|
+
const manifestPath = join3(resolvedDir, "course.yaml");
|
|
665
|
+
if (!existsSync5(manifestPath)) {
|
|
284
666
|
return [
|
|
285
667
|
{
|
|
286
668
|
path: "course.yaml",
|
|
@@ -291,8 +673,8 @@ async function loadManifest(courseDir) {
|
|
|
291
673
|
}
|
|
292
674
|
let raw;
|
|
293
675
|
try {
|
|
294
|
-
const content = await
|
|
295
|
-
raw =
|
|
676
|
+
const content = await readFile2(manifestPath, "utf-8");
|
|
677
|
+
raw = parseYaml2(content);
|
|
296
678
|
} catch (err) {
|
|
297
679
|
return [
|
|
298
680
|
{
|
|
@@ -314,333 +696,56 @@ async function loadManifest(courseDir) {
|
|
|
314
696
|
}
|
|
315
697
|
async function validateCourse(courseDir) {
|
|
316
698
|
const issues = [];
|
|
317
|
-
const resolvedDir =
|
|
699
|
+
const resolvedDir = resolve2(courseDir);
|
|
318
700
|
const loaded = await loadManifest(resolvedDir);
|
|
319
701
|
if (Array.isArray(loaded)) {
|
|
320
702
|
return { valid: false, issues: loaded };
|
|
321
703
|
}
|
|
322
704
|
const { manifest } = loaded;
|
|
323
705
|
for (const lesson of manifest.lessons) {
|
|
324
|
-
|
|
325
|
-
const resolved = resolveCoursePath(resolvedDir, lesson.file);
|
|
326
|
-
if (!resolved.ok) {
|
|
327
|
-
issues.push({
|
|
328
|
-
path: `lessons.${lesson.id}.file`,
|
|
329
|
-
message: resolved.message,
|
|
330
|
-
severity: "error"
|
|
331
|
-
});
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
if (!existsSync(resolved.path)) {
|
|
335
|
-
issues.push({
|
|
336
|
-
path: `lessons.${lesson.id}.file`,
|
|
337
|
-
message: `Lesson file not found: ${lesson.file}`,
|
|
338
|
-
severity: "error"
|
|
339
|
-
});
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
343
|
-
if (!contained.ok) {
|
|
344
|
-
issues.push({
|
|
345
|
-
path: `lessons.${lesson.id}.file`,
|
|
346
|
-
message: contained.message,
|
|
347
|
-
severity: "error"
|
|
348
|
-
});
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
const stat = statSync(resolved.path);
|
|
352
|
-
if (!stat.isFile()) {
|
|
353
|
-
issues.push({
|
|
354
|
-
path: `lessons.${lesson.id}.file`,
|
|
355
|
-
message: `Lesson path is not a file: ${lesson.file}`,
|
|
356
|
-
severity: "error"
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
} else if (lesson.type === "component") {
|
|
360
|
-
if (!isBuiltinComponentId(lesson.component)) {
|
|
361
|
-
const resolved = resolveCoursePath(
|
|
362
|
-
resolvedDir,
|
|
363
|
-
join("components", lesson.component)
|
|
364
|
-
);
|
|
365
|
-
if (!resolved.ok) {
|
|
366
|
-
issues.push({
|
|
367
|
-
path: `lessons.${lesson.id}.component`,
|
|
368
|
-
message: resolved.message,
|
|
369
|
-
severity: "error"
|
|
370
|
-
});
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
if (!existsSync(resolved.path)) {
|
|
374
|
-
issues.push({
|
|
375
|
-
path: `lessons.${lesson.id}.component`,
|
|
376
|
-
message: `Unknown component "${lesson.component}" and no override at components/${lesson.component}`,
|
|
377
|
-
severity: "error"
|
|
378
|
-
});
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
382
|
-
if (!contained.ok) {
|
|
383
|
-
issues.push({
|
|
384
|
-
path: `lessons.${lesson.id}.component`,
|
|
385
|
-
message: contained.message,
|
|
386
|
-
severity: "error"
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
} else if (lesson.type === "html") {
|
|
391
|
-
const resolved = resolveCoursePath(resolvedDir, lesson.path);
|
|
392
|
-
if (!resolved.ok) {
|
|
393
|
-
issues.push({
|
|
394
|
-
path: `lessons.${lesson.id}.path`,
|
|
395
|
-
message: resolved.message,
|
|
396
|
-
severity: "error"
|
|
397
|
-
});
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
if (!existsSync(resolved.path)) {
|
|
401
|
-
issues.push({
|
|
402
|
-
path: `lessons.${lesson.id}.path`,
|
|
403
|
-
message: `HTML interaction directory not found: ${lesson.path}`,
|
|
404
|
-
severity: "error"
|
|
405
|
-
});
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
409
|
-
if (!contained.ok) {
|
|
410
|
-
issues.push({
|
|
411
|
-
path: `lessons.${lesson.id}.path`,
|
|
412
|
-
message: contained.message,
|
|
413
|
-
severity: "error"
|
|
414
|
-
});
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
const stat = statSync(resolved.path);
|
|
418
|
-
if (!stat.isDirectory()) {
|
|
419
|
-
issues.push({
|
|
420
|
-
path: `lessons.${lesson.id}.path`,
|
|
421
|
-
message: `HTML interaction path is not a directory: ${lesson.path}`,
|
|
422
|
-
severity: "error"
|
|
423
|
-
});
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
const indexPath = join(resolved.path, "index.html");
|
|
427
|
-
if (!existsSync(indexPath)) {
|
|
428
|
-
issues.push({
|
|
429
|
-
path: `lessons.${lesson.id}.path`,
|
|
430
|
-
message: `HTML interaction missing index.html: ${lesson.path}`,
|
|
431
|
-
severity: "error"
|
|
432
|
-
});
|
|
433
|
-
} else {
|
|
434
|
-
const indexContained = assertResolvedPathContained(
|
|
435
|
-
resolvedDir,
|
|
436
|
-
indexPath
|
|
437
|
-
);
|
|
438
|
-
if (!indexContained.ok) {
|
|
439
|
-
issues.push({
|
|
440
|
-
path: `lessons.${lesson.id}.path`,
|
|
441
|
-
message: indexContained.message,
|
|
442
|
-
severity: "error"
|
|
443
|
-
});
|
|
444
|
-
} else if (!statSync(indexPath).isFile()) {
|
|
445
|
-
issues.push({
|
|
446
|
-
path: `lessons.${lesson.id}.path`,
|
|
447
|
-
message: `index.html is not a file: ${lesson.path}/index.html`,
|
|
448
|
-
severity: "error"
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
706
|
+
issues.push(...lessonValidators[lesson.type](resolvedDir, lesson));
|
|
453
707
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
message: `Duplicate assessment ID: ${ref.id}`,
|
|
461
|
-
severity: "error"
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
assessmentIds.add(ref.id);
|
|
465
|
-
const resolved = resolveCoursePath(resolvedDir, ref.file);
|
|
466
|
-
if (!resolved.ok) {
|
|
467
|
-
issues.push({
|
|
468
|
-
path: `assessments.${ref.id}.file`,
|
|
469
|
-
message: resolved.message,
|
|
470
|
-
severity: "error"
|
|
471
|
-
});
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
if (!existsSync(resolved.path)) {
|
|
475
|
-
issues.push({
|
|
476
|
-
path: `assessments.${ref.id}.file`,
|
|
477
|
-
message: `Assessment file not found: ${ref.file}`,
|
|
478
|
-
severity: "error"
|
|
479
|
-
});
|
|
480
|
-
continue;
|
|
481
|
-
}
|
|
482
|
-
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
483
|
-
if (!contained.ok) {
|
|
484
|
-
issues.push({
|
|
485
|
-
path: `assessments.${ref.id}.file`,
|
|
486
|
-
message: contained.message,
|
|
487
|
-
severity: "error"
|
|
488
|
-
});
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
const assessmentStat = statSync(resolved.path);
|
|
492
|
-
if (!assessmentStat.isFile()) {
|
|
493
|
-
issues.push({
|
|
494
|
-
path: `assessments.${ref.id}.file`,
|
|
495
|
-
message: `Assessment path is not a file: ${ref.file}`,
|
|
496
|
-
severity: "error"
|
|
497
|
-
});
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
try {
|
|
501
|
-
const content = await readFile(resolved.path, "utf-8");
|
|
502
|
-
const raw = parseYaml(content);
|
|
503
|
-
const parsed = assessmentSchema.safeParse(raw);
|
|
504
|
-
if (!parsed.success) {
|
|
505
|
-
for (const issue of parsed.error.issues) {
|
|
506
|
-
const subPath = issue.path.length ? issue.path.join(".") : "root";
|
|
507
|
-
issues.push({
|
|
508
|
-
path: `${ref.file}:${subPath}`,
|
|
509
|
-
message: issue.message,
|
|
510
|
-
severity: "error"
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
if (parsed.data.id !== ref.id) {
|
|
516
|
-
issues.push({
|
|
517
|
-
path: `assessments.${ref.id}`,
|
|
518
|
-
message: `Assessment file id "${parsed.data.id}" does not match manifest ref id "${ref.id}"`,
|
|
519
|
-
severity: "error"
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
} catch (err) {
|
|
523
|
-
issues.push({
|
|
524
|
-
path: ref.file,
|
|
525
|
-
message: `Failed to parse assessment: ${formatErrorMessage(err)}`,
|
|
526
|
-
severity: "error"
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
const lessonIdCounts = /* @__PURE__ */ new Map();
|
|
532
|
-
for (const lesson of manifest.lessons) {
|
|
533
|
-
lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
|
|
534
|
-
}
|
|
535
|
-
for (const [id, count] of lessonIdCounts) {
|
|
536
|
-
if (count > 1) {
|
|
708
|
+
const assessmentLoad = await loadParsedAssessments(resolvedDir, manifest);
|
|
709
|
+
issues.push(...assessmentLoad.issues);
|
|
710
|
+
issues.push(...validateActivityIds(manifest));
|
|
711
|
+
issues.push(...validateFlow(manifest));
|
|
712
|
+
if (manifest.flow?.length) {
|
|
713
|
+
for (const message of detectFlowCycles(manifest.flow)) {
|
|
537
714
|
issues.push({
|
|
538
|
-
path: "
|
|
539
|
-
message
|
|
715
|
+
path: "flow",
|
|
716
|
+
message,
|
|
540
717
|
severity: "error"
|
|
541
718
|
});
|
|
542
719
|
}
|
|
543
720
|
}
|
|
544
|
-
issues.push(...validateFlow(manifest));
|
|
545
|
-
if (manifest.variables) {
|
|
546
|
-
for (const [name, def] of Object.entries(manifest.variables)) {
|
|
547
|
-
const t = def.type;
|
|
548
|
-
if (t === "string" && typeof def.default !== "string") {
|
|
549
|
-
issues.push({
|
|
550
|
-
path: `variables.${name}.default`,
|
|
551
|
-
message: "Default must be a string when type is string",
|
|
552
|
-
severity: "error"
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
if (t === "number" && typeof def.default !== "number") {
|
|
556
|
-
issues.push({
|
|
557
|
-
path: `variables.${name}.default`,
|
|
558
|
-
message: "Default must be a number when type is number",
|
|
559
|
-
severity: "error"
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
if (t === "boolean" && typeof def.default !== "boolean") {
|
|
563
|
-
issues.push({
|
|
564
|
-
path: `variables.${name}.default`,
|
|
565
|
-
message: "Default must be a boolean when type is boolean",
|
|
566
|
-
severity: "error"
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
721
|
return {
|
|
572
722
|
valid: issues.filter((i) => i.severity === "error").length === 0,
|
|
573
723
|
manifest,
|
|
574
|
-
issues
|
|
724
|
+
issues,
|
|
725
|
+
parsedAssessments: assessmentLoad.parsed
|
|
575
726
|
};
|
|
576
727
|
}
|
|
577
728
|
|
|
578
|
-
// src/
|
|
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 = {};
|
|
729
|
+
// src/activities.ts
|
|
730
|
+
function enumerateActivities(manifest) {
|
|
731
|
+
const activities = manifest.lessons.map((lesson) => ({
|
|
732
|
+
id: lesson.id,
|
|
733
|
+
title: lesson.title ?? lesson.id,
|
|
734
|
+
kind: "lesson"
|
|
735
|
+
}));
|
|
619
736
|
for (const ref of manifest.assessments ?? []) {
|
|
620
|
-
|
|
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;
|
|
737
|
+
activities.push({
|
|
738
|
+
id: ref.id,
|
|
739
|
+
title: ref.id.replace(/_/g, " "),
|
|
740
|
+
kind: "assessment"
|
|
741
|
+
});
|
|
642
742
|
}
|
|
643
|
-
return
|
|
743
|
+
return activities;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// src/html.ts
|
|
747
|
+
function escapeHtml(text) {
|
|
748
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
644
749
|
}
|
|
645
750
|
export {
|
|
646
751
|
BUILTIN_COMPONENT_IDS,
|
|
@@ -649,11 +754,15 @@ export {
|
|
|
649
754
|
assessmentRefSchema,
|
|
650
755
|
assessmentSchema,
|
|
651
756
|
buildRuntimeAssessmentBundle,
|
|
757
|
+
buildRuntimeAssessmentBundleFromParsed,
|
|
652
758
|
collectActivityIds,
|
|
759
|
+
collectAssessmentIds,
|
|
653
760
|
componentLessonSchema,
|
|
654
761
|
conditionSchema,
|
|
655
762
|
courseManifestSchema,
|
|
656
763
|
detectFlowCycles,
|
|
764
|
+
enumerateActivities,
|
|
765
|
+
escapeHtml,
|
|
657
766
|
flowRuleSchema,
|
|
658
767
|
formatErrorMessage,
|
|
659
768
|
formatIssuePath,
|
|
@@ -662,6 +771,7 @@ export {
|
|
|
662
771
|
isPathContained,
|
|
663
772
|
lessonSchema,
|
|
664
773
|
loadManifest,
|
|
774
|
+
loadParsedAssessments,
|
|
665
775
|
markdownLessonSchema,
|
|
666
776
|
resolveCoursePath,
|
|
667
777
|
runtimeConfigSchema,
|