@lessonkit/lxpack 1.1.0 → 1.2.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/dist/index.cjs CHANGED
@@ -61,97 +61,79 @@ __export(index_exports, {
61
61
  });
62
62
  module.exports = __toCommonJS(index_exports);
63
63
 
64
- // src/validateDescriptor.ts
64
+ // src/descriptor/normalize.ts
65
65
  var import_core = require("@lessonkit/core");
66
-
67
- // src/spaPath.ts
68
- var import_node_fs = require("fs");
69
- var import_node_path = require("path");
70
- function resolveComparablePath(p) {
71
- if (/^[a-zA-Z]:[/\\]/.test(p)) {
72
- return import_node_path.win32.resolve(p);
73
- }
74
- return (0, import_node_path.resolve)(p);
75
- }
76
- function isSafeRelativeSpaPath(spaPath) {
77
- if (!spaPath.length || spaPath.includes("\0")) return false;
78
- if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
79
- if (/^[a-zA-Z]:/.test(spaPath)) return false;
80
- if (spaPath === "." || spaPath === "./") return false;
81
- const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
82
- if (segments.some((s) => s === "..")) return false;
83
- return segments.length > 0;
84
- }
85
- function assertResolvedPathUnderRoot(root, target) {
86
- const rootResolved = resolveComparablePath(root);
87
- const targetResolved = resolveComparablePath(target);
88
- const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
89
- const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
90
- if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
91
- !targetResolved.startsWith(win32Prefix)) {
92
- throw new Error(`unsafe path escapes project root: ${target}`);
93
- }
94
- }
95
- function assertRealPathUnderRoot(root, target) {
96
- const rootResolved = resolveComparablePath(root);
97
- const targetResolved = resolveComparablePath(target);
98
- let rootReal;
99
- try {
100
- rootReal = (0, import_node_fs.realpathSync)(rootResolved);
101
- } catch {
102
- rootReal = rootResolved;
103
- }
104
- let targetCheck;
105
- try {
106
- targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
107
- } catch {
108
- const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
109
- if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
110
- throw new Error(`unsafe path escapes project root: ${target}`);
111
- }
112
- targetCheck = (0, import_node_path.resolve)(rootReal, rel);
113
- }
114
- assertResolvedPathUnderRoot(rootReal, targetCheck);
115
- }
116
- function normalizePathForComparison(p) {
117
- const resolved = resolveComparablePath(p);
118
- return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
119
- }
120
- function relativePathUnderRoot(root, target) {
121
- const rootResolved = normalizePathForComparison(root);
122
- const targetResolved = normalizePathForComparison(target);
123
- if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
124
- return import_node_path.win32.relative(rootResolved, targetResolved);
125
- }
126
- return (0, import_node_path.relative)(rootResolved, targetResolved);
127
- }
128
- function isResolvedPathUnderRoot(root, target) {
129
- const rootResolved = normalizePathForComparison(root);
130
- const targetResolved = normalizePathForComparison(target);
131
- if (targetResolved === rootResolved) return true;
132
- const rel = relativePathUnderRoot(root, target);
133
- if (!rel) return true;
134
- return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
135
- }
136
-
137
- // src/theme.ts
138
- var import_themes = require("@lessonkit/themes");
139
- function themeToLxpackRuntime(input) {
140
- const theme = input.theme ?? (0, import_themes.getPresetTheme)(input.preset ?? "default");
141
- const raw = (0, import_themes.themeToCssVariables)(theme);
142
- const cssVariables = {};
143
- for (const [key, value] of Object.entries(raw)) {
144
- cssVariables[key] = String(value);
145
- }
66
+ function normalizeDescriptor(input) {
67
+ const course = (0, import_core.validateId)(input.courseId, "courseId");
68
+ if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
146
69
  return {
147
- theme: theme.name,
148
- cssVariables
70
+ ...input,
71
+ courseId: course.id,
72
+ title: input.title.trim(),
73
+ version: input.version?.trim() || void 0,
74
+ spaLessonId: input.spaLessonId?.trim() || void 0,
75
+ lessons: input.lessons.map((lesson) => {
76
+ const idResult = (0, import_core.validateId)(lesson.id, "lessonId");
77
+ if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
78
+ return {
79
+ ...lesson,
80
+ id: idResult.id,
81
+ title: lesson.title.trim(),
82
+ spaPath: lesson.spaPath?.trim() || void 0
83
+ };
84
+ }),
85
+ assessments: input.assessments?.map((assessment) => {
86
+ const check = (0, import_core.validateId)(assessment.checkId, "checkId");
87
+ if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
88
+ const question = assessment.question.trim();
89
+ if (assessment.kind === "trueFalse") {
90
+ return { ...assessment, checkId: check.id, question };
91
+ }
92
+ if (assessment.kind === "fillInBlanks") {
93
+ return {
94
+ ...assessment,
95
+ checkId: check.id,
96
+ question,
97
+ template: assessment.template.trim(),
98
+ blanks: assessment.blanks?.map((b) => ({
99
+ id: b.id.trim(),
100
+ answer: b.answer.trim()
101
+ }))
102
+ };
103
+ }
104
+ if (assessment.kind === "findHotspot") {
105
+ return {
106
+ ...assessment,
107
+ checkId: check.id,
108
+ question,
109
+ src: assessment.src.trim(),
110
+ alt: assessment.alt.trim(),
111
+ correctTargetId: assessment.correctTargetId.trim()
112
+ };
113
+ }
114
+ if (assessment.kind === "findMultipleHotspots") {
115
+ return {
116
+ ...assessment,
117
+ checkId: check.id,
118
+ question,
119
+ src: assessment.src.trim(),
120
+ alt: assessment.alt.trim(),
121
+ correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
122
+ };
123
+ }
124
+ const mcq = assessment;
125
+ return {
126
+ ...mcq,
127
+ checkId: check.id,
128
+ question,
129
+ choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
130
+ answer: mcq.answer.trim()
131
+ };
132
+ })
149
133
  };
150
134
  }
151
135
 
152
- // src/validateDescriptor.ts
153
- var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
154
- var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
136
+ // src/descriptor/parseInput.ts
155
137
  function isRecord(value) {
156
138
  return typeof value === "object" && value !== null && !Array.isArray(value);
157
139
  }
@@ -193,6 +175,24 @@ function parseAssessmentDescriptor(raw) {
193
175
  })) : void 0
