@lessonkit/lxpack 1.5.0 → 1.6.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
@@ -31,22 +31,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  LESSONKIT_TELEMETRY_EVENTS: () => import_tracking_schema2.LESSONKIT_TELEMETRY_EVENTS,
34
+ assertSpaDistContentsSafe: () => assertSpaDistContentsSafe,
34
35
  assessmentDescriptorToLxpack: () => assessmentDescriptorToLxpack,
35
36
  buildLessonkitProject: () => buildLessonkitProject,
36
37
  buildStagingPackage: () => buildStagingPackage,
37
38
  descriptorToInterchange: () => descriptorToInterchange,
38
39
  ensureOutDirParent: () => ensureOutDirParent,
39
40
  escapeShellText: () => escapeShellText,
41
+ exportLkcourse: () => exportLkcourse,
40
42
  extractAssessments: () => extractAssessments,
41
- lessonkitInterchangeSchema: () => import_validators2.lessonkitInterchangeSchema,
43
+ extractBlockTree: () => extractBlockTree,
44
+ importLkcourse: () => importLkcourse,
45
+ lessonkitInterchangeSchema: () => import_validators4.lessonkitInterchangeSchema,
42
46
  loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
43
47
  mapLessonkitIds: () => mapLessonkitIds,
44
48
  mapLessonkitTelemetryToBridgeAction: () => import_tracking_schema2.mapLessonkitTelemetryToBridgeAction,
45
49
  mapLessonkitTelemetryToLxpack: () => import_tracking_schema2.mapLessonkitTelemetryToLxpack,
46
- materializeLessonkitProject: () => import_validators2.materializeLessonkitProject,
50
+ materializeLessonkitProject: () => import_validators4.materializeLessonkitProject,
47
51
  packageLessonkitCourse: () => packageLessonkitCourse,
48
- parseLessonkitInterchange: () => import_validators2.parseLessonkitInterchange,
52
+ parseLessonkitInterchange: () => import_validators4.parseLessonkitInterchange,
49
53
  parseLessonkitManifest: () => parseLessonkitManifest,
54
+ parseLkcourseEnvelope: () => parseLkcourseEnvelope,
50
55
  promoteStagingToOutDir: () => promoteStagingToOutDir,
51
56
  remapArtifactPaths: () => remapArtifactPaths,
52
57
  resolveSafePackageOutputOverride: () => resolveSafePackageOutputOverride,
@@ -56,6 +61,8 @@ __export(index_exports, {
56
61
  validateDescriptor: () => validateDescriptor,
57
62
  validateDescriptorForTarget: () => validateDescriptorForTarget,
58
63
  validateLessonkitProject: () => validateLessonkitProject,
64
+ validateLkcourse: () => validateLkcourse,
65
+ validateLkcourseArchiveEntries: () => validateLkcourseArchiveEntries,
59
66
  validatePackageInputs: () => validatePackageInputs,
60
67
  validateProjectPaths: () => validateProjectPaths,
61
68
  validateReactManifestParity: () => validateReactManifestParity,
@@ -160,10 +167,18 @@ function parseAssessmentDescriptor(raw) {
160
167
  };
161
168
  const kind = raw.kind;
162
169
  if (kind === "trueFalse") {
170
+ let answer;
171
+ if (typeof raw.answer === "boolean") {
172
+ answer = raw.answer;
173
+ } else if (raw.answer === "true") {
174
+ answer = true;
175
+ } else if (raw.answer === "false") {
176
+ answer = false;
177
+ }
163
178
  return {
164
179
  kind: "trueFalse",
165
180
  ...base,
166
- answer: typeof raw.answer === "boolean" ? raw.answer : raw.answer === "true"
181
+ answer
167
182
  };
168
183
  }
169
184
  if (kind === "fillInBlanks") {
@@ -341,6 +356,98 @@ function isResolvedPathUnderRoot(root, target) {
341
356
  return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
342
357
  }
343
358
 
359
+ // src/validateProjectPaths.ts
360
+ var import_node_fs2 = require("fs");
361
+ var import_node_path2 = require("path");
362
+ var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
363
+ function isReservedOutputPath(value) {
364
+ let normalized = value.replace(/\\/g, "/");
365
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
366
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
367
+ const segments = normalized.split("/").filter(Boolean);
368
+ return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
369
+ }
370
+ function isReservedResolvedOutputPath(projectRoot, resolved) {
371
+ const rootResolved = resolveComparablePath(projectRoot);
372
+ const targetResolved = resolveComparablePath(resolved);
373
+ try {
374
+ const rootReal = (0, import_node_fs2.existsSync)(rootResolved) ? (0, import_node_fs2.realpathSync)(rootResolved) : rootResolved;
375
+ const targetReal = (0, import_node_fs2.existsSync)(targetResolved) ? (0, import_node_fs2.realpathSync)(targetResolved) : targetResolved;
376
+ const rel = relativePathUnderRoot(rootReal, targetReal);
377
+ return isReservedOutputPath(rel);
378
+ } catch {
379
+ return isReservedOutputPath(resolved);
380
+ }
381
+ }
382
+ function validatePathField(value, fieldPath, projectRoot, issues, options) {
383
+ if (!isSafeRelativeSpaPath(value)) {
384
+ issues.push({
385
+ path: fieldPath,
386
+ message: "path must be relative without '..' segments or absolute prefixes"
387
+ });
388
+ return;
389
+ }
390
+ if (options?.rejectReserved && isReservedOutputPath(value)) {
391
+ issues.push({
392
+ path: fieldPath,
393
+ message: "path must not target reserved directories (.git, node_modules, .github)"
394
+ });
395
+ return;
396
+ }
397
+ try {
398
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
399
+ } catch {
400
+ issues.push({
401
+ path: fieldPath,
402
+ message: "path must resolve inside the project root"
403
+ });
404
+ }
405
+ }
406
+ function validateProjectPaths(projectRoot, paths) {
407
+ const issues = [];
408
+ const root = (0, import_node_path2.resolve)(projectRoot);
409
+ if (paths.spaDistDir?.trim()) {
410
+ validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
411
+ rejectReserved: true
412
+ });
413
+ }
414
+ if (paths.lxpackOutDir?.trim()) {
415
+ validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
416
+ rejectReserved: true
417
+ });
418
+ }
419
+ if (paths.outputBaseDir?.trim()) {
420
+ validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
421
+ rejectReserved: true
422
+ });
423
+ }
424
+ return issues;
425
+ }
426
+ function resolveSafePackageOutputOverride(projectRoot, override) {
427
+ const root = (0, import_node_path2.resolve)(projectRoot);
428
+ const trimmed = override.trim();
429
+ if (!trimmed) {
430
+ throw new Error("output override must be a non-empty path");
431
+ }
432
+ if ((0, import_node_path2.isAbsolute)(trimmed)) {
433
+ const resolved2 = (0, import_node_path2.resolve)(trimmed);
434
+ assertRealPathUnderRoot(root, resolved2);
435
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
436
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
437
+ }
438
+ return resolved2;
439
+ }
440
+ if (!isSafeRelativeSpaPath(trimmed)) {
441
+ throw new Error(`unsafe output path: ${override}`);
442
+ }
443
+ const resolved = (0, import_node_path2.resolve)(root, trimmed);
444
+ assertRealPathUnderRoot(root, resolved);
445
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
446
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
447
+ }
448
+ return resolved;
449
+ }
450
+
344
451
  // src/theme.ts
345
452
  var import_themes = require("@lessonkit/themes");
346
453
  function themeToLxpackRuntime(input) {
@@ -417,8 +524,30 @@ var ASSESSMENT_VALIDATORS = {
417
524
  message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
418
525
  });
419
526
  }
