@lessonkit/lxpack 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,32 @@ 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
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
197
+ return {
198
+ kind,
199
+ ...base,
200
+ choices: [],
201
+ answer: ""
202
+ };
203
+ }
196
204
  return {
197
205
  kind: kind === "mcq" ? "mcq" : void 0,
198
206
  ...base,
@@ -239,82 +247,245 @@ function parseCourseDescriptorInput(input) {
239
247
  spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
240
248
  };
241
249
  }
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");
245
- 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
- };
250
+
251
+ // src/descriptor/validateCourse.ts
252
+ var import_core3 = require("@lessonkit/core");
253
+ var import_themes2 = require("@lessonkit/themes");
254
+
255
+ // src/spaPath.ts
256
+ var import_node_fs = require("fs");
257
+ var import_node_path = require("path");
258
+ function resolveComparablePath(p) {
259
+ if (/^[a-zA-Z]:[/\\]/.test(p)) {
260
+ return import_node_path.win32.resolve(p);
261
+ }
262
+ return (0, import_node_path.resolve)(p);
263
+ }
264
+ function isSafeRelativeSpaPath(spaPath) {
265
+ if (!spaPath.length || spaPath.includes("\0")) return false;
266
+ if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
267
+ if (/^[a-zA-Z]:/.test(spaPath)) return false;
268
+ if (spaPath === "." || spaPath === "./") return false;
269
+ const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
270
+ if (segments.some((s) => s === "..")) return false;
271
+ return segments.length > 0;
272
+ }
273
+ function assertResolvedPathUnderRoot(root, target) {
274
+ const rootResolved = resolveComparablePath(root);
275
+ const targetResolved = resolveComparablePath(target);
276
+ const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
277
+ const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
278
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
279
+ !targetResolved.startsWith(win32Prefix)) {
280
+ throw new Error(`unsafe path escapes project root: ${target}`);
281
+ }
282
+ }
283
+ function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
284
+ const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
285
+ if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
286
+ throw new Error(`unsafe path escapes project root: ${targetResolved}`);
287
+ }
288
+ const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
289
+ let current = rootReal;
290
+ for (const segment of segments) {
291
+ const next = (0, import_node_path.join)(current, segment);
292
+ if ((0, import_node_fs.existsSync)(next)) {
293
+ try {
294
+ current = (0, import_node_fs.realpathSync)(next);
295
+ } catch {
296
+ current = next;
279
297
  }
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
- })
298
+ } else {
299
+ current = next;
300
+ }
301
+ assertResolvedPathUnderRoot(rootReal, current);
302
+ }
303
+ return current;
304
+ }
305
+ function assertRealPathUnderRoot(root, target) {
306
+ const rootResolved = resolveComparablePath(root);
307
+ const targetResolved = resolveComparablePath(target);
308
+ let rootReal;
309
+ try {
310
+ rootReal = (0, import_node_fs.realpathSync)(rootResolved);
311
+ } catch {
312
+ rootReal = rootResolved;
313
+ }
314
+ try {
315
+ const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
316
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
317
+ } catch {
318
+ resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
319
+ }
320
+ }
321
+ function normalizePathForComparison(p) {
322
+ const resolved = resolveComparablePath(p);
323
+ return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
324
+ }
325
+ function relativePathUnderRoot(root, target) {
326
+ const rootResolved = normalizePathForComparison(root);
327
+ const targetResolved = normalizePathForComparison(target);
328
+ if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
329
+ return import_node_path.win32.relative(rootResolved, targetResolved);
330
+ }
331
+ return (0, import_node_path.relative)(rootResolved, targetResolved);
332
+ }
333
+ function isResolvedPathUnderRoot(root, target) {
334
+ const rootResolved = normalizePathForComparison(root);
335
+ const targetResolved = normalizePathForComparison(target);
336
+ if (targetResolved === rootResolved) return true;
337
+ const rel = relativePathUnderRoot(root, target);
338
+ if (!rel) return true;
339
+ return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
340
+ }
341
+
342
+ // src/theme.ts
343
+ var import_themes = require("@lessonkit/themes");
344
+ function themeToLxpackRuntime(input) {
345
+ const theme = input.theme ?? (0, import_themes.getPresetTheme)(input.preset ?? "default");
346
+ const raw = (0, import_themes.themeToCssVariables)(theme);
347
+ const cssVariables = {};
348
+ for (const [key, value] of Object.entries(raw)) {
349
+ cssVariables[key] = String(value);
350
+ }
351
+ return {
352
+ theme: theme.name,
353
+ cssVariables
288
354
  };
289
355
  }
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" }] };
356
+
357
+ // src/descriptor/validateAssessments.ts
358
+ var import_core2 = require("@lessonkit/core");
359
+ var validateMcqLike = (assessment, path, issues) => {
360
+ if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
361
+ issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
362
+ return;
294
363
  }