194
176
  };
195
177
  }
178
+ if (kind === "findHotspot") {
179
+ return {
180
+ kind: "findHotspot",
181
+ ...base,
182
+ src: typeof raw.src === "string" ? raw.src : "",
183
+ alt: typeof raw.alt === "string" ? raw.alt : "",
184
+ correctTargetId: typeof raw.correctTargetId === "string" ? raw.correctTargetId : ""
185
+ };
186
+ }
187
+ if (kind === "findMultipleHotspots") {
188
+ return {
189
+ kind: "findMultipleHotspots",
190
+ ...base,
191
+ src: typeof raw.src === "string" ? raw.src : "",
192
+ alt: typeof raw.alt === "string" ? raw.alt : "",
193
+ correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
194
+ };
195
+ }
196
196
  return {
197
197
  kind: kind === "mcq" ? "mcq" : void 0,
198
198
  ...base,
@@ -239,82 +239,158 @@ function parseCourseDescriptorInput(input) {
239
239
  spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
240
240
  };
241
241
  }
242
- function normalizeDescriptor(input) {
243
- const course = (0, import_core.validateId)(input.courseId, "courseId");
244
- if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
242
+
243
+ // src/descriptor/validateCourse.ts
244
+ var import_core3 = require("@lessonkit/core");
245
+
246
+ // src/spaPath.ts
247
+ var import_node_fs = require("fs");
248
+ var import_node_path = require("path");
249
+ function resolveComparablePath(p) {
250
+ if (/^[a-zA-Z]:[/\\]/.test(p)) {
251
+ return import_node_path.win32.resolve(p);
252
+ }
253
+ return (0, import_node_path.resolve)(p);
254
+ }
255
+ function isSafeRelativeSpaPath(spaPath) {
256
+ if (!spaPath.length || spaPath.includes("\0")) return false;
257
+ if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
258
+ if (/^[a-zA-Z]:/.test(spaPath)) return false;
259
+ if (spaPath === "." || spaPath === "./") return false;
260
+ const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
261
+ if (segments.some((s) => s === "..")) return false;
262
+ return segments.length > 0;
263
+ }
264
+ function assertResolvedPathUnderRoot(root, target) {
265
+ const rootResolved = resolveComparablePath(root);
266
+ const targetResolved = resolveComparablePath(target);
267
+ const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
268
+ const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
269
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
270
+ !targetResolved.startsWith(win32Prefix)) {
271
+ throw new Error(`unsafe path escapes project root: ${target}`);
272
+ }
273
+ }
274
+ function assertRealPathUnderRoot(root, target) {
275
+ const rootResolved = resolveComparablePath(root);
276
+ const targetResolved = resolveComparablePath(target);
277
+ let rootReal;
278
+ try {
279
+ rootReal = (0, import_node_fs.realpathSync)(rootResolved);
280
+ } catch {
281
+ rootReal = rootResolved;
282
+ }
283
+ let targetCheck;
284
+ try {
285
+ targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
286
+ } catch {
287
+ const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
288
+ if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
289
+ throw new Error(`unsafe path escapes project root: ${target}`);
290
+ }
291
+ targetCheck = (0, import_node_path.resolve)(rootReal, rel);
292
+ }
293
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
294
+ }
295
+ function normalizePathForComparison(p) {
296
+ const resolved = resolveComparablePath(p);
297
+ return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
298
+ }
299
+ function relativePathUnderRoot(root, target) {
300
+ const rootResolved = normalizePathForComparison(root);
301
+ const targetResolved = normalizePathForComparison(target);
302
+ if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
303
+ return import_node_path.win32.relative(rootResolved, targetResolved);
304
+ }
305
+ return (0, import_node_path.relative)(rootResolved, targetResolved);
306
+ }
307
+ function isResolvedPathUnderRoot(root, target) {
308
+ const rootResolved = normalizePathForComparison(root);
309
+ const targetResolved = normalizePathForComparison(target);
310
+ if (targetResolved === rootResolved) return true;
311
+ const rel = relativePathUnderRoot(root, target);
312
+ if (!rel) return true;
313
+ return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
314
+ }
315
+
316
+ // src/theme.ts
317
+ var import_themes = require("@lessonkit/themes");
318
+ function themeToLxpackRuntime(input) {
319
+ const theme = input.theme ?? (0, import_themes.getPresetTheme)(input.preset ?? "default");
320
+ const raw = (0, import_themes.themeToCssVariables)(theme);
321
+ const cssVariables = {};
322
+ for (const [key, value] of Object.entries(raw)) {
323
+ cssVariables[key] = String(value);
324
+ }
245
325
  return {
246
- ...input,
247
- courseId: course.id,
248
- title: input.title.trim(),
249
- version: input.version?.trim() || void 0,
250
- spaLessonId: input.spaLessonId?.trim() || void 0,
251
- lessons: input.lessons.map((lesson) => {
252
- const idResult = (0, import_core.validateId)(lesson.id, "lessonId");
253
- if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
254
- return {
255
- ...lesson,
256
- id: idResult.id,
257
- title: lesson.title.trim(),
258
- spaPath: lesson.spaPath?.trim() || void 0
259
- };
260
- }),
261
- assessments: input.assessments?.map((assessment) => {
262
- const check = (0, import_core.validateId)(assessment.checkId, "checkId");
263
- if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
264
- const question = assessment.question.trim();
265
- if (assessment.kind === "trueFalse") {
266
- return { ...assessment, checkId: check.id, question };
267
- }
268
- if (assessment.kind === "fillInBlanks") {
269
- return {
270
- ...assessment,
271
- checkId: check.id,
272
- question,
273
- template: assessment.template.trim(),
274
- blanks: assessment.blanks?.map((b) => ({
275
- id: b.id.trim(),
276
- answer: b.answer.trim()
277
- }))
278
- };
279
- }
280
- return {
281
- ...assessment,
282
- checkId: check.id,
283
- question,
284
- choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
285
- answer: assessment.answer.trim()
286
- };
287
- })
326
+ theme: theme.name,
327
+ cssVariables
288
328
  };
289
329
  }