420
- const explicitBlanks = assessment.blanks?.map((b) => ({ id: b.id?.trim() ?? "", answer: b.answer?.trim() ?? "" })).filter((b) => b.id.length > 0 && b.answer.length > 0) ?? [];
421
- if (assessment.blanks !== void 0 && explicitBlanks.length === 0) {
527
+ const explicitBlanks = [];
528
+ if (assessment.blanks !== void 0) {
529
+ for (let i = 0; i < assessment.blanks.length; i++) {
530
+ const blank = assessment.blanks[i];
531
+ if (!blank || typeof blank !== "object") {
532
+ issues.push({
533
+ path: `${path}.blanks[${i}]`,
534
+ message: "blank entry must be an object with non-empty id and answer"
535
+ });
536
+ continue;
537
+ }
538
+ const id = blank.id?.trim() ?? "";
539
+ const answer = blank.answer?.trim() ?? "";
540
+ if (!id || !answer) {
541
+ issues.push({
542
+ path: `${path}.blanks[${i}]`,
543
+ message: "blank entry must include non-empty id and answer"
544
+ });
545
+ continue;
546
+ }
547
+ explicitBlanks.push({ id, answer });
548
+ }
549
+ }
550
+ if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
422
551
  issues.push({
423
552
  path: `${path}.blanks`,
424
553
  message: "blanks must include at least one entry with non-empty id and answer"
@@ -566,6 +695,20 @@ function validateCourseDescriptor(input) {
566
695
  });
567
696
  }
568
697
  }
698
+ const descriptorSpaDistDir = input.spaDistDir?.trim();
699
+ if (descriptorSpaDistDir) {
700
+ if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
701
+ issues.push({
702
+ path: "spaDistDir",
703
+ message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
704
+ });
705
+ } else if (isReservedOutputPath(descriptorSpaDistDir)) {
706
+ issues.push({
707
+ path: "spaDistDir",
708
+ message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
709
+ });
710
+ }
711
+ }
569
712
  if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