295
- return validateDescriptorParsed(parsed);
364
+ if (!("answer" in assessment) || typeof assessment.answer !== "string") {
365
+ issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
366
+ return;
367
+ }
368
+ const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
369
+ if (!trimmedChoices.length) {
370
+ issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
371
+ }
372
+ if (!assessment.answer.trim()) {
373
+ issues.push({ path: `${path}.answer`, message: "answer is required" });
374
+ } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
375
+ issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
376
+ }
377
+ };
378
+ function countStarDelimitedBlanks(template) {
379
+ const matches = template.match(/\*[^*]+\*/g);
380
+ return matches?.length ?? 0;
296
381
  }
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
- };
382
+ function maxAchievableAssessmentScore(assessment) {
383
+ const kind = assessment.kind ?? "mcq";
384
+ if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
385
+ const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
386
+ if (explicit > 0) return explicit;
387
+ return countStarDelimitedBlanks(assessment.template ?? "");
312
388
  }
313
- return result;
389
+ if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
390
+ return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
391
+ }
392
+ return 1;
314
393
  }
315
- function validateDescriptorParsed(input) {
394
+ var ASSESSMENT_VALIDATORS = {
395
+ mcq: validateMcqLike,
396
+ trueFalse: (assessment, path, issues) => {
397
+ if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
398
+ issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
399
+ }
400
+ },
401
+ fillInBlanks: (assessment, path, issues) => {
402
+ if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
403
+ issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
404
+ }
405
+ },
406
+ findHotspot: (assessment, path, issues) => {
407
+ if (assessment.kind !== "findHotspot") return;
408
+ if (!assessment.src?.trim()) {
409
+ issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
410
+ }
411
+ if (!assessment.alt?.trim()) {
412
+ issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
413
+ }
414
+ if (!assessment.correctTargetId?.trim()) {
415
+ issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
416
+ }
417
+ },
418
+ findMultipleHotspots: (assessment, path, issues) => {
419
+ if (assessment.kind !== "findMultipleHotspots") return;
420
+ if (!assessment.src?.trim()) {
421
+ issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
422
+ }
423
+ if (!assessment.alt?.trim()) {
424
+ issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
425
+ }
426
+ const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
427
+ if (!ids.length) {
428
+ issues.push({
429
+ path: `${path}.correctTargetIds`,
430
+ message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
431
+ });
432
+ }
433
+ }
434
+ };
435
+ function validateAssessmentEntry(assessment, index, issues, checkIds) {
436
+ const path = `assessments[${index}]`;
437
+ const check = (0, import_core2.validateId)(assessment.checkId, `${path}.checkId`);
438
+ if (!check.ok) {
439
+ issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
440
+ } else if (checkIds.has(check.id)) {
441
+ issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
442
+ } else {
443
+ checkIds.add(check.id);
444
+ }
445
+ if (!assessment.question?.trim()) {
446
+ issues.push({ path: `${path}.question`, message: "question is required" });
447
+ }
448
+ const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
449
+ if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
450
+ issues.push({
451
+ path: `${path}.kind`,
452
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
453
+ });
454
+ return;
455
+ }
456
+ const kind = assessment.kind ?? "mcq";
457
+ const validator = ASSESSMENT_VALIDATORS[kind];
458
+ if (!validator) {
459
+ issues.push({
460
+ path: `${path}.kind`,
461
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
462
+ });
463
+ return;
464
+ }
465
+ validator(assessment, path, issues);
466
+ const passingScore = assessment.passingScore;
467
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
468
+ issues.push({
469
+ path: `${path}.passingScore`,
470
+ message: "passingScore must be greater than 0 (absolute point threshold)"
471
+ });
472
+ } else if (passingScore !== void 0) {
473
+ const maxAchievable = maxAchievableAssessmentScore(assessment);
474
+ if (maxAchievable > 0 && passingScore > maxAchievable) {
475
+ issues.push({
476
+ path: `${path}.passingScore`,
477
+ message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
478
+ });
479
+ }
480
+ }
481
+ }
482
+
483
+ // src/descriptor/validateCourse.ts
484
+ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
485
+ var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
486
+ function validateCourseDescriptor(input) {
316
487
  const issues = [];
317
- const course = (0, import_core.validateId)(input.courseId, "courseId");
488
+ const course = (0, import_core3.validateId)(input.courseId, "courseId");
318
489
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
319
490
  if (!input.title?.trim()) {
320
491
  issues.push({ path: "title", message: "title is required" });
@@ -339,13 +510,23 @@ function validateDescriptorParsed(input) {
339
510
  });
340
511
  }
