@lessonkit/lxpack 1.3.1 → 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/README.md CHANGED
@@ -4,14 +4,14 @@
4
4
  [![Documentation](https://readthedocs.org/projects/lessonkit/badge/?version=latest)](https://lessonkit.readthedocs.io/en/latest/reference/packaging.html)
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lessonkit)](https://github.com/eddiethedean/lessonkit/blob/main/LICENSE)
6
6
 
7
- Package Vite SPAs for LMS delivery — SCORM 1.2/2004, standalone, xAPI, and cmi5 via [`@lxpack/api`](https://www.npmjs.com/package/@lxpack/api).
7
+ Package Vite SPAs for LMS delivery — SCORM 1.2/2004, standalone, xAPI, and cmi5. `@lessonkit/lxpack` bundles [`@lxpack/*`](https://www.npmjs.com/org/lxpack) as direct dependencies (no separate `@lxpack/api` install).
8
8
 
9
9
  Requires Node.js **18+**.
10
10
 
11
11
  ## Install
12
12
 
13
13
  ```bash
14
- npm install @lessonkit/lxpack @lxpack/api
14
+ npm install @lessonkit/lxpack
15
15
  ```
16
16
 
17
17
  ## Usage
package/dist/bridge.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { TelemetryEvent, CheckId, LessonId } from '@lessonkit/core';
1
+ import { LmsBridgeMode, TelemetryEvent, CheckId, LessonId } from '@lessonkit/core';
2
2
  import { LxpackBridgeV1, LxpackBridgeSubmitAssessmentPayload } from '@lxpack/spa-bridge';
3
3
  export { DEFAULT_BRIDGE_PASSING_SCORE, LXPACK_BRIDGE_VERSIONS, LxpackBridgeSubmitAssessmentPayload, LxpackBridgeV1, createLxpackBridgeHost, getLxpackBridge, normalizePassingThreshold, normalizeScore, supportedBridgeVersions } from '@lxpack/spa-bridge';
4
4
  import { mapLessonkitTelemetryToBridgeAction } from '@lxpack/tracking-schema';
@@ -21,7 +21,8 @@ declare function normalizeAssessmentPassingScore(opts?: {
21
21
  passingScore?: number;
22
22
  maxScore?: number;
23
23
  }): number;
24
- type LxpackBridgeMode = "auto" | "off";
24
+ /** @deprecated Use `LmsBridgeMode` from `@lessonkit/core`. */
25
+ type LxpackBridgeMode = LmsBridgeMode;
25
26
  /** Apply a mapped bridge action to an LXPack bridge instance. */
26
27
  declare function dispatchBridgeAction(bridge: LxpackBridgeV1, action: ReturnType<typeof mapLessonkitTelemetryToBridgeAction>): void;
27
28
  declare function forwardTelemetryToBridge(event: TelemetryEvent, mode?: LxpackBridgeMode, parentWindow?: Window): void;
package/dist/bridge.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { TelemetryEvent, CheckId, LessonId } from '@lessonkit/core';
1
+ import { LmsBridgeMode, TelemetryEvent, CheckId, LessonId } from '@lessonkit/core';
2
2
  import { LxpackBridgeV1, LxpackBridgeSubmitAssessmentPayload } from '@lxpack/spa-bridge';
3
3
  export { DEFAULT_BRIDGE_PASSING_SCORE, LXPACK_BRIDGE_VERSIONS, LxpackBridgeSubmitAssessmentPayload, LxpackBridgeV1, createLxpackBridgeHost, getLxpackBridge, normalizePassingThreshold, normalizeScore, supportedBridgeVersions } from '@lxpack/spa-bridge';
4
4
  import { mapLessonkitTelemetryToBridgeAction } from '@lxpack/tracking-schema';
@@ -21,7 +21,8 @@ declare function normalizeAssessmentPassingScore(opts?: {
21
21
  passingScore?: number;
22
22
  maxScore?: number;
23
23
  }): number;
24
- type LxpackBridgeMode = "auto" | "off";
24
+ /** @deprecated Use `LmsBridgeMode` from `@lessonkit/core`. */
25
+ type LxpackBridgeMode = LmsBridgeMode;
25
26
  /** Apply a mapped bridge action to an LXPack bridge instance. */
26
27
  declare function dispatchBridgeAction(bridge: LxpackBridgeV1, action: ReturnType<typeof mapLessonkitTelemetryToBridgeAction>): void;
27
28
  declare function forwardTelemetryToBridge(event: TelemetryEvent, mode?: LxpackBridgeMode, parentWindow?: Window): void;
package/dist/index.cjs CHANGED
@@ -375,6 +375,10 @@ var validateMcqLike = (assessment, path, issues) => {
375
375
  } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
376
376
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
377
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
+ }
378
382
  };
379
383
  function countStarDelimitedBlanks(template) {
380
384
  const matches = template.match(/\*[^*]+\*/g);
@@ -642,15 +646,8 @@ function assessmentDescriptorToLxpack(assessment) {
642
646
  if (kind === "fillInBlanks") {
643
647
  return null;
644
648
  }
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
- });
649
+ if (kind === "findHotspot") {
650
+ return null;
654
651
  }
655
652
  if (kind === "findMultipleHotspots") {
656
653
  return null;
@@ -664,6 +661,20 @@ function extractAssessments(descriptor) {
664
661
  return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
665
662
  }
666
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
+
667
678
  // src/descriptor/validateForTarget.ts
668
679
  var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
669
680
  "scorm12",
@@ -678,20 +689,21 @@ function validateDescriptorForExportTarget(descriptor, target) {
678
689
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
679
690
  if (!activityIri) {
680
691
  issues.push({
681
- path: "course.tracking.xapi.activityIri",
692
+ path: "tracking.xapi.activityIri",
682
693
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
683
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
+ });
684
700
  }
685
701
  }
686
702
  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
- });
693
- }
694
- });
703
+ issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
704
+ ...issue,
705
+ message: `${issue.message} for target "${target}"`
706
+ })));
695
707
  }