290
- function validateDescriptor(input) {
291
- const parsed = parseCourseDescriptorInput(input);
292
- if (parsed === null) {
293
- return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
330
+
331
+ // src/descriptor/validateAssessments.ts
332
+ var import_core2 = require("@lessonkit/core");
333
+ var validateMcqLike = (assessment, path, issues) => {
334
+ if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
335
+ return;
294
336
  }
295
- return validateDescriptorParsed(parsed);
296
- }
297
- function validateDescriptorForTarget(input, target) {
298
- const result = validateDescriptor(input);
299
- if (!result.ok || !target) return result;
300
- if (target !== "xapi" && target !== "cmi5") return result;
301
- const activityIri = result.descriptor.tracking?.xapi?.activityIri?.trim();
302
- if (!activityIri) {
303
- return {
304
- ok: false,
305
- issues: [
306
- {
307
- path: "course.tracking.xapi.activityIri",
308
- message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
309
- }
310
- ]
311
- };
337
+ const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
338
+ if (!trimmedChoices.length) {
339
+ issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
340
+ }
341
+ if (!assessment.answer.trim()) {
342
+ issues.push({ path: `${path}.answer`, message: "answer is required" });
343
+ } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
344
+ issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
345
+ }
346
+ };
347
+ var ASSESSMENT_VALIDATORS = {
348
+ mcq: validateMcqLike,
349
+ trueFalse: (assessment, path, issues) => {
350
+ if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
351
+ issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
352
+ }
353
+ },
354
+ fillInBlanks: (assessment, path, issues) => {
355
+ if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
356
+ issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
357
+ }
358
+ },
359
+ findHotspot: () => {
360
+ },
361
+ findMultipleHotspots: () => {
362
+ }
363
+ };
364
+ function validateAssessmentEntry(assessment, index, issues, checkIds) {
365
+ const path = `assessments[${index}]`;
366
+ const check = (0, import_core2.validateId)(assessment.checkId, `${path}.checkId`);
367
+ if (!check.ok) {
368
+ issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
369
+ } else if (checkIds.has(check.id)) {
370
+ issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
371
+ } else {
372
+ checkIds.add(check.id);
373
+ }
374
+ if (!assessment.question?.trim()) {
375
+ issues.push({ path: `${path}.question`, message: "question is required" });
376
+ }
377
+ const kind = assessment.kind ?? "mcq";
378
+ ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
379
+ const passingScore = assessment.passingScore;
380
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
381
+ issues.push({
382
+ path: `${path}.passingScore`,
383
+ message: "passingScore must be greater than 0 (absolute point threshold)"
384
+ });
312
385
  }
313
- return result;
314
386
  }