341
512
  if (input.theme?.theme) {
342
- try {
343
- themeToLxpackRuntime({ preset: themePreset, theme: input.theme.theme });
344
- } catch (err) {
345
- issues.push({
346
- path: "theme.theme",
347
- message: err instanceof Error ? err.message : "invalid custom theme"
348
- });
513
+ const themeResult = (0, import_themes2.validateTheme)(input.theme.theme);
514
+ if (!themeResult.ok) {
515
+ for (const issue of themeResult.issues) {
516
+ issues.push({
517
+ path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
518
+ message: issue.message
519
+ });
520
+ }
521
+ } else {
522
+ try {
523
+ themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
524
+ } catch (err) {
525
+ issues.push({
526
+ path: "theme.theme",
527
+ message: err instanceof Error ? err.message : "invalid custom theme"
528
+ });
529
+ }
349
530
  }
350
531
  }
351
532
  const completionThreshold = input.tracking?.completion?.threshold;
@@ -367,7 +548,7 @@ function validateDescriptorParsed(input) {
367
548
  const spaPaths = /* @__PURE__ */ new Set();
368
549
  for (const [index, lesson] of (input.lessons ?? []).entries()) {
369
550
  const path = `lessons[${index}]`;
370
- const lessonResult = (0, import_core.validateId)(lesson.id, `${path}.id`);
551
+ const lessonResult = (0, import_core3.validateId)(lesson.id, `${path}.id`);
371
552
  if (!lessonResult.ok) {
372
553
  issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
373
554
  } else if (lessonIds.has(lessonResult.id)) {
@@ -395,68 +576,147 @@ function validateDescriptorParsed(input) {
395
576
  } else {
396
577
  spaPaths.add(spaPath);
397
578
  }
398
- }
579
+ }
580
+ }
581
+ if (layout === "single-spa" && input.spaLessonId?.trim()) {
582
+ const spaId = input.spaLessonId.trim();
583
+ const spaResult = (0, import_core3.validateId)(spaId, "spaLessonId");
584
+ if (!spaResult.ok) {
585
+ issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
586
+ } else if (!lessonIds.has(spaResult.id)) {
587
+ issues.push({
588
+ path: "spaLessonId",
589
+ message: "spaLessonId must match a lesson id in lessons"
590
+ });
591
+ }
592
+ }
593
+ const checkIds = /* @__PURE__ */ new Set();
594
+ for (const [index, assessment] of (input.assessments ?? []).entries()) {
595
+ validateAssessmentEntry(assessment, index, issues, checkIds);
596
+ }
597
+ return issues;
598
+ }
599
+
600
+ // src/assessments.ts
601
+ function slugChoiceId(text, index) {
602
+ const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
603
+ const stem = base.length ? base : "choice";
604
+ return `${stem}-${index + 1}`;
605
+ }
606
+ function mcqToLxpack(assessment) {
607
+ const choices = assessment.choices.map((text, index) => {
608
+ const id = slugChoiceId(text, index);
609
+ return {
610
+ id,
611
+ text,
612
+ correct: text === assessment.answer
613
+ };
614
+ });
615
+ return {
616
+ id: assessment.checkId,
617
+ passingScore: assessment.passingScore ?? 1,
618
+ questions: [
619
+ {
620
+ id: "q1",
621
+ prompt: assessment.question,
622
+ choices
623
+ }
624
+ ]
625
+ };
626
+ }
627
+ function assessmentDescriptorToLxpack(assessment) {
628
+ const kind = assessment.kind ?? "mcq";
629
+ if (kind === "trueFalse" && assessment.kind === "trueFalse") {
630
+ const choices = ["True", "False"];
631
+ const answerText = assessment.answer ? "True" : "False";
632
+ return mcqToLxpack({
633
+ kind: "mcq",
634
+ checkId: assessment.checkId,
635
+ question: assessment.question,
636
+ choices,
637
+ answer: answerText,
638
+ passingScore: assessment.passingScore
639
+ });
399
640
  }
400
- if (layout === "single-spa" && input.spaLessonId?.trim()) {
401
- const spaId = input.spaLessonId.trim();
402
- const spaResult = (0, import_core.validateId)(spaId, "spaLessonId");
403
- if (!spaResult.ok) {
404
- issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
405
- } else if (!lessonIds.has(spaResult.id)) {
641
+ if (kind === "fillInBlanks") {
642
+ return null;
643
+ }
644
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
645
+ return mcqToLxpack({
646
+ kind: "mcq",
647
+ checkId: assessment.checkId,
648
+ question: assessment.question,
649
+ choices: [assessment.correctTargetId, "other"],
650
+ answer: assessment.correctTargetId,
651
+ passingScore: assessment.passingScore
652
+ });
653
+ }
654
+ if (kind === "findMultipleHotspots") {
655
+ return null;
656
+ }
657
+ if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
658
+ return mcqToLxpack(assessment);
659
+ }
660
+ return null;
661
+ }
662
+ function extractAssessments(descriptor) {
663
+ return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
664
+ }
665
+
666
+ // src/descriptor/validateForTarget.ts
667
+ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
668
+ "scorm12",
669
+ "scorm2004",
670
+ "standalone",
671
+ "xapi",
672
+ "cmi5"
673
+ ]);
674
+ function validateDescriptorForExportTarget(descriptor, target) {
675
+ const issues = [];
676
+ if (target === "xapi" || target === "cmi5") {
677
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
678
+ if (!activityIri) {
406
679
  issues.push({
407
- path: "spaLessonId",
408
- message: "spaLessonId must match a lesson id in lessons"
680
+ path: "course.tracking.xapi.activityIri",
681
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
409
682
  });
410
683
  }
411
684
  }
412
- const checkIds = /* @__PURE__ */ new Set();
413
- 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) {
685
+ if (LMS_SHELL_TARGETS.has(target)) {
686
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
687
+ if (assessmentDescriptorToLxpack(assessment) === null) {
438
688
  issues.push({
439
- path: `${path}.choices`,
440
- message: "at least one non-empty choice is required"
689
+ path: `assessments[${index}]`,
690
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
441
691
  });
442
692
  }
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" });
447
- }
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
- }
693
+ });
456
694
  }
