@lessonkit/lxpack 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -57,6 +57,7 @@ __export(index_exports, {
57
57
  validateLessonkitProject: () => validateLessonkitProject,
58
58
  validatePackageInputs: () => validatePackageInputs,
59
59
  validateProjectPaths: () => validateProjectPaths,
60
+ validateReactManifestParity: () => validateReactManifestParity,
60
61
  writeLxpackProject: () => writeLxpackProject
61
62
  });
62
63
  module.exports = __toCommonJS(index_exports);
@@ -193,6 +194,14 @@ function parseAssessmentDescriptor(raw) {
193
194
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
194
195
  };
195
196
  }
197
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
198
+ return {
199
+ kind,
200
+ ...base,
201
+ choices: [],
202
+ answer: ""
203
+ };
204
+ }
196
205
  return {
197
206
  kind: kind === "mcq" ? "mcq" : void 0,
198
207
  ...base,
@@ -242,6 +251,7 @@ function parseCourseDescriptorInput(input) {
242
251
 
243
252
  // src/descriptor/validateCourse.ts
244
253
  var import_core3 = require("@lessonkit/core");
254
+ var import_themes2 = require("@lessonkit/themes");
245
255
 
246
256
  // src/spaPath.ts
247
257
  var import_node_fs = require("fs");
@@ -271,6 +281,28 @@ function assertResolvedPathUnderRoot(root, target) {
271
281
  throw new Error(`unsafe path escapes project root: ${target}`);
272
282
  }
273
283
  }
284
+ function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
285
+ const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
286
+ if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
287
+ throw new Error(`unsafe path escapes project root: ${targetResolved}`);
288
+ }
289
+ const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
290
+ let current = rootReal;
291
+ for (const segment of segments) {
292
+ const next = (0, import_node_path.join)(current, segment);
293
+ if ((0, import_node_fs.existsSync)(next)) {
294
+ try {
295
+ current = (0, import_node_fs.realpathSync)(next);
296
+ } catch {
297
+ current = next;
298
+ }
299
+ } else {
300
+ current = next;
301
+ }
302
+ assertResolvedPathUnderRoot(rootReal, current);
303
+ }
304
+ return current;
305
+ }
274
306
  function assertRealPathUnderRoot(root, target) {
275
307
  const rootResolved = resolveComparablePath(root);
276
308
  const targetResolved = resolveComparablePath(target);
@@ -280,17 +312,12 @@ function assertRealPathUnderRoot(root, target) {
280
312
  } catch {
281
313
  rootReal = rootResolved;
282
314
  }
283
- let targetCheck;
284
315
  try {
285
- targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
316
+ const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
317
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
286
318
  } catch {
287
- const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
288
- if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
289
- throw new Error(`unsafe path escapes project root: ${target}`);
290
- }
291
- targetCheck = (0, import_node_path.resolve)(rootReal, rel);
319
+ resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
292
320
  }
293
- assertResolvedPathUnderRoot(rootReal, targetCheck);
294
321
  }
295
322
  function normalizePathForComparison(p) {
296
323
  const resolved = resolveComparablePath(p);
@@ -331,7 +358,12 @@ function themeToLxpackRuntime(input) {
331
358
  // src/descriptor/validateAssessments.ts
332
359
  var import_core2 = require("@lessonkit/core");
333
360
  var validateMcqLike = (assessment, path, issues) => {
334
- if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
361
+ if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
362
+ issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
363
+ return;
364
+ }
365
+ if (!("answer" in assessment) || typeof assessment.answer !== "string") {
366
+ issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
335
367
  return;
336
368
  }
337
369
  const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
@@ -344,6 +376,22 @@ var validateMcqLike = (assessment, path, issues) => {
344
376
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
345
377
  }
346
378
  };
379
+ function countStarDelimitedBlanks(template) {
380
+ const matches = template.match(/\*[^*]+\*/g);
381
+ return matches?.length ?? 0;
382
+ }
383
+ function maxAchievableAssessmentScore(assessment) {
384
+ const kind = assessment.kind ?? "mcq";
385
+ if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
386
+ const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
387
+ if (explicit > 0) return explicit;
388
+ return countStarDelimitedBlanks(assessment.template ?? "");
389
+ }
390
+ if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
391
+ return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
392
+ }
393
+ return 1;
394
+ }
347
395
  var ASSESSMENT_VALIDATORS = {
348
396
  mcq: validateMcqLike,
349
397
  trueFalse: (assessment, path, issues) => {
@@ -356,9 +404,33 @@ var ASSESSMENT_VALIDATORS = {
356
404
  issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
357
405
  }
358
406
  },
359
- findHotspot: () => {
407
+ findHotspot: (assessment, path, issues) => {
408
+ if (assessment.kind !== "findHotspot") return;
409
+ if (!assessment.src?.trim()) {
410
+ issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
411
+ }
412
+ if (!assessment.alt?.trim()) {
413
+ issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
414
+ }
415
+ if (!assessment.correctTargetId?.trim()) {
416
+ issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
417
+ }
360
418
  },
361
- findMultipleHotspots: () => {
419
+ findMultipleHotspots: (assessment, path, issues) => {
420
+ if (assessment.kind !== "findMultipleHotspots") return;
421
+ if (!assessment.src?.trim()) {
422
+ issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
423
+ }
424
+ if (!assessment.alt?.trim()) {
425
+ issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
426
+ }
427
+ const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
428
+ if (!ids.length) {
429
+ issues.push({
430
+ path: `${path}.correctTargetIds`,
431
+ message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
432
+ });
433
+ }
362
434
  }
363
435
  };
364
436
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
@@ -374,14 +446,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
374
446
  if (!assessment.question?.trim()) {
375
447
  issues.push({ path: `${path}.question`, message: "question is required" });
376
448
  }
449
+ const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
450
+ if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
451
+ issues.push({
452
+ path: `${path}.kind`,
453
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
454
+ });
455
+ return;
456
+ }
377
457
  const kind = assessment.kind ?? "mcq";
378
- ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
458
+ const validator = ASSESSMENT_VALIDATORS[kind];
459
+ if (!validator) {
460
+ issues.push({
461
+ path: `${path}.kind`,
462
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
463
+ });
464
+ return;
465
+ }
466
+ validator(assessment, path, issues);
379
467
  const passingScore = assessment.passingScore;
380
468
  if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
381
469
  issues.push({
382
470
  path: `${path}.passingScore`,
383
471
  message: "passingScore must be greater than 0 (absolute point threshold)"
384
472
  });
473
+ } else if (passingScore !== void 0) {
474
+ const maxAchievable = maxAchievableAssessmentScore(assessment);
475
+ if (maxAchievable > 0 && passingScore > maxAchievable) {
476
+ issues.push({
477
+ path: `${path}.passingScore`,
478
+ message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
479
+ });
480
+ }
385
481
  }