315
- function validateDescriptorParsed(input) {
387
+
388
+ // src/descriptor/validateCourse.ts
389
+ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
390
+ var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
391
+ function validateCourseDescriptor(input) {
316
392
  const issues = [];
317
- const course = (0, import_core.validateId)(input.courseId, "courseId");
393
+ const course = (0, import_core3.validateId)(input.courseId, "courseId");
318
394
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
319
395
  if (!input.title?.trim()) {
320
396
  issues.push({ path: "title", message: "title is required" });
@@ -367,7 +443,7 @@ function validateDescriptorParsed(input) {
367
443
  const spaPaths = /* @__PURE__ */ new Set();
368
444
  for (const [index, lesson] of (input.lessons ?? []).entries()) {
369
445
  const path = `lessons[${index}]`;
370
- const lessonResult = (0, import_core.validateId)(lesson.id, `${path}.id`);
446
+ const lessonResult = (0, import_core3.validateId)(lesson.id, `${path}.id`);
371
447
  if (!lessonResult.ok) {
372
448
  issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
373
449
  } else if (lessonIds.has(lessonResult.id)) {
@@ -399,7 +475,7 @@ function validateDescriptorParsed(input) {
399
475
  }
400
476
  if (layout === "single-spa" && input.spaLessonId?.trim()) {
401
477
  const spaId = input.spaLessonId.trim();
402
- const spaResult = (0, import_core.validateId)(spaId, "spaLessonId");
478
+ const spaResult = (0, import_core3.validateId)(spaId, "spaLessonId");
403
479
  if (!spaResult.ok) {
404
480
  issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
405
481
  } else if (!lessonIds.has(spaResult.id)) {
@@ -411,52 +487,48 @@ function validateDescriptorParsed(input) {
411
487
  }
412
488
  const checkIds = /* @__PURE__ */ new Set();
413
489
  for (const [index, assessment] of (input.assessments ?? []).entries()) {
414
- const path = `assessments[${index}]`;
415
- const check = (0, import_core.validateId)(assessment.checkId, `${path}.checkId`);
416
- if (!check.ok) {
417
- issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
418
- } else if (checkIds.has(check.id)) {
419
- issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
420
- } else {
421
- checkIds.add(check.id);
422
- }
423
- if (!assessment.question?.trim()) {
424
- issues.push({ path: `${path}.question`, message: "question is required" });
425
- }
426
- const kind = assessment.kind ?? "mcq";
427
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
428
- if (typeof assessment.answer !== "boolean") {
429
- issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
430
- }
431
- } else if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
432
- if (!assessment.template?.trim()) {
433
- issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
434
- }
435
- } else if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
436
- const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
437
- if (!trimmedChoices.length) {
438
- issues.push({
439
- path: `${path}.choices`,
440
- message: "at least one non-empty choice is required"
441
- });
442
- }
443
- if (!assessment.answer.trim()) {
444
- issues.push({ path: `${path}.answer`, message: "answer is required" });
445
- } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
446
- issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
490
+ validateAssessmentEntry(assessment, index, issues, checkIds);
491
+ }
492
+ return issues;
493
+ }
494
+
495
+ // src/descriptor/validateForTarget.ts
496
+ function validateDescriptorForExportTarget(descriptor, target) {
497
+ if (target !== "xapi" && target !== "cmi5") return [];
498
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
499
+ if (!activityIri) {
500
+ return [
501
+ {
502
+ path: "course.tracking.xapi.activityIri",
503
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
447
504
  }
448
- }
449
- const passingScore = assessment.passingScore;
450
- if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
451
- issues.push({
452
- path: `${path}.passingScore`,
453
- message: "passingScore must be greater than 0 (absolute point threshold)"
454
- });
455
- }
505
+ ];
456
506
  }
507
+ return [];
508
+ }
509
+
510
+ // src/validateDescriptor.ts
511
+ function validateDescriptorParsed(input) {
512
+ const issues = validateCourseDescriptor(input);
457
513
  if (issues.length) return { ok: false, issues };
458
514
  return { ok: true, descriptor: normalizeDescriptor(input) };
459
515
  }
516
+ function validateDescriptor(input) {
517
+ const parsed = parseCourseDescriptorInput(input);
518
+ if (parsed === null) {
519
+ return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
520
+ }
521
+ return validateDescriptorParsed(parsed);
522
+ }
523
+ function validateDescriptorForTarget(input, target) {
524
+ const result = validateDescriptor(input);
525
+ if (!result.ok || !target) return result;
526
+ const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
527
+ if (targetIssues.length) {
528
+ return { ok: false, issues: targetIssues };
529
+ }
530
+ return result;
531
+ }
460
532
 
461
533
  // src/validateProjectPaths.ts
462
534
  var import_node_path2 = require("path");
@@ -511,12 +583,12 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
511
583
  }
512
584
 
513
585
  // src/mapIds.ts
514
- var import_core2 = require("@lessonkit/core");
586
+ var import_core4 = require("@lessonkit/core");
515
587
  function mapLessonkitIds(descriptor) {
516
- const courseId = (0, import_core2.assertValidId)(descriptor.courseId, "courseId");
517
- const lessonIds = descriptor.lessons.map((l) => (0, import_core2.assertValidId)(l.id, "lessonId"));
588
+ const courseId = (0, import_core4.assertValidId)(descriptor.courseId, "courseId");
589
+ const lessonIds = descriptor.lessons.map((l) => (0, import_core4.assertValidId)(l.id, "lessonId"));
518
590
  const checkIds = (descriptor.assessments ?? []).map(
519
- (a) => (0, import_core2.assertValidId)(a.checkId, "checkId")
591
+ (a) => (0, import_core4.assertValidId)(a.checkId, "checkId")
520
592
  );
521
593
  return { courseId, lessonIds, checkIds };
522
594
  }
@@ -565,6 +637,19 @@ function assessmentDescriptorToLxpack(assessment) {
565
637
  if (kind === "fillInBlanks") {
566
638
  return null;
567
639
  }
640
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
641
+ return mcqToLxpack({
642
+ kind: "mcq",
643
+ checkId: assessment.checkId,
644
+ question: assessment.question,
645
+ choices: [assessment.correctTargetId, "other"],
646
+ answer: assessment.correctTargetId,
647
+ passingScore: assessment.passingScore
648
+ });
649
+ }
650
+ if (kind === "findMultipleHotspots") {
651
+ return null;
652
+ }
568
653
  if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
569
654
  return mcqToLxpack(assessment);
570
655
  }
package/dist/index.d.cts CHANGED
@@ -40,8 +40,26 @@ type FillInBlanksAssessmentDescriptor = {
40
40
  }>;
41
41
  passingScore?: number;
42
42
  };
43
+ type FindHotspotAssessmentDescriptor = {
44
+ kind: "findHotspot";
45
+ checkId: CheckId;
46
+ question: string;
47
+ src: string;
48
+ alt: string;
49
+ correctTargetId: string;
50
+ passingScore?: number;
51
+ };
52
+ type FindMultipleHotspotsAssessmentDescriptor = {
53
+ kind: "findMultipleHotspots";
54
+ checkId: CheckId;
55
+ question: string;
56
+ src: string;
57
+ alt: string;
58
+ correctTargetIds: string[];
59
+ passingScore?: number;
60
+ };
43
61
  /** Discriminated assessment entries in lessonkit.json (defaults to MCQ when kind omitted). */