695
+ return issues;
696
+ }
697
+
698
+ // src/validateDescriptor.ts
699
+ function validateDescriptorParsed(input) {
700
+ const issues = validateCourseDescriptor(input);
457
701
  if (issues.length) return { ok: false, issues };
458
702
  return { ok: true, descriptor: normalizeDescriptor(input) };
459
703
  }
704
+ function validateDescriptor(input) {
705
+ const parsed = parseCourseDescriptorInput(input);
706
+ if (parsed === null) {
707
+ return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
708
+ }
709
+ return validateDescriptorParsed(parsed);
710
+ }
711
+ function validateDescriptorForTarget(input, target) {
712
+ const result = validateDescriptor(input);
713
+ if (!result.ok || !target) return result;
714
+ const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
715
+ if (targetIssues.length) {
716
+ return { ok: false, issues: targetIssues };
717
+ }
718
+ return result;
719
+ }
460
720
 
461
721
  // src/validateProjectPaths.ts
462
722
  var import_node_path2 = require("path");
@@ -511,69 +771,16 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
511
771
  }
512
772
 
513
773
  // src/mapIds.ts
514
- var import_core2 = require("@lessonkit/core");
774
+ var import_core4 = require("@lessonkit/core");
515
775
  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"));
776
+ const courseId = (0, import_core4.assertValidId)(descriptor.courseId, "courseId");
777
+ const lessonIds = descriptor.lessons.map((l) => (0, import_core4.assertValidId)(l.id, "lessonId"));
518
778
  const checkIds = (descriptor.assessments ?? []).map(
519
- (a) => (0, import_core2.assertValidId)(a.checkId, "checkId")
779
+ (a) => (0, import_core4.assertValidId)(a.checkId, "checkId")
520
780
  );
