@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.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,32 @@ 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
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
138
+ return {
139
+ kind,
140
+ ...base,
141
+ choices: [],
142
+ answer: ""
143
+ };
144
+ }
137
145
  return {
138
146
  kind: kind === "mcq" ? "mcq" : void 0,
139
147
  ...base,
@@ -180,82 +188,245 @@ function parseCourseDescriptorInput(input) {
180
188
  spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
181
189
  };
182
190
  }
183
- function normalizeDescriptor(input) {
184
- const course = validateId(input.courseId, "courseId");
185
- if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
186
- 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
- };
191
+
192
+ // src/descriptor/validateCourse.ts
193
+ import { validateId as validateId3 } from "@lessonkit/core";
194
+ import { validateTheme } from "@lessonkit/themes";
195
+
196
+ // src/spaPath.ts
197
+ import { existsSync, realpathSync } from "fs";
198
+ import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
199
+ function resolveComparablePath(p) {
200
+ if (/^[a-zA-Z]:[/\\]/.test(p)) {
201
+ return win32.resolve(p);
202
+ }
203
+ return resolve(p);
204
+ }
205
+ function isSafeRelativeSpaPath(spaPath) {
206
+ if (!spaPath.length || spaPath.includes("\0")) return false;
207
+ if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
208
+ if (/^[a-zA-Z]:/.test(spaPath)) return false;
209
+ if (spaPath === "." || spaPath === "./") return false;
210
+ const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
211
+ if (segments.some((s) => s === "..")) return false;
212
+ return segments.length > 0;
213
+ }
214
+ function assertResolvedPathUnderRoot(root, target) {
215
+ const rootResolved = resolveComparablePath(root);
216
+ const targetResolved = resolveComparablePath(target);
217
+ const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
218
+ const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
219
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
220
+ !targetResolved.startsWith(win32Prefix)) {
221
+ throw new Error(`unsafe path escapes project root: ${target}`);
222
+ }
223
+ }
224
+ function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
225
+ const rel = relative(rootResolved, targetResolved);
226
+ if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
227
+ throw new Error(`unsafe path escapes project root: ${targetResolved}`);
228
+ }
229
+ const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
230
+ let current = rootReal;
231
+ for (const segment of segments) {
232
+ const next = join(current, segment);
233
+ if (existsSync(next)) {
234
+ try {
235
+ current = realpathSync(next);
236
+ } catch {
237
+ current = next;
220
238
  }
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
- })
239
+ } else {
240
+ current = next;
241
+ }
242
+ assertResolvedPathUnderRoot(rootReal, current);
243
+ }
244
+ return current;
245
+ }
246
+ function assertRealPathUnderRoot(root, target) {
247
+ const rootResolved = resolveComparablePath(root);
248
+ const targetResolved = resolveComparablePath(target);
249
+ let rootReal;
250
+ try {
251
+ rootReal = realpathSync(rootResolved);
252
+ } catch {
253
+ rootReal = rootResolved;
254
+ }
255
+ try {
256
+ const targetCheck = realpathSync(targetResolved);
257
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
258
+ } catch {
259
+ resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
260
+ }
261
+ }
262
+ function normalizePathForComparison(p) {
263
+ const resolved = resolveComparablePath(p);
264
+ return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
265
+ }
266
+ function relativePathUnderRoot(root, target) {
267
+ const rootResolved = normalizePathForComparison(root);
268
+ const targetResolved = normalizePathForComparison(target);
269
+ if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
270
+ return win32.relative(rootResolved, targetResolved);
271
+ }
272
+ return relative(rootResolved, targetResolved);
273
+ }
274
+ function isResolvedPathUnderRoot(root, target) {
275
+ const rootResolved = normalizePathForComparison(root);
276
+ const targetResolved = normalizePathForComparison(target);
277
+ if (targetResolved === rootResolved) return true;
278
+ const rel = relativePathUnderRoot(root, target);
279
+ if (!rel) return true;
280
+ return !rel.startsWith("..") && !isAbsolute(rel);
281
+ }
282
+
283
+ // src/theme.ts
284
+ import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
285
+ function themeToLxpackRuntime(input) {
286
+ const theme = input.theme ?? getPresetTheme(input.preset ?? "default");
287
+ const raw = themeToCssVariables(theme);
288
+ const cssVariables = {};
289
+ for (const [key, value] of Object.entries(raw)) {
290
+ cssVariables[key] = String(value);
291
+ }
292
+ return {
293
+ theme: theme.name,
294
+ cssVariables
229
295
  };
230
296
  }
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" }] };
297
+
298
+ // src/descriptor/validateAssessments.ts
299
+ import { validateId as validateId2 } from "@lessonkit/core";
300
+ var validateMcqLike = (assessment, path, issues) => {
301
+ if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
302
+ issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
303
+ return;
235
304
  }