44
- type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor;
62
+ type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor | FindHotspotAssessmentDescriptor | FindMultipleHotspotsAssessmentDescriptor;
45
63
  type LessonkitCourseDescriptor = {
46
64
  courseId: CourseId;
47
65
  title: string;
package/dist/index.d.ts CHANGED
@@ -40,8 +40,26 @@ type FillInBlanksAssessmentDescriptor = {
40
40
  }>;
41
41
  passingScore?: number;
42
42
  };
43
+ type FindHotspotAssessmentDescriptor = {
44
+ kind: "findHotspot";
45
+ checkId: CheckId;
46
+ question: string;
47
+ src: string;
48
+ alt: string;
49
+ correctTargetId: string;
50
+ passingScore?: number;
51
+ };
52
+ type FindMultipleHotspotsAssessmentDescriptor = {
53
+ kind: "findMultipleHotspots";
54
+ checkId: CheckId;
55
+ question: string;
56
+ src: string;
57
+ alt: string;
58
+ correctTargetIds: string[];
59
+ passingScore?: number;
60
+ };
43
61
  /** Discriminated assessment entries in lessonkit.json (defaults to MCQ when kind omitted). */
44
- type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor;
62
+ type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor | FindHotspotAssessmentDescriptor | FindMultipleHotspotsAssessmentDescriptor;
45
63
  type LessonkitCourseDescriptor = {
46
64
  courseId: CourseId;
47
65
  title: string;
package/dist/index.js CHANGED
@@ -2,97 +2,79 @@ import {
2
2
  telemetryEventToLessonkit
3
3
  } from "./chunk-DYQI222N.js";
4
4
 
5
- // src/validateDescriptor.ts
5
+ // src/descriptor/normalize.ts
6
6
  import { validateId } from "@lessonkit/core";
7
-
8
- // src/spaPath.ts
9
- import { realpathSync } from "fs";
10
- import { isAbsolute, relative, resolve, sep, win32 } from "path";
11
- function resolveComparablePath(p) {
12
- if (/^[a-zA-Z]:[/\\]/.test(p)) {
13
- return win32.resolve(p);
14
- }
15
- return resolve(p);
16
- }
17
- function isSafeRelativeSpaPath(spaPath) {
18
- if (!spaPath.length || spaPath.includes("\0")) return false;
19
- if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
20
- if (/^[a-zA-Z]:/.test(spaPath)) return false;
21
- if (spaPath === "." || spaPath === "./") return false;
22
- const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
23
- if (segments.some((s) => s === "..")) return false;
24
- return segments.length > 0;
25
- }
26
- function assertResolvedPathUnderRoot(root, target) {
27
- const rootResolved = resolveComparablePath(root);
28
- const targetResolved = resolveComparablePath(target);
29
- const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
30
- const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
31
- if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
32
- !targetResolved.startsWith(win32Prefix)) {
33
- throw new Error(`unsafe path escapes project root: ${target}`);
34
- }
35
- }
36
- function assertRealPathUnderRoot(root, target) {
37
- const rootResolved = resolveComparablePath(root);
38
- const targetResolved = resolveComparablePath(target);
39
- let rootReal;
40
- try {
41
- rootReal = realpathSync(rootResolved);
42
- } catch {
43
- rootReal = rootResolved;
44
- }
45
- let targetCheck;
46
- try {
47
- targetCheck = realpathSync(targetResolved);
48
- } catch {
49
- const rel = relative(rootResolved, targetResolved);
50
- if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
51
- throw new Error(`unsafe path escapes project root: ${target}`);
52
- }
53
- targetCheck = resolve(rootReal, rel);
54
- }
55
- assertResolvedPathUnderRoot(rootReal, targetCheck);
56
- }
57
- function normalizePathForComparison(p) {
58
- const resolved = resolveComparablePath(p);
59
- return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
60
- }
61
- function relativePathUnderRoot(root, target) {
62
- const rootResolved = normalizePathForComparison(root);
63
- const targetResolved = normalizePathForComparison(target);
64
- if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
65
- return win32.relative(rootResolved, targetResolved);
66
- }
67
- return relative(rootResolved, targetResolved);
68
- }
69
- function isResolvedPathUnderRoot(root, target) {
70
- const rootResolved = normalizePathForComparison(root);
71
- const targetResolved = normalizePathForComparison(target);
72
- if (targetResolved === rootResolved) return true;
73
- const rel = relativePathUnderRoot(root, target);
74
- if (!rel) return true;
75
- return !rel.startsWith("..") && !isAbsolute(rel);
76
- }
77
-
78
- // src/theme.ts
79
- import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
80
- function themeToLxpackRuntime(input) {
81
- const theme = input.theme ?? getPresetTheme(input.preset ?? "default");
82
- const raw = themeToCssVariables(theme);
83
- const cssVariables = {};
84
- for (const [key, value] of Object.entries(raw)) {
85
- cssVariables[key] = String(value);
86
- }
7
+ function normalizeDescriptor(input) {
8
+ const course = validateId(input.courseId, "courseId");
9
+ if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
87
10
  return {
88
- theme: theme.name,
89
- cssVariables
11
+ ...input,
12
+ courseId: course.id,
13
+ title: input.title.trim(),
14
+ version: input.version?.trim() || void 0,
15
+ spaLessonId: input.spaLessonId?.trim() || void 0,
16
+ lessons: input.lessons.map((lesson) => {
17
+ const idResult = validateId(lesson.id, "lessonId");
18
+ if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
19
+ return {
20
+ ...lesson,
21
+ id: idResult.id,
22
+ title: lesson.title.trim(),
23
+ spaPath: lesson.spaPath?.trim() || void 0
24
+ };
25
+ }),
26
+ assessments: input.assessments?.map((assessment) => {
27
+ const check = validateId(assessment.checkId, "checkId");
28
+ if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
29
+ const question = assessment.question.trim();
30
+ if (assessment.kind === "trueFalse") {
31
+ return { ...assessment, checkId: check.id, question };
32
+ }
33
+ if (assessment.kind === "fillInBlanks") {
34
+ return {
35
+ ...assessment,
36
+ checkId: check.id,
37
+ question,
38
+ template: assessment.template.trim(),
39
+ blanks: assessment.blanks?.map((b) => ({
40
+ id: b.id.trim(),
41
+ answer: b.answer.trim()
42
+ }))
43
+ };
44
+ }
45
+ if (assessment.kind === "findHotspot") {
46
+ return {
47
+ ...assessment,
48
+ checkId: check.id,
49
+ question,
50
+ src: assessment.src.trim(),
51
+ alt: assessment.alt.trim(),
52
+ correctTargetId: assessment.correctTargetId.trim()
53
+ };
54
+ }
55
+ if (assessment.kind === "findMultipleHotspots") {
56
+ return {
57
+ ...assessment,
58
+ checkId: check.id,
59
+ question,
60
+ src: assessment.src.trim(),
61
+ alt: assessment.alt.trim(),
62
+ correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
63
+ };
64
+ }
65
+ const mcq = assessment;
66
+ return {
67
+ ...mcq,
68
+ checkId: check.id,
69
+ question,
70
+ choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
71
+ answer: mcq.answer.trim()
72
+ };
73
+ })
90
74
  };
91
75
  }
92
76
 
93
- // src/validateDescriptor.ts
94
- var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
95
- var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
77
+ // src/descriptor/parseInput.ts
96
78
  function isRecord(value) {
97
79
  return typeof value === "object" && value !== null && !Array.isArray(value);
98
80
  }
@@ -134,6 +116,24 @@ function parseAssessmentDescriptor(raw) {
134
116
  })) : void 0