521
781
  return { courseId, lessonIds, checkIds };
522
782
  }
523
783
 
524
- // src/assessments.ts
525
- function slugChoiceId(text, index) {
526
- const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
527
- const stem = base.length ? base : "choice";
528
- return `${stem}-${index + 1}`;
529
- }
530
- function mcqToLxpack(assessment) {
531
- const choices = assessment.choices.map((text, index) => {
532
- const id = slugChoiceId(text, index);
533
- return {
534
- id,
535
- text,
536
- correct: text === assessment.answer
537
- };
538
- });
539
- return {
540
- id: assessment.checkId,
541
- passingScore: assessment.passingScore ?? 1,
542
- questions: [
543
- {
544
- id: "q1",
545
- prompt: assessment.question,
546
- choices
547
- }
548
- ]
549
- };
550
- }
551
- function assessmentDescriptorToLxpack(assessment) {
552
- const kind = assessment.kind ?? "mcq";
553
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
554
- const choices = ["True", "False"];
555
- const answerText = assessment.answer ? "True" : "False";
556
- return mcqToLxpack({
557
- kind: "mcq",
558
- checkId: assessment.checkId,
559
- question: assessment.question,
560
- choices,
561
- answer: answerText,
562
- passingScore: assessment.passingScore
563
- });
564
- }
565
- if (kind === "fillInBlanks") {
566
- return null;
567
- }
568
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
569
- return mcqToLxpack(assessment);
570
- }
571
- return null;
572
- }
573
- function extractAssessments(descriptor) {
574
- return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
575
- }
576
-
577
784
  // src/interchange.ts
