@lessonkit/lxpack 1.4.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,21 +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,
40
+ escapeShellText: () => escapeShellText,
41
+ exportLkcourse: () => exportLkcourse,
39
42
  extractAssessments: () => extractAssessments,
40
- lessonkitInterchangeSchema: () => import_validators2.lessonkitInterchangeSchema,
43
+ extractBlockTree: () => extractBlockTree,
44
+ importLkcourse: () => importLkcourse,
45
+ lessonkitInterchangeSchema: () => import_validators4.lessonkitInterchangeSchema,
41
46
  loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
42
47
  mapLessonkitIds: () => mapLessonkitIds,
43
48
  mapLessonkitTelemetryToBridgeAction: () => import_tracking_schema2.mapLessonkitTelemetryToBridgeAction,
44
49
  mapLessonkitTelemetryToLxpack: () => import_tracking_schema2.mapLessonkitTelemetryToLxpack,
45
- materializeLessonkitProject: () => import_validators2.materializeLessonkitProject,
50
+ materializeLessonkitProject: () => import_validators4.materializeLessonkitProject,
46
51
  packageLessonkitCourse: () => packageLessonkitCourse,
47
- parseLessonkitInterchange: () => import_validators2.parseLessonkitInterchange,
52
+ parseLessonkitInterchange: () => import_validators4.parseLessonkitInterchange,
48
53
  parseLessonkitManifest: () => parseLessonkitManifest,
54
+ parseLkcourseEnvelope: () => parseLkcourseEnvelope,
49
55
  promoteStagingToOutDir: () => promoteStagingToOutDir,
50
56
  remapArtifactPaths: () => remapArtifactPaths,
51
57
  resolveSafePackageOutputOverride: () => resolveSafePackageOutputOverride,
@@ -55,6 +61,8 @@ __export(index_exports, {
55
61
  validateDescriptor: () => validateDescriptor,
56
62
  validateDescriptorForTarget: () => validateDescriptorForTarget,
57
63
  validateLessonkitProject: () => validateLessonkitProject,
64
+ validateLkcourse: () => validateLkcourse,
65
+ validateLkcourseArchiveEntries: () => validateLkcourseArchiveEntries,
58
66
  validatePackageInputs: () => validatePackageInputs,
59
67
  validateProjectPaths: () => validateProjectPaths,
60
68
  validateReactManifestParity: () => validateReactManifestParity,
@@ -159,10 +167,18 @@ function parseAssessmentDescriptor(raw) {
159
167
  };
160
168
  const kind = raw.kind;
161
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
+ }
162
178
  return {
163
179
  kind: "trueFalse",
164
180
  ...base,
165
- answer: typeof raw.answer === "boolean" ? raw.answer : raw.answer === "true"
181
+ answer
166
182
  };
167
183
  }
168
184
  if (kind === "fillInBlanks") {
@@ -340,6 +356,98 @@ function isResolvedPathUnderRoot(root, target) {
340
356
  return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
341
357
  }
342
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
+
343
451
  // src/theme.ts
344
452
  var import_themes = require("@lessonkit/themes");
345
453
  function themeToLxpackRuntime(input) {
@@ -404,8 +512,52 @@ var ASSESSMENT_VALIDATORS = {
404
512
  }
405
513
  },
406
514
  fillInBlanks: (assessment, path, issues) => {
407
- if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
515
+ if (assessment.kind !== "fillInBlanks") return;
516
+ if (!assessment.template?.trim()) {
408
517
  issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
518
+ return;
519
+ }
520
+ const templateBlankCount = countStarDelimitedBlanks(assessment.template);
521
+ if (templateBlankCount === 0) {
522
+ issues.push({
523
+ path: `${path}.template`,
524
+ message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
525
+ });
526
+ }
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`))) {
551
+ issues.push({
552
+ path: `${path}.blanks`,
553
+ message: "blanks must include at least one entry with non-empty id and answer"
554
+ });
555
+ }
556
+ if (explicitBlanks.length > 0 && explicitBlanks.length !== templateBlankCount) {
557
+ issues.push({
558
+ path: `${path}.blanks`,
559
+ message: `blanks length (${explicitBlanks.length}) must match template blank count (${templateBlankCount})`
560
+ });
409
561
  }
410
562
  },
411
563
  findHotspot: (assessment, path, issues) => {
@@ -543,6 +695,20 @@ function validateCourseDescriptor(input) {
543
695
  });
544
696
  }
545
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
+ }
546
712
  if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
547
713
  issues.push({
548
714
  path: "lessons",
@@ -603,27 +769,49 @@ function validateCourseDescriptor(input) {
603
769
  }
604
770
 
605
771
  // src/assessments.ts
772
+ var DEFAULT_SHELL_PASSING_SCORE = 1;
773
+ function escapeShellText(text) {
774
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
775
+ }
776
+ function decodeShellEntities(text) {
777
+ return text.replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)));
778
+ }
779
+ function containsUnsafeShellMarkup(text) {
780
+ const decoded = decodeShellEntities(text);
781
+ return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
782
+ }
783
+ function sanitizeShellField(text) {
784
+ if (containsUnsafeShellMarkup(text)) return null;
785
+ return escapeShellText(text);
786
+ }
606
787
  function slugChoiceId(text, index) {
607
788
  const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
608
789
  const stem = base.length ? base : "choice";
609
790
  return `${stem}-${index + 1}`;
610
791
  }
611
792
  function mcqToLxpack(assessment) {
793
+ const checkId = sanitizeShellField(assessment.checkId);
794
+ const prompt = sanitizeShellField(assessment.question);
795
+ if (!checkId || !prompt) return null;
796
+ const normalizedAnswer = assessment.answer.trim();
612
797
  const choices = assessment.choices.map((text, index) => {
798
+ const sanitizedText = sanitizeShellField(text);
799
+ if (!sanitizedText) return null;
613
800
  const id = slugChoiceId(text, index);
614
801
  return {
615
802
  id,
616
- text,
617
- correct: text === assessment.answer
803
+ text: sanitizedText,
804
+ correct: text.trim() === normalizedAnswer
618
805
  };
619
806
  });
807
+ if (choices.some((choice) => choice === null)) return null;
620
808
  return {
621
- id: assessment.checkId,
622
- passingScore: assessment.passingScore ?? 1,
809
+ id: checkId,
810
+ passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
623
811
  questions: [
624
812
  {
625
813
  id: "q1",
626
- prompt: assessment.question,
814
+ prompt,
627
815
  choices
628
816
  }
629
817
  ]
@@ -664,11 +852,14 @@ function extractAssessments(descriptor) {
664
852
  // src/descriptor/validateInjectableAssessments.ts
665
853
  function validateInjectableAssessments(descriptor) {
666
854
  const issues = [];
855
+ const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
667
856
  (descriptor.assessments ?? []).forEach((assessment, index) => {
668
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)" : "";
669
860
  issues.push({
670
861
  path: `assessments[${index}]`,
671
- 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}`
672
863
  });