236
- return validateDescriptorParsed(parsed);
305
+ if (!("answer" in assessment) || typeof assessment.answer !== "string") {
306
+ issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
307
+ return;
308
+ }
309
+ const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
310
+ if (!trimmedChoices.length) {
311
+ issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
312
+ }
313
+ if (!assessment.answer.trim()) {
314
+ issues.push({ path: `${path}.answer`, message: "answer is required" });
315
+ } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
316
+ issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
317
+ }
318
+ };
319
+ function countStarDelimitedBlanks(template) {
320
+ const matches = template.match(/\*[^*]+\*/g);
321
+ return matches?.length ?? 0;
237
322
  }
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
- };
323
+ function maxAchievableAssessmentScore(assessment) {
324
+ const kind = assessment.kind ?? "mcq";
325
+ if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
326
+ const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
327
+ if (explicit > 0) return explicit;
328
+ return countStarDelimitedBlanks(assessment.template ?? "");
253
329
  }
254
- return result;
330
+ if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
331
+ return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
332
+ }
333
+ return 1;
255
334
  }
256
- function validateDescriptorParsed(input) {
335
+ var ASSESSMENT_VALIDATORS = {
336
+ mcq: validateMcqLike,
337
+ trueFalse: (assessment, path, issues) => {
338
+ if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
339
+ issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
340
+ }
341
+ },
342
+ fillInBlanks: (assessment, path, issues) => {
343
+ if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
344
+ issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
345
+ }
346
+ },
347
+ findHotspot: (assessment, path, issues) => {
348
+ if (assessment.kind !== "findHotspot") return;
349
+ if (!assessment.src?.trim()) {
350
+ issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
351
+ }
352
+ if (!assessment.alt?.trim()) {
353
+ issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
354
+ }
355
+ if (!assessment.correctTargetId?.trim()) {
356
+ issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
357
+ }
358
+ },
359
+ findMultipleHotspots: (assessment, path, issues) => {
360
+ if (assessment.kind !== "findMultipleHotspots") return;
361
+ if (!assessment.src?.trim()) {
362
+ issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
363
+ }
364
+ if (!assessment.alt?.trim()) {
365
+ issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
366
+ }
367
+ const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
368
+ if (!ids.length) {
369
+ issues.push({
370
+ path: `${path}.correctTargetIds`,
371
+ message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
372
+ });
373
+ }
374
+ }
375
+ };
376
+ function validateAssessmentEntry(assessment, index, issues, checkIds) {
377
+ const path = `assessments[${index}]`;
378
+ const check = validateId2(assessment.checkId, `${path}.checkId`);
379
+ if (!check.ok) {
380
+ issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
381
+ } else if (checkIds.has(check.id)) {
382
+ issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
383
+ } else {
384
+ checkIds.add(check.id);
385
+ }
386
+ if (!assessment.question?.trim()) {
387
+ issues.push({ path: `${path}.question`, message: "question is required" });
388
+ }
389
+ const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
390
+ if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
391
+ issues.push({
392
+ path: `${path}.kind`,
393
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
394
+ });
395
+ return;
396
+ }
397
+ const kind = assessment.kind ?? "mcq";
398
+ const validator = ASSESSMENT_VALIDATORS[kind];
399
+ if (!validator) {
400
+ issues.push({
401
+ path: `${path}.kind`,
402
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
403
+ });
404
+ return;
405
+ }
406
+ validator(assessment, path, issues);
407
+ const passingScore = assessment.passingScore;
408
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
409
+ issues.push({
410
+ path: `${path}.passingScore`,
411
+ message: "passingScore must be greater than 0 (absolute point threshold)"
412
+ });
413
+ } else if (passingScore !== void 0) {
414
+ const maxAchievable = maxAchievableAssessmentScore(assessment);
415
+ if (maxAchievable > 0 && passingScore > maxAchievable) {
416
+ issues.push({
417
+ path: `${path}.passingScore`,
418
+ message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
419
+ });
420
+ }
421
+ }
422
+ }
423
+
424
+ // src/descriptor/validateCourse.ts
425
+ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
426
+ var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
427
+ function validateCourseDescriptor(input) {
257
428
  const issues = [];
258
- const course = validateId(input.courseId, "courseId");
429
+ const course = validateId3(input.courseId, "courseId");
259
430
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
260
431
  if (!input.title?.trim()) {
261
432
  issues.push({ path: "title", message: "title is required" });
@@ -280,13 +451,23 @@ function validateDescriptorParsed(input) {
280
451
  });
281
452
  }