135
117
  };
136
118
  }
119
+ if (kind === "findHotspot") {
120
+ return {
121
+ kind: "findHotspot",
122
+ ...base,
123
+ src: typeof raw.src === "string" ? raw.src : "",
124
+ alt: typeof raw.alt === "string" ? raw.alt : "",
125
+ correctTargetId: typeof raw.correctTargetId === "string" ? raw.correctTargetId : ""
126
+ };
127
+ }
128
+ if (kind === "findMultipleHotspots") {
129
+ return {
130
+ kind: "findMultipleHotspots",
131
+ ...base,
132
+ src: typeof raw.src === "string" ? raw.src : "",
133
+ alt: typeof raw.alt === "string" ? raw.alt : "",
134
+ correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
135
+ };
136
+ }
137
137
  return {
138
138
  kind: kind === "mcq" ? "mcq" : void 0,
139
139
  ...base,
@@ -180,82 +180,158 @@ function parseCourseDescriptorInput(input) {
180
180
  spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
181
181
  };
182
182
  }
183
- function normalizeDescriptor(input) {
184
- const course = validateId(input.courseId, "courseId");
185
- if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
183
+
184
+ // src/descriptor/validateCourse.ts
185
+ import { validateId as validateId3 } from "@lessonkit/core";
186
+
187
+ // src/spaPath.ts
188
+ import { realpathSync } from "fs";
189
+ import { isAbsolute, relative, resolve, sep, win32 } from "path";
190
+ function resolveComparablePath(p) {
191
+ if (/^[a-zA-Z]:[/\\]/.test(p)) {
192
+ return win32.resolve(p);
193
+ }
194
+ return resolve(p);
195
+ }
196
+ function isSafeRelativeSpaPath(spaPath) {
197
+ if (!spaPath.length || spaPath.includes("\0")) return false;
198
+ if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
199
+ if (/^[a-zA-Z]:/.test(spaPath)) return false;
200
+ if (spaPath === "." || spaPath === "./") return false;
201
+ const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
202
+ if (segments.some((s) => s === "..")) return false;
203
+ return segments.length > 0;
204
+ }
205
+ function assertResolvedPathUnderRoot(root, target) {
206
+ const rootResolved = resolveComparablePath(root);
207
+ const targetResolved = resolveComparablePath(target);
208
+ const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
209
+ const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
210
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
211
+ !targetResolved.startsWith(win32Prefix)) {
212
+ throw new Error(`unsafe path escapes project root: ${target}`);
213
+ }
214
+ }
215
+ function assertRealPathUnderRoot(root, target) {
216
+ const rootResolved = resolveComparablePath(root);
217
+ const targetResolved = resolveComparablePath(target);
218
+ let rootReal;
219
+ try {
220
+ rootReal = realpathSync(rootResolved);
221
+ } catch {
222
+ rootReal = rootResolved;
223
+ }
224
+ let targetCheck;
225
+ try {
226
+ targetCheck = realpathSync(targetResolved);
227
+ } catch {
228
+ const rel = relative(rootResolved, targetResolved);
229
+ if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
230
+ throw new Error(`unsafe path escapes project root: ${target}`);
231
+ }
232
+ targetCheck = resolve(rootReal, rel);
233
+ }
234
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
235
+ }
236
+ function normalizePathForComparison(p) {
237
+ const resolved = resolveComparablePath(p);
238
+ return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
239
+ }
240
+ function relativePathUnderRoot(root, target) {
241
+ const rootResolved = normalizePathForComparison(root);
242
+ const targetResolved = normalizePathForComparison(target);
243
+ if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
244
+ return win32.relative(rootResolved, targetResolved);
245
+ }
246
+ return relative(rootResolved, targetResolved);
247
+ }
248
+ function isResolvedPathUnderRoot(root, target) {
249
+ const rootResolved = normalizePathForComparison(root);
250
+ const targetResolved = normalizePathForComparison(target);
251
+ if (targetResolved === rootResolved) return true;
252
+ const rel = relativePathUnderRoot(root, target);
253
+ if (!rel) return true;
254
+ return !rel.startsWith("..") && !isAbsolute(rel);
255
+ }
256
+
257
+ // src/theme.ts
258
+ import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
259
+ function themeToLxpackRuntime(input) {
260
+ const theme = input.theme ?? getPresetTheme(input.preset ?? "default");
261
+ const raw = themeToCssVariables(theme);
262
+ const cssVariables = {};
263
+ for (const [key, value] of Object.entries(raw)) {
264
+ cssVariables[key] = String(value);
265
+ }
186
266
  return {
187
- ...input,
188
- courseId: course.id,
189
- title: input.title.trim(),
190
- version: input.version?.trim() || void 0,
191
- spaLessonId: input.spaLessonId?.trim() || void 0,
192
- lessons: input.lessons.map((lesson) => {
193
- const idResult = validateId(lesson.id, "lessonId");
194
- if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
195
- return {
196
- ...lesson,
197
- id: idResult.id,
198
- title: lesson.title.trim(),
199
- spaPath: lesson.spaPath?.trim() || void 0
200
- };
201
- }),
202
- assessments: input.assessments?.map((assessment) => {
203
- const check = validateId(assessment.checkId, "checkId");
204
- if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
205
- const question = assessment.question.trim();
206
- if (assessment.kind === "trueFalse") {
207
- return { ...assessment, checkId: check.id, question };
208
- }
209
- if (assessment.kind === "fillInBlanks") {
210
- return {
211
- ...assessment,
212
- checkId: check.id,
213
- question,
214
- template: assessment.template.trim(),
215
- blanks: assessment.blanks?.map((b) => ({
216
- id: b.id.trim(),
217
- answer: b.answer.trim()
218
- }))
219
- };
220
- }
221
- return {
222
- ...assessment,
223
- checkId: check.id,
224
- question,
225
- choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
226
- answer: assessment.answer.trim()
227
- };
228
- })
267
+ theme: theme.name,
268
+ cssVariables
229
269
  };
230
270
  }