673
864
  }
674
865
  });
@@ -683,22 +874,29 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
683
874
  "xapi",
684
875
  "cmi5"
685
876
  ]);
877
+ function appendActivityIriIssues(issues, descriptor, target) {
878
+ const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
879
+ const requiresForTarget = target === "xapi" || target === "cmi5";
880
+ if (!hasXapiTracking && !requiresForTarget) return;
881
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
882
+ const targetSuffix = target === "xapi" || target === "cmi5" ? ` for ${target} export targets` : " when tracking.xapi is configured";
883
+ if (!activityIri) {
884
+ issues.push({
885
+ path: "tracking.xapi.activityIri",
886
+ message: `tracking.xapi.activityIri is required${targetSuffix}`
887
+ });
888
+ return;
889
+ }
890
+ if (!/^https:\/\/.+/i.test(activityIri)) {
891
+ issues.push({
892
+ path: "tracking.xapi.activityIri",
893
+ message: `tracking.xapi.activityIri must be an HTTPS URL${targetSuffix}`
894
+ });
895
+ }
896
+ }
686
897
  function validateDescriptorForExportTarget(descriptor, target) {
687
898
  const issues = [];
688
- if (target === "xapi" || target === "cmi5") {
689
- const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
690
- if (!activityIri) {
691
- issues.push({
692
- path: "tracking.xapi.activityIri",
693
- message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
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
- });
700
- }
701
- }
899
+ appendActivityIriIssues(issues, descriptor, target);
702
900
  if (LMS_SHELL_TARGETS.has(target)) {
703
901
  issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
704
902
  ...issue,
@@ -732,40 +930,123 @@ function validateDescriptorForTarget(input, target) {
732
930
  }
733
931
 
734
932
  // src/validateReactParity.ts
735
- var import_node_fs2 = require("fs");
736
- var import_node_path2 = require("path");
933
+ var import_node_fs3 = require("fs");
934
+ var import_node_path3 = require("path");
737
935
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
738
- function collectSourceUnderSrc(projectRoot) {
739
- const srcDir = (0, import_node_path2.join)(projectRoot, "src");
740
- if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
936
+ function collectSourceUnderSrc(projectRoot, issues) {
937
+ const srcDir = (0, import_node_path3.join)(projectRoot, "src");
938
+ if (!(0, import_node_fs3.existsSync)(srcDir)) return [];
741
939
  const results = [];
742
940
  const walk = (dir) => {
743
- for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
744
- const abs = (0, import_node_path2.join)(dir, entry);
745
- if ((0, import_node_fs2.statSync)(abs).isDirectory()) {
941
+ for (const entry of (0, import_node_fs3.readdirSync)(dir)) {
942
+ const abs = (0, import_node_path3.join)(dir, entry);
943
+ let stat2;
944
+ try {
945
+ stat2 = (0, import_node_fs3.lstatSync)(abs);
946
+ } catch {
947
+ continue;
948
+ }
949
+ if (stat2.isSymbolicLink()) {
950
+ issues.push({
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)}`,
953
+ severity: "error"
954
+ });
955
+ continue;
956
+ }
957
+ if (stat2.isDirectory()) {
958
+ try {
959
+ assertRealPathUnderRoot(projectRoot, abs);
960
+ } catch {
961
+ issues.push({
962
+ path: (0, import_node_path3.relative)(projectRoot, abs),
963
+ message: `Source directory escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
964
+ severity: "error"
965
+ });
966
+ continue;
967
+ }
746
968
  walk(abs);
747
969
  } else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
748
- results.push((0, import_node_path2.relative)(projectRoot, abs));
970
+ try {
971
+ assertRealPathUnderRoot(projectRoot, abs);
972
+ } catch {
973
+ issues.push({
974
+ path: (0, import_node_path3.relative)(projectRoot, abs),
975
+ message: `Source file escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
976
+ severity: "error"
977
+ });
978
+ continue;
979
+ }
980
+ results.push((0, import_node_path3.relative)(projectRoot, abs));
749
981
  }
750
982
  }
751
983
  };
752
984
  walk(srcDir);
753
985
  return results;
754
986
  }
755
- function readAppSources(projectRoot, appSources) {
756
- return appSources.map((rel) => (0, import_node_path2.join)(projectRoot, rel)).filter((abs) => (0, import_node_fs2.existsSync)(abs)).map((abs) => (0, import_node_fs2.readFileSync)(abs, "utf8")).join("\n");
987
+ function readAppSources(projectRoot, appSources, issues, customSourcesProvided) {
988
+ return appSources.map((rel) => {
989
+ if (!isSafeRelativeSpaPath(rel)) {
990
+ if (customSourcesProvided) {
991
+ issues.push({
992
+ path: rel,
993
+ message: `Unsafe appSources path skipped: ${rel}`,
994
+ severity: "warning"
995
+ });
996
+ }
997
+ return null;
998
+ }
999
+ const abs = (0, import_node_path3.join)(projectRoot, rel);
1000
+ try {
1001
+ assertRealPathUnderRoot(projectRoot, abs);
1002
+ if ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.lstatSync)(abs).isSymbolicLink()) {
1003
+ issues.push({
1004
+ path: rel,
1005
+ message: `appSources path is a symlink: ${rel}`,
1006
+ severity: "error"
1007
+ });
1008
+ return null;
1009
+ }
1010
+ } catch {
1011
+ issues.push({
1012
+ path: rel,
1013
+ message: `appSources path escapes project root: ${rel}`,
1014
+ severity: "error"
1015
+ });
1016
+ return null;
1017
+ }
1018
+ if (!(0, import_node_fs3.existsSync)(abs)) return null;
1019
+ return (0, import_node_fs3.readFileSync)(abs, "utf8");
1020
+ }).filter((content) => content != null).join("\n");
757
1021
  }
758
1022
  function stripComments(source) {
759
1023
  return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
760
1024
  }
761
- function idPropPatterns(prop, id) {
762
- return [
763
- `${prop}="${id}"`,
764
- `${prop}='${id}'`,
765
- `${prop}={'${id}'}`,
766
- `${prop}={"${id}"}`,
767
- `${prop}={\`${id}\`}`
768
- ];
1025
+ function maskUnrelatedStringLiterals(source) {
1026
+ return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (match, _quote, offset, full) => {
1027
+ const before = full.slice(Math.max(0, offset - 24), offset);
1028
+ if (/\b(?:courseId|checkId|lessonId)\s*=\s*$/.test(before)) {
1029
+ return match;
1030
+ }
1031
+ return '""';
1032
+ });
1033
+ }
1034
+ function idPropPresent(source, prop, id) {
1035
+ const stripped = stripComments(source);
1036
+ const masked = maskUnrelatedStringLiterals(stripped);
1037
+ return jsxPropRegex(prop, id).test(masked);
1038
+ }
1039
+ function escapeRegExp(value) {
1040
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1041
+ }
1042
+ function jsxPropRegex(prop, id) {
1043
+ const escapedId = escapeRegExp(id);
1044
+ return new RegExp(
1045
+ `(?<![A-Za-z0-9_$])${prop}\\s*=\\s*(?:"${escapedId}"|'${escapedId}'|\\{\\s*["'\`]${escapedId}["'\`]\\s*\\}|\\{\\s*\`${escapedId}\`\\s*\\})`
1046
+ );
1047
+ }
1048
+ function maskStringLiterals(source) {
1049
+ return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
769
1050
  }
770
1051
  function extractStringConstants(source) {
771
1052
  const stripped = stripComments(source);
@@ -776,7 +1057,9 @@ function extractStringConstants(source) {
776
1057
  }
777
1058
  return map;
778
1059
  }
779
- function idUsedViaConstant(stripped, prop, id, constants) {
1060
+ function idUsedViaConstant(source, prop, id, constants) {
1061
+ const stripped = stripComments(source);
1062
+ const masked = maskStringLiterals(stripped);
780
1063
  for (const [name, value] of constants) {
781
1064
  if (value !== id) continue;
782
1065
  const jsxPatterns = [
@@ -785,40 +1068,72 @@ function idUsedViaConstant(stripped, prop, id, constants) {
785
1068
  `${prop}={${name} }`,
786
1069
  `${prop}={ ${name}}`
787
1070
  ];
788
- if (jsxPatterns.some((p) => stripped.includes(p))) return true;
789
- const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
790
- if (objPatterns.some((p) => stripped.includes(p))) return true;
1071
+ if (jsxPatterns.some((p) => masked.includes(p))) return true;
791
1072
  }
792
1073
  return false;
793
1074
  }
794
- function courseIdPresent(source, courseId) {
1075
+ function lessonIdInDataLiteral(source, lessonId) {
795
1076
  const stripped = stripComments(source);
796
- if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
797
- return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
1077
+ const escaped = escapeRegExp(lessonId);
1078
+ return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
798
1079
  }
799
- function checkIdPresent(source, checkId) {
1080
+ function lessonIdPresent(source, lessonId) {
1081
+ if (idPropPresent(source, "lessonId", lessonId)) return true;
1082
+ if (idUsedViaConstant(source, "lessonId", lessonId, extractStringConstants(source))) return true;
1083
+ return lessonIdInDataLiteral(source, lessonId);
1084
+ }
1085
+ function courseConfigCourseIdPresent(source, courseId) {
800
1086
  const stripped = stripComments(source);
801
- if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
802
- return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
1087
+ const escaped = escapeRegExp(courseId);
1088
+ const literalPattern = new RegExp(
1089
+ `(?<![A-Za-z0-9_$])courseId\\s*:\\s*(?:"${escaped}"|'${escaped}')`
1090
+ );
1091
+ if (literalPattern.test(stripped)) return true;
1092
+ return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
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
+ }
1104
+ function courseIdPresent(source, courseId) {
1105
+ if (idPropPresent(source, "courseId", courseId)) return true;
1106
+ if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
1107
+ if (courseMetaCourseIdPresent(source, courseId)) return true;
1108
+ return courseConfigCourseIdPresent(source, courseId);
1109
+ }
1110
+ function checkIdPresent(source, checkId) {
1111
+ if (idPropPresent(source, "checkId", checkId)) return true;
1112
+ return idUsedViaConstant(source, "checkId", checkId, extractStringConstants(source));
803
1113
  }
804
1114
  var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
805
1115
  function parityHint(message) {
806
1116
  return `${message} See ${ID_SYNC_DOC}`;
807
1117
  }
808
1118
  function validateReactManifestParity(opts) {
809
- const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
810
- const source = readAppSources(opts.projectRoot, appSources);
1119
+ const issues = [];
1120
+ const customSourcesProvided = opts.appSources !== void 0;
1121
+ const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot, issues);
1122
+ const source = readAppSources(
1123
+ opts.projectRoot,
1124
+ appSources,
1125
+ issues,
1126
+ customSourcesProvided
1127
+ );
811
1128
  const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
812
1129
  if (!source.trim()) {
813
- return [
814
- {
815
- path: appSources.length > 0 ? appSources.join(", ") : "src/",
816
- message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
817
- severity: hasDescriptorIds ? "error" : "warning"
818
- }
819
- ];
1130
+ issues.push({
1131
+ path: appSources.length > 0 ? appSources.join(", ") : "src/",
1132
+ message: hasDescriptorIds ? "React app source required for ID parity check when descriptor defines courseId or assessments" : "React app source not found for ID parity check",
1133
+ severity: hasDescriptorIds ? "error" : "warning"
1134
+ });
1135
+ return issues;
820
1136
  }
821
- const issues = [];
822
1137
  const courseId = opts.descriptor.courseId;
823
1138
  if (!courseIdPresent(source, courseId)) {
824
1139
  issues.push({
@@ -829,6 +1144,19 @@ function validateReactManifestParity(opts) {
829
1144
  severity: "error"
830
1145
  });
831
1146
  }
1147
+ for (const lesson of opts.descriptor.lessons ?? []) {
1148
+ const lessonId = lesson.id;
1149
+ if (!lessonId) continue;
1150
+ if (!lessonIdPresent(source, lessonId)) {
1151
+ issues.push({
1152
+ path: `lessons.id:${lessonId}`,
1153
+ message: parityHint(
1154
+ `React app source missing lessonId="${lessonId}" declared in lessonkit.json.`
1155
+ ),
1156
+ severity: "error"
1157
+ });
1158
+ }
1159
+ }
832
1160
  for (const assessment of opts.descriptor.assessments ?? []) {
833
1161
  const checkId = assessment.checkId;
834
1162
  if (!checkId) continue;
@@ -845,58 +1173,6 @@ function validateReactManifestParity(opts) {
845
1173
  return issues;
846
1174
  }
847
1175
 
848
- // src/validateProjectPaths.ts
849
- var import_node_path3 = require("path");
850
- function validatePathField(value, fieldPath, projectRoot, issues) {
851
- if (!isSafeRelativeSpaPath(value)) {
852
- issues.push({
853
- path: fieldPath,
854
- message: "path must be relative without '..' segments or absolute prefixes"
855
- });
856
- return;
857
- }
858
- try {
859
- assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
860
- } catch {
861
- issues.push({
862
- path: fieldPath,
863
- message: "path must resolve inside the project root"
864
- });
865
- }
866
- }
867
- function validateProjectPaths(projectRoot, paths) {
868
- const issues = [];
869
- const root = (0, import_node_path3.resolve)(projectRoot);
870
- if (paths.spaDistDir?.trim()) {
871
- validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
872
- }
873
- if (paths.lxpackOutDir?.trim()) {
874
- validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues);
875
- }
876
- if (paths.outputBaseDir?.trim()) {
877
- validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues);
878
- }
879
- return issues;
880
- }
881
- function resolveSafePackageOutputOverride(projectRoot, override) {
882
- const root = (0, import_node_path3.resolve)(projectRoot);
883
- const trimmed = override.trim();
884
- if (!trimmed) {
885
- throw new Error("output override must be a non-empty path");
886
- }
887
- if ((0, import_node_path3.isAbsolute)(trimmed)) {
888
- const resolved2 = (0, import_node_path3.resolve)(trimmed);
889
- assertRealPathUnderRoot(root, resolved2);
890
- return resolved2;
891
- }
892
- if (!isSafeRelativeSpaPath(trimmed)) {
893
- throw new Error(`unsafe output path: ${override}`);
894
- }
895
- const resolved = (0, import_node_path3.resolve)(root, trimmed);
896
- assertRealPathUnderRoot(root, resolved);
897
- return resolved;
898
- }
899
-
900
1176
  // src/mapIds.ts
901
1177
  var import_core4 = require("@lessonkit/core");
902
1178
  function mapLessonkitIds(descriptor) {
@@ -1028,7 +1304,7 @@ async function resolveSpaDirs(options) {
1028
1304
 
1029
1305
  // src/spaDistValidation.ts
1030
1306
  var import_promises2 = require("fs/promises");
1031
- var import_node_fs3 = require("fs");
1307
+ var import_node_fs4 = require("fs");
1032
1308
  var import_node_path5 = require("path");
1033
1309
  async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1034
1310
  for (const [label, dir] of Object.entries(spaDirs)) {
@@ -1039,7 +1315,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1039
1315
  }
1040
1316
  let rootReal;
1041
1317
  try {
1042
- rootReal = (0, import_node_fs3.realpathSync)(dirResolved);
1318
+ rootReal = (0, import_node_fs4.realpathSync)(dirResolved);
1043
1319
  } catch {
1044
1320
  throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
1045
1321
  }
@@ -1068,9 +1344,12 @@ async function walkDistDir(rootReal, current, label) {
1068
1344
  }
1069
1345
  let entryReal;
1070
1346
  try {
1071
- entryReal = (0, import_node_fs3.realpathSync)(entryPath);
1072
- } catch {
1073
- entryReal = entryPath;
1347
+ entryReal = (0, import_node_fs4.realpathSync)(entryPath);
1348
+ } catch (err) {
1349
+ throw new Error(
1350
+ `spa dist for "${label}" could not resolve path: ${entryPath}`,
1351
+ { cause: err }
1352
+ );
1074
1353
  }
1075
1354
  assertResolvedPathUnderRoot(rootReal, entryReal);
1076
1355
  if (stat2.isDirectory()) {
@@ -1090,12 +1369,12 @@ async function writeLxpackProject(options) {
1090
1369
  const descriptor = validation.descriptor;
1091
1370
  const injectableIssues = validateInjectableAssessments(descriptor);
1092
1371
  if (injectableIssues.length > 0) {
1093
- 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
+ );
1094
1375
  }
1095
1376
  const outDir = (0, import_node_path6.resolve)(options.outDir);
1096
- if (options.projectRoot) {
1097
- assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
1098
- }
1377
+ assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
1099
1378
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1100
1379
  await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
1101
1380
  const interchange = descriptorToInterchange(descriptor);
@@ -1155,6 +1434,19 @@ function validatePackageInputs(options) {
1155
1434
  ]
1156
1435
  };
1157
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
+ }
1158
1450
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1159
1451
  return {
1160
1452
  ok: false,
@@ -1212,6 +1504,19 @@ function validatePackageInputs(options) {
1212
1504
  ]
1213
1505
  };
1214
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
+ }
1215
1520
  }
1216
1521
  if (output) {
1217
1522
  const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
@@ -1233,28 +1538,57 @@ function validatePackageInputs(options) {
1233
1538
  ]
1234
1539
  };
1235
1540
  }
1236
- }
1237
- return { ok: true, outDir, projectRoot };
1238
- }
1239
- function validateArtifactInStaging(stagingRoot, artifactPath, field) {
1240
- if (!artifactPath) return null;
1241
- const resolved = resolveComparablePath(artifactPath);
1242
- if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1243
- return {
1244
- path: field,
1245
- message: `${field} is outside the staging directory: ${artifactPath}`
1246
- };
1247
- }
1248
- return null;
1249
- }
1250
- function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1251
- if (!artifactPath) return void 0;
1252
- const resolved = resolveComparablePath(artifactPath);
1253
- if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1254
- throw new Error(`${artifactPath} is outside the staging directory`);
1255
- }
1256
- const rel = relativePathUnderRoot(stagingRoot, resolved);
1257
- if (rel.startsWith("..") || (0, import_node_path7.isAbsolute)(rel)) {
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
+ }
1570
+ }
1571
+ return { ok: true, outDir, projectRoot };
1572
+ }
1573
+ function validateArtifactInStaging(stagingRoot, artifactPath, field) {
1574
+ if (!artifactPath) return null;
1575
+ const resolved = resolveComparablePath(artifactPath);
1576
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1577
+ return {
1578
+ path: field,
1579
+ message: `${field} is outside the staging directory: ${artifactPath}`
1580
+ };
1581
+ }
1582
+ return null;
1583
+ }
1584
+ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1585
+ if (!artifactPath) return void 0;
1586
+ const resolved = resolveComparablePath(artifactPath);
1587
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1588
+ throw new Error(`${artifactPath} is outside the staging directory`);
1589
+ }
1590
+ const rel = relativePathUnderRoot(stagingRoot, resolved);
1591
+ if (rel.startsWith("..") || (0, import_node_path7.isAbsolute)(rel)) {
1258
1592
  throw new Error(`${artifactPath} is outside the staging directory`);
1259
1593
  }