386
482
  }
387
483
 
@@ -415,13 +511,23 @@ function validateCourseDescriptor(input) {
415
511
  });
416
512
  }
417
513
  if (input.theme?.theme) {
418
- try {
419
- themeToLxpackRuntime({ preset: themePreset, theme: input.theme.theme });
420
- } catch (err) {
421
- issues.push({
422
- path: "theme.theme",
423
- message: err instanceof Error ? err.message : "invalid custom theme"
424
- });
514
+ const themeResult = (0, import_themes2.validateTheme)(input.theme.theme);
515
+ if (!themeResult.ok) {
516
+ for (const issue of themeResult.issues) {
517
+ issues.push({
518
+ path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
519
+ message: issue.message
520
+ });
521
+ }
522
+ } else {
523
+ try {
524
+ themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
525
+ } catch (err) {
526
+ issues.push({
527
+ path: "theme.theme",
528
+ message: err instanceof Error ? err.message : "invalid custom theme"
529
+ });
530
+ }
425
531
  }
426
532
  }
427
533
  const completionThreshold = input.tracking?.completion?.threshold;
@@ -492,19 +598,102 @@ function validateCourseDescriptor(input) {
492
598
  return issues;
493
599
  }
494
600
 
601
+ // src/assessments.ts
602
+ function slugChoiceId(text, index) {
603
+ const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
604
+ const stem = base.length ? base : "choice";
605
+ return `${stem}-${index + 1}`;
606
+ }
607
+ function mcqToLxpack(assessment) {
608
+ const choices = assessment.choices.map((text, index) => {
609
+ const id = slugChoiceId(text, index);
610
+ return {
611
+ id,
612
+ text,
613
+ correct: text === assessment.answer
614
+ };
615
+ });
616
+ return {
617
+ id: assessment.checkId,
618
+ passingScore: assessment.passingScore ?? 1,
619
+ questions: [
620
+ {
621
+ id: "q1",
622
+ prompt: assessment.question,
623
+ choices
624
+ }
625
+ ]
626
+ };
627
+ }
628
+ function assessmentDescriptorToLxpack(assessment) {
629
+ const kind = assessment.kind ?? "mcq";
630
+ if (kind === "trueFalse" && assessment.kind === "trueFalse") {
631
+ const choices = ["True", "False"];
632
+ const answerText = assessment.answer ? "True" : "False";
633
+ return mcqToLxpack({
634
+ kind: "mcq",
635
+ checkId: assessment.checkId,
636
+ question: assessment.question,
637
+ choices,
638
+ answer: answerText,
639
+ passingScore: assessment.passingScore
640
+ });
641
+ }
642
+ if (kind === "fillInBlanks") {
643
+ return null;
644
+ }
645
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
646
+ return mcqToLxpack({
647
+ kind: "mcq",
648
+ checkId: assessment.checkId,
649
+ question: assessment.question,
650
+ choices: [assessment.correctTargetId, "other"],
651
+ answer: assessment.correctTargetId,
652
+ passingScore: assessment.passingScore
653
+ });
654
+ }
655
+ if (kind === "findMultipleHotspots") {
656
+ return null;
657
+ }
658
+ if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
659
+ return mcqToLxpack(assessment);
660
+ }
661
+ return null;
662
+ }
663
+ function extractAssessments(descriptor) {
664
+ return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
665
+ }
666
+
495
667
  // src/descriptor/validateForTarget.ts
668
+ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
669
+ "scorm12",
670
+ "scorm2004",
671
+ "standalone",
672
+ "xapi",
673
+ "cmi5"
674
+ ]);
496
675
  function validateDescriptorForExportTarget(descriptor, target) {
497
- if (target !== "xapi" && target !== "cmi5") return [];
498
- const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
499
- if (!activityIri) {
500
- return [
501
- {
676
+ const issues = [];
677
+ if (target === "xapi" || target === "cmi5") {
678
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
679
+ if (!activityIri) {
680
+ issues.push({
502
681
  path: "course.tracking.xapi.activityIri",
503
682
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
683
+ });
684
+ }
685
+ }
686
+ if (LMS_SHELL_TARGETS.has(target)) {
687
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
688
+ if (assessmentDescriptorToLxpack(assessment) === null) {
689
+ issues.push({
690
+ path: `assessments[${index}]`,
691
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
692
+ });
504
693
  }
505
- ];
694
+ });
506
695
  }
507
- return [];
696
+ return issues;
508
697
  }
