@lessonkit/lxpack 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -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);
@@ -374,6 +375,10 @@ var validateMcqLike = (assessment, path, issues) => {
374
375
  } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
375
376
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
376
377
  }
378
+ const uniqueChoices = new Set(trimmedChoices);
379
+ if (trimmedChoices.length !== uniqueChoices.size) {
380
+ issues.push({ path: `${path}.choices`, message: "choices must be unique" });
381
+ }
377
382
  };
378
383
  function countStarDelimitedBlanks(template) {
379
384
  const matches = template.match(/\*[^*]+\*/g);
@@ -641,15 +646,8 @@ function assessmentDescriptorToLxpack(assessment) {
641
646
  if (kind === "fillInBlanks") {
642
647
  return null;
643
648
  }
644
- if (kind === "findHotspot" && assessment.kind === "findHotspot") {
645
- return mcqToLxpack({
646
- kind: "mcq",
647
- checkId: assessment.checkId,
648
- question: assessment.question,
649
- choices: [assessment.correctTargetId, "other"],
650
- answer: assessment.correctTargetId,
651
- passingScore: assessment.passingScore
652
- });
649
+ if (kind === "findHotspot") {
650
+ return null;
653
651
  }
654
652
  if (kind === "findMultipleHotspots") {
655
653
  return null;
@@ -663,6 +661,20 @@ function extractAssessments(descriptor) {
663
661
  return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
664
662
  }
665
663
 
664
+ // src/descriptor/validateInjectableAssessments.ts
665
+ function validateInjectableAssessments(descriptor) {
666
+ const issues = [];
667
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
668
+ if (assessmentDescriptorToLxpack(assessment) === null) {
669
+ issues.push({
670
+ path: `assessments[${index}]`,
671
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
672
+ });
673
+ }
674
+ });
675
+ return issues;
676
+ }
677
+
666
678
  // src/descriptor/validateForTarget.ts
667
679
  var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
668
680
  "scorm12",
@@ -677,20 +689,21 @@ function validateDescriptorForExportTarget(descriptor, target) {
677
689
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
678
690
  if (!activityIri) {
679
691
  issues.push({
680
- path: "course.tracking.xapi.activityIri",
692
+ path: "tracking.xapi.activityIri",
681
693
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
682
694
  });
695
+ } else if (!/^https:\/\/.+/i.test(activityIri)) {
696
+ issues.push({
697
+ path: "tracking.xapi.activityIri",
698
+ message: "tracking.xapi.activityIri must be an HTTPS URL for xapi and cmi5 export targets"
699
+ });
683
700
  }
684
701
  }
685
702
  if (LMS_SHELL_TARGETS.has(target)) {
686
- (descriptor.assessments ?? []).forEach((assessment, index) => {
687
- if (assessmentDescriptorToLxpack(assessment) === null) {
688
- issues.push({
689
- path: `assessments[${index}]`,
690
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
691
- });
692
- }
693
- });
703
+ issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
704
+ ...issue,
705
+ message: `${issue.message} for target "${target}"`
706
+ })));
694
707
  }
695
708
  return issues;
696
709
  }
@@ -718,8 +731,122 @@ function validateDescriptorForTarget(input, target) {
718
731
  return result;
719
732
  }
720
733
 
721
- // src/validateProjectPaths.ts
734
+ // src/validateReactParity.ts
735
+ var import_node_fs2 = require("fs");
722
736
  var import_node_path2 = require("path");