1260
1594
  if (!rel) return outDir;
@@ -1291,33 +1625,53 @@ function promoteLockPath(outDir) {
1291
1625
  const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path8.resolve)(outDir)).digest("hex").slice(0, 16);
1292
1626
  return (0, import_node_path8.join)(parent, `.lk-promote-lock-${hash}`);
1293
1627
  }
1294
- var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1628
+ var STALE_ARTIFACT_TTL_MS = 5 * 60 * 1e3;
1629
+ var MAX_LOCK_AGE_MS = 30 * 60 * 1e3;
1630
+ var LOCK_TOKEN_RE = /^(\d+)\n([0-9a-f-]{36})(?:\n(\d+))?\n?$/i;
1295
1631
  async function isStalePromoteLock(lockPath) {
1296
1632
  try {
1633
+ const stat2 = await fsp.stat(lockPath);
1297
1634
  const content = await fsp.readFile(lockPath, "utf8");
1298
- const pid = Number.parseInt(content.trim(), 10);
1299
- if (Number.isFinite(pid) && pid > 0) {
1300
- try {
1301
- process.kill(pid, 0);
1302
- return false;
1303
- } catch {
1304
- return true;
1635
+ const match = content.match(LOCK_TOKEN_RE);
1636
+ let lockAgeMs = Date.now() - stat2.mtimeMs;
1637
+ if (match?.[3]) {
1638
+ const startedAt = Number.parseInt(match[3], 10);
1639
+ if (Number.isFinite(startedAt) && startedAt > 0) {
1640
+ lockAgeMs = Date.now() - startedAt;
1305
1641
  }
1306
1642
  }
1307
- const stat2 = await fsp.stat(lockPath);
1308
- return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
1643
+ if (lockAgeMs > MAX_LOCK_AGE_MS) {
1644
+ return true;
1645
+ }
1646
+ if (match) {
1647
+ const pid = Number.parseInt(match[1], 10);
1648
+ if (Number.isFinite(pid) && pid > 0) {
1649
+ try {
1650
+ process.kill(pid, 0);
1651
+ return false;
1652
+ } catch {
1653
+ return true;
1654
+ }
1655
+ }
1656
+ }
1657
+ return lockAgeMs > STALE_ARTIFACT_TTL_MS;
1309
1658
  } catch {
1310
1659
  return true;
1311
1660
  }
1312
1661
  }
1662
+ var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1313
1663
  async function withPromoteLock(outDir, fn) {
1314
1664
  const lockPath = promoteLockPath(outDir);
1315
1665
  await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1316
1666
  let lockHandle;
1317
- for (let attempt = 0; attempt < 200; attempt++) {
1667
+ const maxAttempts = 400;
1668
+ const started = Date.now();
1669
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1318
1670
  try {
1319
1671
  lockHandle = await fsp.open(lockPath, "wx");
1320
1672
  await lockHandle.writeFile(`${process.pid}
1673
+ ${(0, import_node_crypto.randomUUID)()}
1674
+ ${Date.now()}
1321
1675
  `, "utf8");
1322
1676
  break;
1323
1677
  } catch (err) {
@@ -1330,7 +1684,9 @@ async function withPromoteLock(outDir, fn) {
1330
1684
  );
1331
1685
  continue;
1332
1686
  }
1333
- 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));
1334
1690
  }
1335
1691
  }