231
- function validateDescriptor(input) {
232
- const parsed = parseCourseDescriptorInput(input);
233
- if (parsed === null) {
234
- return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
271
+
272
+ // src/descriptor/validateAssessments.ts
273
+ import { validateId as validateId2 } from "@lessonkit/core";
274
+ var validateMcqLike = (assessment, path, issues) => {
275
+ if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
276
+ return;
235
277
  }
236
- return validateDescriptorParsed(parsed);
237
- }
238
- function validateDescriptorForTarget(input, target) {
239
- const result = validateDescriptor(input);
240
- if (!result.ok || !target) return result;
241
- if (target !== "xapi" && target !== "cmi5") return result;
242
- const activityIri = result.descriptor.tracking?.xapi?.activityIri?.trim();
243
- if (!activityIri) {
244
- return {
245
- ok: false,
246
- issues: [
247
- {
248
- path: "course.tracking.xapi.activityIri",
249
- message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
250
- }
251
- ]
252
- };
278
+ const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
279
+ if (!trimmedChoices.length) {
280
+ issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
281
+ }
282
+ if (!assessment.answer.trim()) {
283
+ issues.push({ path: `${path}.answer`, message: "answer is required" });
284
+ } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
285
+ issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
286
+ }
287
+ };
288
+ var ASSESSMENT_VALIDATORS = {
289
+ mcq: validateMcqLike,
290
+ trueFalse: (assessment, path, issues) => {
291
+ if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
292
+ issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
293
+ }
294
+ },
295
+ fillInBlanks: (assessment, path, issues) => {
296
+ if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
297
+ issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
298
+ }
299
+ },
300
+ findHotspot: () => {
301
+ },
302
+ findMultipleHotspots: () => {
303
+ }
304
+ };
305
+ function validateAssessmentEntry(assessment, index, issues, checkIds) {
306
+ const path = `assessments[${index}]`;
307
+ const check = validateId2(assessment.checkId, `${path}.checkId`);
308
+ if (!check.ok) {
309
+ issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
310
+ } else if (checkIds.has(check.id)) {
311
+ issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
312
+ } else {
313
+ checkIds.add(check.id);
314
+ }
315
+ if (!assessment.question?.trim()) {
316
+ issues.push({ path: `${path}.question`, message: "question is required" });
317
+ }
318
+ const kind = assessment.kind ?? "mcq";
319
+ ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
320
+ const passingScore = assessment.passingScore;
321
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
322
+ issues.push({
323
+ path: `${path}.passingScore`,
324
+ message: "passingScore must be greater than 0 (absolute point threshold)"
325
+ });
253
326
  }
254
- return result;
255
327
  }