282
453
  if (input.theme?.theme) {
283
- try {
284
- themeToLxpackRuntime({ preset: themePreset, theme: input.theme.theme });
285
- } catch (err) {
286
- issues.push({
287
- path: "theme.theme",
288
- message: err instanceof Error ? err.message : "invalid custom theme"
289
- });
454
+ const themeResult = validateTheme(input.theme.theme);
455
+ if (!themeResult.ok) {
456
+ for (const issue of themeResult.issues) {
457
+ issues.push({
458
+ path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
459
+ message: issue.message
460
+ });
461
+ }
462
+ } else {
463
+ try {
464
+ themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
465
+ } catch (err) {
466
+ issues.push({
467
+ path: "theme.theme",
468
+ message: err instanceof Error ? err.message : "invalid custom theme"
469
+ });
470
+ }
290
471
  }
291
472
  }
292
473
  const completionThreshold = input.tracking?.completion?.threshold;
@@ -308,7 +489,7 @@ function validateDescriptorParsed(input) {
308
489
  const spaPaths = /* @__PURE__ */ new Set();
309
490
  for (const [index, lesson] of (input.lessons ?? []).entries()) {
310
491
  const path = `lessons[${index}]`;
311
- const lessonResult = validateId(lesson.id, `${path}.id`);
492
+ const lessonResult = validateId3(lesson.id, `${path}.id`);
312
493
  if (!lessonResult.ok) {
313
494
  issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
314
495
  } else if (lessonIds.has(lessonResult.id)) {
@@ -336,68 +517,147 @@ function validateDescriptorParsed(input) {
336
517
  } else {
337
518
  spaPaths.add(spaPath);
338
519
  }
339
- }
520
+ }
521
+ }
522
+ if (layout === "single-spa" && input.spaLessonId?.trim()) {
523
+ const spaId = input.spaLessonId.trim();
524
+ const spaResult = validateId3(spaId, "spaLessonId");
525
+ if (!spaResult.ok) {
526
+ issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
527
+ } else if (!lessonIds.has(spaResult.id)) {
528
+ issues.push({
529
+ path: "spaLessonId",
530
+ message: "spaLessonId must match a lesson id in lessons"
531
+ });
532
+ }
533
+ }
534
+ const checkIds = /* @__PURE__ */ new Set();
535
+ for (const [index, assessment] of (input.assessments ?? []).entries()) {
536
+ validateAssessmentEntry(assessment, index, issues, checkIds);
537
+ }
538
+ return issues;
539
+ }
540
+
541
+ // src/assessments.ts
542
+ function slugChoiceId(text, index) {
543
+ const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
544
+ const stem = base.length ? base : "choice";
545
+ return `${stem}-${index + 1}`;
546
+ }
547
+ function mcqToLxpack(assessment) {
548
+ const choices = assessment.choices.map((text, index) => {
549
+ const id = slugChoiceId(text, index);
550
+ return {
551
+ id,
552
+ text,
553
+ correct: text === assessment.answer
554
+ };
555
+ });
556
+ return {
557
+ id: assessment.checkId,
558
+ passingScore: assessment.passingScore ?? 1,
559
+ questions: [
560
+ {
561
+ id: "q1",
562
+ prompt: assessment.question,
563
+ choices
564
+ }
565
+ ]
566
+ };
567
+ }
568
+ function assessmentDescriptorToLxpack(assessment) {
569
+ const kind = assessment.kind ?? "mcq";
570
+ if (kind === "trueFalse" && assessment.kind === "trueFalse") {
571
+ const choices = ["True", "False"];
572
+ const answerText = assessment.answer ? "True" : "False";
573
+ return mcqToLxpack({
574
+ kind: "mcq",
575
+ checkId: assessment.checkId,
576
+ question: assessment.question,
577
+ choices,
578
+ answer: answerText,
579
+ passingScore: assessment.passingScore
580
+ });
340
581
  }
341
- if (layout === "single-spa" && input.spaLessonId?.trim()) {
342
- const spaId = input.spaLessonId.trim();
343
- const spaResult = validateId(spaId, "spaLessonId");
344
- if (!spaResult.ok) {
345
- issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
346
- } else if (!lessonIds.has(spaResult.id)) {
582
+ if (kind === "fillInBlanks") {
583
+ return null;
584
+ }
585
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
586
+ return mcqToLxpack({
587
+ kind: "mcq",
588
+ checkId: assessment.checkId,
589
+ question: assessment.question,
590
+ choices: [assessment.correctTargetId, "other"],
591
+ answer: assessment.correctTargetId,
592
+ passingScore: assessment.passingScore
593
+ });
594
+ }
595
+ if (kind === "findMultipleHotspots") {
596
+ return null;
597
+ }
598
+ if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
599
+ return mcqToLxpack(assessment);
600
+ }
601
+ return null;
602
+ }
603
+ function extractAssessments(descriptor) {
604
+ return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
605
+ }
606
+
607
+ // src/descriptor/validateForTarget.ts
608
+ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
609
+ "scorm12",
610
+ "scorm2004",
611
+ "standalone",
612
+ "xapi",
613
+ "cmi5"
614
+ ]);
615
+ function validateDescriptorForExportTarget(descriptor, target) {
616
+ const issues = [];
617
+ if (target === "xapi" || target === "cmi5") {
618
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
619
+ if (!activityIri) {
347
620
  issues.push({
348
- path: "spaLessonId",
349
- message: "spaLessonId must match a lesson id in lessons"
621
+ path: "course.tracking.xapi.activityIri",
622
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
350
623
  });
351
624
  }
352
625
  }
353
- const checkIds = /* @__PURE__ */ new Set();
354
- 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) {
626
+ if (LMS_SHELL_TARGETS.has(target)) {
627
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
628
+ if (assessmentDescriptorToLxpack(assessment) === null) {
379
629
  issues.push({
380
- path: `${path}.choices`,
381
- message: "at least one non-empty choice is required"
630
+ path: `assessments[${index}]`,
631
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
382
632
  });
383
633
  }
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" });
388
- }
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
- }
634
+ });
397
635
  }