1336
1692
  if (!lockHandle) {
@@ -1349,22 +1705,77 @@ async function withPromoteLock(outDir, fn) {
1349
1705
  );
1350
1706
  }
1351
1707
  }
1352
- async function assertNoLegacyPromoteArtifacts(outDir) {
1708
+ async function removeStaleLegacyPromoteArtifacts(outDir) {
1353
1709
  const legacyTmp = `${outDir}.tmp-promote`;
1354
1710
  const legacyBak = `${outDir}.bak`;
1355
- const stale = [];
1356
- if (await pathExists(legacyTmp)) stale.push(legacyTmp);
1357
- if (await pathExists(legacyBak)) stale.push(legacyBak);
1358
- if (stale.length) {
1711
+ const blocked = [];
1712
+ for (const legacyPath of [legacyTmp, legacyBak]) {
1713
+ if (!await pathExists(legacyPath)) continue;
1714
+ try {
1715
+ const stat2 = await fsp.stat(legacyPath);
1716
+ if (Date.now() - stat2.mtimeMs > STALE_ARTIFACT_TTL_MS) {
1717
+ await fsp.rm(legacyPath, { recursive: true, force: true }).catch(
1718
+ /* v8 ignore next */
1719
+ () => void 0
1720
+ );
1721
+ continue;
1722
+ }
1723
+ } catch {
1724
+ }
1725
+ blocked.push(legacyPath);
1726
+ }
1727
+ if (blocked.length) {
1728
+ const rmHint = blocked.map((p) => `rm -rf ${JSON.stringify(p)}`).join("; ");
1359
1729
  throw new Error(
1360
- `[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${stale.join(", ")}`
1730
+ `[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${blocked.join(", ")}. Try: ${rmHint}`
1361
1731
  );
1362
1732
  }
1363
1733
  }
1364
- async function promoteStagingToOutDir(stagingDir, outDir) {
1734
+ async function listRelativePaths(root, dir = root) {
1735
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
1736
+ const paths = [];
1737
+ for (const entry of entries) {
1738
+ const full = (0, import_node_path8.join)(dir, entry.name);
1739
+ if (entry.isDirectory()) {
1740
+ paths.push(...await listRelativePaths(root, full));
1741
+ } else if (entry.isFile()) {
1742
+ paths.push(full.slice(root.length + 1));
1743
+ } else {
1744
+ }
1745
+ }
1746
+ return paths;
1747
+ }
1748
+ async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, newArtifactPaths) {
1749
+ if (!await pathExists(priorArtifactsDir)) return;
1750
+ for (const rel of await listRelativePaths(priorArtifactsDir)) {
1751
+ if (newArtifactPaths.has(rel)) continue;
1752
+ const src = (0, import_node_path8.join)(priorArtifactsDir, rel);
1753
+ const dest = (0, import_node_path8.join)(destArtifactsDir, rel);
1754
+ await fsp.mkdir((0, import_node_path8.dirname)(dest), { recursive: true });
1755
+ await fsp.cp(src, dest, { force: true });
1756
+ }
1757
+ }
1758
+ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1759
+ const outputBaseDir = options?.outputBaseDir ?? ".lxpack/out";
1760
+ if (options?.projectRoot) {
1761
+ assertRealPathUnderRoot((0, import_node_path8.resolve)(options.projectRoot), (0, import_node_path8.resolve)(outDir));
1762
+ }
1365
1763
  return withPromoteLock(outDir, async () => {
1366
- await assertNoLegacyPromoteArtifacts(outDir);
1764
+ await removeStaleLegacyPromoteArtifacts(outDir);
1765
+ const stagingArtifactsDir = (0, import_node_path8.join)(stagingDir, outputBaseDir);
1766
+ const newArtifactPaths = /* @__PURE__ */ new Set();
1767
+ if (await pathExists(stagingArtifactsDir)) {
1768
+ for (const rel of await listRelativePaths(stagingArtifactsDir)) {
1769
+ newArtifactPaths.add(rel);
1770
+ }
1771
+ }
1367
1772
  const parent = (0, import_node_path8.dirname)(outDir);
1773
+ let priorArtifactsBackup;
1774
+ const existingArtifactsDir = (0, import_node_path8.join)(outDir, outputBaseDir);
1775
+ if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
1776
+ priorArtifactsBackup = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-prior-out-"));
1777
+ await fsp.cp(existingArtifactsDir, (0, import_node_path8.join)(priorArtifactsBackup, outputBaseDir), { recursive: true });
1778
+ }
1368
1779
  const tmpPromote = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-promote-"));
1369
1780
  await renameOrCopy(stagingDir, tmpPromote);
1370
1781
  const hadOutDir = await pathExists(outDir);
@@ -1421,6 +1832,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1421
1832
  }
1422
1833
  throw promoteError;
1423
1834
  }
1835
+ if (priorArtifactsBackup) {
1836
+ try {
1837
+ await mergePreservedOutArtifacts(
1838
+ (0, import_node_path8.join)(priorArtifactsBackup, outputBaseDir),
1839
+ (0, import_node_path8.join)(outDir, outputBaseDir),
1840
+ newArtifactPaths
1841
+ );
1842
+ } finally {
1843
+ await fsp.rm(priorArtifactsBackup, { recursive: true, force: true }).catch(
1844
+ /* v8 ignore next */
1845
+ () => void 0
1846
+ );
1847
+ }
1848
+ }
1424
1849
  if (backup) {
1425
1850
  await fsp.rm(backup, { recursive: true, force: true }).catch(
1426
1851
  /* v8 ignore next */
@@ -1438,6 +1863,7 @@ var import_api = require("@lxpack/api");
1438
1863
  async function buildStagingPackage(options) {
1439
1864
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1440
1865
  const stagingDir = await fsp2.mkdtemp((0, import_node_path9.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1866
+ let succeeded = false;
1441
1867
  try {
1442
1868
  let spaDirs;
1443
1869
  try {
@@ -1493,6 +1919,7 @@ async function buildStagingPackage(options) {
1493
1919
  }))
1494
1920
  };
1495
1921
  }
1922
+ succeeded = true;
1496
1923
  return {
1497
1924
  ok: true,
1498
1925
  stagingDir,
@@ -1506,6 +1933,13 @@ async function buildStagingPackage(options) {
1506
1933
  () => void 0
1507
1934
  );
1508
1935
  throw err;
1936
+ } finally {
1937
+ if (!succeeded) {
1938
+ await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
1939
+ /* v8 ignore next */
1940
+ () => void 0
1941
+ );
1942
+ }
1509
1943
  }
1510
1944
  }
1511
1945
  async function ensureOutDirParent(outDir) {
@@ -1520,6 +1954,12 @@ function isPackagingErrorIssue(issue) {
1520
1954
  function findPackagingErrorIssues(issues) {
1521
1955
  return (issues ?? []).filter(isPackagingErrorIssue);
1522
1956
  }
1957
+ function isPackagingWarningIssue(issue) {
1958
+ return issue.severity?.toLowerCase() === "warning";
1959
+ }
1960
+ function findPackagingWarningIssues(issues) {
1961
+ return (issues ?? []).filter(isPackagingWarningIssue);
1962
+ }
1523
1963
 
1524
1964
  // src/packageCourse.ts
1525
1965
  async function validateLessonkitProject(options) {
@@ -1570,33 +2010,46 @@ async function packageLessonkitCourse(options) {
1570
2010
  };
1571
2011
  }
1572
2012
  const descriptor = descriptorValidation.descriptor;
1573
- if (writeOpts.projectRoot) {
1574
- const parityIssues = validateReactManifestParity({
1575
- projectRoot: writeOpts.projectRoot,
1576
- descriptor
2013
+ const parityIssues = validateReactManifestParity({
2014
+ projectRoot: writeOpts.projectRoot,
2015
+ descriptor
2016
+ });
2017
+ const parityFailures = writeOpts.strictParity ? parityIssues : parityIssues.filter((i) => i.severity === "error");
2018
+ if (parityFailures.length > 0) {
2019
+ return {
2020
+ ok: false,
2021
+ courseDir: outDir,
2022
+ target,
2023
+ issues: parityFailures.map((i) => ({
2024
+ path: i.path,
2025
+ message: i.message,
2026
+ severity: i.severity
2027
+ }))
2028
+ };
2029
+ }
2030
+ let staged;
2031
+ try {
2032
+ staged = await buildStagingPackage({
2033
+ ...writeOpts,
2034
+ descriptor,
2035
+ target,
2036
+ output,
2037
+ dir,
2038
+ outputBaseDir
1577
2039
  });
1578
- const parityErrors = parityIssues.filter((i) => i.severity === "error");
1579
- if (parityErrors.length > 0) {
1580
- return {
1581
- ok: false,
1582
- courseDir: outDir,
1583
- target,
1584
- issues: parityErrors.map((i) => ({
1585
- path: i.path,
1586
- message: i.message,
1587
- severity: i.severity
1588
- }))
1589
- };
1590
- }
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
+ };
1591
2052
  }
1592
- const staged = await buildStagingPackage({
1593
- ...writeOpts,
1594
- descriptor,
1595
- target,
1596
- output,
1597
- dir,
1598
- outputBaseDir
1599
- });
1600
2053
  if (!staged.ok) {
1601
2054
  await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1602
2055
  /* v8 ignore next */
@@ -1646,11 +2099,30 @@ async function packageLessonkitCourse(options) {
1646
2099
  ok: false,
1647
2100
  courseDir: outDir,
1648
2101
  target,
1649
- validation: { ok: true, manifest: build.manifest, issues: build.issues },
2102
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1650
2103
  build,
1651
2104
  issues: artifactIssues
1652
2105
  };
1653
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
+ }
1654
2126
  const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
1655
2127
  const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
1656
2128
  const validation = {
@@ -1660,7 +2132,10 @@ async function packageLessonkitCourse(options) {
1660
2132
  };
1661
2133
  try {
1662
2134
  await ensureOutDirParent(outDir);
1663
- await promoteStagingToOutDir(stagingDir, outDir);
2135
+ await promoteStagingToOutDir(stagingDir, outDir, {
2136
+ outputBaseDir: outputBaseDir ?? ".lxpack/out",
2137
+ projectRoot: writeOpts.projectRoot
2138
+ });
1664
2139
  } catch (err) {
1665
2140
  await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1666
2141
  /* v8 ignore next */
@@ -1783,6 +2258,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
1783
2258
  message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
1784
2259
  });
1785
2260
  }
2261
+ for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
2262
+ const value = paths[key];
2263
+ if (!isSafeRelativeSpaPath(value)) {
2264
+ issues.push({
2265
+ path: `paths.${key}`,
2266
+ message: "path must be relative without '..' segments or absolute prefixes"
2267
+ });
2268
+ } else if (isReservedOutputPath(value)) {
2269
+ issues.push({
2270
+ path: `paths.${key}`,
2271
+ message: "path must not target reserved directories (.git, node_modules, .github)"
2272
+ });
2273
+ }
2274
+ }
1786
2275
  if (projectRoot) {
1787
2276
  const pathIssues = validateProjectPaths(projectRoot, paths);
1788
2277
  for (const pi of pathIssues) {
@@ -1814,53 +2303,879 @@ var import_tracking_schema2 = require("@lxpack/tracking-schema");
1814
2303
 
1815
2304
  // src/telemetry.ts
1816
2305
  var import_tracking_schema = require("@lxpack/tracking-schema");
1817
- var SUPPORTED = new Set(import_tracking_schema.LESSONKIT_TELEMETRY_EVENTS);
2306
+ var BRANCH_TELEMETRY_EVENTS = ["branch_node_viewed", "branch_selected"];
2307
+ var ASSESSMENT_TELEMETRY_EVENTS = ["assessment_answered"];
2308
+ var SUPPORTED = /* @__PURE__ */ new Set([
2309
+ ...import_tracking_schema.LESSONKIT_TELEMETRY_EVENTS,
2310
+ ...BRANCH_TELEMETRY_EVENTS,
2311
+ ...ASSESSMENT_TELEMETRY_EVENTS
2312
+ ]);
1818
2313
  function isQuizAnsweredData(data) {
1819
- return typeof data === "object" && data !== null && typeof data.checkId === "string";
2314
+ return typeof data === "object" && data !== null && typeof data.checkId === "string" && data.checkId.length > 0;
1820
2315
  }
1821
2316
  function isQuizCompletedData(data) {
1822
- return typeof data === "object" && data !== null && typeof data.checkId === "string";
2317
+ return typeof data === "object" && data !== null && typeof data.checkId === "string" && data.checkId.length > 0;
2318
+ }
2319
+ function isAssessmentAnsweredData(data) {
2320
+ return typeof data === "object" && data !== null && typeof data.checkId === "string" && data.checkId.length > 0;
1823
2321
  }
1824
2322
  function isInteractionData(data) {
1825
2323
  return typeof data === "object" && data !== null;
1826
2324
  }
2325
+ function isBranchNodeViewedData(data) {
2326
+ return typeof data === "object" && data !== null && typeof data.blockId === "string" && typeof data.nodeId === "string";
2327
+ }
2328
+ function isBranchSelectedData(data) {
2329
+ return typeof data === "object" && data !== null && typeof data.blockId === "string" && typeof data.fromNodeId === "string" && typeof data.toNodeId === "string";
2330
+ }
1827
2331
  function telemetryEventToLessonkit(event) {
1828
2332
  if (!SUPPORTED.has(event.name)) {
1829
2333
  return null;
1830
2334
  }
1831
- const name = event.name;
1832
2335
  const mapped = {
1833
- name,
2336
+ name: event.name,
1834
2337
  lessonId: event.lessonId
1835
2338
  };
1836
- if (name === "quiz_completed" || name === "quiz_answered") {
2339
+ if (event.name === "quiz_completed" || event.name === "quiz_answered" || event.name === "assessment_answered") {
1837
2340
  const data = event.data;
1838
- if (isQuizAnsweredData(data) || isQuizCompletedData(data)) {
1839
- mapped.assessmentId = data.checkId;
1840
- if ("score" in data) {
1841
- mapped.score = data.score;
1842
- mapped.maxScore = data.maxScore;
1843
- mapped.passingScore = data.passingScore;
1844
- }
1845
- mapped.data = data;
2341
+ if (!isQuizAnsweredData(data) && !isQuizCompletedData(data) && !isAssessmentAnsweredData(data)) {
2342
+ return null;
1846
2343
  }
1847
- } else if (name === "interaction" && event.data && isInteractionData(event.data)) {
2344
+ mapped.assessmentId = data.checkId;
2345
+ if ("score" in data) {
2346
+ mapped.score = data.score;
2347
+ mapped.maxScore = data.maxScore;
2348
+ mapped.passingScore = data.passingScore;
2349
+ }
2350
+ mapped.data = data;
2351
+ } else if (mapped.name === "interaction" && event.data && isInteractionData(event.data)) {
2352
+ mapped.data = event.data;
2353
+ } else if (event.name === "branch_node_viewed" && isBranchNodeViewedData(event.data)) {
2354
+ mapped.data = event.data;
2355
+ } else if (event.name === "branch_selected" && isBranchSelectedData(event.data)) {
1848
2356
  mapped.data = event.data;
1849
2357
  }
1850
2358
  return mapped;
1851
2359
  }
1852
2360
 
1853
2361
  // src/index.ts
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");
1854
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
+ }
1855
3165
  // Annotate the CommonJS export names for ESM import in node:
1856
3166
  0 && (module.exports = {
1857
3167
  LESSONKIT_TELEMETRY_EVENTS,
3168
+ assertSpaDistContentsSafe,
1858
3169
  assessmentDescriptorToLxpack,
1859
3170
  buildLessonkitProject,
1860
3171
  buildStagingPackage,
1861
3172
  descriptorToInterchange,
1862
3173
  ensureOutDirParent,
3174
+ escapeShellText,
3175
+ exportLkcourse,
1863
3176
  extractAssessments,
3177
+ extractBlockTree,
3178
+ importLkcourse,
1864
3179
  lessonkitInterchangeSchema,
1865
3180
  loadLessonkitManifestFromFile,
1866
3181
  mapLessonkitIds,
@@ -1870,6 +3185,7 @@ var import_validators2 = require("@lxpack/validators");
1870
3185
  packageLessonkitCourse,
1871
3186
  parseLessonkitInterchange,
1872
3187
  parseLessonkitManifest,
3188
+ parseLkcourseEnvelope,
1873
3189
  promoteStagingToOutDir,
1874
3190
  remapArtifactPaths,
1875
3191
  resolveSafePackageOutputOverride,
@@ -1879,6 +3195,8 @@ var import_validators2 = require("@lxpack/validators");
1879
3195
  validateDescriptor,
1880
3196
  validateDescriptorForTarget,
1881
3197
  validateLessonkitProject,
3198
+ validateLkcourse,
3199
+ validateLkcourseArchiveEntries,
1882
3200
  validatePackageInputs,
1883
3201
  validateProjectPaths,
1884
3202
  validateReactManifestParity,