256
- function validateDescriptorParsed(input) {
328
+
329
+ // src/descriptor/validateCourse.ts
330
+ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
331
+ var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
332
+ function validateCourseDescriptor(input) {
257
333
  const issues = [];
258
- const course = validateId(input.courseId, "courseId");
334
+ const course = validateId3(input.courseId, "courseId");
259
335
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
260
336
  if (!input.title?.trim()) {
261
337
  issues.push({ path: "title", message: "title is required" });
@@ -308,7 +384,7 @@ function validateDescriptorParsed(input) {
308
384
  const spaPaths = /* @__PURE__ */ new Set();
309
385
  for (const [index, lesson] of (input.lessons ?? []).entries()) {
310
386
  const path = `lessons[${index}]`;
311
- const lessonResult = validateId(lesson.id, `${path}.id`);
387
+ const lessonResult = validateId3(lesson.id, `${path}.id`);
312
388
  if (!lessonResult.ok) {
313
389
  issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
314
390
  } else if (lessonIds.has(lessonResult.id)) {
@@ -340,7 +416,7 @@ function validateDescriptorParsed(input) {
340
416
  }
341
417
  if (layout === "single-spa" && input.spaLessonId?.trim()) {
342
418
  const spaId = input.spaLessonId.trim();
343
- const spaResult = validateId(spaId, "spaLessonId");
419
+ const spaResult = validateId3(spaId, "spaLessonId");
344
420
  if (!spaResult.ok) {
345
421
  issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
346
422
  } else if (!lessonIds.has(spaResult.id)) {
@@ -352,52 +428,48 @@ function validateDescriptorParsed(input) {
352
428
  }
353
429
  const checkIds = /* @__PURE__ */ new Set();
354
430
  for (const [index, assessment] of (input.assessments ?? []).entries()) {
355
- const path = `assessments[${index}]`;
356
- const check = validateId(assessment.checkId, `${path}.checkId`);
357
- if (!check.ok) {
358
- issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
359
- } else if (checkIds.has(check.id)) {
360
- issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
361
- } else {
362
- checkIds.add(check.id);
363
- }
364
- if (!assessment.question?.trim()) {
365
- issues.push({ path: `${path}.question`, message: "question is required" });
366
- }
367
- const kind = assessment.kind ?? "mcq";
368
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
369
- if (typeof assessment.answer !== "boolean") {
370
- issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
371
- }
372
- } else if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
373
- if (!assessment.template?.trim()) {
374
- issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
375
- }
376
- } else if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
377
- const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
378
- if (!trimmedChoices.length) {
379
- issues.push({
380
- path: `${path}.choices`,
381
- message: "at least one non-empty choice is required"
382
- });
383
- }
384
- if (!assessment.answer.trim()) {
385
- issues.push({ path: `${path}.answer`, message: "answer is required" });
386
- } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
387
- issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
431
+ validateAssessmentEntry(assessment, index, issues, checkIds);
432
+ }
433
+ return issues;
434
+ }
435
+
436
+ // src/descriptor/validateForTarget.ts
437
+ function validateDescriptorForExportTarget(descriptor, target) {
438
+ if (target !== "xapi" && target !== "cmi5") return [];
439
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
440
+ if (!activityIri) {
441
+ return [
442
+ {
443
+ path: "course.tracking.xapi.activityIri",
444
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
388
445
  }
389
- }
390
- const passingScore = assessment.passingScore;
391
- if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
392
- issues.push({
393
- path: `${path}.passingScore`,
394
- message: "passingScore must be greater than 0 (absolute point threshold)"
395
- });
396
- }
446
+ ];
397
447
  }
448
+ return [];
449
+ }
450
+
451
+ // src/validateDescriptor.ts
452
+ function validateDescriptorParsed(input) {
453
+ const issues = validateCourseDescriptor(input);
398
454
  if (issues.length) return { ok: false, issues };
399
455
  return { ok: true, descriptor: normalizeDescriptor(input) };
400
456
  }
457
+ function validateDescriptor(input) {
458
+ const parsed = parseCourseDescriptorInput(input);
459
+ if (parsed === null) {
460
+ return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
461
+ }
462
+ return validateDescriptorParsed(parsed);
463
+ }
464
+ function validateDescriptorForTarget(input, target) {
465
+ const result = validateDescriptor(input);
466
+ if (!result.ok || !target) return result;
467
+ const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
468
+ if (targetIssues.length) {
469
+ return { ok: false, issues: targetIssues };
470
+ }
471
+ return result;
472
+ }
401
473
 
402
474
  // src/validateProjectPaths.ts
403
475
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
@@ -506,6 +578,19 @@ function assessmentDescriptorToLxpack(assessment) {
506
578
  if (kind === "fillInBlanks") {
507
579
  return null;
508
580
  }
581
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
582
+ return mcqToLxpack({
583
+ kind: "mcq",
584
+ checkId: assessment.checkId,
585
+ question: assessment.question,
586
+ choices: [assessment.correctTargetId, "other"],
587
+ answer: assessment.correctTargetId,
588
+ passingScore: assessment.passingScore
589
+ });
590
+ }
591
+ if (kind === "findMultipleHotspots") {
592
+ return null;
593
+ }
509
594
  if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
510
595
  return mcqToLxpack(assessment);
511
596
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/lxpack",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "LXPack export adapter for LessonKit courses (SCORM, standalone, xAPI, cmi5).",
6
6
  "license": "Apache-2.0",
@@ -9,7 +9,7 @@
9
9
  "url": "git+https://github.com/eddiethedean/lessonkit.git",
10
10
  "directory": "packages/lxpack"
11
11
  },
12
- "homepage": "https://github.com/eddiethedean/lessonkit",
12
+ "homepage": "https://lessonkit.readthedocs.io/en/latest/reference/packaging.html",
13
13
  "bugs": {
14
14
  "url": "https://github.com/eddiethedean/lessonkit/issues"
15
15
  },
@@ -55,8 +55,8 @@
55
55
  "lint": "echo \"(no lint configured yet)\""
56
56
  },
57
57
  "dependencies": {
58
- "@lessonkit/core": "1.1.0",
59
- "@lessonkit/themes": "1.1.0",
58
+ "@lessonkit/core": "1.2.0",
59
+ "@lessonkit/themes": "1.2.0",
60
60
  "@lxpack/api": "^0.6.2",
61
61
  "@lxpack/spa-bridge": "^0.6.2",
62
62
  "@lxpack/tracking-schema": "^0.6.2",