509
698
 
510
699
  // src/validateDescriptor.ts
@@ -530,8 +719,114 @@ function validateDescriptorForTarget(input, target) {
530
719
  return result;
531
720
  }
532
721
 
533
- // src/validateProjectPaths.ts
722
+ // src/validateReactParity.ts
723
+ var import_node_fs2 = require("fs");
534
724
  var import_node_path2 = require("path");
725
+ var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
726
+ function collectSourceUnderSrc(projectRoot) {
727
+ const srcDir = (0, import_node_path2.join)(projectRoot, "src");
728
+ if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
729
+ const results = [];
730
+ const walk = (dir) => {
731
+ for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
732
+ const abs = (0, import_node_path2.join)(dir, entry);
733
+ if ((0, import_node_fs2.statSync)(abs).isDirectory()) {
734
+ walk(abs);
735
+ } else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
736
+ results.push((0, import_node_path2.relative)(projectRoot, abs));
737
+ }
738
+ }
739
+ };
740
+ walk(srcDir);
741
+ return results;
742
+ }
743
+ function readAppSources(projectRoot, appSources) {
744
+ return appSources.map((rel) => (0, import_node_path2.join)(projectRoot, rel)).filter((abs) => (0, import_node_fs2.existsSync)(abs)).map((abs) => (0, import_node_fs2.readFileSync)(abs, "utf8")).join("\n");
745
+ }
746
+ function stripComments(source) {
747
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
748
+ }
749
+ function idPropPatterns(prop, id) {
750
+ return [
751
+ `${prop}="${id}"`,
752
+ `${prop}='${id}'`,
753
+ `${prop}={'${id}'}`,
754
+ `${prop}={"${id}"}`,
755
+ `${prop}={\`${id}\`}`
756
+ ];
757
+ }
758
+ function extractStringConstants(source) {
759
+ const stripped = stripComments(source);
760
+ const map = /* @__PURE__ */ new Map();
761
+ const re = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
762
+ for (const match of stripped.matchAll(re)) {
763
+ map.set(match[1], match[3]);
764
+ }
765
+ return map;
766
+ }
767
+ function idUsedViaConstant(stripped, prop, id, constants) {
768
+ for (const [name, value] of constants) {
769
+ if (value !== id) continue;
770
+ const jsxPatterns = [
771
+ `${prop}={${name}}`,
772
+ `${prop}={ ${name} }`,
773
+ `${prop}={${name} }`,
774
+ `${prop}={ ${name}}`
775
+ ];
776
+ if (jsxPatterns.some((p) => stripped.includes(p))) return true;
777
+ const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
778
+ if (objPatterns.some((p) => stripped.includes(p))) return true;
779
+ }
780
+ return false;
781
+ }
782
+ function courseIdPresent(source, courseId) {
783
+ const stripped = stripComments(source);
784
+ if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
785
+ return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
786
+ }
787
+ function checkIdPresent(source, checkId) {
788
+ const stripped = stripComments(source);
789
+ if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
790
+ return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
791
+ }
792
+ function validateReactManifestParity(opts) {
793
+ const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
794
+ const source = readAppSources(opts.projectRoot, appSources);
795
+ const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
796
+ if (!source.trim()) {
797
+ return [
798
+ {
799
+ path: appSources.length > 0 ? appSources.join(", ") : "src/",
800
+ message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
801
+ severity: hasDescriptorIds ? "error" : "warning"
802
+ }
803
+ ];
804
+ }
805
+ const issues = [];
806
+ const courseId = opts.descriptor.courseId;
807
+ if (!courseIdPresent(source, courseId)) {
808
+ issues.push({
809
+ path: "course.courseId",
810
+ message: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
811
+ severity: "error"
812
+ });
813
+ }
814
+ for (const assessment of opts.descriptor.assessments ?? []) {
815
+ const checkId = assessment.checkId;
816
+ if (!checkId) continue;
817
+ if (!checkIdPresent(source, checkId)) {
818
+ issues.push({
819
+ path: `assessments.checkId:${checkId}`,
820
+ message: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
821
+ severity: "error"
822
+ });
823
+ }
824
+ }
825
+ return issues;
826
+ }
827
+
828
+ // src/validateProjectPaths.ts
829
+ var import_node_path3 = require("path");
535
830
  function validatePathField(value, fieldPath, projectRoot, issues) {
536
831
  if (!isSafeRelativeSpaPath(value)) {
537
832
  issues.push({
@@ -541,7 +836,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
541
836
  return;
542
837
  }
543
838
  try {
544
- assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
839
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
545
840
  } catch {
546
841
  issues.push({
547
842
  path: fieldPath,
@@ -551,7 +846,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
551
846
  }
552
847
  function validateProjectPaths(projectRoot, paths) {
553
848
  const issues = [];
554
- const root = (0, import_node_path2.resolve)(projectRoot);
849
+ const root = (0, import_node_path3.resolve)(projectRoot);
555
850
  if (paths.spaDistDir?.trim()) {
556
851
  validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
557
852
  }
@@ -564,20 +859,20 @@ function validateProjectPaths(projectRoot, paths) {
564
859
  return issues;
565
860
  }
566
861
  function resolveSafePackageOutputOverride(projectRoot, override) {
567
- const root = (0, import_node_path2.resolve)(projectRoot);
862
+ const root = (0, import_node_path3.resolve)(projectRoot);
568
863
  const trimmed = override.trim();
569
864
  if (!trimmed) {
570
865
  throw new Error("output override must be a non-empty path");
571
866
  }
572
- if ((0, import_node_path2.isAbsolute)(trimmed)) {
573
- const resolved2 = (0, import_node_path2.resolve)(trimmed);
867
+ if ((0, import_node_path3.isAbsolute)(trimmed)) {
868
+ const resolved2 = (0, import_node_path3.resolve)(trimmed);
574
869
  assertRealPathUnderRoot(root, resolved2);
575
870
  return resolved2;
576
871
  }
577
872
  if (!isSafeRelativeSpaPath(trimmed)) {
578
873
  throw new Error(`unsafe output path: ${override}`);
579
874
  }
580
- const resolved = (0, import_node_path2.resolve)(root, trimmed);
875
+ const resolved = (0, import_node_path3.resolve)(root, trimmed);
581
876
  assertRealPathUnderRoot(root, resolved);
582
877
  return resolved;
583
878
  }
@@ -593,72 +888,6 @@ function mapLessonkitIds(descriptor) {
593
888
  return { courseId, lessonIds, checkIds };
594
889
  }
595
890
 
596
- // src/assessments.ts
597
- function slugChoiceId(text, index) {
598
- const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
599
- const stem = base.length ? base : "choice";
600
- return `${stem}-${index + 1}`;
601
- }
602
- function mcqToLxpack(assessment) {
603
- const choices = assessment.choices.map((text, index) => {
604
- const id = slugChoiceId(text, index);
605
- return {
606
- id,
607
- text,
608
- correct: text === assessment.answer
609
- };
610
- });
611
- return {
612
- id: assessment.checkId,
613
- passingScore: assessment.passingScore ?? 1,
614
- questions: [
615
- {
616
- id: "q1",
617
- prompt: assessment.question,
618
- choices
619
- }
620
- ]
621
- };
622
- }
623
- function assessmentDescriptorToLxpack(assessment) {
624
- const kind = assessment.kind ?? "mcq";
625
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
626
- const choices = ["True", "False"];
627
- const answerText = assessment.answer ? "True" : "False";
628
- return mcqToLxpack({
629
- kind: "mcq",
630
- checkId: assessment.checkId,
631
- question: assessment.question,
632
- choices,
633
- answer: answerText,
634
- passingScore: assessment.passingScore
635
- });
636
- }
637
- if (kind === "fillInBlanks") {
638
- return null;
639
- }
640
- if (kind === "findHotspot" && assessment.kind === "findHotspot") {
641
- return mcqToLxpack({
642
- kind: "mcq",
643
- checkId: assessment.checkId,
644
- question: assessment.question,
645
- choices: [assessment.correctTargetId, "other"],
646
- answer: assessment.correctTargetId,
647
- passingScore: assessment.passingScore
648
- });
649
- }
650
- if (kind === "findMultipleHotspots") {
651
- return null;
652
- }
653
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
654
- return mcqToLxpack(assessment);
655
- }
656
- return null;
657
- }
658
- function extractAssessments(descriptor) {
659
- return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
660
- }
661
-
662
891
  // src/interchange.ts
663
892
  function mapDescriptorTracking(tracking) {
664
893
  if (!tracking) return void 0;
@@ -719,21 +948,21 @@ function descriptorToInterchange(descriptor) {
719
948
  }
720
949
 
721
950
  // src/writeProject.ts
722
- var import_node_path4 = require("path");
951
+ var import_node_path5 = require("path");
723
952
  var import_validators = require("@lxpack/validators");
724
953
 
725
954
  // src/spaDirs.ts
726
955
  var import_promises = require("fs/promises");
727
- var import_node_path3 = require("path");
956
+ var import_node_path4 = require("path");
728
957
  async function resolveSpaDirs(options) {
729
958
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
730
959
  const spaLessons = resolveSpaLessons(descriptor);
731
960
  if (descriptor.layout === "single-spa") {
732
961
  const spaDistRelative = spaDistDir ?? descriptor.spaDistDir ?? /* v8 ignore next */
733
962
  "dist";
734
- const srcDist = projectRoot ? (0, import_node_path3.resolve)(projectRoot, spaDistRelative) : (0, import_node_path3.resolve)(spaDistRelative);
963
+ const srcDist = projectRoot ? (0, import_node_path4.resolve)(projectRoot, spaDistRelative) : (0, import_node_path4.resolve)(spaDistRelative);
735
964
  if (projectRoot) {
736
- assertRealPathUnderRoot((0, import_node_path3.resolve)(projectRoot), srcDist);
965
+ assertRealPathUnderRoot((0, import_node_path4.resolve)(projectRoot), srcDist);
737
966
  }
738
967
  try {
739
968
  await (0, import_promises.access)(srcDist);
@@ -741,9 +970,9 @@ async function resolveSpaDirs(options) {
741
970
  throw new Error(`spaDistDir not found: ${srcDist}`);
742
971
  }
743
972
  try {
744
- await (0, import_promises.access)((0, import_node_path3.join)(srcDist, "index.html"));
973
+ await (0, import_promises.access)((0, import_node_path4.join)(srcDist, "index.html"));
745
974
  } catch {
746
- throw new Error(`spaDistDir must contain index.html: ${(0, import_node_path3.join)(srcDist, "index.html")}`);
975
+ throw new Error(`spaDistDir must contain index.html: ${(0, import_node_path4.join)(srcDist, "index.html")}`);
747
976
  }
748
977
  const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
749
978
  "main";
@@ -756,9 +985,9 @@ async function resolveSpaDirs(options) {
756
985
  if (!src) {
757
986
  throw new Error(`lessonSpaDirs missing build output for lesson "${lesson.id}"`);
758
987
  }
759
- const resolved = projectRoot ? (0, import_node_path3.resolve)(projectRoot, src) : (0, import_node_path3.resolve)(src);
988
+ const resolved = projectRoot ? (0, import_node_path4.resolve)(projectRoot, src) : (0, import_node_path4.resolve)(src);
760
989
  if (projectRoot) {
761
- assertRealPathUnderRoot((0, import_node_path3.resolve)(projectRoot), resolved);
990
+ assertRealPathUnderRoot((0, import_node_path4.resolve)(projectRoot), resolved);
762
991
  }
763
992
  try {
764
993
  await (0, import_promises.access)(resolved);
@@ -766,10 +995,10 @@ async function resolveSpaDirs(options) {
766
995
  throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
767
996
  }
768
997
  try {
769
- await (0, import_promises.access)((0, import_node_path3.join)(resolved, "index.html"));
998
+ await (0, import_promises.access)((0, import_node_path4.join)(resolved, "index.html"));
770
999
  } catch {
771
1000
  throw new Error(
772
- `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0, import_node_path3.join)(resolved, "index.html")}`
1001
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0, import_node_path4.join)(resolved, "index.html")}`
773
1002
  );
774
1003
  }
775
1004
  dirs[lesson.id] = resolved;
@@ -786,9 +1015,9 @@ async function writeLxpackProject(options) {
786
1015
  );
787
1016
  }
788
1017
  const descriptor = validation.descriptor;
789
- const outDir = (0, import_node_path4.resolve)(options.outDir);
1018
+ const outDir = (0, import_node_path5.resolve)(options.outDir);
790
1019
  if (options.projectRoot) {
791
- assertRealPathUnderRoot((0, import_node_path4.resolve)(options.projectRoot), outDir);
1020
+ assertRealPathUnderRoot((0, import_node_path5.resolve)(options.projectRoot), outDir);
792
1021
  }
793
1022
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
794
1023
  const interchange = descriptorToInterchange(descriptor);
@@ -806,60 +1035,87 @@ async function writeLxpackProject(options) {
806
1035
  const courseDir = materialized.courseDir;
807
1036
  return {
808
1037
  outDir: courseDir,
809
- courseYamlPath: (0, import_node_path4.join)(courseDir, "course.yaml"),
810
- lessonkitJsonPath: (0, import_node_path4.join)(courseDir, "lessonkit.json")
1038
+ courseYamlPath: (0, import_node_path5.join)(courseDir, "course.yaml"),
1039
+ lessonkitJsonPath: (0, import_node_path5.join)(courseDir, "lessonkit.json")
811
1040
  };
812
1041
  }
813
1042
 
814
1043
  // src/packageCourse.ts
815
- var import_node_path8 = require("path");
1044
+ var import_node_path9 = require("path");
816
1045
  var fsp3 = __toESM(require("fs/promises"), 1);
817
1046
  var import_api2 = require("@lxpack/api");
818
1047
 
819
1048
  // src/packaging/validateInputs.ts
820
- var import_node_path5 = require("path");
1049
+ var import_node_path6 = require("path");
821
1050
  function validatePackageInputs(options) {
822
1051
  const { target, output, outputBaseDir } = options;
823
- const outDir = (0, import_node_path5.resolve)(options.outDir);
824
- const projectRoot = options.projectRoot ? (0, import_node_path5.resolve)(options.projectRoot) : void 0;
825
- if (projectRoot) {
826
- try {
827
- assertRealPathUnderRoot(projectRoot, outDir);
828
- } catch (err) {
829
- return {
830
- ok: false,
831
- courseDir: outDir,
832
- target,
833
- issues: [
834
- {
835
- path: "outDir",
836
- message: (
837
- /* v8 ignore next */
838
- err instanceof Error ? err.message : String(err)
839
- )
840
- }
841
- ]
842
- };
843
- }
1052
+ const outDir = (0, import_node_path6.resolve)(options.outDir);
1053
+ if (!options.projectRoot) {
1054
+ return {
1055
+ ok: false,
1056
+ courseDir: outDir,
1057
+ target,
1058
+ issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
1059
+ };
844
1060
  }
845
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1061
+ const projectRoot = (0, import_node_path6.resolve)(options.projectRoot);
1062
+ try {
1063
+ assertRealPathUnderRoot(projectRoot, outDir);
1064
+ } catch (err) {
846
1065
  return {
847
1066
  ok: false,
848
1067
  courseDir: outDir,
849
1068
  target,
850
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
1069
+ issues: [
1070
+ {
1071
+ path: "outDir",
1072
+ message: (
1073
+ /* v8 ignore next */
1074
+ err instanceof Error ? err.message : String(err)
1075
+ )
1076
+ }
1077
+ ]
851
1078
  };
852
1079
  }
853
- if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
1080
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
854
1081
  return {
855
1082
  ok: false,
856
1083
  courseDir: outDir,
857
1084
  target,
858
- issues: [{ path: "output", message: `unsafe output: ${output}` }]
1085
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
859
1086
  };
860
1087
  }
861
- if (projectRoot && outputBaseDir) {
862
- const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
1088
+ if (output && !isSafeRelativeSpaPath(output)) {
1089
+ if ((0, import_node_path6.isAbsolute)(output)) {
1090
+ try {
1091
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path6.resolve)(output));
1092
+ } catch (err) {
1093
+ return {
1094
+ ok: false,
1095
+ courseDir: outDir,
1096
+ target,
1097
+ issues: [
1098
+ {
1099
+ path: "output",
1100
+ message: (
1101
+ /* v8 ignore next */
1102
+ err instanceof Error ? err.message : `unsafe output: ${output}`
1103
+ )
1104
+ }
1105
+ ]
1106
+ };
1107
+ }
1108
+ } else {
1109
+ return {
1110
+ ok: false,
1111
+ courseDir: outDir,
1112
+ target,
1113
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
1114
+ };
1115
+ }
1116
+ }
1117
+ if (outputBaseDir) {
1118
+ const resolvedOutputBase = (0, import_node_path6.resolve)(projectRoot, outputBaseDir);
863
1119
  try {
864
1120
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
865
1121
  } catch (err) {
@@ -879,8 +1135,8 @@ function validatePackageInputs(options) {
879
1135
  };
880
1136
  }
881
1137
  }
882
- if (projectRoot && output) {
883
- const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
1138
+ if (output) {
1139
+ const resolvedOutput = (0, import_node_path6.isAbsolute)(output) ? (0, import_node_path6.resolve)(output) : (0, import_node_path6.resolve)(projectRoot, output);
884
1140
  try {
885
1141
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
886
1142
  } catch (err) {
@@ -917,23 +1173,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
917
1173
  if (!artifactPath) return void 0;
918
1174
  const resolved = resolveComparablePath(artifactPath);
919
1175
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
920
- return artifactPath;
1176
+ throw new Error(`${artifactPath} is outside the staging directory`);
921
1177
  }
922
1178
  const rel = relativePathUnderRoot(stagingRoot, resolved);
923
- if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
924
- return artifactPath;
1179
+ if (rel.startsWith("..") || (0, import_node_path6.isAbsolute)(rel)) {
1180
+ throw new Error(`${artifactPath} is outside the staging directory`);
925
1181
  }
926
1182
  if (!rel) return outDir;
927
1183
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
928
- return import_node_path5.win32.join(outDir, rel.replace(/\//g, import_node_path5.win32.sep));
1184
+ return import_node_path6.win32.join(outDir, rel.replace(/\//g, import_node_path6.win32.sep));
929
1185
  }
930
- return (0, import_node_path5.join)(outDir, rel);
1186
+ return (0, import_node_path6.join)(outDir, rel);
931
1187
  }
932
1188
 
933
1189
  // src/packaging/promote.ts
934
1190
  var fsp = __toESM(require("fs/promises"), 1);
935
1191
  var import_node_crypto = require("crypto");
936
- var import_node_path6 = require("path");
1192
+ var import_node_path7 = require("path");
937
1193
  async function pathExists(path) {
938
1194
  try {
939
1195
  await fsp.access(path);
@@ -952,6 +1208,69 @@ async function renameOrCopy(from, to) {
952
1208
  await fsp.rm(from, { recursive: true, force: true });
953
1209
  }
954
1210
  }
1211
+ function promoteLockPath(outDir) {
1212
+ const parent = (0, import_node_path7.dirname)(outDir);
1213
+ const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path7.resolve)(outDir)).digest("hex").slice(0, 16);
1214
+ return (0, import_node_path7.join)(parent, `.lk-promote-lock-${hash}`);
1215
+ }
1216
+ var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1217
+ async function isStalePromoteLock(lockPath) {
1218
+ try {
1219
+ const content = await fsp.readFile(lockPath, "utf8");
1220
+ const pid = Number.parseInt(content.trim(), 10);
1221
+ if (Number.isFinite(pid) && pid > 0) {
1222
+ try {
1223
+ process.kill(pid, 0);
1224
+ return false;
1225
+ } catch {
1226
+ return true;
1227
+ }
1228
+ }
1229
+ const stat2 = await fsp.stat(lockPath);
1230
+ return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
1231
+ } catch {
1232
+ return true;
1233
+ }
1234
+ }
1235
+ async function withPromoteLock(outDir, fn) {
1236
+ const lockPath = promoteLockPath(outDir);
1237
+ await fsp.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
1238
+ let lockHandle;
1239
+ for (let attempt = 0; attempt < 200; attempt++) {
1240
+ try {
1241
+ lockHandle = await fsp.open(lockPath, "wx");
1242
+ await lockHandle.writeFile(`${process.pid}
1243
+ `, "utf8");
1244
+ break;
1245
+ } catch (err) {
1246
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
1247
+ if (code !== "EEXIST") throw err;
1248
+ if (await isStalePromoteLock(lockPath)) {
1249
+ await fsp.rm(lockPath, { force: true }).catch(
1250
+ /* v8 ignore next */
1251
+ () => void 0
1252
+ );
1253
+ continue;
1254
+ }
1255
+ await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1256
+ }
1257
+ }
1258
+ if (!lockHandle) {
1259
+ throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
1260
+ }
1261
+ try {
1262
+ return await fn();
1263
+ } finally {
1264
+ await lockHandle.close().catch(
1265
+ /* v8 ignore next */
1266
+ () => void 0
1267
+ );
1268
+ await fsp.rm(lockPath, { force: true }).catch(
1269
+ /* v8 ignore next */
1270
+ () => void 0
1271
+ );
1272
+ }
1273
+ }
955
1274
  async function assertNoLegacyPromoteArtifacts(outDir) {
956
1275
  const legacyTmp = `${outDir}.tmp-promote`;
957
1276
  const legacyBak = `${outDir}.bak`;
@@ -965,45 +1284,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
965
1284
  }
966
1285
  }
967
1286
  async function promoteStagingToOutDir(stagingDir, outDir) {
968
- await assertNoLegacyPromoteArtifacts(outDir);
969
- const parent = (0, import_node_path6.dirname)(outDir);
970
- const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
971
- await renameOrCopy(stagingDir, tmpPromote);
972
- const hadOutDir = await pathExists(outDir);
973
- const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
974
- if (hadOutDir && backup) {
975
- await renameOrCopy(outDir, backup);
976
- }
977
- try {
978
- await renameOrCopy(tmpPromote, outDir);
979
- } catch (promoteError) {
1287
+ return withPromoteLock(outDir, async () => {
1288
+ await assertNoLegacyPromoteArtifacts(outDir);
1289
+ const parent = (0, import_node_path7.dirname)(outDir);
1290
+ const tmpPromote = await fsp.mkdtemp((0, import_node_path7.join)(parent, ".lk-promote-"));
1291
+ await renameOrCopy(stagingDir, tmpPromote);
1292
+ const hadOutDir = await pathExists(outDir);
1293
+ const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path7.join)(parent, ".lk-backup-")) : void 0;
980
1294
  if (hadOutDir && backup) {
981
- try {
982
- await renameOrCopy(backup, outDir);
983
- } catch (restoreError) {
984
- const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1295
+ await renameOrCopy(outDir, backup);
1296
+ }
1297
+ try {
1298
+ await renameOrCopy(tmpPromote, outDir);
1299
+ } catch (promoteError) {
1300
+ if (hadOutDir && backup) {
985
1301
  try {
986
- await renameOrCopy(tmpPromote, failedPromote2);
987
- } catch {
1302
+ await renameOrCopy(backup, outDir);
1303
+ } catch (restoreError) {
1304
+ const failedPromote2 = (0, import_node_path7.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1305
+ try {
1306
+ await renameOrCopy(tmpPromote, failedPromote2);
1307
+ } catch {
1308
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1309
+ /* v8 ignore next */
1310
+ () => void 0
1311
+ );
1312
+ }
1313
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1314
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1315
+ throw new Error(
1316
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1317
+ );
1318
+ }
1319
+ } else {
1320
+ try {
1321
+ await renameOrCopy(tmpPromote, stagingDir);
1322
+ } catch (restoreError) {
1323
+ console.warn(
1324
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1325
+ restoreError instanceof Error ? restoreError.message : restoreError
1326
+ );
988
1327
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
989
1328
  /* v8 ignore next */
990
1329
  () => void 0
991
1330
  );
992
1331
  }
993
- const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
994
- const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
995
- throw new Error(
996
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
997
- );
1332
+ throw promoteError;
998
1333
  }
999
- } else {
1334
+ const failedPromote = (0, import_node_path7.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1000
1335
  try {
1001
- await renameOrCopy(tmpPromote, stagingDir);
1002
- } catch (restoreError) {
1003
- console.warn(
1004
- `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1005
- restoreError instanceof Error ? restoreError.message : restoreError
1006
- );
1336
+ await renameOrCopy(tmpPromote, failedPromote);
1337
+ } catch {
1007
1338
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1008
1339
  /* v8 ignore next */
1009
1340
  () => void 0
@@ -1011,33 +1342,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1011
1342
  }
1012
1343
  throw promoteError;
1013
1344
  }
1014
- const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1015
- try {
1016
- await renameOrCopy(tmpPromote, failedPromote);
1017
- } catch {
1018
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1345
+ if (backup) {
1346
+ await fsp.rm(backup, { recursive: true, force: true }).catch(
1019
1347
  /* v8 ignore next */
1020
1348
  () => void 0
1021
1349
  );
1022
1350
  }
1023
- throw promoteError;
1024
- }
1025
- if (backup) {
1026
- await fsp.rm(backup, { recursive: true, force: true }).catch(
1027
- /* v8 ignore next */
1028
- () => void 0
1029
- );
1030
- }
1351
+ });
1031
1352
  }
1032
1353
 
1033
1354
  // src/packaging/staging.ts
1034
1355
  var fsp2 = __toESM(require("fs/promises"), 1);
1035
- var import_node_path7 = require("path");
1356
+ var import_node_path8 = require("path");
1036
1357
  var import_node_os = require("os");
1037
1358
  var import_api = require("@lxpack/api");
1038
1359
  async function buildStagingPackage(options) {
1039
1360
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1040
- const stagingDir = await fsp2.mkdtemp((0, import_node_path7.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1361
+ const stagingDir = await fsp2.mkdtemp((0, import_node_path8.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1041
1362
  try {
1042
1363
  let spaDirs;
1043
1364
  try {
@@ -1056,8 +1377,8 @@ async function buildStagingPackage(options) {
1056
1377
  }
1057
1378
  const interchange = descriptorToInterchange(descriptor);
1058
1379
  const outputBase = outputBaseDir ?? ".lxpack/out";
1059
- await fsp2.mkdir((0, import_node_path7.join)(stagingDir, outputBase), { recursive: true });
1060
- const defaultOutput = output ?? (dir ? (0, import_node_path7.join)(outputBase, target) : (0, import_node_path7.join)(outputBase, `course-${target}.zip`));
1380
+ await fsp2.mkdir((0, import_node_path8.join)(stagingDir, outputBase), { recursive: true });
1381
+ const defaultOutput = output ?? (dir ? (0, import_node_path8.join)(outputBase, target) : (0, import_node_path8.join)(outputBase, `course-${target}.zip`));
1061
1382
  const build = await (0, import_api.packageLessonkit)({
1062
1383
  interchange,
1063
1384
  spaDirs,
@@ -1097,19 +1418,28 @@ async function buildStagingPackage(options) {
1097
1418
  }
1098
1419
  }
1099
1420
  async function ensureOutDirParent(outDir) {
1100
- await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
1421
+ await fsp2.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1422
+ }
1423
+
1424
+ // src/packaging/issueSeverity.ts
1425
+ function isPackagingErrorIssue(issue) {
1426
+ const severity = issue.severity?.toLowerCase();
1427
+ return severity === "error" || severity === "fatal";
1428
+ }
1429
+ function findPackagingErrorIssues(issues) {
1430
+ return (issues ?? []).filter(isPackagingErrorIssue);
1101
1431
  }
1102
1432
 
1103
1433
  // src/packageCourse.ts
1104
1434
  async function validateLessonkitProject(options) {
1105
1435
  return (0, import_api2.validateCourse)({
1106
- courseDir: (0, import_node_path8.resolve)(options.courseDir),
1436
+ courseDir: (0, import_node_path9.resolve)(options.courseDir),
1107
1437
  target: options.target
1108
1438
  });
1109
1439
  }
1110
1440
  async function buildLessonkitProject(options) {
1111
1441
  const buildOptions = {
1112
- courseDir: (0, import_node_path8.resolve)(options.courseDir),
1442
+ courseDir: (0, import_node_path9.resolve)(options.courseDir),
1113
1443
  target: options.target,
1114
1444
  output: options.output,
1115
1445
  dir: options.dir,
@@ -1140,7 +1470,7 @@ async function packageLessonkitCourse(options) {
1140
1470
  if (!descriptorValidation.ok) {
1141
1471
  return {
1142
1472
  ok: false,
1143
- courseDir: (0, import_node_path8.resolve)(writeOpts.outDir),
1473
+ courseDir: (0, import_node_path9.resolve)(writeOpts.outDir),
1144
1474
  target,
1145
1475
  issues: descriptorValidation.issues.map((i) => ({
1146
1476
  path: i.path,
@@ -1149,6 +1479,37 @@ async function packageLessonkitCourse(options) {
1149
1479
  };
1150
1480
  }
1151
1481
  const descriptor = descriptorValidation.descriptor;
1482
+ if (writeOpts.projectRoot) {
1483
+ const parityIssues = validateReactManifestParity({
1484
+ projectRoot: writeOpts.projectRoot,
1485
+ descriptor
1486
+ });
1487
+ const parityErrors = parityIssues.filter((i) => i.severity === "error");
1488
+ if (parityErrors.length > 0) {
1489
+ return {
1490
+ ok: false,
1491
+ courseDir: outDir,
1492
+ target,
1493
+ issues: parityErrors.map((i) => ({
1494
+ path: i.path,
1495
+ message: i.message,
1496
+ severity: i.severity
1497
+ }))
1498
+ };
1499
+ }
1500
+ }
1501
+ const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1502
+ if (nonInjectableAssessments.length > 0) {
1503
+ return {
1504
+ ok: false,
1505
+ courseDir: outDir,
1506
+ target,
1507
+ issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1508
+ path: `assessments[${index}]`,
1509
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1510
+ }))
1511
+ };
1512
+ }
1152
1513
  const staged = await buildStagingPackage({
1153
1514
  ...writeOpts,
1154
1515
  descriptor,
@@ -1173,6 +1534,25 @@ async function packageLessonkitCourse(options) {
1173
1534
  };
1174
1535
  }
1175
1536
  const { stagingDir, build } = staged;
1537
+ const buildErrorIssues = findPackagingErrorIssues(build.issues);
1538
+ if (buildErrorIssues.length > 0) {
1539
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1540
+ /* v8 ignore next */
1541
+ () => void 0
1542
+ );
1543
+ return {
1544
+ ok: false,
1545
+ courseDir: outDir,
1546
+ target,
1547
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1548
+ build,
1549
+ issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
1550
+ path: i.path ?? "build",
1551
+ message: i.message,
1552
+ severity: i.severity
1553
+ }))
1554
+ };
1555
+ }
1176
1556
  const stagingRoot = await fsp3.realpath(stagingDir);
1177
1557
  const artifactIssues = [
1178
1558
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
@@ -1203,6 +1583,10 @@ async function packageLessonkitCourse(options) {
1203
1583
  await ensureOutDirParent(outDir);
1204
1584
  await promoteStagingToOutDir(stagingDir, outDir);
1205
1585
  } catch (err) {
1586
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1587
+ /* v8 ignore next */
1588
+ () => void 0
1589
+ );
1206
1590
  return {
1207
1591
  ok: false,
1208
1592
  courseDir: outDir,
@@ -1418,5 +1802,6 @@ var import_validators2 = require("@lxpack/validators");
1418
1802
  validateLessonkitProject,
1419
1803
  validatePackageInputs,
1420
1804
  validateProjectPaths,
1805
+ validateReactManifestParity,
1421
1806
  writeLxpackProject
1422
1807
  });