737
+ var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
738
+ function collectSourceUnderSrc(projectRoot) {
739
+ const srcDir = (0, import_node_path2.join)(projectRoot, "src");
740
+ if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
741
+ const results = [];
742
+ const walk = (dir) => {
743
+ for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
744
+ const abs = (0, import_node_path2.join)(dir, entry);
745
+ if ((0, import_node_fs2.statSync)(abs).isDirectory()) {
746
+ walk(abs);
747
+ } else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
748
+ results.push((0, import_node_path2.relative)(projectRoot, abs));
749
+ }
750
+ }
751
+ };
752
+ walk(srcDir);
753
+ return results;
754
+ }
755
+ function readAppSources(projectRoot, appSources) {
756
+ 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");
757
+ }
758
+ function stripComments(source) {
759
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
760
+ }
761
+ function idPropPatterns(prop, id) {
762
+ return [
763
+ `${prop}="${id}"`,
764
+ `${prop}='${id}'`,
765
+ `${prop}={'${id}'}`,
766
+ `${prop}={"${id}"}`,
767
+ `${prop}={\`${id}\`}`
768
+ ];
769
+ }
770
+ function extractStringConstants(source) {
771
+ const stripped = stripComments(source);
772
+ const map = /* @__PURE__ */ new Map();
773
+ const re = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
774
+ for (const match of stripped.matchAll(re)) {
775
+ map.set(match[1], match[3]);
776
+ }
777
+ return map;
778
+ }
779
+ function idUsedViaConstant(stripped, prop, id, constants) {
780
+ for (const [name, value] of constants) {
781
+ if (value !== id) continue;
782
+ const jsxPatterns = [
783
+ `${prop}={${name}}`,
784
+ `${prop}={ ${name} }`,
785
+ `${prop}={${name} }`,
786
+ `${prop}={ ${name}}`
787
+ ];
788
+ if (jsxPatterns.some((p) => stripped.includes(p))) return true;
789
+ const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
790
+ if (objPatterns.some((p) => stripped.includes(p))) return true;
791
+ }
792
+ return false;
793
+ }
794
+ function courseIdPresent(source, courseId) {
795
+ const stripped = stripComments(source);
796
+ if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
797
+ return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
798
+ }
799
+ function checkIdPresent(source, checkId) {
800
+ const stripped = stripComments(source);
801
+ if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
802
+ return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
803
+ }
804
+ var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
805
+ function parityHint(message) {
806
+ return `${message} See ${ID_SYNC_DOC}`;
807
+ }
808
+ function validateReactManifestParity(opts) {
809
+ const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
810
+ const source = readAppSources(opts.projectRoot, appSources);
811
+ const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
812
+ if (!source.trim()) {
813
+ return [
814
+ {
815
+ path: appSources.length > 0 ? appSources.join(", ") : "src/",
816
+ message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
817
+ severity: hasDescriptorIds ? "error" : "warning"
818
+ }
819
+ ];
820
+ }
821
+ const issues = [];
822
+ const courseId = opts.descriptor.courseId;
823
+ if (!courseIdPresent(source, courseId)) {
824
+ issues.push({
825
+ path: "course.courseId",
826
+ message: parityHint(
827
+ `React app source does not reference courseId="${courseId}" from lessonkit.json.`
828
+ ),
829
+ severity: "error"
830
+ });
831
+ }
832
+ for (const assessment of opts.descriptor.assessments ?? []) {
833
+ const checkId = assessment.checkId;
834
+ if (!checkId) continue;
835
+ if (!checkIdPresent(source, checkId)) {
836
+ issues.push({
837
+ path: `assessments.checkId:${checkId}`,
838
+ message: parityHint(
839
+ `React app source missing checkId="${checkId}" declared in lessonkit.json.`
840
+ ),
841
+ severity: "error"
842
+ });
843
+ }
844
+ }
845
+ return issues;
846
+ }
847
+
848
+ // src/validateProjectPaths.ts
849
+ var import_node_path3 = require("path");
723
850
  function validatePathField(value, fieldPath, projectRoot, issues) {
724
851
  if (!isSafeRelativeSpaPath(value)) {
725
852
  issues.push({
@@ -729,7 +856,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
729
856
  return;
730
857
  }
731
858
  try {
732
- assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
859
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
733
860
  } catch {
734
861
  issues.push({
735
862
  path: fieldPath,
@@ -739,7 +866,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
739
866
  }
740
867
  function validateProjectPaths(projectRoot, paths) {
741
868
  const issues = [];
742
- const root = (0, import_node_path2.resolve)(projectRoot);
869
+ const root = (0, import_node_path3.resolve)(projectRoot);
743
870
  if (paths.spaDistDir?.trim()) {
744
871
  validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
745
872
  }
@@ -752,20 +879,20 @@ function validateProjectPaths(projectRoot, paths) {
752
879
  return issues;
753
880
  }
754
881
  function resolveSafePackageOutputOverride(projectRoot, override) {
755
- const root = (0, import_node_path2.resolve)(projectRoot);
882
+ const root = (0, import_node_path3.resolve)(projectRoot);
756
883
  const trimmed = override.trim();
757
884
  if (!trimmed) {
758
885
  throw new Error("output override must be a non-empty path");
759
886
  }
760
- if ((0, import_node_path2.isAbsolute)(trimmed)) {
761
- const resolved2 = (0, import_node_path2.resolve)(trimmed);
887
+ if ((0, import_node_path3.isAbsolute)(trimmed)) {
888
+ const resolved2 = (0, import_node_path3.resolve)(trimmed);
762
889
  assertRealPathUnderRoot(root, resolved2);
763
890
  return resolved2;
764
891
  }
765
892
  if (!isSafeRelativeSpaPath(trimmed)) {
766
893
  throw new Error(`unsafe output path: ${override}`);
767
894
  }
768
- const resolved = (0, import_node_path2.resolve)(root, trimmed);
895
+ const resolved = (0, import_node_path3.resolve)(root, trimmed);
769
896
  assertRealPathUnderRoot(root, resolved);
770
897
  return resolved;
771
898
  }
@@ -841,21 +968,21 @@ function descriptorToInterchange(descriptor) {
841
968
  }
842
969
 
843
970
  // src/writeProject.ts
844
- var import_node_path4 = require("path");
971
+ var import_node_path6 = require("path");
845
972
  var import_validators = require("@lxpack/validators");
846
973
 
847
974
  // src/spaDirs.ts
848
975
  var import_promises = require("fs/promises");
849
- var import_node_path3 = require("path");
976
+ var import_node_path4 = require("path");
850
977
  async function resolveSpaDirs(options) {
851
978
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
852
979
  const spaLessons = resolveSpaLessons(descriptor);
853
980
  if (descriptor.layout === "single-spa") {
854
981
  const spaDistRelative = spaDistDir ?? descriptor.spaDistDir ?? /* v8 ignore next */
855
982
  "dist";
856
- const srcDist = projectRoot ? (0, import_node_path3.resolve)(projectRoot, spaDistRelative) : (0, import_node_path3.resolve)(spaDistRelative);
983
+ const srcDist = projectRoot ? (0, import_node_path4.resolve)(projectRoot, spaDistRelative) : (0, import_node_path4.resolve)(spaDistRelative);
857
984
  if (projectRoot) {
858
- assertRealPathUnderRoot((0, import_node_path3.resolve)(projectRoot), srcDist);
985
+ assertRealPathUnderRoot((0, import_node_path4.resolve)(projectRoot), srcDist);
859
986
  }
860
987
  try {
861
988
  await (0, import_promises.access)(srcDist);
@@ -863,9 +990,9 @@ async function resolveSpaDirs(options) {
863
990
  throw new Error(`spaDistDir not found: ${srcDist}`);
864
991
  }
865
992
  try {
866
- await (0, import_promises.access)((0, import_node_path3.join)(srcDist, "index.html"));
993
+ await (0, import_promises.access)((0, import_node_path4.join)(srcDist, "index.html"));
867
994
  } catch {
868
- throw new Error(`spaDistDir must contain index.html: ${(0, import_node_path3.join)(srcDist, "index.html")}`);
995
+ throw new Error(`spaDistDir must contain index.html: ${(0, import_node_path4.join)(srcDist, "index.html")}`);
869
996
  }
870
997
  const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
871
998
  "main";
@@ -878,9 +1005,9 @@ async function resolveSpaDirs(options) {
878
1005
  if (!src) {
879
1006
  throw new Error(`lessonSpaDirs missing build output for lesson "${lesson.id}"`);
880
1007
  }
881
- const resolved = projectRoot ? (0, import_node_path3.resolve)(projectRoot, src) : (0, import_node_path3.resolve)(src);
1008
+ const resolved = projectRoot ? (0, import_node_path4.resolve)(projectRoot, src) : (0, import_node_path4.resolve)(src);
882
1009
  if (projectRoot) {
883
- assertRealPathUnderRoot((0, import_node_path3.resolve)(projectRoot), resolved);
1010
+ assertRealPathUnderRoot((0, import_node_path4.resolve)(projectRoot), resolved);
884
1011
  }
885
1012
  try {
886
1013
  await (0, import_promises.access)(resolved);
@@ -888,10 +1015,10 @@ async function resolveSpaDirs(options) {
888
1015
  throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
889
1016
  }
890
1017
  try {
891
- await (0, import_promises.access)((0, import_node_path3.join)(resolved, "index.html"));
1018
+ await (0, import_promises.access)((0, import_node_path4.join)(resolved, "index.html"));
892
1019
  } catch {
893
1020
  throw new Error(
894
- `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0, import_node_path3.join)(resolved, "index.html")}`
1021
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0, import_node_path4.join)(resolved, "index.html")}`
895
1022
  );
896
1023
  }
897
1024
  dirs[lesson.id] = resolved;
@@ -899,6 +1026,59 @@ async function resolveSpaDirs(options) {
899
1026
  return dirs;
900
1027
  }
901
1028
 
1029
+ // src/spaDistValidation.ts
1030
+ var import_promises2 = require("fs/promises");
1031
+ var import_node_fs3 = require("fs");
1032
+ var import_node_path5 = require("path");
1033
+ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1034
+ for (const [label, dir] of Object.entries(spaDirs)) {
1035
+ const dirResolved = resolveComparablePath(dir);
1036
+ const dirStat = await (0, import_promises2.lstat)(dirResolved);
1037
+ if (dirStat.isSymbolicLink()) {
1038
+ throw new Error(`spa dist for "${label}" cannot be a symlink: ${dir}`);
1039
+ }
1040
+ let rootReal;
1041
+ try {
1042
+ rootReal = (0, import_node_fs3.realpathSync)(dirResolved);
1043
+ } catch {
1044
+ throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
1045
+ }
1046
+ if (projectRoot) {
1047
+ assertRealPathUnderRoot(projectRoot, dir);
1048
+ }
1049
+ assertResolvedPathUnderRoot(rootReal, rootReal);
1050
+ await walkDistDir(rootReal, rootReal, label);
1051
+ }
1052
+ }
1053
+ async function walkDistDir(rootReal, current, label) {
1054
+ let entries;
1055
+ try {
1056
+ entries = await (0, import_promises2.readdir)(current, { withFileTypes: true });
1057
+ } catch (err) {
1058
+ throw new Error(
1059
+ `spa dist for "${label}" is not readable: ${err instanceof Error ? err.message : String(err)}`,
1060
+ { cause: err }
1061
+ );
1062
+ }
1063
+ for (const entry of entries) {
1064
+ const entryPath = (0, import_node_path5.join)(current, entry.name);
1065
+ const stat2 = await (0, import_promises2.lstat)(entryPath);
1066
+ if (stat2.isSymbolicLink()) {
1067
+ throw new Error(`spa dist for "${label}" contains symlink: ${entryPath}`);
1068
+ }
1069
+ let entryReal;
1070
+ try {
1071
+ entryReal = (0, import_node_fs3.realpathSync)(entryPath);
1072
+ } catch {
1073
+ entryReal = entryPath;
1074
+ }
1075
+ assertResolvedPathUnderRoot(rootReal, entryReal);
1076
+ if (stat2.isDirectory()) {
1077
+ await walkDistDir(rootReal, entryPath, label);
1078
+ }
1079
+ }
1080
+ }
1081
+
902
1082
  // src/writeProject.ts
903
1083
  async function writeLxpackProject(options) {
904
1084
  const validation = validateDescriptor(options.descriptor);
@@ -908,11 +1088,16 @@ async function writeLxpackProject(options) {
908
1088
  );
909
1089
  }
910
1090
  const descriptor = validation.descriptor;
911
- const outDir = (0, import_node_path4.resolve)(options.outDir);
1091
+ const injectableIssues = validateInjectableAssessments(descriptor);
1092
+ if (injectableIssues.length > 0) {
1093
+ throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1094
+ }
1095
+ const outDir = (0, import_node_path6.resolve)(options.outDir);
912
1096
  if (options.projectRoot) {
913
- assertRealPathUnderRoot((0, import_node_path4.resolve)(options.projectRoot), outDir);
1097
+ assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
914
1098
  }
915
1099
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1100
+ await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
916
1101
  const interchange = descriptorToInterchange(descriptor);
917
1102
  const materialized = await (0, import_validators.materializeLessonkitProject)({
918
1103
  interchange,
@@ -928,21 +1113,21 @@ async function writeLxpackProject(options) {
928
1113
  const courseDir = materialized.courseDir;
929
1114
  return {
930
1115
  outDir: courseDir,
931
- courseYamlPath: (0, import_node_path4.join)(courseDir, "course.yaml"),
932
- lessonkitJsonPath: (0, import_node_path4.join)(courseDir, "lessonkit.json")
1116
+ courseYamlPath: (0, import_node_path6.join)(courseDir, "course.yaml"),
1117
+ lessonkitJsonPath: (0, import_node_path6.join)(courseDir, "lessonkit.json")
933
1118
  };
934
1119
  }
935
1120
 
936
1121
  // src/packageCourse.ts
937
- var import_node_path8 = require("path");
1122
+ var import_node_path10 = require("path");
938
1123
  var fsp3 = __toESM(require("fs/promises"), 1);
939
1124
  var import_api2 = require("@lxpack/api");
940
1125
 
941
1126
  // src/packaging/validateInputs.ts
942
- var import_node_path5 = require("path");
1127
+ var import_node_path7 = require("path");
943
1128
  function validatePackageInputs(options) {
944
1129
  const { target, output, outputBaseDir } = options;
945
- const outDir = (0, import_node_path5.resolve)(options.outDir);
1130
+ const outDir = (0, import_node_path7.resolve)(options.outDir);
946
1131
  if (!options.projectRoot) {
947
1132
  return {
948
1133
  ok: false,
@@ -951,7 +1136,7 @@ function validatePackageInputs(options) {
951
1136
  issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
952
1137
  };
953
1138
  }
954
- const projectRoot = (0, import_node_path5.resolve)(options.projectRoot);
1139
+ const projectRoot = (0, import_node_path7.resolve)(options.projectRoot);
955
1140
  try {
956
1141
  assertRealPathUnderRoot(projectRoot, outDir);
957
1142
  } catch (err) {
@@ -979,9 +1164,9 @@ function validatePackageInputs(options) {
979
1164
  };
980
1165
  }
981
1166
  if (output && !isSafeRelativeSpaPath(output)) {
982
- if ((0, import_node_path5.isAbsolute)(output)) {
1167
+ if ((0, import_node_path7.isAbsolute)(output)) {
983
1168
  try {
984
- assertRealPathUnderRoot(projectRoot, (0, import_node_path5.resolve)(output));
1169
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path7.resolve)(output));
985
1170
  } catch (err) {
986
1171
  return {
987
1172
  ok: false,
@@ -1008,7 +1193,7 @@ function validatePackageInputs(options) {
1008
1193
  }
1009
1194
  }
1010
1195
  if (outputBaseDir) {
1011
- const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
1196
+ const resolvedOutputBase = (0, import_node_path7.resolve)(projectRoot, outputBaseDir);
1012
1197
  try {
1013
1198
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
1014
1199
  } catch (err) {
@@ -1029,7 +1214,7 @@ function validatePackageInputs(options) {
1029
1214
  }
1030
1215
  }
1031
1216
  if (output) {
1032
- const resolvedOutput = (0, import_node_path5.isAbsolute)(output) ? (0, import_node_path5.resolve)(output) : (0, import_node_path5.resolve)(projectRoot, output);
1217
+ const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
1033
1218
  try {
1034
1219
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
1035
1220
  } catch (err) {
@@ -1069,20 +1254,20 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1069
1254
  throw new Error(`${artifactPath} is outside the staging directory`);
1070
1255
  }
1071
1256
  const rel = relativePathUnderRoot(stagingRoot, resolved);
1072
- if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
1257
+ if (rel.startsWith("..") || (0, import_node_path7.isAbsolute)(rel)) {
1073
1258
  throw new Error(`${artifactPath} is outside the staging directory`);
1074
1259
  }
1075
1260
  if (!rel) return outDir;
1076
1261
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
1077
- return import_node_path5.win32.join(outDir, rel.replace(/\//g, import_node_path5.win32.sep));
1262
+ return import_node_path7.win32.join(outDir, rel.replace(/\//g, import_node_path7.win32.sep));
1078
1263
  }
1079
- return (0, import_node_path5.join)(outDir, rel);
1264
+ return (0, import_node_path7.join)(outDir, rel);
1080
1265
  }
1081
1266
 
1082
1267
  // src/packaging/promote.ts
1083
1268
  var fsp = __toESM(require("fs/promises"), 1);
1084
1269
  var import_node_crypto = require("crypto");
1085
- var import_node_path6 = require("path");
1270
+ var import_node_path8 = require("path");
1086
1271
  async function pathExists(path) {
1087
1272
  try {
1088
1273
  await fsp.access(path);
@@ -1102,31 +1287,32 @@ async function renameOrCopy(from, to) {
1102
1287
  }
1103
1288
  }
1104
1289
  function promoteLockPath(outDir) {
1105
- const parent = (0, import_node_path6.dirname)(outDir);
1106
- const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path6.resolve)(outDir)).digest("hex").slice(0, 16);
1107
- return (0, import_node_path6.join)(parent, `.lk-promote-lock-${hash}`);
1290
+ const parent = (0, import_node_path8.dirname)(outDir);
1291
+ const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path8.resolve)(outDir)).digest("hex").slice(0, 16);
1292
+ return (0, import_node_path8.join)(parent, `.lk-promote-lock-${hash}`);
1108
1293
  }
1109
1294
  var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1110
1295
  async function isStalePromoteLock(lockPath) {
1111
1296
  try {
1112
- const stat2 = await fsp.stat(lockPath);
1113
- if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
1114
1297
  const content = await fsp.readFile(lockPath, "utf8");
1115
1298
  const pid = Number.parseInt(content.trim(), 10);
1116
- if (!Number.isFinite(pid) || pid <= 0) return true;
1117
- try {
1118
- process.kill(pid, 0);
1119
- return false;
1120
- } catch {
1121
- return true;
1299
+ if (Number.isFinite(pid) && pid > 0) {
1300
+ try {
1301
+ process.kill(pid, 0);
1302
+ return false;
1303
+ } catch {
1304
+ return true;
1305
+ }
1122
1306
  }
1307
+ const stat2 = await fsp.stat(lockPath);
1308
+ return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
1123
1309
  } catch {
1124
1310
  return true;
1125
1311
  }
1126
1312
  }
1127
1313
  async function withPromoteLock(outDir, fn) {
1128
1314
  const lockPath = promoteLockPath(outDir);
1129
- await fsp.mkdir((0, import_node_path6.dirname)(outDir), { recursive: true });
1315
+ await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1130
1316
  let lockHandle;
1131
1317
  for (let attempt = 0; attempt < 200; attempt++) {
1132
1318
  try {
@@ -1178,11 +1364,11 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
1178
1364
  async function promoteStagingToOutDir(stagingDir, outDir) {
1179
1365
  return withPromoteLock(outDir, async () => {
1180
1366
  await assertNoLegacyPromoteArtifacts(outDir);
1181
- const parent = (0, import_node_path6.dirname)(outDir);
1182
- const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
1367
+ const parent = (0, import_node_path8.dirname)(outDir);
1368
+ const tmpPromote = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-promote-"));
1183
1369
  await renameOrCopy(stagingDir, tmpPromote);
1184
1370
  const hadOutDir = await pathExists(outDir);
1185
- const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
1371
+ const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-backup-")) : void 0;
1186
1372
  if (hadOutDir && backup) {
1187
1373
  await renameOrCopy(outDir, backup);
1188
1374
  }
@@ -1193,7 +1379,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1193
1379
  try {
1194
1380
  await renameOrCopy(backup, outDir);
1195
1381
  } catch (restoreError) {
1196
- const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1382
+ const failedPromote2 = (0, import_node_path8.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1197
1383
  try {
1198
1384
  await renameOrCopy(tmpPromote, failedPromote2);
1199
1385
  } catch {
@@ -1205,7 +1391,8 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1205
1391
  const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1206
1392
  const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1207
1393
  throw new Error(
1208
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1394
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`,
1395
+ { cause: restoreError }
1209
1396
  );
1210
1397
  }
1211
1398
  } else {
@@ -1223,7 +1410,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1223
1410
  }
1224
1411
  throw promoteError;
1225
1412
  }
1226
- const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1413
+ const failedPromote = (0, import_node_path8.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1227
1414
  try {
1228
1415
  await renameOrCopy(tmpPromote, failedPromote);
1229
1416
  } catch {
@@ -1245,16 +1432,17 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1245
1432
 
1246
1433
  // src/packaging/staging.ts
1247
1434
  var fsp2 = __toESM(require("fs/promises"), 1);
1248
- var import_node_path7 = require("path");
1435
+ var import_node_path9 = require("path");
1249
1436
  var import_node_os = require("os");
1250
1437
  var import_api = require("@lxpack/api");
1251
1438
  async function buildStagingPackage(options) {
1252
1439
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1253
- const stagingDir = await fsp2.mkdtemp((0, import_node_path7.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1440
+ const stagingDir = await fsp2.mkdtemp((0, import_node_path9.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1254
1441
  try {
1255
1442
  let spaDirs;
1256
1443
  try {
1257
1444
  spaDirs = await resolveSpaDirs({ ...writeOpts, descriptor });
1445
+ await assertSpaDistContentsSafe(spaDirs, writeOpts.projectRoot);
1258
1446
  } catch (err) {
1259
1447
  return {
1260
1448
  ok: false,
@@ -1267,10 +1455,21 @@ async function buildStagingPackage(options) {
1267
1455
  ]
1268
1456
  };
1269
1457
  }
1458
+ const injectableIssues = validateInjectableAssessments(descriptor);
1459
+ if (injectableIssues.length > 0) {
1460
+ return {
1461
+ ok: false,
1462
+ stagingDir,
1463
+ issues: injectableIssues.map((i) => ({
1464
+ path: i.path,
1465
+ message: i.message
1466
+ }))
1467
+ };
1468
+ }
1270
1469
  const interchange = descriptorToInterchange(descriptor);
1271
1470
  const outputBase = outputBaseDir ?? ".lxpack/out";
1272
- await fsp2.mkdir((0, import_node_path7.join)(stagingDir, outputBase), { recursive: true });
1273
- const defaultOutput = output ?? (dir ? (0, import_node_path7.join)(outputBase, target) : (0, import_node_path7.join)(outputBase, `course-${target}.zip`));
1471
+ await fsp2.mkdir((0, import_node_path9.join)(stagingDir, outputBase), { recursive: true });
1472
+ const defaultOutput = output ?? (dir ? (0, import_node_path9.join)(outputBase, target) : (0, import_node_path9.join)(outputBase, `course-${target}.zip`));
1274
1473
  const build = await (0, import_api.packageLessonkit)({
1275
1474
  interchange,
1276
1475
  spaDirs,
@@ -1310,7 +1509,7 @@ async function buildStagingPackage(options) {
1310
1509
  }
1311
1510
  }
1312
1511
  async function ensureOutDirParent(outDir) {
1313
- await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
1512
+ await fsp2.mkdir((0, import_node_path9.dirname)(outDir), { recursive: true });
1314
1513
  }
1315
1514
 
1316
1515
  // src/packaging/issueSeverity.ts
@@ -1325,13 +1524,13 @@ function findPackagingErrorIssues(issues) {
1325
1524
  // src/packageCourse.ts
1326
1525
  async function validateLessonkitProject(options) {
1327
1526
  return (0, import_api2.validateCourse)({
1328
- courseDir: (0, import_node_path8.resolve)(options.courseDir),
1527
+ courseDir: (0, import_node_path10.resolve)(options.courseDir),
1329
1528
  target: options.target
1330
1529
  });
1331
1530
  }
1332
1531
  async function buildLessonkitProject(options) {
1333
1532
  const buildOptions = {
1334
- courseDir: (0, import_node_path8.resolve)(options.courseDir),
1533
+ courseDir: (0, import_node_path10.resolve)(options.courseDir),
1335
1534
  target: options.target,
1336
1535
  output: options.output,
1337
1536
  dir: options.dir,
@@ -1362,7 +1561,7 @@ async function packageLessonkitCourse(options) {
1362
1561
  if (!descriptorValidation.ok) {
1363
1562
  return {
1364
1563
  ok: false,
1365
- courseDir: (0, import_node_path8.resolve)(writeOpts.outDir),
1564
+ courseDir: (0, import_node_path10.resolve)(writeOpts.outDir),
1366
1565
  target,
1367
1566
  issues: descriptorValidation.issues.map((i) => ({
1368
1567
  path: i.path,
@@ -1371,17 +1570,24 @@ async function packageLessonkitCourse(options) {
1371
1570
  };
1372
1571
  }
1373
1572
  const descriptor = descriptorValidation.descriptor;
1374
- const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1375
- if (nonInjectableAssessments.length > 0) {
1376
- return {
1377
- ok: false,
1378
- courseDir: outDir,
1379
- target,
1380
- issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1381
- path: `assessments[${index}]`,
1382
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1383
- }))
1384
- };
1573
+ if (writeOpts.projectRoot) {
1574
+ const parityIssues = validateReactManifestParity({
1575
+ projectRoot: writeOpts.projectRoot,
1576
+ descriptor
1577
+ });
1578
+ const parityErrors = parityIssues.filter((i) => i.severity === "error");
1579
+ if (parityErrors.length > 0) {
1580
+ return {
1581
+ ok: false,
1582
+ courseDir: outDir,
1583
+ target,
1584
+ issues: parityErrors.map((i) => ({
1585
+ path: i.path,
1586
+ message: i.message,
1587
+ severity: i.severity
1588
+ }))
1589
+ };
1590
+ }
1385
1591
  }
1386
1592
  const staged = await buildStagingPackage({
1387
1593
  ...writeOpts,
@@ -1675,5 +1881,6 @@ var import_validators2 = require("@lxpack/validators");
1675
1881
  validateLessonkitProject,
1676
1882
  validatePackageInputs,
1677
1883
  validateProjectPaths,
1884
+ validateReactManifestParity,
1678
1885
  writeLxpackProject
1679
1886
  });