636
+ return issues;
637
+ }
638
+
639
+ // src/validateDescriptor.ts
640
+ function validateDescriptorParsed(input) {
641
+ const issues = validateCourseDescriptor(input);
398
642
  if (issues.length) return { ok: false, issues };
399
643
  return { ok: true, descriptor: normalizeDescriptor(input) };
400
644
  }
645
+ function validateDescriptor(input) {
646
+ const parsed = parseCourseDescriptorInput(input);
647
+ if (parsed === null) {
648
+ return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
649
+ }
650
+ return validateDescriptorParsed(parsed);
651
+ }
652
+ function validateDescriptorForTarget(input, target) {
653
+ const result = validateDescriptor(input);
654
+ if (!result.ok || !target) return result;
655
+ const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
656
+ if (targetIssues.length) {
657
+ return { ok: false, issues: targetIssues };
658
+ }
659
+ return result;
660
+ }
401
661
 
402
662
  // src/validateProjectPaths.ts
403
663
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
@@ -462,59 +722,6 @@ function mapLessonkitIds(descriptor) {
462
722
  return { courseId, lessonIds, checkIds };
463
723
  }
464
724
 
465
- // src/assessments.ts
466
- function slugChoiceId(text, index) {
467
- const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
468
- const stem = base.length ? base : "choice";
469
- return `${stem}-${index + 1}`;
470
- }
471
- function mcqToLxpack(assessment) {
472
- const choices = assessment.choices.map((text, index) => {
473
- const id = slugChoiceId(text, index);
474
- return {
475
- id,
476
- text,
477
- correct: text === assessment.answer
478
- };
479
- });
480
- return {
481
- id: assessment.checkId,
482
- passingScore: assessment.passingScore ?? 1,
483
- questions: [
484
- {
485
- id: "q1",
486
- prompt: assessment.question,
487
- choices
488
- }
489
- ]
490
- };
491
- }
492
- function assessmentDescriptorToLxpack(assessment) {
493
- const kind = assessment.kind ?? "mcq";
494
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
495
- const choices = ["True", "False"];
496
- const answerText = assessment.answer ? "True" : "False";
497
- return mcqToLxpack({
498
- kind: "mcq",
499
- checkId: assessment.checkId,
500
- question: assessment.question,
501
- choices,
502
- answer: answerText,
503
- passingScore: assessment.passingScore
504
- });
505
- }
506
- if (kind === "fillInBlanks") {
507
- return null;
508
- }
509
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
510
- return mcqToLxpack(assessment);
511
- }
512
- return null;
513
- }
514
- function extractAssessments(descriptor) {
515
- return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
516
- }
517
-
518
725
  // src/interchange.ts