696
708
  return issues;
697
709
  }
@@ -789,6 +801,10 @@ function checkIdPresent(source, checkId) {
789
801
  if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
790
802
  return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
791
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
+ }
792
808
  function validateReactManifestParity(opts) {
793
809
  const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
794
810
  const source = readAppSources(opts.projectRoot, appSources);
@@ -807,7 +823,9 @@ function validateReactManifestParity(opts) {
807
823
  if (!courseIdPresent(source, courseId)) {
808
824
  issues.push({
809
825
  path: "course.courseId",
810
- message: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
826
+ message: parityHint(
827
+ `React app source does not reference courseId="${courseId}" from lessonkit.json.`
828
+ ),
811
829
  severity: "error"
812
830
  });
813
831
  }
@@ -817,7 +835,9 @@ function validateReactManifestParity(opts) {
817
835
  if (!checkIdPresent(source, checkId)) {
818
836
  issues.push({
819
837
  path: `assessments.checkId:${checkId}`,
820
- message: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
838
+ message: parityHint(
839
+ `React app source missing checkId="${checkId}" declared in lessonkit.json.`
840
+ ),
821
841
  severity: "error"
822
842
  });
823
843
  }
@@ -948,7 +968,7 @@ function descriptorToInterchange(descriptor) {
948
968
  }
949
969
 
950
970
  // src/writeProject.ts
951
- var import_node_path5 = require("path");
971
+ var import_node_path6 = require("path");
952
972
  var import_validators = require("@lxpack/validators");
953
973
 
954
974
  // src/spaDirs.ts
@@ -1006,6 +1026,59 @@ async function resolveSpaDirs(options) {
1006
1026
  return dirs;
1007
1027
  }
1008
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
+
1009
1082
  // src/writeProject.ts
1010
1083
  async function writeLxpackProject(options) {
1011
1084
  const validation = validateDescriptor(options.descriptor);
@@ -1015,11 +1088,16 @@ async function writeLxpackProject(options) {
1015
1088
  );
1016
1089
  }
1017
1090
  const descriptor = validation.descriptor;
1018
- const outDir = (0, import_node_path5.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);
1019
1096
  if (options.projectRoot) {
1020
- assertRealPathUnderRoot((0, import_node_path5.resolve)(options.projectRoot), outDir);
1097
+ assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
1021
1098
  }
1022
1099
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1100
+ await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
1023
1101
  const interchange = descriptorToInterchange(descriptor);
1024
1102
  const materialized = await (0, import_validators.materializeLessonkitProject)({
1025
1103
  interchange,
@@ -1035,21 +1113,21 @@ async function writeLxpackProject(options) {
1035
1113
  const courseDir = materialized.courseDir;
1036
1114
  return {
1037
1115
  outDir: courseDir,
1038
- courseYamlPath: (0, import_node_path5.join)(courseDir, "course.yaml"),
1039
- lessonkitJsonPath: (0, import_node_path5.join)(courseDir, "lessonkit.json")
1116
+ courseYamlPath: (0, import_node_path6.join)(courseDir, "course.yaml"),
1117
+ lessonkitJsonPath: (0, import_node_path6.join)(courseDir, "lessonkit.json")
1040
1118
  };
1041
1119
  }
1042
1120
 
1043
1121
  // src/packageCourse.ts
1044
- var import_node_path9 = require("path");
1122
+ var import_node_path10 = require("path");
1045
1123
  var fsp3 = __toESM(require("fs/promises"), 1);
1046
1124
  var import_api2 = require("@lxpack/api");
1047
1125
 
1048
1126
  // src/packaging/validateInputs.ts
1049
- var import_node_path6 = require("path");
1127
+ var import_node_path7 = require("path");
1050
1128
  function validatePackageInputs(options) {
1051
1129
  const { target, output, outputBaseDir } = options;
1052
- const outDir = (0, import_node_path6.resolve)(options.outDir);
1130
+ const outDir = (0, import_node_path7.resolve)(options.outDir);
1053
1131
  if (!options.projectRoot) {
1054
1132
  return {
1055
1133
  ok: false,
@@ -1058,7 +1136,7 @@ function validatePackageInputs(options) {
1058
1136
  issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
1059
1137
  };
1060
1138
  }
1061
- const projectRoot = (0, import_node_path6.resolve)(options.projectRoot);
1139
+ const projectRoot = (0, import_node_path7.resolve)(options.projectRoot);
1062
1140
  try {
1063
1141
  assertRealPathUnderRoot(projectRoot, outDir);
1064
1142
  } catch (err) {
@@ -1086,9 +1164,9 @@ function validatePackageInputs(options) {
1086
1164
  };
1087
1165
  }
1088
1166
  if (output && !isSafeRelativeSpaPath(output)) {
1089
- if ((0, import_node_path6.isAbsolute)(output)) {
1167
+ if ((0, import_node_path7.isAbsolute)(output)) {
1090
1168
  try {
1091
- assertRealPathUnderRoot(projectRoot, (0, import_node_path6.resolve)(output));
1169
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path7.resolve)(output));
1092
1170
  } catch (err) {
1093
1171
  return {
1094
1172
  ok: false,
@@ -1115,7 +1193,7 @@ function validatePackageInputs(options) {
1115
1193
  }
1116
1194
  }
1117
1195
  if (outputBaseDir) {
1118
- const resolvedOutputBase = (0, import_node_path6.resolve)(projectRoot, outputBaseDir);
1196
+ const resolvedOutputBase = (0, import_node_path7.resolve)(projectRoot, outputBaseDir);
1119
1197
  try {
1120
1198
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
1121
1199
  } catch (err) {
@@ -1136,7 +1214,7 @@ function validatePackageInputs(options) {
1136
1214
  }
1137
1215
  }
1138
1216
  if (output) {
1139
- const resolvedOutput = (0, import_node_path6.isAbsolute)(output) ? (0, import_node_path6.resolve)(output) : (0, import_node_path6.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);
1140
1218
  try {
1141
1219
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
1142
1220
  } catch (err) {
@@ -1176,20 +1254,20 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1176
1254
  throw new Error(`${artifactPath} is outside the staging directory`);
1177
1255
  }
1178
1256
  const rel = relativePathUnderRoot(stagingRoot, resolved);
1179
- if (rel.startsWith("..") || (0, import_node_path6.isAbsolute)(rel)) {
1257
+ if (rel.startsWith("..") || (0, import_node_path7.isAbsolute)(rel)) {
1180
1258
  throw new Error(`${artifactPath} is outside the staging directory`);
1181
1259
  }
1182
1260
  if (!rel) return outDir;
1183
1261
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
1184
- return import_node_path6.win32.join(outDir, rel.replace(/\//g, import_node_path6.win32.sep));
1262
+ return import_node_path7.win32.join(outDir, rel.replace(/\//g, import_node_path7.win32.sep));
1185
1263
  }
1186
- return (0, import_node_path6.join)(outDir, rel);
1264
+ return (0, import_node_path7.join)(outDir, rel);
1187
1265
  }
1188
1266
 
1189
1267
  // src/packaging/promote.ts
1190
1268
  var fsp = __toESM(require("fs/promises"), 1);
1191
1269
  var import_node_crypto = require("crypto");
1192
- var import_node_path7 = require("path");
1270
+ var import_node_path8 = require("path");
1193
1271
  async function pathExists(path) {
1194
1272
  try {
1195
1273
  await fsp.access(path);
@@ -1209,9 +1287,9 @@ async function renameOrCopy(from, to) {
1209
1287
  }
1210
1288
  }
1211
1289
  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}`);
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}`);
1215
1293
  }
1216
1294
  var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1217
1295
  async function isStalePromoteLock(lockPath) {
@@ -1234,7 +1312,7 @@ async function isStalePromoteLock(lockPath) {
1234
1312
  }
1235
1313
  async function withPromoteLock(outDir, fn) {
1236
1314
  const lockPath = promoteLockPath(outDir);
1237
- await fsp.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
1315
+ await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1238
1316
  let lockHandle;
1239
1317
  for (let attempt = 0; attempt < 200; attempt++) {
1240
1318
  try {
@@ -1286,11 +1364,11 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
1286
1364
  async function promoteStagingToOutDir(stagingDir, outDir) {
1287
1365
  return withPromoteLock(outDir, async () => {
1288
1366
  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-"));
1367
+ const parent = (0, import_node_path8.dirname)(outDir);
1368
+ const tmpPromote = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-promote-"));
1291
1369
  await renameOrCopy(stagingDir, tmpPromote);
1292
1370
  const hadOutDir = await pathExists(outDir);
1293
- const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path7.join)(parent, ".lk-backup-")) : void 0;
1371
+ const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-backup-")) : void 0;
1294
1372
  if (hadOutDir && backup) {
1295
1373
  await renameOrCopy(outDir, backup);
1296
1374
  }
@@ -1301,7 +1379,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1301
1379
  try {
1302
1380
  await renameOrCopy(backup, outDir);
1303
1381
  } catch (restoreError) {
1304
- const failedPromote2 = (0, import_node_path7.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)()}`);
1305
1383
  try {
1306
1384
  await renameOrCopy(tmpPromote, failedPromote2);
1307
1385
  } catch {
@@ -1313,7 +1391,8 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1313
1391
  const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1314
1392
  const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1315
1393
  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}.`
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 }
1317
1396
  );
1318
1397
  }
1319
1398
  } else {
@@ -1331,7 +1410,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1331
1410
  }
1332
1411
  throw promoteError;
1333
1412
  }
1334
- const failedPromote = (0, import_node_path7.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)()}`);
1335
1414
  try {
1336
1415
  await renameOrCopy(tmpPromote, failedPromote);
1337
1416
  } catch {
@@ -1353,16 +1432,17 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1353
1432
 
1354
1433
  // src/packaging/staging.ts
1355
1434
  var fsp2 = __toESM(require("fs/promises"), 1);
1356
- var import_node_path8 = require("path");
1435
+ var import_node_path9 = require("path");
1357
1436
  var import_node_os = require("os");
1358
1437
  var import_api = require("@lxpack/api");
1359
1438
  async function buildStagingPackage(options) {
1360
1439
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1361
- const stagingDir = await fsp2.mkdtemp((0, import_node_path8.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-"));
1362
1441
  try {
1363
1442
  let spaDirs;
1364
1443
  try {
1365
1444
  spaDirs = await resolveSpaDirs({ ...writeOpts, descriptor });
1445
+ await assertSpaDistContentsSafe(spaDirs, writeOpts.projectRoot);
1366
1446
  } catch (err) {
1367
1447
  return {
1368
1448
  ok: false,
@@ -1375,10 +1455,21 @@ async function buildStagingPackage(options) {
1375
1455
  ]
1376
1456
  };
1377
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
+ }
1378
1469
  const interchange = descriptorToInterchange(descriptor);
1379
1470
  const outputBase = outputBaseDir ?? ".lxpack/out";
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`));
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`));
1382
1473
  const build = await (0, import_api.packageLessonkit)({
1383
1474
  interchange,
1384
1475
  spaDirs,
@@ -1418,7 +1509,7 @@ async function buildStagingPackage(options) {
1418
1509
  }
1419
1510
  }
1420
1511
  async function ensureOutDirParent(outDir) {
1421
- await fsp2.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1512
+ await fsp2.mkdir((0, import_node_path9.dirname)(outDir), { recursive: true });
1422
1513
  }
1423
1514
 
1424
1515
  // src/packaging/issueSeverity.ts
@@ -1433,13 +1524,13 @@ function findPackagingErrorIssues(issues) {
1433
1524
  // src/packageCourse.ts
1434
1525
  async function validateLessonkitProject(options) {
1435
1526
  return (0, import_api2.validateCourse)({
1436
- courseDir: (0, import_node_path9.resolve)(options.courseDir),
1527
+ courseDir: (0, import_node_path10.resolve)(options.courseDir),
1437
1528
  target: options.target
1438
1529
  });
1439
1530
  }
1440
1531
  async function buildLessonkitProject(options) {
1441
1532
  const buildOptions = {
1442
- courseDir: (0, import_node_path9.resolve)(options.courseDir),
1533
+ courseDir: (0, import_node_path10.resolve)(options.courseDir),
1443
1534
  target: options.target,
1444
1535
  output: options.output,
1445
1536
  dir: options.dir,
@@ -1470,7 +1561,7 @@ async function packageLessonkitCourse(options) {
1470
1561
  if (!descriptorValidation.ok) {
1471
1562
  return {
1472
1563
  ok: false,
1473
- courseDir: (0, import_node_path9.resolve)(writeOpts.outDir),
1564
+ courseDir: (0, import_node_path10.resolve)(writeOpts.outDir),
1474
1565
  target,
1475
1566
  issues: descriptorValidation.issues.map((i) => ({
1476
1567
  path: i.path,
@@ -1498,18 +1589,6 @@ async function packageLessonkitCourse(options) {
1498
1589
  };
1499
1590
  }
1500
1591
  }
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
- }
1513
1592
  const staged = await buildStagingPackage({
1514
1593
  ...writeOpts,
1515
1594
  descriptor,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { CheckId, CourseId, LessonId } from '@lessonkit/core';
1
+ import { McqAssessmentProps, CheckId, CourseId, LessonId } from '@lessonkit/core';
2
+ export { LmsBridgeMode, McqAssessmentProps } from '@lessonkit/core';
2
3
  import { ThemePresetName, LessonkitThemeV1 } from '@lessonkit/themes';
3
4
  import { ExportTarget, BuildCourseResult, ValidateCourseResult } from '@lxpack/api';
4
5
  export { ExportTarget } from '@lxpack/api';
@@ -14,14 +15,8 @@ type LessonDescriptor = {
14
15
  /** Built SPA folder relative to the LXPack project root (`per-lesson-spa` only). */
15
16
  spaPath?: string;
16
17
  };
17
- type McqAssessmentDescriptor = {
18
- kind?: "mcq";
19
- checkId: CheckId;
20
- question: string;
21
- choices: string[];
22
- answer: string;
23
- passingScore?: number;
24
- };
18
+ /** @deprecated Use `McqAssessmentProps` from `@lessonkit/core`. */
19
+ type McqAssessmentDescriptor = McqAssessmentProps;
25
20
  type TrueFalseAssessmentDescriptor = {
26
21
  kind: "trueFalse";
27
22
  checkId: CheckId;
@@ -235,6 +230,8 @@ type BuildStagingPackageResult = {
235
230
  declare function buildStagingPackage(options: BuildStagingPackageOptions): Promise<BuildStagingPackageResult>;
236
231
  declare function ensureOutDirParent(outDir: string): Promise<void>;
237
232
 
233
+ /** LessonKit-owned alias for LMS export targets (maps to `@lxpack/api` `ExportTarget`). */
234
+ type LessonkitExportTarget = ExportTarget;
238
235
  type ValidateLessonkitProjectOptions = {
239
236
  courseDir: string;
240
237
  target?: ExportTarget;
@@ -325,4 +322,4 @@ type ParseManifestResult = {
325
322
  declare function parseLessonkitManifest(raw: unknown, label?: string, projectRoot?: string): ParseManifestResult;
326
323
  declare function loadLessonkitManifestFromFile(readJson: () => Promise<unknown>, label?: string, projectRoot?: string): Promise<ParseManifestResult>;
327
324
 
328
- export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type ReactParityIssue, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, validateReactManifestParity, writeLxpackProject };
325
+ export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitExportTarget, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type ReactParityIssue, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, validateReactManifestParity, writeLxpackProject };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { CheckId, CourseId, LessonId } from '@lessonkit/core';
1
+ import { McqAssessmentProps, CheckId, CourseId, LessonId } from '@lessonkit/core';
2
+ export { LmsBridgeMode, McqAssessmentProps } from '@lessonkit/core';
2
3
  import { ThemePresetName, LessonkitThemeV1 } from '@lessonkit/themes';
3
4
  import { ExportTarget, BuildCourseResult, ValidateCourseResult } from '@lxpack/api';
4
5
  export { ExportTarget } from '@lxpack/api';
@@ -14,14 +15,8 @@ type LessonDescriptor = {
14
15
  /** Built SPA folder relative to the LXPack project root (`per-lesson-spa` only). */
15
16
  spaPath?: string;
16
17
  };
17
- type McqAssessmentDescriptor = {
18
- kind?: "mcq";
19
- checkId: CheckId;
20
- question: string;
21
- choices: string[];
22
- answer: string;
23
- passingScore?: number;
24
- };
18
+ /** @deprecated Use `McqAssessmentProps` from `@lessonkit/core`. */
19
+ type McqAssessmentDescriptor = McqAssessmentProps;
25
20
  type TrueFalseAssessmentDescriptor = {
26
21
  kind: "trueFalse";
27
22
  checkId: CheckId;
@@ -235,6 +230,8 @@ type BuildStagingPackageResult = {
235
230
  declare function buildStagingPackage(options: BuildStagingPackageOptions): Promise<BuildStagingPackageResult>;
236
231
  declare function ensureOutDirParent(outDir: string): Promise<void>;
237
232
 
233
+ /** LessonKit-owned alias for LMS export targets (maps to `@lxpack/api` `ExportTarget`). */
234
+ type LessonkitExportTarget = ExportTarget;
238
235
  type ValidateLessonkitProjectOptions = {
239
236
  courseDir: string;
240
237
  target?: ExportTarget;
@@ -325,4 +322,4 @@ type ParseManifestResult = {
325
322
  declare function parseLessonkitManifest(raw: unknown, label?: string, projectRoot?: string): ParseManifestResult;
326
323
  declare function loadLessonkitManifestFromFile(readJson: () => Promise<unknown>, label?: string, projectRoot?: string): Promise<ParseManifestResult>;
327
324
 
328
- export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type ReactParityIssue, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, validateReactManifestParity, writeLxpackProject };
325
+ export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitExportTarget, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type ReactParityIssue, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, validateReactManifestParity, writeLxpackProject };
package/dist/index.js CHANGED
@@ -315,6 +315,10 @@ var validateMcqLike = (assessment, path, issues) => {
315
315
  } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
316
316
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
317
317
  }
318
+ const uniqueChoices = new Set(trimmedChoices);
319
+ if (trimmedChoices.length !== uniqueChoices.size) {
320
+ issues.push({ path: `${path}.choices`, message: "choices must be unique" });
321
+ }
318
322
  };
319
323
  function countStarDelimitedBlanks(template) {
320
324
  const matches = template.match(/\*[^*]+\*/g);
@@ -582,15 +586,8 @@ function assessmentDescriptorToLxpack(assessment) {
582
586
  if (kind === "fillInBlanks") {
583
587
  return null;
584
588
  }
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
- });
589
+ if (kind === "findHotspot") {
590
+ return null;
594
591
  }
595
592
  if (kind === "findMultipleHotspots") {
596
593
  return null;
@@ -604,6 +601,20 @@ function extractAssessments(descriptor) {
604
601
  return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
605
602
  }
606
603
 
604
+ // src/descriptor/validateInjectableAssessments.ts
605
+ function validateInjectableAssessments(descriptor) {
606
+ const issues = [];
607
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
608
+ if (assessmentDescriptorToLxpack(assessment) === null) {
609
+ issues.push({
610
+ path: `assessments[${index}]`,
611
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
612
+ });
613
+ }
614
+ });
615
+ return issues;
616
+ }
617
+
607
618
  // src/descriptor/validateForTarget.ts
608
619
  var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
609
620
  "scorm12",
@@ -618,20 +629,21 @@ function validateDescriptorForExportTarget(descriptor, target) {
618
629
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
619
630
  if (!activityIri) {
620
631
  issues.push({
621
- path: "course.tracking.xapi.activityIri",
632
+ path: "tracking.xapi.activityIri",
622
633
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
623
634
  });
635
+ } else if (!/^https:\/\/.+/i.test(activityIri)) {
636
+ issues.push({
637
+ path: "tracking.xapi.activityIri",
638
+ message: "tracking.xapi.activityIri must be an HTTPS URL for xapi and cmi5 export targets"
639
+ });
624
640
  }
625
641
  }
626
642
  if (LMS_SHELL_TARGETS.has(target)) {
627
- (descriptor.assessments ?? []).forEach((assessment, index) => {
628
- if (assessmentDescriptorToLxpack(assessment) === null) {
629
- issues.push({
630
- path: `assessments[${index}]`,
631
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
632
- });
633
- }
634
- });
643
+ issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
644
+ ...issue,
645
+ message: `${issue.message} for target "${target}"`
646
+ })));
635
647
  }
636
648
  return issues;
637
649
  }
@@ -729,6 +741,10 @@ function checkIdPresent(source, checkId) {
729
741
  if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
730
742
  return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
731
743
  }
744
+ var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
745
+ function parityHint(message) {
746
+ return `${message} See ${ID_SYNC_DOC}`;
747
+ }
732
748
  function validateReactManifestParity(opts) {
733
749
  const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
734
750
  const source = readAppSources(opts.projectRoot, appSources);
@@ -747,7 +763,9 @@ function validateReactManifestParity(opts) {
747
763
  if (!courseIdPresent(source, courseId)) {
748
764
  issues.push({
749
765
  path: "course.courseId",
750
- message: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
766
+ message: parityHint(
767
+ `React app source does not reference courseId="${courseId}" from lessonkit.json.`
768
+ ),
751
769
  severity: "error"
752
770
  });
753
771
  }
@@ -757,7 +775,9 @@ function validateReactManifestParity(opts) {
757
775
  if (!checkIdPresent(source, checkId)) {
758
776
  issues.push({
759
777
  path: `assessments.checkId:${checkId}`,
760
- message: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
778
+ message: parityHint(
779
+ `React app source missing checkId="${checkId}" declared in lessonkit.json.`
780
+ ),
761
781
  severity: "error"
762
782
  });
763
783
  }
@@ -888,7 +908,7 @@ function descriptorToInterchange(descriptor) {
888
908
  }
889
909
 
890
910
  // src/writeProject.ts
891
- import { join as join4, resolve as resolve4 } from "path";
911
+ import { join as join5, resolve as resolve4 } from "path";
892
912
  import { materializeLessonkitProject } from "@lxpack/validators";
893
913
 
894
914
  // src/spaDirs.ts
@@ -946,6 +966,59 @@ async function resolveSpaDirs(options) {
946
966
  return dirs;
947
967
  }
948
968
 
969
+ // src/spaDistValidation.ts
970
+ import { lstat, readdir } from "fs/promises";
971
+ import { realpathSync as realpathSync2 } from "fs";
972
+ import { join as join4 } from "path";
973
+ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
974
+ for (const [label, dir] of Object.entries(spaDirs)) {
975
+ const dirResolved = resolveComparablePath(dir);
976
+ const dirStat = await lstat(dirResolved);
977
+ if (dirStat.isSymbolicLink()) {
978
+ throw new Error(`spa dist for "${label}" cannot be a symlink: ${dir}`);
979
+ }
980
+ let rootReal;
981
+ try {
982
+ rootReal = realpathSync2(dirResolved);
983
+ } catch {
984
+ throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
985
+ }
986
+ if (projectRoot) {
987
+ assertRealPathUnderRoot(projectRoot, dir);
988
+ }
989
+ assertResolvedPathUnderRoot(rootReal, rootReal);
990
+ await walkDistDir(rootReal, rootReal, label);
991
+ }
992
+ }
993
+ async function walkDistDir(rootReal, current, label) {
994
+ let entries;
995
+ try {
996
+ entries = await readdir(current, { withFileTypes: true });
997
+ } catch (err) {
998
+ throw new Error(
999
+ `spa dist for "${label}" is not readable: ${err instanceof Error ? err.message : String(err)}`,
1000
+ { cause: err }
1001
+ );
1002
+ }
1003
+ for (const entry of entries) {
1004
+ const entryPath = join4(current, entry.name);
1005
+ const stat2 = await lstat(entryPath);
1006
+ if (stat2.isSymbolicLink()) {
1007
+ throw new Error(`spa dist for "${label}" contains symlink: ${entryPath}`);
1008
+ }
1009
+ let entryReal;
1010
+ try {
1011
+ entryReal = realpathSync2(entryPath);
1012
+ } catch {
1013
+ entryReal = entryPath;
1014
+ }
1015
+ assertResolvedPathUnderRoot(rootReal, entryReal);
1016
+ if (stat2.isDirectory()) {
1017
+ await walkDistDir(rootReal, entryPath, label);
1018
+ }
1019
+ }
1020
+ }
1021
+
949
1022
  // src/writeProject.ts
950
1023
  async function writeLxpackProject(options) {
951
1024
  const validation = validateDescriptor(options.descriptor);
@@ -955,11 +1028,16 @@ async function writeLxpackProject(options) {
955
1028
  );
956
1029
  }
957
1030
  const descriptor = validation.descriptor;
1031
+ const injectableIssues = validateInjectableAssessments(descriptor);
1032
+ if (injectableIssues.length > 0) {
1033
+ throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1034
+ }
958
1035
  const outDir = resolve4(options.outDir);
959
1036
  if (options.projectRoot) {
960
1037
  assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
961
1038
  }
962
1039
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1040
+ await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
963
1041
  const interchange = descriptorToInterchange(descriptor);
964
1042
  const materialized = await materializeLessonkitProject({
965
1043
  interchange,
@@ -975,8 +1053,8 @@ async function writeLxpackProject(options) {
975
1053
  const courseDir = materialized.courseDir;
976
1054
  return {
977
1055
  outDir: courseDir,
978
- courseYamlPath: join4(courseDir, "course.yaml"),
979
- lessonkitJsonPath: join4(courseDir, "lessonkit.json")
1056
+ courseYamlPath: join5(courseDir, "course.yaml"),
1057
+ lessonkitJsonPath: join5(courseDir, "lessonkit.json")
980
1058
  };
981
1059
  }
982
1060
 
@@ -989,7 +1067,7 @@ import {
989
1067
  } from "@lxpack/api";
990
1068
 
991
1069
  // src/packaging/validateInputs.ts
992
- import { isAbsolute as isAbsolute3, join as join5, resolve as resolve5, win32 as win322 } from "path";
1070
+ import { isAbsolute as isAbsolute3, join as join6, resolve as resolve5, win32 as win322 } from "path";
993
1071
  function validatePackageInputs(options) {
994
1072
  const { target, output, outputBaseDir } = options;
995
1073
  const outDir = resolve5(options.outDir);
@@ -1126,13 +1204,13 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1126
1204
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
1127
1205
  return win322.join(outDir, rel.replace(/\//g, win322.sep));
1128
1206
  }
1129
- return join5(outDir, rel);
1207
+ return join6(outDir, rel);
1130
1208
  }
1131
1209
 
1132
1210
  // src/packaging/promote.ts
1133
1211
  import * as fsp from "fs/promises";
1134
1212
  import { createHash, randomUUID } from "crypto";
1135
- import { dirname, join as join6, resolve as resolve6 } from "path";
1213
+ import { dirname, join as join7, resolve as resolve6 } from "path";
1136
1214
  async function pathExists(path) {
1137
1215
  try {
1138
1216
  await fsp.access(path);
@@ -1154,7 +1232,7 @@ async function renameOrCopy(from, to) {
1154
1232
  function promoteLockPath(outDir) {
1155
1233
  const parent = dirname(outDir);
1156
1234
  const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1157
- return join6(parent, `.lk-promote-lock-${hash}`);
1235
+ return join7(parent, `.lk-promote-lock-${hash}`);
1158
1236
  }
1159
1237
  var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1160
1238
  async function isStalePromoteLock(lockPath) {
@@ -1230,10 +1308,10 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1230
1308
  return withPromoteLock(outDir, async () => {
1231
1309
  await assertNoLegacyPromoteArtifacts(outDir);
1232
1310
  const parent = dirname(outDir);
1233
- const tmpPromote = await fsp.mkdtemp(join6(parent, ".lk-promote-"));
1311
+ const tmpPromote = await fsp.mkdtemp(join7(parent, ".lk-promote-"));
1234
1312
  await renameOrCopy(stagingDir, tmpPromote);
1235
1313
  const hadOutDir = await pathExists(outDir);
1236
- const backup = hadOutDir ? await fsp.mkdtemp(join6(parent, ".lk-backup-")) : void 0;
1314
+ const backup = hadOutDir ? await fsp.mkdtemp(join7(parent, ".lk-backup-")) : void 0;
1237
1315
  if (hadOutDir && backup) {
1238
1316
  await renameOrCopy(outDir, backup);
1239
1317
  }
@@ -1244,7 +1322,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1244
1322
  try {
1245
1323
  await renameOrCopy(backup, outDir);
1246
1324
  } catch (restoreError) {
1247
- const failedPromote2 = join6(parent, `.lk-failed-promote-${randomUUID()}`);
1325
+ const failedPromote2 = join7(parent, `.lk-failed-promote-${randomUUID()}`);
1248
1326
  try {
1249
1327
  await renameOrCopy(tmpPromote, failedPromote2);
1250
1328
  } catch {
@@ -1256,7 +1334,8 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1256
1334
  const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1257
1335
  const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1258
1336
  throw new Error(
1259
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1337
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`,
1338
+ { cause: restoreError }
1260
1339
  );
1261
1340
  }
1262
1341
  } else {
@@ -1274,7 +1353,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1274
1353
  }
1275
1354
  throw promoteError;
1276
1355
  }
1277
- const failedPromote = join6(parent, `.lk-failed-promote-${randomUUID()}`);
1356
+ const failedPromote = join7(parent, `.lk-failed-promote-${randomUUID()}`);
1278
1357
  try {
1279
1358
  await renameOrCopy(tmpPromote, failedPromote);
1280
1359
  } catch {
@@ -1296,16 +1375,17 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1296
1375
 
1297
1376
  // src/packaging/staging.ts
1298
1377
  import * as fsp2 from "fs/promises";
1299
- import { dirname as dirname2, join as join7 } from "path";
1378
+ import { dirname as dirname2, join as join8 } from "path";
1300
1379
  import { tmpdir } from "os";
1301
1380
  import { packageLessonkit } from "@lxpack/api";
1302
1381
  async function buildStagingPackage(options) {
1303
1382
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1304
- const stagingDir = await fsp2.mkdtemp(join7(tmpdir(), "lessonkit-lxpack-"));
1383
+ const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1305
1384
  try {
1306
1385
  let spaDirs;
1307
1386
  try {
1308
1387
  spaDirs = await resolveSpaDirs({ ...writeOpts, descriptor });
1388
+ await assertSpaDistContentsSafe(spaDirs, writeOpts.projectRoot);
1309
1389
  } catch (err) {
1310
1390
  return {
1311
1391
  ok: false,
@@ -1318,10 +1398,21 @@ async function buildStagingPackage(options) {
1318
1398
  ]
1319
1399
  };
1320
1400
  }
1401
+ const injectableIssues = validateInjectableAssessments(descriptor);
1402
+ if (injectableIssues.length > 0) {
1403
+ return {
1404
+ ok: false,
1405
+ stagingDir,
1406
+ issues: injectableIssues.map((i) => ({
1407
+ path: i.path,
1408
+ message: i.message
1409
+ }))
1410
+ };
1411
+ }
1321
1412
  const interchange = descriptorToInterchange(descriptor);
1322
1413
  const outputBase = outputBaseDir ?? ".lxpack/out";
1323
- await fsp2.mkdir(join7(stagingDir, outputBase), { recursive: true });
1324
- const defaultOutput = output ?? (dir ? join7(outputBase, target) : join7(outputBase, `course-${target}.zip`));
1414
+ await fsp2.mkdir(join8(stagingDir, outputBase), { recursive: true });
1415
+ const defaultOutput = output ?? (dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`));
1325
1416
  const build = await packageLessonkit({
1326
1417
  interchange,
1327
1418
  spaDirs,
@@ -1441,18 +1532,6 @@ async function packageLessonkitCourse(options) {
1441
1532
  };
1442
1533
  }
1443
1534
  }
1444
- const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1445
- if (nonInjectableAssessments.length > 0) {
1446
- return {
1447
- ok: false,
1448
- courseDir: outDir,
1449
- target,
1450
- issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1451
- path: `assessments[${index}]`,
1452
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1453
- }))
1454
- };
1455
- }
1456
1535
  const staged = await buildStagingPackage({
1457
1536
  ...writeOpts,
1458
1537
  descriptor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/lxpack",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "description": "LXPack export adapter for LessonKit courses (SCORM, standalone, xAPI, cmi5).",
6
6
  "license": "Apache-2.0",
@@ -55,15 +55,15 @@
55
55
  "lint": "echo \"(no lint configured yet)\""
56
56
  },
57
57
  "dependencies": {
58
- "@lessonkit/core": "1.3.1",
59
- "@lessonkit/themes": "1.3.1",
60
- "@lxpack/api": "^0.6.2",
61
- "@lxpack/spa-bridge": "^0.6.2",
62
- "@lxpack/tracking-schema": "^0.6.2",
63
- "@lxpack/validators": "^0.6.2"
58
+ "@lessonkit/core": "1.4.0",
59
+ "@lessonkit/themes": "1.4.0",
60
+ "@lxpack/api": "0.6.4",
61
+ "@lxpack/spa-bridge": "0.6.4",
62
+ "@lxpack/tracking-schema": "0.6.4",
63
+ "@lxpack/validators": "0.6.4"
64
64
  },
65
65
  "devDependencies": {
66
- "@types/node": "^22.13.10",
66
+ "@types/node": "^25.9.2",
67
67
  "tsup": "^8.5.0",
68
68
  "typescript": "^5.8.3",
69
69
  "vitest": "^4.1.8"