578
785
  function mapDescriptorTracking(tracking) {
579
786
  if (!tracking) return void 0;
@@ -736,44 +943,71 @@ var import_node_path5 = require("path");
736
943
  function validatePackageInputs(options) {
737
944
  const { target, output, outputBaseDir } = options;
738
945
  const outDir = (0, import_node_path5.resolve)(options.outDir);
739
- const projectRoot = options.projectRoot ? (0, import_node_path5.resolve)(options.projectRoot) : void 0;
740
- if (projectRoot) {
741
- try {
742
- assertRealPathUnderRoot(projectRoot, outDir);
743
- } catch (err) {
744
- return {
745
- ok: false,
746
- courseDir: outDir,
747
- target,
748
- issues: [
749
- {
750
- path: "outDir",
751
- message: (
752
- /* v8 ignore next */
753
- err instanceof Error ? err.message : String(err)
754
- )
755
- }
756
- ]
757
- };
758
- }
946
+ if (!options.projectRoot) {
947
+ return {
948
+ ok: false,
949
+ courseDir: outDir,
950
+ target,
951
+ issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
952
+ };
759
953
  }
760
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
954
+ const projectRoot = (0, import_node_path5.resolve)(options.projectRoot);
955
+ try {
956
+ assertRealPathUnderRoot(projectRoot, outDir);
957
+ } catch (err) {
761
958
  return {
762
959
  ok: false,
763
960
  courseDir: outDir,
764
961
  target,
765
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
962
+ issues: [
963
+ {
964
+ path: "outDir",
965
+ message: (
966
+ /* v8 ignore next */
967
+ err instanceof Error ? err.message : String(err)
968
+ )
969
+ }
970
+ ]
766
971
  };
767
972
  }
768
- if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
973
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
769
974
  return {
770
975
  ok: false,
771
976
  courseDir: outDir,
772
977
  target,
773
- issues: [{ path: "output", message: `unsafe output: ${output}` }]
978
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
774
979
  };
775
980
  }
776
- if (projectRoot && outputBaseDir) {
981
+ if (output && !isSafeRelativeSpaPath(output)) {
982
+ if ((0, import_node_path5.isAbsolute)(output)) {
983
+ try {
984
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path5.resolve)(output));
985
+ } catch (err) {
986
+ return {
987
+ ok: false,
988
+ courseDir: outDir,
989
+ target,
990
+ issues: [
991
+ {
992
+ path: "output",
993
+ message: (
994
+ /* v8 ignore next */
995
+ err instanceof Error ? err.message : `unsafe output: ${output}`
996
+ )
997
+ }
998
+ ]
999
+ };
1000
+ }
1001
+ } else {
1002
+ return {
1003
+ ok: false,
1004
+ courseDir: outDir,
1005
+ target,
1006
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
1007
+ };
1008
+ }
1009
+ }
1010
+ if (outputBaseDir) {
777
1011
  const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
778
1012
  try {
779
1013
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
@@ -794,8 +1028,8 @@ function validatePackageInputs(options) {
794
1028
  };
795
1029
  }
796
1030
  }
797
- if (projectRoot && output) {
798
- const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
1031
+ if (output) {
1032
+ const resolvedOutput = (0, import_node_path5.isAbsolute)(output) ? (0, import_node_path5.resolve)(output) : (0, import_node_path5.resolve)(projectRoot, output);
799
1033
  try {
800
1034
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
801
1035
  } catch (err) {
@@ -832,11 +1066,11 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
832
1066
  if (!artifactPath) return void 0;
833
1067
  const resolved = resolveComparablePath(artifactPath);
834
1068
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
835
- return artifactPath;
1069
+ throw new Error(`${artifactPath} is outside the staging directory`);
836
1070
  }
837
1071
  const rel = relativePathUnderRoot(stagingRoot, resolved);
838
1072
  if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
839
- return artifactPath;
1073
+ throw new Error(`${artifactPath} is outside the staging directory`);
840
1074
  }
841
1075
  if (!rel) return outDir;
842
1076
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
@@ -867,6 +1101,68 @@ async function renameOrCopy(from, to) {
867
1101
  await fsp.rm(from, { recursive: true, force: true });
868
1102
  }
869
1103
  }
1104
+ function promoteLockPath(outDir) {
1105
+ const parent = (0, import_node_path6.dirname)(outDir);
1106
+ const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path6.resolve)(outDir)).digest("hex").slice(0, 16);
1107
+ return (0, import_node_path6.join)(parent, `.lk-promote-lock-${hash}`);
1108
+ }
1109
+ var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1110
+ async function isStalePromoteLock(lockPath) {
1111
+ try {
1112
+ const stat2 = await fsp.stat(lockPath);
1113
+ if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
1114
+ const content = await fsp.readFile(lockPath, "utf8");
1115
+ const pid = Number.parseInt(content.trim(), 10);
1116
+ if (!Number.isFinite(pid) || pid <= 0) return true;
1117
+ try {
1118
+ process.kill(pid, 0);
1119
+ return false;
1120
+ } catch {
1121
+ return true;
1122
+ }
1123
+ } catch {
1124
+ return true;
1125
+ }
1126
+ }
1127
+ async function withPromoteLock(outDir, fn) {
1128
+ const lockPath = promoteLockPath(outDir);
1129
+ await fsp.mkdir((0, import_node_path6.dirname)(outDir), { recursive: true });
1130
+ let lockHandle;
1131
+ for (let attempt = 0; attempt < 200; attempt++) {
1132
+ try {
1133
+ lockHandle = await fsp.open(lockPath, "wx");
1134
+ await lockHandle.writeFile(`${process.pid}
1135
+ `, "utf8");
1136
+ break;
1137
+ } catch (err) {
1138
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
1139
+ if (code !== "EEXIST") throw err;
1140
+ if (await isStalePromoteLock(lockPath)) {
1141
+ await fsp.rm(lockPath, { force: true }).catch(
1142
+ /* v8 ignore next */
1143
+ () => void 0
1144
+ );
1145
+ continue;
1146
+ }
1147
+ await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1148
+ }
1149
+ }
1150
+ if (!lockHandle) {
1151
+ throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
1152
+ }
1153
+ try {
1154
+ return await fn();
1155
+ } finally {
1156
+ await lockHandle.close().catch(
1157
+ /* v8 ignore next */
1158
+ () => void 0
1159
+ );
1160
+ await fsp.rm(lockPath, { force: true }).catch(
1161
+ /* v8 ignore next */
1162
+ () => void 0
1163
+ );
1164
+ }
1165
+ }
870
1166
  async function assertNoLegacyPromoteArtifacts(outDir) {
871
1167
  const legacyTmp = `${outDir}.tmp-promote`;
872
1168
  const legacyBak = `${outDir}.bak`;
@@ -880,45 +1176,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
880
1176
  }