570
713
  issues.push({
571
714
  path: "lessons",
@@ -626,6 +769,7 @@ function validateCourseDescriptor(input) {
626
769
  }
627
770
 
628
771
  // src/assessments.ts
772
+ var DEFAULT_SHELL_PASSING_SCORE = 1;
629
773
  function escapeShellText(text) {
630
774
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
631
775
  }
@@ -634,7 +778,7 @@ function decodeShellEntities(text) {
634
778
  }
635
779
  function containsUnsafeShellMarkup(text) {
636
780
  const decoded = decodeShellEntities(text);
637
- return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /</.test(decoded);
781
+ return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
638
782
  }
639
783
  function sanitizeShellField(text) {
640
784
  if (containsUnsafeShellMarkup(text)) return null;
@@ -649,6 +793,7 @@ function mcqToLxpack(assessment) {
649
793
  const checkId = sanitizeShellField(assessment.checkId);
650
794
  const prompt = sanitizeShellField(assessment.question);
651
795
  if (!checkId || !prompt) return null;
796
+ const normalizedAnswer = assessment.answer.trim();
652
797
  const choices = assessment.choices.map((text, index) => {
653
798
  const sanitizedText = sanitizeShellField(text);
654
799
  if (!sanitizedText) return null;
@@ -656,13 +801,13 @@ function mcqToLxpack(assessment) {
656
801
  return {
657
802
  id,
658
803
  text: sanitizedText,
659
- correct: text === assessment.answer
804
+ correct: text.trim() === normalizedAnswer
660
805
  };
661
806
  });
662
807
  if (choices.some((choice) => choice === null)) return null;
663
808
  return {
664
809
  id: checkId,
665
- passingScore: assessment.passingScore ?? 1,
810
+ passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
666
811
  questions: [
667
812
  {
668
813
  id: "q1",
@@ -707,11 +852,14 @@ function extractAssessments(descriptor) {
707
852
  // src/descriptor/validateInjectableAssessments.ts
708
853
  function validateInjectableAssessments(descriptor) {
709
854
  const issues = [];
855
+ const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
710
856
  (descriptor.assessments ?? []).forEach((assessment, index) => {
711
857
  if (assessmentDescriptorToLxpack(assessment) === null) {
858
+ const kind = assessment.kind ?? "mcq";
859
+ const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
712
860
  issues.push({
713
861
  path: `assessments[${index}]`,
714
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
862
+ message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
715
863
  });
716
864
  }
717
865
  });
@@ -727,7 +875,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
727
875
  "cmi5"
728
876
  ]);
729
877
  function appendActivityIriIssues(issues, descriptor, target) {
730
- const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
878
+ const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
731
879
  const requiresForTarget = target === "xapi" || target === "cmi5";
732
880
  if (!hasXapiTracking && !requiresForTarget) return;
733
881
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
@@ -782,26 +930,26 @@ function validateDescriptorForTarget(input, target) {
782
930
  }
783
931
 
784
932
  // src/validateReactParity.ts
785
- var import_node_fs2 = require("fs");
786
- var import_node_path2 = require("path");
933
+ var import_node_fs3 = require("fs");
934
+ var import_node_path3 = require("path");
787
935
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
788
936
  function collectSourceUnderSrc(projectRoot, issues) {
789
- const srcDir = (0, import_node_path2.join)(projectRoot, "src");
790
- if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
937
+ const srcDir = (0, import_node_path3.join)(projectRoot, "src");
938
+ if (!(0, import_node_fs3.existsSync)(srcDir)) return [];
791
939
  const results = [];
792
940
  const walk = (dir) => {
793
- for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
794
- const abs = (0, import_node_path2.join)(dir, entry);
941
+ for (const entry of (0, import_node_fs3.readdirSync)(dir)) {
942
+ const abs = (0, import_node_path3.join)(dir, entry);
795
943
  let stat2;
796
944
  try {
797
- stat2 = (0, import_node_fs2.lstatSync)(abs);
945
+ stat2 = (0, import_node_fs3.lstatSync)(abs);
798
946
  } catch {
799
947
  continue;
800
948
  }
801
949
  if (stat2.isSymbolicLink()) {
802
950
  issues.push({
803
- path: (0, import_node_path2.relative)(projectRoot, abs),
804
- message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path2.relative)(projectRoot, abs)}`,
951
+ path: (0, import_node_path3.relative)(projectRoot, abs),
952
+ message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path3.relative)(projectRoot, abs)}`,
805
953
  severity: "error"
806
954
  });
807
955
  continue;
@@ -811,8 +959,8 @@ function collectSourceUnderSrc(projectRoot, issues) {
811
959
  assertRealPathUnderRoot(projectRoot, abs);
812
960
  } catch {
813
961
  issues.push({
814
- path: (0, import_node_path2.relative)(projectRoot, abs),
815
- message: `Source directory escapes project root: ${(0, import_node_path2.relative)(projectRoot, abs)}`,
962
+ path: (0, import_node_path3.relative)(projectRoot, abs),
963
+ message: `Source directory escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
816
964
  severity: "error"
817
965
  });
818
966
  continue;
@@ -823,13 +971,13 @@ function collectSourceUnderSrc(projectRoot, issues) {
823
971
  assertRealPathUnderRoot(projectRoot, abs);
824
972
  } catch {
825
973
  issues.push({
826
- path: (0, import_node_path2.relative)(projectRoot, abs),
827
- message: `Source file escapes project root: ${(0, import_node_path2.relative)(projectRoot, abs)}`,
974
+ path: (0, import_node_path3.relative)(projectRoot, abs),
975
+ message: `Source file escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
828
976
  severity: "error"
829
977
  });
830
978
  continue;
831
979
  }
832
- results.push((0, import_node_path2.relative)(projectRoot, abs));
980
+ results.push((0, import_node_path3.relative)(projectRoot, abs));
833
981
  }
834
982
  }
835
983
  };
@@ -848,10 +996,10 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
848
996
  }
849
997
  return null;
850
998
  }
851
- const abs = (0, import_node_path2.join)(projectRoot, rel);
999
+ const abs = (0, import_node_path3.join)(projectRoot, rel);
852
1000
  try {
853
1001
  assertRealPathUnderRoot(projectRoot, abs);
854
- if ((0, import_node_fs2.existsSync)(abs) && (0, import_node_fs2.lstatSync)(abs).isSymbolicLink()) {
1002
+ if ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.lstatSync)(abs).isSymbolicLink()) {
855
1003
  issues.push({
856
1004
  path: rel,
857
1005
  message: `appSources path is a symlink: ${rel}`,
@@ -867,8 +1015,8 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
867
1015
  });
868
1016
  return null;
869
1017
  }
870
- if (!(0, import_node_fs2.existsSync)(abs)) return null;
871
- return (0, import_node_fs2.readFileSync)(abs, "utf8");
1018
+ if (!(0, import_node_fs3.existsSync)(abs)) return null;
1019
+ return (0, import_node_fs3.readFileSync)(abs, "utf8");
872
1020
  }).filter((content) => content != null).join("\n");
873
1021
  }
874
1022
  function stripComments(source) {
@@ -943,9 +1091,20 @@ function courseConfigCourseIdPresent(source, courseId) {
943
1091
  if (literalPattern.test(stripped)) return true;
944
1092
  return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
945
1093
  }
1094
+ function courseMetaCourseIdPresent(source, courseId) {
1095
+ const constants = extractStringConstants(source);
1096
+ const stripped = stripComments(source);
1097
+ for (const [name, value] of constants) {
1098
+ if (value !== courseId) continue;
1099
+ if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
1100
+ if (/\blessons\s*:\s*\S/.test(stripped)) return true;
1101
+ }
1102
+ return false;
1103
+ }
946
1104
  function courseIdPresent(source, courseId) {
947
1105
  if (idPropPresent(source, "courseId", courseId)) return true;
948
1106
  if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
1107
+ if (courseMetaCourseIdPresent(source, courseId)) return true;
949
1108
  return courseConfigCourseIdPresent(source, courseId);
950
1109
  }
951
1110
  function checkIdPresent(source, checkId) {
@@ -1014,81 +1173,6 @@ function validateReactManifestParity(opts) {
1014
1173
  return issues;
1015
1174
  }
1016
1175
 
1017
- // src/validateProjectPaths.ts
1018
- var import_node_path3 = require("path");
1019
- var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
1020
- function isReservedOutputPath(value) {
1021
- const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
1022
- const segments = normalized.split("/").filter(Boolean);
1023
- return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
1024
- }
1025
- function validatePathField(value, fieldPath, projectRoot, issues, options) {
1026
- if (!isSafeRelativeSpaPath(value)) {
1027
- issues.push({
1028
- path: fieldPath,
1029
- message: "path must be relative without '..' segments or absolute prefixes"
1030
- });
1031
- return;
1032
- }
1033
- if (options?.rejectReserved && isReservedOutputPath(value)) {
1034
- issues.push({
1035
- path: fieldPath,
1036
- message: "path must not target reserved directories (.git, node_modules, .github)"
1037
- });
1038
- return;
1039
- }
1040
- try {
1041
- assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
1042
- } catch {
1043
- issues.push({
1044
- path: fieldPath,
1045
- message: "path must resolve inside the project root"
1046
- });
1047
- }
1048
- }
1049
- function validateProjectPaths(projectRoot, paths) {
1050
- const issues = [];
1051
- const root = (0, import_node_path3.resolve)(projectRoot);
1052
- if (paths.spaDistDir?.trim()) {
1053
- validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
1054
- }
1055
- if (paths.lxpackOutDir?.trim()) {
1056
- validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
1057
- rejectReserved: true
1058
- });
1059
- }
1060
- if (paths.outputBaseDir?.trim()) {
1061
- validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
1062
- rejectReserved: true
1063
- });
1064
- }
1065
- return issues;
1066
- }
1067
- function resolveSafePackageOutputOverride(projectRoot, override) {
1068
- const root = (0, import_node_path3.resolve)(projectRoot);
1069
- const trimmed = override.trim();
1070
- if (!trimmed) {
1071
- throw new Error("output override must be a non-empty path");
1072
- }
1073
- if ((0, import_node_path3.isAbsolute)(trimmed)) {
1074
- const resolved2 = (0, import_node_path3.resolve)(trimmed);
1075
- assertRealPathUnderRoot(root, resolved2);
1076
- if (isReservedOutputPath(trimmed)) {
1077
- throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1078
- }
1079
- return resolved2;
1080
- }
1081
- if (!isSafeRelativeSpaPath(trimmed)) {
1082
- throw new Error(`unsafe output path: ${override}`);
1083
- }
1084
- if (isReservedOutputPath(trimmed)) {
1085
- throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1086
- }
1087
- const resolved = (0, import_node_path3.resolve)(root, trimmed);
1088
- assertRealPathUnderRoot(root, resolved);
1089
- return resolved;
1090
- }
1091
-
1092
1176
  // src/mapIds.ts
1093
1177
  var import_core4 = require("@lessonkit/core");
1094
1178
  function mapLessonkitIds(descriptor) {
@@ -1220,7 +1304,7 @@ async function resolveSpaDirs(options) {
1220
1304
 
1221
1305
  // src/spaDistValidation.ts
1222
1306
  var import_promises2 = require("fs/promises");
1223
- var import_node_fs3 = require("fs");
1307
+ var import_node_fs4 = require("fs");
1224
1308
  var import_node_path5 = require("path");
1225
1309
  async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1226
1310
  for (const [label, dir] of Object.entries(spaDirs)) {
@@ -1231,7 +1315,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1231
1315
  }
1232
1316
  let rootReal;
1233
1317
  try {
1234
- rootReal = (0, import_node_fs3.realpathSync)(dirResolved);
1318
+ rootReal = (0, import_node_fs4.realpathSync)(dirResolved);
1235
1319
  } catch {
1236
1320
  throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
1237
1321
  }
@@ -1260,7 +1344,7 @@ async function walkDistDir(rootReal, current, label) {
1260
1344
  }
1261
1345
  let entryReal;
1262
1346
  try {
1263
- entryReal = (0, import_node_fs3.realpathSync)(entryPath);
1347
+ entryReal = (0, import_node_fs4.realpathSync)(entryPath);
1264
1348
  } catch (err) {
1265
1349
  throw new Error(
1266
1350
  `spa dist for "${label}" could not resolve path: ${entryPath}`,
@@ -1285,7 +1369,9 @@ async function writeLxpackProject(options) {
1285
1369
  const descriptor = validation.descriptor;
1286
1370
  const injectableIssues = validateInjectableAssessments(descriptor);
1287
1371
  if (injectableIssues.length > 0) {
1288
- throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1372
+ throw new Error(
1373
+ injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
1374
+ );
1289
1375
  }
1290
1376
  const outDir = (0, import_node_path6.resolve)(options.outDir);
1291
1377
  assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
@@ -1348,6 +1434,19 @@ function validatePackageInputs(options) {
1348
1434
  ]
1349
1435
  };
1350
1436
  }
1437
+ if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
1438
+ return {
1439
+ ok: false,
1440
+ courseDir: outDir,
1441
+ target,
1442
+ issues: [
1443
+ {
1444
+ path: "outDir",
1445
+ message: "outDir must not target reserved directories (.git, node_modules, .github)"
1446
+ }
1447
+ ]
1448
+ };
1449
+ }
1351
1450
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1352
1451
  return {
1353
1452
  ok: false,
@@ -1405,6 +1504,19 @@ function validatePackageInputs(options) {
1405
1504
  ]
1406
1505
  };
1407
1506
  }
1507
+ if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
1508
+ return {
1509
+ ok: false,
1510
+ courseDir: outDir,
1511
+ target,
1512
+ issues: [
1513
+ {
1514
+ path: "outputBaseDir",
1515
+ message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
1516
+ }
1517
+ ]
1518
+ };
1519
+ }
1408
1520
  }