519
726
  function mapDescriptorTracking(tracking) {
520
727
  if (!tracking) return void 0;
@@ -575,12 +782,12 @@ function descriptorToInterchange(descriptor) {
575
782
  }
576
783
 
577
784
  // src/writeProject.ts
578
- import { join as join2, resolve as resolve4 } from "path";
785
+ import { join as join3, resolve as resolve4 } from "path";
579
786
  import { materializeLessonkitProject } from "@lxpack/validators";
580
787
 
581
788
  // src/spaDirs.ts
582
789
  import { access } from "fs/promises";
583
- import { join, resolve as resolve3 } from "path";
790
+ import { join as join2, resolve as resolve3 } from "path";
584
791
  async function resolveSpaDirs(options) {
585
792
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
586
793
  const spaLessons = resolveSpaLessons(descriptor);
@@ -597,9 +804,9 @@ async function resolveSpaDirs(options) {
597
804
  throw new Error(`spaDistDir not found: ${srcDist}`);
598
805
  }
599
806
  try {
600
- await access(join(srcDist, "index.html"));
807
+ await access(join2(srcDist, "index.html"));
601
808
  } catch {
602
- throw new Error(`spaDistDir must contain index.html: ${join(srcDist, "index.html")}`);
809
+ throw new Error(`spaDistDir must contain index.html: ${join2(srcDist, "index.html")}`);
603
810
  }
604
811
  const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
605
812
  "main";
@@ -622,10 +829,10 @@ async function resolveSpaDirs(options) {
622
829
  throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
623
830
  }
624
831
  try {
625
- await access(join(resolved, "index.html"));
832
+ await access(join2(resolved, "index.html"));
626
833
  } catch {
627
834
  throw new Error(
628
- `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join(resolved, "index.html")}`
835
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join2(resolved, "index.html")}`
629
836
  );
630
837
  }
631
838
  dirs[lesson.id] = resolved;
@@ -662,13 +869,13 @@ async function writeLxpackProject(options) {
662
869
  const courseDir = materialized.courseDir;
663
870
  return {
664
871
  outDir: courseDir,
665
- courseYamlPath: join2(courseDir, "course.yaml"),
666
- lessonkitJsonPath: join2(courseDir, "lessonkit.json")
872
+ courseYamlPath: join3(courseDir, "course.yaml"),
873
+ lessonkitJsonPath: join3(courseDir, "lessonkit.json")
667
874
  };
668
875
  }
669
876
 
670
877
  // src/packageCourse.ts
671
- import { resolve as resolve6 } from "path";
878
+ import { resolve as resolve7 } from "path";
672
879
  import * as fsp3 from "fs/promises";
673
880
  import {
674
881
  buildCourse,
@@ -676,48 +883,75 @@ import {
676
883
  } from "@lxpack/api";
677
884
 
678
885
  // src/packaging/validateInputs.ts
679
- import { isAbsolute as isAbsolute3, join as join3, resolve as resolve5, win32 as win322 } from "path";
886
+ import { isAbsolute as isAbsolute3, join as join4, resolve as resolve5, win32 as win322 } from "path";
680
887
  function validatePackageInputs(options) {
681
888
  const { target, output, outputBaseDir } = options;
682
889
  const outDir = resolve5(options.outDir);
683
- const projectRoot = options.projectRoot ? resolve5(options.projectRoot) : void 0;
684
- if (projectRoot) {
685
- try {
686
- assertRealPathUnderRoot(projectRoot, outDir);
687
- } catch (err) {
688
- return {
689
- ok: false,
690
- courseDir: outDir,
691
- target,
692
- issues: [
693
- {
694
- path: "outDir",
695
- message: (
696
- /* v8 ignore next */
697
- err instanceof Error ? err.message : String(err)
698
- )
699
- }
700
- ]
701
- };
702
- }
890
+ if (!options.projectRoot) {
891
+ return {
892
+ ok: false,
893
+ courseDir: outDir,
894
+ target,
895
+ issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
896
+ };
703
897
  }
704
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
898
+ const projectRoot = resolve5(options.projectRoot);
899
+ try {
900
+ assertRealPathUnderRoot(projectRoot, outDir);
901
+ } catch (err) {
705
902
  return {
706
903
  ok: false,
707
904
  courseDir: outDir,
708
905
  target,
709
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
906
+ issues: [
907
+ {
908
+ path: "outDir",
909
+ message: (
910
+ /* v8 ignore next */
911
+ err instanceof Error ? err.message : String(err)
912
+ )
913
+ }
914
+ ]
710
915
  };
711
916
  }
712
- if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
917
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
713
918
  return {
714
919
  ok: false,
715
920
  courseDir: outDir,
716
921
  target,
717
- issues: [{ path: "output", message: `unsafe output: ${output}` }]
922
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
718
923
  };
719
924
  }
720
- if (projectRoot && outputBaseDir) {
925
+ if (output && !isSafeRelativeSpaPath(output)) {
926
+ if (isAbsolute3(output)) {
927
+ try {
928
+ assertRealPathUnderRoot(projectRoot, resolve5(output));
929
+ } catch (err) {
930
+ return {
931
+ ok: false,
932
+ courseDir: outDir,
933
+ target,
934
+ issues: [
935
+ {
936
+ path: "output",
937
+ message: (
938
+ /* v8 ignore next */
939
+ err instanceof Error ? err.message : `unsafe output: ${output}`
940
+ )
941
+ }
942
+ ]
943
+ };
944
+ }
945
+ } else {
946
+ return {
947
+ ok: false,
948
+ courseDir: outDir,
949
+ target,
950
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
951
+ };
952
+ }
953
+ }
954
+ if (outputBaseDir) {
721
955
  const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
722
956
  try {
723
957
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
@@ -738,8 +972,8 @@ function validatePackageInputs(options) {
738
972
  };
739
973
  }
740
974
  }
741
- if (projectRoot && output) {
742
- const resolvedOutput = resolve5(projectRoot, output);
975
+ if (output) {
976
+ const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
743
977
  try {
744
978
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
745
979
  } catch (err) {
@@ -776,23 +1010,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
776
1010
  if (!artifactPath) return void 0;
777
1011
  const resolved = resolveComparablePath(artifactPath);
778
1012
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
779
- return artifactPath;
1013
+ throw new Error(`${artifactPath} is outside the staging directory`);
780
1014
  }
781
1015
  const rel = relativePathUnderRoot(stagingRoot, resolved);
782
1016
  if (rel.startsWith("..") || isAbsolute3(rel)) {
783
- return artifactPath;
1017
+ throw new Error(`${artifactPath} is outside the staging directory`);
784
1018
  }
785
1019
  if (!rel) return outDir;
786
1020
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
787
1021
  return win322.join(outDir, rel.replace(/\//g, win322.sep));
788
1022
  }
789
- return join3(outDir, rel);
1023
+ return join4(outDir, rel);
790
1024
  }
791
1025
 
792
1026
  // src/packaging/promote.ts
793
1027
  import * as fsp from "fs/promises";
794
- import { randomUUID } from "crypto";
795
- import { dirname, join as join4 } from "path";
1028
+ import { createHash, randomUUID } from "crypto";
1029
+ import { dirname, join as join5, resolve as resolve6 } from "path";
796
1030
  async function pathExists(path) {
797
1031
  try {
798
1032
  await fsp.access(path);
@@ -811,6 +1045,68 @@ async function renameOrCopy(from, to) {
811
1045
  await fsp.rm(from, { recursive: true, force: true });
812
1046
  }
813
1047
  }
1048
+ function promoteLockPath(outDir) {
1049
+ const parent = dirname(outDir);
1050
+ const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1051
+ return join5(parent, `.lk-promote-lock-${hash}`);
1052
+ }
1053
+ var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1054
+ async function isStalePromoteLock(lockPath) {
1055
+ try {
1056
+ const stat2 = await fsp.stat(lockPath);
1057
+ if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
1058
+ const content = await fsp.readFile(lockPath, "utf8");
1059
+ const pid = Number.parseInt(content.trim(), 10);
1060
+ if (!Number.isFinite(pid) || pid <= 0) return true;
1061
+ try {
1062
+ process.kill(pid, 0);
1063
+ return false;
1064
+ } catch {
1065
+ return true;
1066
+ }
1067
+ } catch {
1068
+ return true;
1069
+ }
1070
+ }
1071
+ async function withPromoteLock(outDir, fn) {
1072
+ const lockPath = promoteLockPath(outDir);
1073
+ await fsp.mkdir(dirname(outDir), { recursive: true });
1074
+ let lockHandle;
1075
+ for (let attempt = 0; attempt < 200; attempt++) {
1076
+ try {
1077
+ lockHandle = await fsp.open(lockPath, "wx");
1078
+ await lockHandle.writeFile(`${process.pid}
1079
+ `, "utf8");
1080
+ break;
1081
+ } catch (err) {
1082
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
1083
+ if (code !== "EEXIST") throw err;
1084
+ if (await isStalePromoteLock(lockPath)) {
1085
+ await fsp.rm(lockPath, { force: true }).catch(
1086
+ /* v8 ignore next */
1087
+ () => void 0
1088
+ );
1089
+ continue;
1090
+ }
1091
+ await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1092
+ }
1093
+ }
1094
+ if (!lockHandle) {
1095
+ throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
1096
+ }
1097
+ try {
1098
+ return await fn();
1099
+ } finally {
1100
+ await lockHandle.close().catch(
1101
+ /* v8 ignore next */
1102
+ () => void 0
1103
+ );
1104
+ await fsp.rm(lockPath, { force: true }).catch(
1105
+ /* v8 ignore next */
1106
+ () => void 0
1107
+ );
1108
+ }
1109
+ }
814
1110
  async function assertNoLegacyPromoteArtifacts(outDir) {
815
1111
  const legacyTmp = `${outDir}.tmp-promote`;
816
1112
  const legacyBak = `${outDir}.bak`;
@@ -824,45 +1120,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
824
1120
  }
825
1121
  }
826
1122
  async function promoteStagingToOutDir(stagingDir, outDir) {
827
- await assertNoLegacyPromoteArtifacts(outDir);
828
- const parent = dirname(outDir);
829
- const tmpPromote = await fsp.mkdtemp(join4(parent, ".lk-promote-"));
830
- await renameOrCopy(stagingDir, tmpPromote);
831
- const hadOutDir = await pathExists(outDir);
832
- const backup = hadOutDir ? await fsp.mkdtemp(join4(parent, ".lk-backup-")) : void 0;
833
- if (hadOutDir && backup) {
834
- await renameOrCopy(outDir, backup);
835
- }
836
- try {
837
- await renameOrCopy(tmpPromote, outDir);
838
- } catch (promoteError) {
1123
+ return withPromoteLock(outDir, async () => {
1124
+ await assertNoLegacyPromoteArtifacts(outDir);
1125
+ const parent = dirname(outDir);
1126
+ const tmpPromote = await fsp.mkdtemp(join5(parent, ".lk-promote-"));
1127
+ await renameOrCopy(stagingDir, tmpPromote);
1128
+ const hadOutDir = await pathExists(outDir);
1129
+ const backup = hadOutDir ? await fsp.mkdtemp(join5(parent, ".lk-backup-")) : void 0;
839
1130
  if (hadOutDir && backup) {
840
- try {
841
- await renameOrCopy(backup, outDir);
842
- } catch (restoreError) {
843
- const failedPromote2 = join4(parent, `.lk-failed-promote-${randomUUID()}`);
1131
+ await renameOrCopy(outDir, backup);
1132
+ }
1133
+ try {
1134
+ await renameOrCopy(tmpPromote, outDir);
1135
+ } catch (promoteError) {
1136
+ if (hadOutDir && backup) {
1137
+ try {
1138
+ await renameOrCopy(backup, outDir);
1139
+ } catch (restoreError) {
1140
+ const failedPromote2 = join5(parent, `.lk-failed-promote-${randomUUID()}`);
1141
+ try {
1142
+ await renameOrCopy(tmpPromote, failedPromote2);
1143
+ } catch {
1144
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1145
+ /* v8 ignore next */
1146
+ () => void 0
1147
+ );
1148
+ }
1149
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1150
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1151
+ throw new Error(
1152
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1153
+ );
1154
+ }
1155
+ } else {
844
1156
  try {
845
- await renameOrCopy(tmpPromote, failedPromote2);
846
- } catch {
1157
+ await renameOrCopy(tmpPromote, stagingDir);
1158
+ } catch (restoreError) {
1159
+ console.warn(
1160
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1161
+ restoreError instanceof Error ? restoreError.message : restoreError
1162
+ );
847
1163
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
848
1164
  /* v8 ignore next */
849
1165
  () => void 0
850
1166
  );
851
1167
  }
852
- const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
853
- const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
854
- throw new Error(
855
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
856
- );
1168
+ throw promoteError;
857
1169
  }
858
- } else {
1170
+ const failedPromote = join5(parent, `.lk-failed-promote-${randomUUID()}`);
859
1171
  try {
860
- await renameOrCopy(tmpPromote, stagingDir);
861
- } catch (restoreError) {
862
- console.warn(
863
- `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
864
- restoreError instanceof Error ? restoreError.message : restoreError
865
- );
1172
+ await renameOrCopy(tmpPromote, failedPromote);
1173
+ } catch {
866
1174
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
867
1175
  /* v8 ignore next */
868
1176
  () => void 0
@@ -870,33 +1178,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
870
1178
  }
871
1179
  throw promoteError;
872
1180
  }