881
1177
  }
882
1178
  async function promoteStagingToOutDir(stagingDir, outDir) {
883
- await assertNoLegacyPromoteArtifacts(outDir);
884
- const parent = (0, import_node_path6.dirname)(outDir);
885
- const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
886
- await renameOrCopy(stagingDir, tmpPromote);
887
- const hadOutDir = await pathExists(outDir);
888
- const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
889
- if (hadOutDir && backup) {
890
- await renameOrCopy(outDir, backup);
891
- }
892
- try {
893
- await renameOrCopy(tmpPromote, outDir);
894
- } catch (promoteError) {
1179
+ return withPromoteLock(outDir, async () => {
1180
+ await assertNoLegacyPromoteArtifacts(outDir);
1181
+ const parent = (0, import_node_path6.dirname)(outDir);
1182
+ const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
1183
+ await renameOrCopy(stagingDir, tmpPromote);
1184
+ const hadOutDir = await pathExists(outDir);
1185
+ const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
895
1186
  if (hadOutDir && backup) {
896
- try {
897
- await renameOrCopy(backup, outDir);
898
- } catch (restoreError) {
899
- const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1187
+ await renameOrCopy(outDir, backup);
1188
+ }
1189
+ try {
1190
+ await renameOrCopy(tmpPromote, outDir);
1191
+ } catch (promoteError) {
1192
+ if (hadOutDir && backup) {
1193
+ try {
1194
+ await renameOrCopy(backup, outDir);
1195
+ } catch (restoreError) {
1196
+ const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1197
+ try {
1198
+ await renameOrCopy(tmpPromote, failedPromote2);
1199
+ } catch {
1200
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1201
+ /* v8 ignore next */
1202
+ () => void 0
1203
+ );
1204
+ }
1205
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1206
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1207
+ throw new Error(
1208
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1209
+ );
1210
+ }
1211
+ } else {
900
1212
  try {
901
- await renameOrCopy(tmpPromote, failedPromote2);
902
- } catch {
1213
+ await renameOrCopy(tmpPromote, stagingDir);
1214
+ } catch (restoreError) {
1215
+ console.warn(
1216
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1217
+ restoreError instanceof Error ? restoreError.message : restoreError
1218
+ );
903
1219
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
904
1220
  /* v8 ignore next */
905
1221
  () => void 0
906
1222
  );
907
1223
  }
908
- const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
909
- const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
910
- throw new Error(
911
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
912
- );
1224
+ throw promoteError;
913
1225
  }
914
- } else {
1226
+ const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
915
1227
  try {
916
- await renameOrCopy(tmpPromote, stagingDir);
917
- } catch (restoreError) {
918
- console.warn(
919
- `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
920
- restoreError instanceof Error ? restoreError.message : restoreError
921
- );
1228
+ await renameOrCopy(tmpPromote, failedPromote);
1229
+ } catch {
922
1230
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
923
1231
  /* v8 ignore next */
924
1232
  () => void 0
@@ -926,23 +1234,13 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
926
1234
  }
927
1235
  throw promoteError;
928
1236
  }
929
- const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
930
- try {
931
- await renameOrCopy(tmpPromote, failedPromote);
932
- } catch {
933
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1237
+ if (backup) {
1238
+ await fsp.rm(backup, { recursive: true, force: true }).catch(
934
1239
  /* v8 ignore next */
935
1240
  () => void 0
936
1241
  );
937
1242
  }
938
- throw promoteError;
939
- }
940
- if (backup) {
941
- await fsp.rm(backup, { recursive: true, force: true }).catch(
942
- /* v8 ignore next */
943
- () => void 0
944
- );
945
- }
1243
+ });
946
1244
  }
947
1245
 
948
1246
  // src/packaging/staging.ts
@@ -1015,6 +1313,15 @@ async function ensureOutDirParent(outDir) {
1015
1313
  await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
1016
1314
  }
1017
1315
 
1316
+ // src/packaging/issueSeverity.ts
1317
+ function isPackagingErrorIssue(issue) {
1318
+ const severity = issue.severity?.toLowerCase();
1319
+ return severity === "error" || severity === "fatal";
1320
+ }
1321
+ function findPackagingErrorIssues(issues) {
1322
+ return (issues ?? []).filter(isPackagingErrorIssue);
1323
+ }
1324
+
1018
1325
  // src/packageCourse.ts
1019
1326
  async function validateLessonkitProject(options) {
1020
1327
  return (0, import_api2.validateCourse)({
@@ -1064,6 +1371,18 @@ async function packageLessonkitCourse(options) {
1064
1371
  };
1065
1372
  }
1066
1373
  const descriptor = descriptorValidation.descriptor;
1374
+ const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1375
+ if (nonInjectableAssessments.length > 0) {
1376
+ return {
1377
+ ok: false,
1378
+ courseDir: outDir,
1379
+ target,
1380
+ issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1381
+ path: `assessments[${index}]`,
1382
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1383
+ }))
1384
+ };
1385
+ }
1067
1386
  const staged = await buildStagingPackage({
1068
1387
  ...writeOpts,
1069
1388
  descriptor,
@@ -1088,6 +1407,25 @@ async function packageLessonkitCourse(options) {
1088
1407
  };
1089
1408
  }
1090
1409
  const { stagingDir, build } = staged;
1410
+ const buildErrorIssues = findPackagingErrorIssues(build.issues);
1411
+ if (buildErrorIssues.length > 0) {
1412
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1413
+ /* v8 ignore next */
1414
+ () => void 0
1415
+ );
1416
+ return {
1417
+ ok: false,
1418
+ courseDir: outDir,
1419
+ target,
1420
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1421
+ build,
1422
+ issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
1423
+ path: i.path ?? "build",
1424
+ message: i.message,
1425
+ severity: i.severity
1426
+ }))
1427
+ };
1428
+ }
1091
1429
  const stagingRoot = await fsp3.realpath(stagingDir);
1092
1430
  const artifactIssues = [
1093
1431
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
@@ -1118,6 +1456,10 @@ async function packageLessonkitCourse(options) {
1118
1456
  await ensureOutDirParent(outDir);
1119
1457
  await promoteStagingToOutDir(stagingDir, outDir);
1120
1458
  } catch (err) {
1459
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1460
+ /* v8 ignore next */
1461
+ () => void 0
1462
+ );
1121
1463
  return {
1122
1464
  ok: false,
1123
1465
  courseDir: outDir,