1409
1521
  if (output) {
1410
1522
  const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
@@ -1426,6 +1538,35 @@ function validatePackageInputs(options) {
1426
1538
  ]
1427
1539
  };
1428
1540
  }
1541
+ const outputRel = (0, import_node_path7.isAbsolute)(output) ? output : output;
1542
+ if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
1543
+ return {
1544
+ ok: false,
1545
+ courseDir: outDir,
1546
+ target,
1547
+ issues: [
1548
+ {
1549
+ path: "output",
1550
+ message: "output must not target reserved directories (.git, node_modules, .github)"
1551
+ }
1552
+ ]
1553
+ };
1554
+ }
1555
+ try {
1556
+ relativePathUnderRoot(outDir, resolvedOutput);
1557
+ } catch {
1558
+ return {
1559
+ ok: false,
1560
+ courseDir: outDir,
1561
+ target,
1562
+ issues: [
1563
+ {
1564
+ path: "output",
1565
+ message: "output must resolve inside outDir"
1566
+ }
1567
+ ]
1568
+ };
1569
+ }
1429
1570
  }
1430
1571
  return { ok: true, outDir, projectRoot };
1431
1572
  }
@@ -1518,11 +1659,14 @@ async function isStalePromoteLock(lockPath) {
1518
1659
  return true;
1519
1660
  }
1520
1661
  }