873
- const failedPromote = join4(parent, `.lk-failed-promote-${randomUUID()}`);
874
- try {
875
- await renameOrCopy(tmpPromote, failedPromote);
876
- } catch {
877
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1181
+ if (backup) {
1182
+ await fsp.rm(backup, { recursive: true, force: true }).catch(
878
1183
  /* v8 ignore next */
879
1184
  () => void 0
880
1185
  );
881
1186
  }
882
- throw promoteError;
883
- }
884
- if (backup) {
885
- await fsp.rm(backup, { recursive: true, force: true }).catch(
886
- /* v8 ignore next */
887
- () => void 0
888
- );
889
- }
1187
+ });
890
1188
  }
891
1189
 
892
1190
  // src/packaging/staging.ts
893
1191
  import * as fsp2 from "fs/promises";
894
- import { dirname as dirname2, join as join5 } from "path";
1192
+ import { dirname as dirname2, join as join6 } from "path";
895
1193
  import { tmpdir } from "os";
896
1194
  import { packageLessonkit } from "@lxpack/api";
897
1195
  async function buildStagingPackage(options) {
898
1196
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
899
- const stagingDir = await fsp2.mkdtemp(join5(tmpdir(), "lessonkit-lxpack-"));
1197
+ const stagingDir = await fsp2.mkdtemp(join6(tmpdir(), "lessonkit-lxpack-"));
900
1198
  try {
901
1199
  let spaDirs;
902
1200
  try {
@@ -915,8 +1213,8 @@ async function buildStagingPackage(options) {
915
1213
  }
916
1214
  const interchange = descriptorToInterchange(descriptor);
917
1215
  const outputBase = outputBaseDir ?? ".lxpack/out";
918
- await fsp2.mkdir(join5(stagingDir, outputBase), { recursive: true });
919
- const defaultOutput = output ?? (dir ? join5(outputBase, target) : join5(outputBase, `course-${target}.zip`));
1216
+ await fsp2.mkdir(join6(stagingDir, outputBase), { recursive: true });
1217
+ const defaultOutput = output ?? (dir ? join6(outputBase, target) : join6(outputBase, `course-${target}.zip`));
920
1218
  const build = await packageLessonkit({
921
1219
  interchange,
922
1220
  spaDirs,
@@ -959,16 +1257,25 @@ async function ensureOutDirParent(outDir) {
959
1257
  await fsp2.mkdir(dirname2(outDir), { recursive: true });
960
1258
  }
961
1259
 
1260
+ // src/packaging/issueSeverity.ts
1261
+ function isPackagingErrorIssue(issue) {
1262
+ const severity = issue.severity?.toLowerCase();
1263
+ return severity === "error" || severity === "fatal";
1264
+ }
1265
+ function findPackagingErrorIssues(issues) {
1266
+ return (issues ?? []).filter(isPackagingErrorIssue);
1267
+ }
1268
+
962
1269
  // src/packageCourse.ts
963
1270
  async function validateLessonkitProject(options) {
964
1271
  return validateCourse({
965
- courseDir: resolve6(options.courseDir),
1272
+ courseDir: resolve7(options.courseDir),
966
1273
  target: options.target
967
1274
  });
968
1275
  }
969
1276
  async function buildLessonkitProject(options) {
970
1277
  const buildOptions = {
971
- courseDir: resolve6(options.courseDir),
1278
+ courseDir: resolve7(options.courseDir),
972
1279
  target: options.target,
973
1280
  output: options.output,
974
1281
  dir: options.dir,
@@ -999,7 +1306,7 @@ async function packageLessonkitCourse(options) {
999
1306
  if (!descriptorValidation.ok) {
1000
1307
  return {
1001
1308
  ok: false,
1002
- courseDir: resolve6(writeOpts.outDir),
1309
+ courseDir: resolve7(writeOpts.outDir),
1003
1310
  target,
1004
1311
  issues: descriptorValidation.issues.map((i) => ({
1005
1312
  path: i.path,
@@ -1008,6 +1315,18 @@ async function packageLessonkitCourse(options) {
1008
1315
  };
1009
1316
  }
1010
1317
  const descriptor = descriptorValidation.descriptor;
1318
+ const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1319
+ if (nonInjectableAssessments.length > 0) {
1320
+ return {
1321
+ ok: false,
1322
+ courseDir: outDir,
1323
+ target,
1324
+ issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1325
+ path: `assessments[${index}]`,
1326
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1327
+ }))
1328
+ };
1329
+ }
1011
1330
  const staged = await buildStagingPackage({
1012
1331
  ...writeOpts,
1013
1332
  descriptor,
@@ -1032,6 +1351,25 @@ async function packageLessonkitCourse(options) {
1032
1351
  };
1033
1352
  }
1034
1353
  const { stagingDir, build } = staged;
1354
+ const buildErrorIssues = findPackagingErrorIssues(build.issues);
1355
+ if (buildErrorIssues.length > 0) {
1356
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1357
+ /* v8 ignore next */
1358
+ () => void 0
1359
+ );
1360
+ return {
1361
+ ok: false,
1362
+ courseDir: outDir,
1363
+ target,
1364
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1365
+ build,
1366
+ issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
1367
+ path: i.path ?? "build",
1368
+ message: i.message,
1369
+ severity: i.severity
1370
+ }))
1371
+ };
1372
+ }
1035
1373
  const stagingRoot = await fsp3.realpath(stagingDir);
1036
1374
  const artifactIssues = [
1037
1375
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
@@ -1062,6 +1400,10 @@ async function packageLessonkitCourse(options) {
1062
1400
  await ensureOutDirParent(outDir);
1063
1401
  await promoteStagingToOutDir(stagingDir, outDir);
1064
1402
  } catch (err) {
1403
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1404
+ /* v8 ignore next */
1405
+ () => void 0
1406
+ );
1065
1407
  return {
1066
1408
  ok: false,
1067
1409
  courseDir: outDir,