1662
+ var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1521
1663
  async function withPromoteLock(outDir, fn) {
1522
1664
  const lockPath = promoteLockPath(outDir);
1523
1665
  await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1524
1666
  let lockHandle;
1525
- for (let attempt = 0; attempt < 200; attempt++) {
1667
+ const maxAttempts = 400;
1668
+ const started = Date.now();
1669
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1526
1670
  try {
1527
1671
  lockHandle = await fsp.open(lockPath, "wx");
1528
1672
  await lockHandle.writeFile(`${process.pid}
@@ -1540,7 +1684,9 @@ ${Date.now()}
1540
1684
  );
1541
1685
  continue;
1542
1686
  }
1543
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1687
+ if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
1688
+ const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
1689
+ await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
1544
1690
  }
1545
1691
  }
1546
1692
  if (!lockHandle) {
@@ -1808,6 +1954,12 @@ function isPackagingErrorIssue(issue) {
1808
1954
  function findPackagingErrorIssues(issues) {
1809
1955
  return (issues ?? []).filter(isPackagingErrorIssue);
1810
1956
  }
1957
+ function isPackagingWarningIssue(issue) {
1958
+ return issue.severity?.toLowerCase() === "warning";
1959
+ }
1960
+ function findPackagingWarningIssues(issues) {
1961
+ return (issues ?? []).filter(isPackagingWarningIssue);
1962
+ }
1811
1963
 
1812
1964
  // src/packageCourse.ts
1813
1965
  async function validateLessonkitProject(options) {
@@ -1875,14 +2027,29 @@ async function packageLessonkitCourse(options) {
1875
2027
  }))
1876
2028
  };
1877
2029
  }
1878
- const staged = await buildStagingPackage({
1879
- ...writeOpts,
1880
- descriptor,
1881
- target,
1882
- output,
1883
- dir,
1884
- outputBaseDir
1885
- });
2030
+ let staged;
2031
+ try {
2032
+ staged = await buildStagingPackage({
2033
+ ...writeOpts,
2034
+ descriptor,
2035
+ target,
2036
+ output,
2037
+ dir,
2038
+ outputBaseDir
2039
+ });
2040
+ } catch (err) {
2041
+ return {
2042
+ ok: false,
2043
+ courseDir: outDir,
2044
+ target,
2045
+ issues: [
2046
+ {
2047
+ path: "staging",
2048
+ message: err instanceof Error ? err.message : String(err)
2049
+ }
2050
+ ]
2051
+ };
2052
+ }
1886
2053
  if (!staged.ok) {
1887
2054
  await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1888
2055
  /* v8 ignore next */
@@ -1937,6 +2104,25 @@ async function packageLessonkitCourse(options) {
1937
2104
  issues: artifactIssues
1938
2105
  };
1939
2106
  }
2107
+ const buildWarningIssues = findPackagingWarningIssues(build.issues);
2108
+ if (options.strictBuild && buildWarningIssues.length > 0) {
2109
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2110
+ /* v8 ignore next */
2111
+ () => void 0
2112
+ );
2113
+ return {
2114
+ ok: false,
2115
+ courseDir: outDir,
2116
+ target,
2117
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
2118
+ build,
2119
+ issues: buildWarningIssues.map((i) => ({
2120
+ path: i.path ?? "build",
2121
+ message: i.message ?? "build warning",
2122
+ severity: i.severity
2123
+ }))
2124
+ };
2125
+ }
1940
2126
  const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
1941
2127
  const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
1942
2128
  const validation = {
@@ -2079,7 +2265,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2079
2265
  path: `paths.${key}`,
2080
2266
  message: "path must be relative without '..' segments or absolute prefixes"
2081
2267
  });
2082
- } else if ((key === "lxpackOutDir" || key === "outputBaseDir") && isReservedOutputPath(value)) {
2268
+ } else if (isReservedOutputPath(value)) {
2083
2269
  issues.push({
2084
2270
  path: `paths.${key}`,
2085
2271
  message: "path must not target reserved directories (.git, node_modules, .github)"
@@ -2173,17 +2359,823 @@ function telemetryEventToLessonkit(event) {
2173
2359
  }
2174
2360
 
2175
2361
  // src/index.ts
2176
- var import_validators2 = require("@lxpack/validators");
2362
+ var import_validators4 = require("@lxpack/validators");
2363
+
2364
+ // src/lkcourse/zip.ts
2365
+ var import_node_fs5 = require("fs");
2366
+ var import_node_path11 = require("path");
2367
+ var import_fflate = require("fflate");
2368
+ var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2369
+ function canonicalZipEntryPath(entryPath) {
2370
+ const slashNormalized = entryPath.replace(/\\/g, "/");
2371
+ const canonical = (0, import_node_path11.normalize)(slashNormalized).replace(/\\/g, "/");
2372
+ if (canonical !== slashNormalized) return null;
2373
+ return canonical;
2374
+ }
2375
+ function isSafeZipEntryPath(entryPath) {
2376
+ const canonical = canonicalZipEntryPath(entryPath);
2377
+ if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
2378
+ return false;
2379
+ }
2380
+ const segments = canonical.split("/").filter((s) => s.length > 0);
2381
+ if (segments.some((s) => s === "..")) return false;
2382
+ return segments.length > 0;
2383
+ }
2384
+ function createZip(entries) {
2385
+ const zipped = {};
2386
+ for (const [path, data] of entries) {
2387
+ if (!isSafeZipEntryPath(path)) {
2388
+ throw new Error(`unsafe zip entry path: ${path}`);
2389
+ }
2390
+ zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
2391
+ }
2392
+ return (0, import_fflate.zipSync)(zipped, { level: 6 });
2393
+ }
2394
+ function readZip(archivePath) {
2395
+ const issues = [];
2396
+ let raw;
2397
+ try {
2398
+ raw = (0, import_node_fs5.readFileSync)(archivePath);
2399
+ } catch {
2400
+ return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
2401
+ }
2402
+ if (!raw.length) {
2403
+ return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
2404
+ }
2405
+ let unzipped;
2406
+ try {
2407
+ unzipped = (0, import_fflate.unzipSync)(raw);
2408
+ } catch {
2409
+ return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
2410
+ }
2411
+ const entries = /* @__PURE__ */ new Map();
2412
+ let totalUncompressed = 0;
2413
+ for (const [path, data] of Object.entries(unzipped)) {
2414
+ const canonical = canonicalZipEntryPath(path);
2415
+ if (!canonical || !isSafeZipEntryPath(canonical)) {
2416
+ issues.push({ path, message: "unsafe zip entry path" });
2417
+ continue;
2418
+ }
2419
+ if (entries.has(canonical)) {
2420
+ issues.push({ path: canonical, message: "duplicate zip entry path" });
2421
+ continue;
2422
+ }
2423
+ totalUncompressed += data.byteLength;
2424
+ if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
2425
+ return {
2426
+ ok: false,
2427
+ issues: [
2428
+ {
2429
+ path: archivePath,
2430
+ message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
2431
+ }
2432
+ ]
2433
+ };
2434
+ }
2435
+ entries.set(canonical, data);
2436
+ }
2437
+ if (issues.length) return { ok: false, issues };
2438
+ return { ok: true, entries };
2439
+ }
2440
+ async function collectDistEntries(distDir, spaDistRelative) {
2441
+ const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
2442
+ const entries = /* @__PURE__ */ new Map();
2443
+ const walk = async (absDir, relPrefix) => {
2444
+ const dirEntries = await readdir4(absDir, { withFileTypes: true });
2445
+ for (const entry of dirEntries) {
2446
+ const abs = (0, import_node_path11.join)(absDir, entry.name);
2447
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
2448
+ const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
2449
+ if (!isSafeRelativeSpaPath(zipPath)) {
2450
+ throw new Error(`unsafe dist path: ${zipPath}`);
2451
+ }
2452
+ const stat2 = await lstat2(abs);
2453
+ if (stat2.isSymbolicLink()) {
2454
+ throw new Error(`dist contains symlink: ${abs}`);
2455
+ }
2456
+ if (stat2.isDirectory()) {
2457
+ await walk(abs, rel);
2458
+ } else if (stat2.isFile()) {
2459
+ entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
2460
+ }
2461
+ }
2462
+ };
2463
+ await walk(distDir, "");
2464
+ return entries;
2465
+ }
2466
+ function entryToUtf8(data) {
2467
+ return (0, import_fflate.strFromU8)(data);
2468
+ }
2469
+ function utf8ToEntry(text) {
2470
+ return (0, import_fflate.strToU8)(text);
2471
+ }
2472
+
2473
+ // src/lkcourse/parseEnvelope.ts
2474
+ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2475
+ const issues = [];
2476
+ if (!raw || typeof raw !== "object") {
2477
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
2478
+ }
2479
+ const obj = raw;
2480
+ if (obj.format !== "lkcourse") {
2481
+ issues.push({
2482
+ path: "format",
2483
+ message: `must be "lkcourse" (got ${String(obj.format)})`
2484
+ });
2485
+ }
2486
+ let schemaVersion = obj.schemaVersion;
2487
+ if (schemaVersion === "1") schemaVersion = 1;
2488
+ if (schemaVersion !== 1) {
2489
+ issues.push({
2490
+ path: "schemaVersion",
2491
+ message: `must be 1 (got ${String(obj.schemaVersion)})`
2492
+ });
2493
+ }
2494
+ const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
2495
+ if (!lessonkitVersion) {
2496
+ issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
2497
+ }
2498
+ const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
2499
+ if (!exportedAt) {
2500
+ issues.push({ path: "exportedAt", message: "must be a non-empty string" });
2501
+ }
2502
+ const entriesRaw = obj.entries;
2503
+ const entries = [];
2504
+ if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
2505
+ issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
2506
+ } else {
2507
+ for (let i = 0; i < entriesRaw.length; i++) {
2508
+ const entry = entriesRaw[i];
2509
+ if (typeof entry !== "string" || !entry.trim()) {
2510
+ issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
2511
+ } else {
2512
+ const trimmed = entry.trim();
2513
+ if (!isSafeZipEntryPath(trimmed)) {
2514
+ issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
2515
+ } else {
2516
+ entries.push(trimmed);
2517
+ }
2518
+ }
2519
+ }
2520
+ }
2521
+ if (issues.length) return { ok: false, issues };
2522
+ const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
2523
+ if (!manifestParsed.ok) {
2524
+ return {
2525
+ ok: false,
2526
+ issues: manifestParsed.issues.map((issue) => ({
2527
+ path: `sourceManifest.${issue.path}`,
2528
+ message: issue.message
2529
+ }))
2530
+ };
2531
+ }
2532
+ return {
2533
+ ok: true,
2534
+ envelope: {
2535
+ format: "lkcourse",
2536
+ schemaVersion: 1,
2537
+ lessonkitVersion,
2538
+ exportedAt,
2539
+ sourceManifest: manifestParsed.manifest,
2540
+ entries
2541
+ }
2542
+ };
2543
+ }
2544
+
2545
+ // src/lkcourse/blockTree.ts
2546
+ var import_node_fs6 = require("fs");
2547
+ var import_node_module = require("module");
2548
+ var import_node_path12 = require("path");
2549
+ var import_core5 = require("@lessonkit/core");
2550
+ var import_meta = {};
2551
+ var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
2552
+ var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
2553
+ function stripComments2(source) {
2554
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
2555
+ }
2556
+ function collectSourceUnderSrc2(projectRoot) {
2557
+ const srcDir = (0, import_node_path12.join)(projectRoot, "src");
2558
+ if (!(0, import_node_fs6.existsSync)(srcDir)) return [];
2559
+ const results = [];
2560
+ const walk = (dir) => {
2561
+ for (const entry of (0, import_node_fs6.readdirSync)(dir)) {
2562
+ const abs = (0, import_node_path12.join)(dir, entry);
2563
+ try {
2564
+ assertRealPathUnderRoot(projectRoot, abs);
2565
+ } catch {
2566
+ continue;
2567
+ }
2568
+ const stat2 = (0, import_node_fs6.lstatSync)(abs);
2569
+ if (stat2.isSymbolicLink()) continue;
2570
+ if (stat2.isDirectory()) {
2571
+ walk(abs);
2572
+ } else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
2573
+ results.push((0, import_node_path12.relative)(projectRoot, abs));
2574
+ }
2575
+ }
2576
+ };
2577
+ walk(srcDir);
2578
+ return results;
2579
+ }
2580
+ function loadCatalogBlockTypes(blockTypes) {
2581
+ if (blockTypes?.length) return blockTypes;
2582
+ try {
2583
+ const require2 = (0, import_node_module.createRequire)(import_meta.url);
2584
+ const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
2585
+ const catalog = JSON.parse((0, import_node_fs6.readFileSync)(catalogPath, "utf8"));
2586
+ return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
2587
+ } catch {
2588
+ return [
2589
+ "Course",
2590
+ "Lesson",
2591
+ "Scenario",
2592
+ "Quiz",
2593
+ "KnowledgeCheck",
2594
+ "ProgressTracker",
2595
+ "Reflection",
2596
+ "TrueFalse",
2597
+ "MarkTheWords",
2598
+ "FillInTheBlanks",
2599
+ "DragTheWords",
2600
+ "DragAndDrop",
2601
+ "AssessmentSequence",
2602
+ "Text",
2603
+ "Heading",
2604
+ "Image",
2605
+ "Video",
2606
+ "Page",
2607
+ "InteractiveBook",
2608
+ "Slide",
2609
+ "SlideDeck",
2610
+ "TimedCue",
2611
+ "InteractiveVideo",
2612
+ "Summary",
2613
+ "BranchingScenario",
2614
+ "BranchNode",
2615
+ "BranchChoice",
2616
+ "Embed",
2617
+ "Chart"
2618
+ ];
2619
+ }
2620
+ }
2621
+ function extractIdProp(tagSource, prop) {
2622
+ const re = new RegExp(
2623
+ `\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
2624
+ );
2625
+ const match = tagSource.match(re);
2626
+ if (!match) return void 0;
2627
+ return match[1] ?? match[2] ?? match[3];
2628
+ }
2629
+ function parseJsxBlocks(source, blockTypes) {
2630
+ const stripped = stripComments2(source);
2631
+ const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
2632
+ const stack = [];
2633
+ const roots = [];
2634
+ for (const match of stripped.matchAll(tagRe)) {
2635
+ const rawTag = match[1];
2636
+ const attrs = match[2] ?? "";
2637
+ const selfClosing = match[3] === "/";
2638
+ if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
2639
+ const known = blockTypes.has(rawTag);
2640
+ const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
2641
+ for (const prop of ID_PROPS) {
2642
+ const value = extractIdProp(attrs, prop);
2643
+ if (value) node[prop] = value;
2644
+ }
2645
+ if (selfClosing) {
2646
+ if (stack.length) {
2647
+ const parent = stack[stack.length - 1];
2648
+ parent.children = parent.children ?? [];
2649
+ parent.children.push(node);
2650
+ } else {
2651
+ roots.push(node);
2652
+ }
2653
+ continue;
2654
+ }
2655
+ const closeRe = new RegExp(`</${rawTag}>`);
2656
+ const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
2657
+ if (!closeMatch) {
2658
+ if (stack.length) {
2659
+ const parent = stack[stack.length - 1];
2660
+ parent.children = parent.children ?? [];
2661
+ parent.children.push(node);
2662
+ } else {
2663
+ roots.push(node);
2664
+ }
2665
+ continue;
2666
+ }
2667
+ stack.push(node);
2668
+ const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
2669
+ const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
2670
+ if (!inner.includes("<")) {
2671
+ stack.pop();
2672
+ if (stack.length) {
2673
+ const parent = stack[stack.length - 1];
2674
+ parent.children = parent.children ?? [];
2675
+ parent.children.push(node);
2676
+ } else {
2677
+ roots.push(node);
2678
+ }
2679
+ }
2680
+ }
2681
+ return roots.length ? roots : stack;
2682
+ }
2683
+ function validateNodeIds(node, pathPrefix, issues) {
2684
+ for (const prop of ID_PROPS) {
2685
+ const value = node[prop];
2686
+ if (value === void 0) continue;
2687
+ const validated = (0, import_core5.validateId)(value, prop);
2688
+ if (!validated.ok) {
2689
+ issues.push({
2690
+ path: `${pathPrefix}.${prop}`,
2691
+ message: validated.issues[0]?.message ?? `invalid ${prop}`
2692
+ });
2693
+ }
2694
+ }
2695
+ node.children?.forEach((child, index) => {
2696
+ validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
2697
+ });
2698
+ }
2699
+ function validateBlockTreeIds(tree) {
2700
+ const issues = [];
2701
+ tree.blocks.forEach((block, index) => {
2702
+ validateNodeIds(block, `blocks[${index}]`, issues);
2703
+ });
2704
+ return issues;
2705
+ }
2706
+ function extractBlockTree(options) {
2707
+ const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
2708
+ const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
2709
+ const blocks = [];
2710
+ for (const rel of sources) {
2711
+ const abs = (0, import_node_path12.join)(options.projectRoot, rel);
2712
+ if (!(0, import_node_fs6.existsSync)(abs)) continue;
2713
+ const source = (0, import_node_fs6.readFileSync)(abs, "utf8");
2714
+ const parsed = parseJsxBlocks(source, blockTypes);
2715
+ blocks.push(...parsed);
2716
+ }
2717
+ return {
2718
+ schemaVersion: 1,
2719
+ sources,
2720
+ blocks
2721
+ };
2722
+ }
2723
+
2724
+ // src/lkcourse/export.ts
2725
+ var import_promises3 = require("fs/promises");
2726
+ var import_node_module2 = require("module");
2727
+ var import_node_path13 = require("path");
2728
+ var import_validators2 = require("@lxpack/validators");
2729
+ var import_meta2 = {};
2730
+ function resolveLessonkitVersion(explicit) {
2731
+ if (explicit?.trim()) return explicit.trim();
2732
+ try {
2733
+ const require2 = (0, import_node_module2.createRequire)(import_meta2.url);
2734
+ const pkg = require2("../../package.json");
2735
+ return pkg.version ?? "0.0.0";
2736
+ } catch {
2737
+ return "0.0.0";
2738
+ }
2739
+ }
2740
+ async function exportLkcourse(options) {
2741
+ const projectRoot = (0, import_node_path13.resolve)(options.projectRoot);
2742
+ const manifest = options.manifest;
2743
+ const spaDistDir = (0, import_node_path13.join)(projectRoot, manifest.paths.spaDistDir);
2744
+ try {
2745
+ assertRealPathUnderRoot(projectRoot, spaDistDir);
2746
+ await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
2747
+ } catch (err) {
2748
+ return {
2749
+ ok: false,
2750
+ issues: [
2751
+ {
2752
+ path: manifest.paths.spaDistDir,
2753
+ message: err instanceof Error ? err.message : String(err)
2754
+ }
2755
+ ]
2756
+ };
2757
+ }
2758
+ const interchange = descriptorToInterchange(manifest.course);
2759
+ const interchangeParsed = (0, import_validators2.parseLessonkitInterchange)(interchange);
2760
+ if (!interchangeParsed.ok) {
2761
+ return {
2762
+ ok: false,
2763
+ issues: interchangeParsed.issues.map((i) => ({
2764
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2765
+ message: i.message
2766
+ }))
2767
+ };
2768
+ }
2769
+ const validatedInterchange = interchangeParsed.data;
2770
+ const interchangeCourseId = validatedInterchange.course?.id;
2771
+ if (!interchangeCourseId) {
2772
+ return {
2773
+ ok: false,
2774
+ issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
2775
+ };
2776
+ }
2777
+ if (manifest.course.courseId !== interchangeCourseId) {
2778
+ return {
2779
+ ok: false,
2780
+ issues: [
2781
+ {
2782
+ path: "course.courseId",
2783
+ message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
2784
+ }
2785
+ ]
2786
+ };
2787
+ }
2788
+ const zipEntries = /* @__PURE__ */ new Map();
2789
+ const interchangeJson = JSON.stringify(interchange, null, 2);
2790
+ zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
2791
+ let blockTreeJson;
2792
+ if (options.includeBlockTree) {
2793
+ const blockTree = extractBlockTree({ projectRoot });
2794
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2795
+ if (blockTreeIssues.length) {
2796
+ return {
2797
+ ok: false,
2798
+ issues: blockTreeIssues.map((issue) => ({
2799
+ path: `block-tree.${issue.path}`,
2800
+ message: issue.message
2801
+ }))
2802
+ };
2803
+ }
2804
+ blockTreeJson = JSON.stringify(blockTree, null, 2);
2805
+ zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
2806
+ }
2807
+ let distEntries;
2808
+ try {
2809
+ distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
2810
+ } catch (err) {
2811
+ return {
2812
+ ok: false,
2813
+ issues: [
2814
+ {
2815
+ path: manifest.paths.spaDistDir,
2816
+ message: err instanceof Error ? err.message : String(err)
2817
+ }
2818
+ ]
2819
+ };
2820
+ }
2821
+ if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
2822
+ return {
2823
+ ok: false,
2824
+ issues: [
2825
+ {
2826
+ path: `${manifest.paths.spaDistDir}/index.html`,
2827
+ message: "dist must contain index.html before export"
2828
+ }
2829
+ ]
2830
+ };
2831
+ }
2832
+ for (const [path, data] of distEntries) {
2833
+ zipEntries.set(path, data);
2834
+ }
2835
+ const entryPaths = [...zipEntries.keys()].sort();
2836
+ const envelope = {
2837
+ format: "lkcourse",
2838
+ schemaVersion: 1,
2839
+ lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
2840
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2841
+ sourceManifest: manifest,
2842
+ entries: entryPaths
2843
+ };
2844
+ const envelopeCheck = parseLkcourseEnvelope(envelope);
2845
+ if (!envelopeCheck.ok) {
2846
+ return { ok: false, issues: envelopeCheck.issues };
2847
+ }
2848
+ zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
2849
+ const archivePath = (0, import_node_path13.resolve)(
2850
+ projectRoot,
2851
+ options.outPath ?? `${manifest.name}.lkcourse`
2852
+ );
2853
+ try {
2854
+ assertRealPathUnderRoot(projectRoot, archivePath);
2855
+ } catch (err) {
2856
+ return {
2857
+ ok: false,
2858
+ issues: [
2859
+ {
2860
+ path: options.outPath ?? `${manifest.name}.lkcourse`,
2861
+ message: err instanceof Error ? err.message : String(err)
2862
+ }
2863
+ ]
2864
+ };
2865
+ }
2866
+ if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
2867
+ return {
2868
+ ok: false,
2869
+ issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
2870
+ };
2871
+ }
2872
+ try {
2873
+ await (0, import_promises3.mkdir)((0, import_node_path13.dirname)(archivePath), { recursive: true });
2874
+ const zipped = createZip(zipEntries);
2875
+ await (0, import_promises3.writeFile)(archivePath, zipped);
2876
+ } catch (err) {
2877
+ return {
2878
+ ok: false,
2879
+ issues: [
2880
+ {
2881
+ path: archivePath,
2882
+ message: err instanceof Error ? err.message : String(err)
2883
+ }
2884
+ ]
2885
+ };
2886
+ }
2887
+ return {
2888
+ ok: true,
2889
+ archivePath,
2890
+ fileCount: zipEntries.size,
2891
+ includeBlockTree: Boolean(options.includeBlockTree)
2892
+ };
2893
+ }
2894
+
2895
+ // src/lkcourse/validate.ts
2896
+ var import_validators3 = require("@lxpack/validators");
2897
+ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2898
+ const issues = [];
2899
+ const manifestData = entries.get("manifest.json");
2900
+ if (!manifestData) {
2901
+ return {
2902
+ ok: false,
2903
+ issues: [{ path: "manifest.json", message: "required file missing from archive" }]
2904
+ };
2905
+ }
2906
+ let envelopeRaw;
2907
+ try {
2908
+ envelopeRaw = JSON.parse(entryToUtf8(manifestData));
2909
+ } catch {
2910
+ return {
2911
+ ok: false,
2912
+ issues: [{ path: "manifest.json", message: "invalid JSON" }]
2913
+ };
2914
+ }
2915
+ const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
2916
+ if (!envelopeParsed.ok) {
2917
+ return { ok: false, issues: envelopeParsed.issues };
2918
+ }
2919
+ const envelope = envelopeParsed.envelope;
2920
+ const interchangeData = entries.get("interchange.json");
2921
+ if (!interchangeData) {
2922
+ issues.push({ path: "interchange.json", message: "required file missing from archive" });
2923
+ }
2924
+ const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
2925
+ const spaIndexPath = `${spaDistDir}/index.html`;
2926
+ if (!entries.has(spaIndexPath)) {
2927
+ issues.push({ path: spaIndexPath, message: "required file missing from archive" });
2928
+ }
2929
+ for (const entryPath of envelope.entries) {
2930
+ if (!entries.has(entryPath)) {
2931
+ issues.push({
2932
+ path: entryPath,
2933
+ message: "listed in manifest.entries but missing from archive"
2934
+ });
2935
+ }
2936
+ }
2937
+ if (issues.length) return { ok: false, issues };
2938
+ let interchangeRaw;
2939
+ try {
2940
+ interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
2941
+ } catch {
2942
+ return {
2943
+ ok: false,
2944
+ issues: [{ path: "interchange.json", message: "invalid JSON" }]
2945
+ };
2946
+ }
2947
+ const interchangeParsed = (0, import_validators3.parseLessonkitInterchange)(interchangeRaw);
2948
+ if (!interchangeParsed.ok) {
2949
+ return {
2950
+ ok: false,
2951
+ issues: interchangeParsed.issues.map((i) => ({
2952
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2953
+ message: i.message
2954
+ }))
2955
+ };
2956
+ }
2957
+ const interchange = interchangeParsed.data;
2958
+ const interchangeCourseId = interchange.course?.id;
2959
+ if (!interchangeCourseId) {
2960
+ issues.push({
2961
+ path: "interchange.course.id",
2962
+ message: "missing course id in interchange"
2963
+ });
2964
+ } else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
2965
+ issues.push({
2966
+ path: "sourceManifest.course.courseId",
2967
+ message: `does not match interchange.course.id (${interchangeCourseId})`
2968
+ });
2969
+ }
2970
+ if (issues.length) return { ok: false, issues };
2971
+ const blockTreeData = entries.get("block-tree.json");
2972
+ if (blockTreeData) {
2973
+ let blockTreeRaw;
2974
+ try {
2975
+ blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
2976
+ } catch {
2977
+ return {
2978
+ ok: false,
2979
+ issues: [{ path: "block-tree.json", message: "invalid JSON" }]
2980
+ };
2981
+ }
2982
+ const blockTree = blockTreeRaw;
2983
+ if (Array.isArray(blockTree?.blocks)) {
2984
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2985
+ if (blockTreeIssues.length) {
2986
+ return {
2987
+ ok: false,
2988
+ issues: blockTreeIssues.map((issue) => ({
2989
+ path: `block-tree.${issue.path}`,
2990
+ message: issue.message
2991
+ }))
2992
+ };
2993
+ }
2994
+ }
2995
+ }
2996
+ return {
2997
+ ok: true,
2998
+ envelope,
2999
+ interchange
3000
+ };
3001
+ }
3002
+ function validateLkcourse(archivePath) {
3003
+ const read = readZip(archivePath);
3004
+ if (!read.ok) return read;
3005
+ return validateLkcourseArchiveEntries(read.entries, archivePath);
3006
+ }
3007
+
3008
+ // src/lkcourse/import.ts
3009
+ var import_promises4 = require("fs/promises");
3010
+ var import_node_path14 = require("path");
3011
+ var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
3012
+ async function pathExists2(path) {
3013
+ try {
3014
+ await (0, import_promises4.access)(path);
3015
+ return true;
3016
+ } catch {
3017
+ return false;
3018
+ }
3019
+ }
3020
+ async function renameOrCopy2(from, to, opts) {
3021
+ const renameFn = opts?.renameFn ?? import_promises4.rename;
3022
+ try {
3023
+ await renameFn(from, to);
3024
+ } catch (err) {
3025
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
3026
+ if (code !== "EXDEV") throw err;
3027
+ await (0, import_promises4.cp)(from, to, { recursive: true });
3028
+ await (0, import_promises4.rm)(from, { recursive: true, force: true });
3029
+ }
3030
+ }
3031
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
3032
+ let fileCount = 0;
3033
+ await (0, import_promises4.writeFile)(
3034
+ (0, import_node_path14.join)(stagingDir, "lessonkit.json"),
3035
+ `${JSON.stringify(manifest, null, 2)}
3036
+ `,
3037
+ "utf8"
3038
+ );
3039
+ fileCount += 1;
3040
+ for (const [entryPath, data] of entries) {
3041
+ const normalized = entryPath.replace(/\\/g, "/");
3042
+ if (!normalized.startsWith(`${spaDistDir}/`)) continue;
3043
+ const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
3044
+ const outPath = (0, import_node_path14.join)(stagingDir, spaDistDir, relativeUnderSpa);
3045
+ const resolvedOut = (0, import_node_path14.resolve)(outPath);
3046
+ assertRealPathUnderRoot(stagingDir, resolvedOut);
3047
+ if (!isSafeZipEntryPath((0, import_node_path14.join)(spaDistDir, relativeUnderSpa))) {
3048
+ throw new Error(`unsafe extraction path: ${entryPath}`);
3049
+ }
3050
+ await (0, import_promises4.mkdir)((0, import_node_path14.dirname)(resolvedOut), { recursive: true });
3051
+ await (0, import_promises4.writeFile)(resolvedOut, data);
3052
+ fileCount += 1;
3053
+ }
3054
+ return fileCount;
3055
+ }
3056
+ async function backupImportArtifacts(targetDir) {
3057
+ const existing = [];
3058
+ for (const name of IMPORT_ARTIFACTS) {
3059
+ if (await pathExists2((0, import_node_path14.join)(targetDir, name))) {
3060
+ existing.push(name);
3061
+ }
3062
+ }
3063
+ if (!existing.length) return void 0;
3064
+ const backupDir = await (0, import_promises4.mkdtemp)((0, import_node_path14.join)(targetDir, ".lkcourse-backup-"));
3065
+ for (const name of existing) {
3066
+ await renameOrCopy2((0, import_node_path14.join)(targetDir, name), (0, import_node_path14.join)(backupDir, name));
3067
+ }
3068
+ return backupDir;
3069
+ }
3070
+ async function restoreImportBackup(targetDir, backupDir) {
3071
+ for (const name of IMPORT_ARTIFACTS) {
3072
+ const backupPath = (0, import_node_path14.join)(backupDir, name);
3073
+ if (!await pathExists2(backupPath)) continue;
3074
+ const destPath = (0, import_node_path14.join)(targetDir, name);
3075
+ if (await pathExists2(destPath)) {
3076
+ await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
3077
+ }
3078
+ await renameOrCopy2(backupPath, destPath);
3079
+ }
3080
+ }
3081
+ async function promoteImportStaging(stagingDir, targetDir) {
3082
+ const entries = await (0, import_promises4.readdir)(stagingDir, { withFileTypes: true });
3083
+ for (const entry of entries) {
3084
+ const srcPath = (0, import_node_path14.join)(stagingDir, entry.name);
3085
+ const destPath = (0, import_node_path14.join)(targetDir, entry.name);
3086
+ if (entry.isDirectory()) {
3087
+ await (0, import_promises4.cp)(srcPath, destPath, { recursive: true, force: true });
3088
+ } else if (entry.isFile()) {
3089
+ await (0, import_promises4.mkdir)((0, import_node_path14.dirname)(destPath), { recursive: true });
3090
+ await (0, import_promises4.cp)(srcPath, destPath);
3091
+ }
3092
+ }
3093
+ }
3094
+ var promoteImportStagingImpl = promoteImportStaging;
3095
+ async function importLkcourse(options) {
3096
+ const archivePath = (0, import_node_path14.resolve)(options.archivePath);
3097
+ const targetDir = (0, import_node_path14.resolve)(options.targetDir);
3098
+ const validated = validateLkcourse(archivePath);
3099
+ if (!validated.ok) return validated;
3100
+ const { envelope, interchange } = validated;
3101
+ const manifest = envelope.sourceManifest;
3102
+ const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
3103
+ try {
3104
+ await (0, import_promises4.mkdir)(targetDir, { recursive: true });
3105
+ assertRealPathUnderRoot(targetDir, targetDir);
3106
+ } catch (err) {
3107
+ return {
3108
+ ok: false,
3109
+ issues: [
3110
+ {
3111
+ path: targetDir,
3112
+ message: err instanceof Error ? err.message : String(err)
3113
+ }
3114
+ ]
3115
+ };
3116
+ }
3117
+ const read = readZip(archivePath);
3118
+ if (!read.ok) return read;
3119
+ let stagingDir;
3120
+ let backupDir;
3121
+ try {
3122
+ stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path14.join)(targetDir, ".lkcourse-import-"));
3123
+ const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
3124
+ backupDir = await backupImportArtifacts(targetDir);
3125
+ try {
3126
+ await promoteImportStagingImpl(stagingDir, targetDir);
3127
+ } catch (promoteError) {
3128
+ if (backupDir) {
3129
+ await restoreImportBackup(targetDir, backupDir);
3130
+ }
3131
+ throw promoteError;
3132
+ }
3133
+ if (backupDir) {
3134
+ await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
3135
+ backupDir = void 0;
3136
+ }
3137
+ await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true });
3138
+ stagingDir = void 0;
3139
+ return {
3140
+ ok: true,
3141
+ targetDir,
3142
+ manifest,
3143
+ interchange,
3144
+ fileCount
3145
+ };
3146
+ } catch (err) {
3147
+ if (backupDir) {
3148
+ await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3149
+ await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
3150
+ }
3151
+ if (stagingDir) {
3152
+ await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true }).catch(() => void 0);
3153
+ }
3154
+ return {
3155
+ ok: false,
3156
+ issues: [
3157
+ {
3158
+ path: targetDir,
3159
+ message: err instanceof Error ? err.message : String(err)
3160
+ }
3161
+ ]
3162
+ };
3163
+ }
3164
+ }
2177
3165
  // Annotate the CommonJS export names for ESM import in node:
2178
3166
  0 && (module.exports = {
2179
3167
  LESSONKIT_TELEMETRY_EVENTS,
3168
+ assertSpaDistContentsSafe,
2180
3169
  assessmentDescriptorToLxpack,
2181
3170
  buildLessonkitProject,
2182
3171
  buildStagingPackage,
2183
3172
  descriptorToInterchange,
2184
3173
  ensureOutDirParent,
2185
3174
  escapeShellText,
3175
+ exportLkcourse,
2186
3176
  extractAssessments,
3177
+ extractBlockTree,
3178
+ importLkcourse,
2187
3179
  lessonkitInterchangeSchema,
2188
3180
  loadLessonkitManifestFromFile,
2189
3181
  mapLessonkitIds,
@@ -2193,6 +3185,7 @@ var import_validators2 = require("@lxpack/validators");
2193
3185
  packageLessonkitCourse,
2194
3186
  parseLessonkitInterchange,
2195
3187
  parseLessonkitManifest,
3188
+ parseLkcourseEnvelope,
2196
3189
  promoteStagingToOutDir,
2197
3190
  remapArtifactPaths,
2198
3191
  resolveSafePackageOutputOverride,
@@ -2202,6 +3195,8 @@ var import_validators2 = require("@lxpack/validators");
2202
3195
  validateDescriptor,
2203
3196
  validateDescriptorForTarget,
2204
3197
  validateLessonkitProject,
3198
+ validateLkcourse,
3199
+ validateLkcourseArchiveEntries,
2205
3200
  validatePackageInputs,
2206
3201
  validateProjectPaths,
2207
3202
  validateReactManifestParity,