@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.js CHANGED
@@ -99,10 +99,18 @@ function parseAssessmentDescriptor(raw) {
99
99
  };
100
100
  const kind = raw.kind;
101
101
  if (kind === "trueFalse") {
102
+ let answer;
103
+ if (typeof raw.answer === "boolean") {
104
+ answer = raw.answer;
105
+ } else if (raw.answer === "true") {
106
+ answer = true;
107
+ } else if (raw.answer === "false") {
108
+ answer = false;
109
+ }
102
110
  return {
103
111
  kind: "trueFalse",
104
112
  ...base,
105
- answer: typeof raw.answer === "boolean" ? raw.answer : raw.answer === "true"
113
+ answer
106
114
  };
107
115
  }
108
116
  if (kind === "fillInBlanks") {
@@ -280,6 +288,98 @@ function isResolvedPathUnderRoot(root, target) {
280
288
  return !rel.startsWith("..") && !isAbsolute(rel);
281
289
  }
282
290
 
291
+ // src/validateProjectPaths.ts
292
+ import { existsSync as existsSync2, realpathSync as realpathSync2 } from "fs";
293
+ import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
294
+ var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
295
+ function isReservedOutputPath(value) {
296
+ let normalized = value.replace(/\\/g, "/");
297
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
298
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
299
+ const segments = normalized.split("/").filter(Boolean);
300
+ return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
301
+ }
302
+ function isReservedResolvedOutputPath(projectRoot, resolved) {
303
+ const rootResolved = resolveComparablePath(projectRoot);
304
+ const targetResolved = resolveComparablePath(resolved);
305
+ try {
306
+ const rootReal = existsSync2(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
307
+ const targetReal = existsSync2(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
308
+ const rel = relativePathUnderRoot(rootReal, targetReal);
309
+ return isReservedOutputPath(rel);
310
+ } catch {
311
+ return isReservedOutputPath(resolved);
312
+ }
313
+ }
314
+ function validatePathField(value, fieldPath, projectRoot, issues, options) {
315
+ if (!isSafeRelativeSpaPath(value)) {
316
+ issues.push({
317
+ path: fieldPath,
318
+ message: "path must be relative without '..' segments or absolute prefixes"
319
+ });
320
+ return;
321
+ }
322
+ if (options?.rejectReserved && isReservedOutputPath(value)) {
323
+ issues.push({
324
+ path: fieldPath,
325
+ message: "path must not target reserved directories (.git, node_modules, .github)"
326
+ });
327
+ return;
328
+ }
329
+ try {
330
+ assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
331
+ } catch {
332
+ issues.push({
333
+ path: fieldPath,
334
+ message: "path must resolve inside the project root"
335
+ });
336
+ }
337
+ }
338
+ function validateProjectPaths(projectRoot, paths) {
339
+ const issues = [];
340
+ const root = resolve2(projectRoot);
341
+ if (paths.spaDistDir?.trim()) {
342
+ validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
343
+ rejectReserved: true
344
+ });
345
+ }
346
+ if (paths.lxpackOutDir?.trim()) {
347
+ validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
348
+ rejectReserved: true
349
+ });
350
+ }
351
+ if (paths.outputBaseDir?.trim()) {
352
+ validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
353
+ rejectReserved: true
354
+ });
355
+ }
356
+ return issues;
357
+ }
358
+ function resolveSafePackageOutputOverride(projectRoot, override) {
359
+ const root = resolve2(projectRoot);
360
+ const trimmed = override.trim();
361
+ if (!trimmed) {
362
+ throw new Error("output override must be a non-empty path");
363
+ }
364
+ if (isAbsolute2(trimmed)) {
365
+ const resolved2 = resolve2(trimmed);
366
+ assertRealPathUnderRoot(root, resolved2);
367
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
368
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
369
+ }
370
+ return resolved2;
371
+ }
372
+ if (!isSafeRelativeSpaPath(trimmed)) {
373
+ throw new Error(`unsafe output path: ${override}`);
374
+ }
375
+ const resolved = resolve2(root, trimmed);
376
+ assertRealPathUnderRoot(root, resolved);
377
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
378
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
379
+ }
380
+ return resolved;
381
+ }
382
+
283
383
  // src/theme.ts
284
384
  import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
285
385
  function themeToLxpackRuntime(input) {
@@ -356,8 +456,30 @@ var ASSESSMENT_VALIDATORS = {
356
456
  message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
357
457
  });
358
458
  }
359
- const explicitBlanks = assessment.blanks?.map((b) => ({ id: b.id?.trim() ?? "", answer: b.answer?.trim() ?? "" })).filter((b) => b.id.length > 0 && b.answer.length > 0) ?? [];
360
- if (assessment.blanks !== void 0 && explicitBlanks.length === 0) {
459
+ const explicitBlanks = [];
460
+ if (assessment.blanks !== void 0) {
461
+ for (let i = 0; i < assessment.blanks.length; i++) {
462
+ const blank = assessment.blanks[i];
463
+ if (!blank || typeof blank !== "object") {
464
+ issues.push({
465
+ path: `${path}.blanks[${i}]`,
466
+ message: "blank entry must be an object with non-empty id and answer"
467
+ });
468
+ continue;
469
+ }
470
+ const id = blank.id?.trim() ?? "";
471
+ const answer = blank.answer?.trim() ?? "";
472
+ if (!id || !answer) {
473
+ issues.push({
474
+ path: `${path}.blanks[${i}]`,
475
+ message: "blank entry must include non-empty id and answer"
476
+ });
477
+ continue;
478
+ }
479
+ explicitBlanks.push({ id, answer });
480
+ }
481
+ }
482
+ if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
361
483
  issues.push({
362
484
  path: `${path}.blanks`,
363
485
  message: "blanks must include at least one entry with non-empty id and answer"
@@ -505,6 +627,20 @@ function validateCourseDescriptor(input) {
505
627
  });
506
628
  }
507
629
  }
630
+ const descriptorSpaDistDir = input.spaDistDir?.trim();
631
+ if (descriptorSpaDistDir) {
632
+ if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
633
+ issues.push({
634
+ path: "spaDistDir",
635
+ message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
636
+ });
637
+ } else if (isReservedOutputPath(descriptorSpaDistDir)) {
638
+ issues.push({
639
+ path: "spaDistDir",
640
+ message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
641
+ });
642
+ }
643
+ }
508
644
  if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
509
645
  issues.push({
510
646
  path: "lessons",
@@ -565,6 +701,7 @@ function validateCourseDescriptor(input) {
565
701
  }
566
702
 
567
703
  // src/assessments.ts
704
+ var DEFAULT_SHELL_PASSING_SCORE = 1;
568
705
  function escapeShellText(text) {
569
706
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
570
707
  }
@@ -573,7 +710,7 @@ function decodeShellEntities(text) {
573
710
  }
574
711
  function containsUnsafeShellMarkup(text) {
575
712
  const decoded = decodeShellEntities(text);
576
- return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /</.test(decoded);
713
+ return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
577
714
  }
578
715
  function sanitizeShellField(text) {
579
716
  if (containsUnsafeShellMarkup(text)) return null;
@@ -588,6 +725,7 @@ function mcqToLxpack(assessment) {
588
725
  const checkId = sanitizeShellField(assessment.checkId);
589
726
  const prompt = sanitizeShellField(assessment.question);
590
727
  if (!checkId || !prompt) return null;
728
+ const normalizedAnswer = assessment.answer.trim();
591
729
  const choices = assessment.choices.map((text, index) => {
592
730
  const sanitizedText = sanitizeShellField(text);
593
731
  if (!sanitizedText) return null;
@@ -595,13 +733,13 @@ function mcqToLxpack(assessment) {
595
733
  return {
596
734
  id,
597
735
  text: sanitizedText,
598
- correct: text === assessment.answer
736
+ correct: text.trim() === normalizedAnswer
599
737
  };
600
738
  });
601
739
  if (choices.some((choice) => choice === null)) return null;
602
740
  return {
603
741
  id: checkId,
604
- passingScore: assessment.passingScore ?? 1,
742
+ passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
605
743
  questions: [
606
744
  {
607
745
  id: "q1",
@@ -646,11 +784,14 @@ function extractAssessments(descriptor) {
646
784
  // src/descriptor/validateInjectableAssessments.ts
647
785
  function validateInjectableAssessments(descriptor) {
648
786
  const issues = [];
787
+ const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
649
788
  (descriptor.assessments ?? []).forEach((assessment, index) => {
650
789
  if (assessmentDescriptorToLxpack(assessment) === null) {
790
+ const kind = assessment.kind ?? "mcq";
791
+ 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)" : "";
651
792
  issues.push({
652
793
  path: `assessments[${index}]`,
653
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
794
+ message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
654
795
  });
655
796
  }
656
797
  });
@@ -666,7 +807,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
666
807
  "cmi5"
667
808
  ]);
668
809
  function appendActivityIriIssues(issues, descriptor, target) {
669
- const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
810
+ const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
670
811
  const requiresForTarget = target === "xapi" || target === "cmi5";
671
812
  if (!hasXapiTracking && !requiresForTarget) return;
672
813
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
@@ -721,12 +862,12 @@ function validateDescriptorForTarget(input, target) {
721
862
  }
722
863
 
723
864
  // src/validateReactParity.ts
724
- import { readFileSync, existsSync as existsSync2, readdirSync, lstatSync } from "fs";
865
+ import { readFileSync, existsSync as existsSync3, readdirSync, lstatSync } from "fs";
725
866
  import { join as join2, relative as relative2 } from "path";
726
867
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
727
868
  function collectSourceUnderSrc(projectRoot, issues) {
728
869
  const srcDir = join2(projectRoot, "src");
729
- if (!existsSync2(srcDir)) return [];
870
+ if (!existsSync3(srcDir)) return [];
730
871
  const results = [];
731
872
  const walk = (dir) => {
732
873
  for (const entry of readdirSync(dir)) {
@@ -790,7 +931,7 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
790
931
  const abs = join2(projectRoot, rel);
791
932
  try {
792
933
  assertRealPathUnderRoot(projectRoot, abs);
793
- if (existsSync2(abs) && lstatSync(abs).isSymbolicLink()) {
934
+ if (existsSync3(abs) && lstatSync(abs).isSymbolicLink()) {
794
935
  issues.push({
795
936
  path: rel,
796
937
  message: `appSources path is a symlink: ${rel}`,
@@ -806,7 +947,7 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
806
947
  });
807
948
  return null;
808
949
  }
809
- if (!existsSync2(abs)) return null;
950
+ if (!existsSync3(abs)) return null;
810
951
  return readFileSync(abs, "utf8");
811
952
  }).filter((content) => content != null).join("\n");
812
953
  }
@@ -882,9 +1023,20 @@ function courseConfigCourseIdPresent(source, courseId) {
882
1023
  if (literalPattern.test(stripped)) return true;
883
1024
  return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
884
1025
  }
1026
+ function courseMetaCourseIdPresent(source, courseId) {
1027
+ const constants = extractStringConstants(source);
1028
+ const stripped = stripComments(source);
1029
+ for (const [name, value] of constants) {
1030
+ if (value !== courseId) continue;
1031
+ if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
1032
+ if (/\blessons\s*:\s*\S/.test(stripped)) return true;
1033
+ }
1034
+ return false;
1035
+ }
885
1036
  function courseIdPresent(source, courseId) {
886
1037
  if (idPropPresent(source, "courseId", courseId)) return true;
887
1038
  if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
1039
+ if (courseMetaCourseIdPresent(source, courseId)) return true;
888
1040
  return courseConfigCourseIdPresent(source, courseId);
889
1041
  }
890
1042
  function checkIdPresent(source, checkId) {
@@ -953,81 +1105,6 @@ function validateReactManifestParity(opts) {
953
1105
  return issues;
954
1106
  }
955
1107
 
956
- // src/validateProjectPaths.ts
957
- import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
958
- var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
959
- function isReservedOutputPath(value) {
960
- const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
961
- const segments = normalized.split("/").filter(Boolean);
962
- return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
963
- }
964
- function validatePathField(value, fieldPath, projectRoot, issues, options) {
965
- if (!isSafeRelativeSpaPath(value)) {
966
- issues.push({
967
- path: fieldPath,
968
- message: "path must be relative without '..' segments or absolute prefixes"
969
- });
970
- return;
971
- }
972
- if (options?.rejectReserved && isReservedOutputPath(value)) {
973
- issues.push({
974
- path: fieldPath,
975
- message: "path must not target reserved directories (.git, node_modules, .github)"
976
- });
977
- return;
978
- }
979
- try {
980
- assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
981
- } catch {
982
- issues.push({
983
- path: fieldPath,
984
- message: "path must resolve inside the project root"
985
- });
986
- }
987
- }
988
- function validateProjectPaths(projectRoot, paths) {
989
- const issues = [];
990
- const root = resolve2(projectRoot);
991
- if (paths.spaDistDir?.trim()) {
992
- validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
993
- }
994
- if (paths.lxpackOutDir?.trim()) {
995
- validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
996
- rejectReserved: true
997
- });
998
- }
999
- if (paths.outputBaseDir?.trim()) {
1000
- validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
1001
- rejectReserved: true
1002
- });
1003
- }
1004
- return issues;
1005
- }
1006
- function resolveSafePackageOutputOverride(projectRoot, override) {
1007
- const root = resolve2(projectRoot);
1008
- const trimmed = override.trim();
1009
- if (!trimmed) {
1010
- throw new Error("output override must be a non-empty path");
1011
- }
1012
- if (isAbsolute2(trimmed)) {
1013
- const resolved2 = resolve2(trimmed);
1014
- assertRealPathUnderRoot(root, resolved2);
1015
- if (isReservedOutputPath(trimmed)) {
1016
- throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1017
- }
1018
- return resolved2;
1019
- }
1020
- if (!isSafeRelativeSpaPath(trimmed)) {
1021
- throw new Error(`unsafe output path: ${override}`);
1022
- }
1023
- if (isReservedOutputPath(trimmed)) {
1024
- throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1025
- }
1026
- const resolved = resolve2(root, trimmed);
1027
- assertRealPathUnderRoot(root, resolved);
1028
- return resolved;
1029
- }
1030
-
1031
1108
  // src/mapIds.ts
1032
1109
  import { assertValidId } from "@lessonkit/core";
1033
1110
  function mapLessonkitIds(descriptor) {
@@ -1159,7 +1236,7 @@ async function resolveSpaDirs(options) {
1159
1236
 
1160
1237
  // src/spaDistValidation.ts
1161
1238
  import { lstat, readdir } from "fs/promises";
1162
- import { realpathSync as realpathSync2 } from "fs";
1239
+ import { realpathSync as realpathSync3 } from "fs";
1163
1240
  import { join as join4 } from "path";
1164
1241
  async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1165
1242
  for (const [label, dir] of Object.entries(spaDirs)) {
@@ -1170,7 +1247,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1170
1247
  }
1171
1248
  let rootReal;
1172
1249
  try {
1173
- rootReal = realpathSync2(dirResolved);
1250
+ rootReal = realpathSync3(dirResolved);
1174
1251
  } catch {
1175
1252
  throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
1176
1253
  }
@@ -1199,7 +1276,7 @@ async function walkDistDir(rootReal, current, label) {
1199
1276
  }
1200
1277
  let entryReal;
1201
1278
  try {
1202
- entryReal = realpathSync2(entryPath);
1279
+ entryReal = realpathSync3(entryPath);
1203
1280
  } catch (err) {
1204
1281
  throw new Error(
1205
1282
  `spa dist for "${label}" could not resolve path: ${entryPath}`,
@@ -1224,7 +1301,9 @@ async function writeLxpackProject(options) {
1224
1301
  const descriptor = validation.descriptor;
1225
1302
  const injectableIssues = validateInjectableAssessments(descriptor);
1226
1303
  if (injectableIssues.length > 0) {
1227
- throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1304
+ throw new Error(
1305
+ injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
1306
+ );
1228
1307
  }
1229
1308
  const outDir = resolve4(options.outDir);
1230
1309
  assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
@@ -1290,6 +1369,19 @@ function validatePackageInputs(options) {
1290
1369
  ]
1291
1370
  };
1292
1371
  }
1372
+ if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
1373
+ return {
1374
+ ok: false,
1375
+ courseDir: outDir,
1376
+ target,
1377
+ issues: [
1378
+ {
1379
+ path: "outDir",
1380
+ message: "outDir must not target reserved directories (.git, node_modules, .github)"
1381
+ }
1382
+ ]
1383
+ };
1384
+ }
1293
1385
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1294
1386
  return {
1295
1387
  ok: false,
@@ -1347,6 +1439,19 @@ function validatePackageInputs(options) {
1347
1439
  ]
1348
1440
  };
1349
1441
  }
1442
+ if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
1443
+ return {
1444
+ ok: false,
1445
+ courseDir: outDir,
1446
+ target,
1447
+ issues: [
1448
+ {
1449
+ path: "outputBaseDir",
1450
+ message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
1451
+ }
1452
+ ]
1453
+ };
1454
+ }
1350
1455
  }
1351
1456
  if (output) {
1352
1457
  const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
@@ -1368,6 +1473,35 @@ function validatePackageInputs(options) {
1368
1473
  ]
1369
1474
  };
1370
1475
  }
1476
+ const outputRel = isAbsolute3(output) ? output : output;
1477
+ if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
1478
+ return {
1479
+ ok: false,
1480
+ courseDir: outDir,
1481
+ target,
1482
+ issues: [
1483
+ {
1484
+ path: "output",
1485
+ message: "output must not target reserved directories (.git, node_modules, .github)"
1486
+ }
1487
+ ]
1488
+ };
1489
+ }
1490
+ try {
1491
+ relativePathUnderRoot(outDir, resolvedOutput);
1492
+ } catch {
1493
+ return {
1494
+ ok: false,
1495
+ courseDir: outDir,
1496
+ target,
1497
+ issues: [
1498
+ {
1499
+ path: "output",
1500
+ message: "output must resolve inside outDir"
1501
+ }
1502
+ ]
1503
+ };
1504
+ }
1371
1505
  }
1372
1506
  return { ok: true, outDir, projectRoot };
1373
1507
  }
@@ -1460,11 +1594,14 @@ async function isStalePromoteLock(lockPath) {
1460
1594
  return true;
1461
1595
  }
1462
1596
  }
1597
+ var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1463
1598
  async function withPromoteLock(outDir, fn) {
1464
1599
  const lockPath = promoteLockPath(outDir);
1465
1600
  await fsp.mkdir(dirname(outDir), { recursive: true });
1466
1601
  let lockHandle;
1467
- for (let attempt = 0; attempt < 200; attempt++) {
1602
+ const maxAttempts = 400;
1603
+ const started = Date.now();
1604
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1468
1605
  try {
1469
1606
  lockHandle = await fsp.open(lockPath, "wx");
1470
1607
  await lockHandle.writeFile(`${process.pid}
@@ -1482,7 +1619,9 @@ ${Date.now()}
1482
1619
  );
1483
1620
  continue;
1484
1621
  }
1485
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1622
+ if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
1623
+ const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
1624
+ await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
1486
1625
  }
1487
1626
  }
1488
1627
  if (!lockHandle) {
@@ -1750,6 +1889,12 @@ function isPackagingErrorIssue(issue) {
1750
1889
  function findPackagingErrorIssues(issues) {
1751
1890
  return (issues ?? []).filter(isPackagingErrorIssue);
1752
1891
  }
1892
+ function isPackagingWarningIssue(issue) {
1893
+ return issue.severity?.toLowerCase() === "warning";
1894
+ }
1895
+ function findPackagingWarningIssues(issues) {
1896
+ return (issues ?? []).filter(isPackagingWarningIssue);
1897
+ }
1753
1898
 
1754
1899
  // src/packageCourse.ts
1755
1900
  async function validateLessonkitProject(options) {
@@ -1817,14 +1962,29 @@ async function packageLessonkitCourse(options) {
1817
1962
  }))
1818
1963
  };
1819
1964
  }
1820
- const staged = await buildStagingPackage({
1821
- ...writeOpts,
1822
- descriptor,
1823
- target,
1824
- output,
1825
- dir,
1826
- outputBaseDir
1827
- });
1965
+ let staged;
1966
+ try {
1967
+ staged = await buildStagingPackage({
1968
+ ...writeOpts,
1969
+ descriptor,
1970
+ target,
1971
+ output,
1972
+ dir,
1973
+ outputBaseDir
1974
+ });
1975
+ } catch (err) {
1976
+ return {
1977
+ ok: false,
1978
+ courseDir: outDir,
1979
+ target,
1980
+ issues: [
1981
+ {
1982
+ path: "staging",
1983
+ message: err instanceof Error ? err.message : String(err)
1984
+ }
1985
+ ]
1986
+ };
1987
+ }
1828
1988
  if (!staged.ok) {
1829
1989
  await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1830
1990
  /* v8 ignore next */
@@ -1879,6 +2039,25 @@ async function packageLessonkitCourse(options) {
1879
2039
  issues: artifactIssues
1880
2040
  };
1881
2041
  }
2042
+ const buildWarningIssues = findPackagingWarningIssues(build.issues);
2043
+ if (options.strictBuild && buildWarningIssues.length > 0) {
2044
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2045
+ /* v8 ignore next */
2046
+ () => void 0
2047
+ );
2048
+ return {
2049
+ ok: false,
2050
+ courseDir: outDir,
2051
+ target,
2052
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
2053
+ build,
2054
+ issues: buildWarningIssues.map((i) => ({
2055
+ path: i.path ?? "build",
2056
+ message: i.message ?? "build warning",
2057
+ severity: i.severity
2058
+ }))
2059
+ };
2060
+ }
1882
2061
  const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
1883
2062
  const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
1884
2063
  const validation = {
@@ -2021,7 +2200,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2021
2200
  path: `paths.${key}`,
2022
2201
  message: "path must be relative without '..' segments or absolute prefixes"
2023
2202
  });
2024
- } else if ((key === "lxpackOutDir" || key === "outputBaseDir") && isReservedOutputPath(value)) {
2203
+ } else if (isReservedOutputPath(value)) {
2025
2204
  issues.push({
2026
2205
  path: `paths.${key}`,
2027
2206
  message: "path must not target reserved directories (.git, node_modules, .github)"
@@ -2063,17 +2242,821 @@ import {
2063
2242
  import {
2064
2243
  lessonkitInterchangeSchema,
2065
2244
  materializeLessonkitProject as materializeLessonkitProject2,
2066
- parseLessonkitInterchange
2245
+ parseLessonkitInterchange as parseLessonkitInterchange3
2067
2246
  } from "@lxpack/validators";
2247
+
2248
+ // src/lkcourse/zip.ts
2249
+ import { readFileSync as readFileSync2, statSync } from "fs";
2250
+ import { dirname as dirname3, join as join9, normalize } from "path";
2251
+ import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
2252
+ var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2253
+ function canonicalZipEntryPath(entryPath) {
2254
+ const slashNormalized = entryPath.replace(/\\/g, "/");
2255
+ const canonical = normalize(slashNormalized).replace(/\\/g, "/");
2256
+ if (canonical !== slashNormalized) return null;
2257
+ return canonical;
2258
+ }
2259
+ function isSafeZipEntryPath(entryPath) {
2260
+ const canonical = canonicalZipEntryPath(entryPath);
2261
+ if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
2262
+ return false;
2263
+ }
2264
+ const segments = canonical.split("/").filter((s) => s.length > 0);
2265
+ if (segments.some((s) => s === "..")) return false;
2266
+ return segments.length > 0;
2267
+ }
2268
+ function createZip(entries) {
2269
+ const zipped = {};
2270
+ for (const [path, data] of entries) {
2271
+ if (!isSafeZipEntryPath(path)) {
2272
+ throw new Error(`unsafe zip entry path: ${path}`);
2273
+ }
2274
+ zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
2275
+ }
2276
+ return zipSync(zipped, { level: 6 });
2277
+ }
2278
+ function readZip(archivePath) {
2279
+ const issues = [];
2280
+ let raw;
2281
+ try {
2282
+ raw = readFileSync2(archivePath);
2283
+ } catch {
2284
+ return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
2285
+ }
2286
+ if (!raw.length) {
2287
+ return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
2288
+ }
2289
+ let unzipped;
2290
+ try {
2291
+ unzipped = unzipSync(raw);
2292
+ } catch {
2293
+ return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
2294
+ }
2295
+ const entries = /* @__PURE__ */ new Map();
2296
+ let totalUncompressed = 0;
2297
+ for (const [path, data] of Object.entries(unzipped)) {
2298
+ const canonical = canonicalZipEntryPath(path);
2299
+ if (!canonical || !isSafeZipEntryPath(canonical)) {
2300
+ issues.push({ path, message: "unsafe zip entry path" });
2301
+ continue;
2302
+ }
2303
+ if (entries.has(canonical)) {
2304
+ issues.push({ path: canonical, message: "duplicate zip entry path" });
2305
+ continue;
2306
+ }
2307
+ totalUncompressed += data.byteLength;
2308
+ if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
2309
+ return {
2310
+ ok: false,
2311
+ issues: [
2312
+ {
2313
+ path: archivePath,
2314
+ message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
2315
+ }
2316
+ ]
2317
+ };
2318
+ }
2319
+ entries.set(canonical, data);
2320
+ }
2321
+ if (issues.length) return { ok: false, issues };
2322
+ return { ok: true, entries };
2323
+ }
2324
+ async function collectDistEntries(distDir, spaDistRelative) {
2325
+ const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
2326
+ const entries = /* @__PURE__ */ new Map();
2327
+ const walk = async (absDir, relPrefix) => {
2328
+ const dirEntries = await readdir4(absDir, { withFileTypes: true });
2329
+ for (const entry of dirEntries) {
2330
+ const abs = join9(absDir, entry.name);
2331
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
2332
+ const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
2333
+ if (!isSafeRelativeSpaPath(zipPath)) {
2334
+ throw new Error(`unsafe dist path: ${zipPath}`);
2335
+ }
2336
+ const stat2 = await lstat2(abs);
2337
+ if (stat2.isSymbolicLink()) {
2338
+ throw new Error(`dist contains symlink: ${abs}`);
2339
+ }
2340
+ if (stat2.isDirectory()) {
2341
+ await walk(abs, rel);
2342
+ } else if (stat2.isFile()) {
2343
+ entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
2344
+ }
2345
+ }
2346
+ };
2347
+ await walk(distDir, "");
2348
+ return entries;
2349
+ }
2350
+ function entryToUtf8(data) {
2351
+ return strFromU8(data);
2352
+ }
2353
+ function utf8ToEntry(text) {
2354
+ return strToU8(text);
2355
+ }
2356
+
2357
+ // src/lkcourse/parseEnvelope.ts
2358
+ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2359
+ const issues = [];
2360
+ if (!raw || typeof raw !== "object") {
2361
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
2362
+ }
2363
+ const obj = raw;
2364
+ if (obj.format !== "lkcourse") {
2365
+ issues.push({
2366
+ path: "format",
2367
+ message: `must be "lkcourse" (got ${String(obj.format)})`
2368
+ });
2369
+ }
2370
+ let schemaVersion = obj.schemaVersion;
2371
+ if (schemaVersion === "1") schemaVersion = 1;
2372
+ if (schemaVersion !== 1) {
2373
+ issues.push({
2374
+ path: "schemaVersion",
2375
+ message: `must be 1 (got ${String(obj.schemaVersion)})`
2376
+ });
2377
+ }
2378
+ const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
2379
+ if (!lessonkitVersion) {
2380
+ issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
2381
+ }
2382
+ const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
2383
+ if (!exportedAt) {
2384
+ issues.push({ path: "exportedAt", message: "must be a non-empty string" });
2385
+ }
2386
+ const entriesRaw = obj.entries;
2387
+ const entries = [];
2388
+ if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
2389
+ issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
2390
+ } else {
2391
+ for (let i = 0; i < entriesRaw.length; i++) {
2392
+ const entry = entriesRaw[i];
2393
+ if (typeof entry !== "string" || !entry.trim()) {
2394
+ issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
2395
+ } else {
2396
+ const trimmed = entry.trim();
2397
+ if (!isSafeZipEntryPath(trimmed)) {
2398
+ issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
2399
+ } else {
2400
+ entries.push(trimmed);
2401
+ }
2402
+ }
2403
+ }
2404
+ }
2405
+ if (issues.length) return { ok: false, issues };
2406
+ const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
2407
+ if (!manifestParsed.ok) {
2408
+ return {
2409
+ ok: false,
2410
+ issues: manifestParsed.issues.map((issue) => ({
2411
+ path: `sourceManifest.${issue.path}`,
2412
+ message: issue.message
2413
+ }))
2414
+ };
2415
+ }
2416
+ return {
2417
+ ok: true,
2418
+ envelope: {
2419
+ format: "lkcourse",
2420
+ schemaVersion: 1,
2421
+ lessonkitVersion,
2422
+ exportedAt,
2423
+ sourceManifest: manifestParsed.manifest,
2424
+ entries
2425
+ }
2426
+ };
2427
+ }
2428
+
2429
+ // src/lkcourse/blockTree.ts
2430
+ import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
2431
+ import { createRequire } from "module";
2432
+ import { join as join10, relative as relative3 } from "path";
2433
+ import { validateId as validateId4 } from "@lessonkit/core";
2434
+ var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
2435
+ var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
2436
+ function stripComments2(source) {
2437
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
2438
+ }
2439
+ function collectSourceUnderSrc2(projectRoot) {
2440
+ const srcDir = join10(projectRoot, "src");
2441
+ if (!existsSync4(srcDir)) return [];
2442
+ const results = [];
2443
+ const walk = (dir) => {
2444
+ for (const entry of readdirSync2(dir)) {
2445
+ const abs = join10(dir, entry);
2446
+ try {
2447
+ assertRealPathUnderRoot(projectRoot, abs);
2448
+ } catch {
2449
+ continue;
2450
+ }
2451
+ const stat2 = lstatSync2(abs);
2452
+ if (stat2.isSymbolicLink()) continue;
2453
+ if (stat2.isDirectory()) {
2454
+ walk(abs);
2455
+ } else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
2456
+ results.push(relative3(projectRoot, abs));
2457
+ }
2458
+ }
2459
+ };
2460
+ walk(srcDir);
2461
+ return results;
2462
+ }
2463
+ function loadCatalogBlockTypes(blockTypes) {
2464
+ if (blockTypes?.length) return blockTypes;
2465
+ try {
2466
+ const require2 = createRequire(import.meta.url);
2467
+ const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
2468
+ const catalog = JSON.parse(readFileSync3(catalogPath, "utf8"));
2469
+ return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
2470
+ } catch {
2471
+ return [
2472
+ "Course",
2473
+ "Lesson",
2474
+ "Scenario",
2475
+ "Quiz",
2476
+ "KnowledgeCheck",
2477
+ "ProgressTracker",
2478
+ "Reflection",
2479
+ "TrueFalse",
2480
+ "MarkTheWords",
2481
+ "FillInTheBlanks",
2482
+ "DragTheWords",
2483
+ "DragAndDrop",
2484
+ "AssessmentSequence",
2485
+ "Text",
2486
+ "Heading",
2487
+ "Image",
2488
+ "Video",
2489
+ "Page",
2490
+ "InteractiveBook",
2491
+ "Slide",
2492
+ "SlideDeck",
2493
+ "TimedCue",
2494
+ "InteractiveVideo",
2495
+ "Summary",
2496
+ "BranchingScenario",
2497
+ "BranchNode",
2498
+ "BranchChoice",
2499
+ "Embed",
2500
+ "Chart"
2501
+ ];
2502
+ }
2503
+ }
2504
+ function extractIdProp(tagSource, prop) {
2505
+ const re = new RegExp(
2506
+ `\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
2507
+ );
2508
+ const match = tagSource.match(re);
2509
+ if (!match) return void 0;
2510
+ return match[1] ?? match[2] ?? match[3];
2511
+ }
2512
+ function parseJsxBlocks(source, blockTypes) {
2513
+ const stripped = stripComments2(source);
2514
+ const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
2515
+ const stack = [];
2516
+ const roots = [];
2517
+ for (const match of stripped.matchAll(tagRe)) {
2518
+ const rawTag = match[1];
2519
+ const attrs = match[2] ?? "";
2520
+ const selfClosing = match[3] === "/";
2521
+ if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
2522
+ const known = blockTypes.has(rawTag);
2523
+ const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
2524
+ for (const prop of ID_PROPS) {
2525
+ const value = extractIdProp(attrs, prop);
2526
+ if (value) node[prop] = value;
2527
+ }
2528
+ if (selfClosing) {
2529
+ if (stack.length) {
2530
+ const parent = stack[stack.length - 1];
2531
+ parent.children = parent.children ?? [];
2532
+ parent.children.push(node);
2533
+ } else {
2534
+ roots.push(node);
2535
+ }
2536
+ continue;
2537
+ }
2538
+ const closeRe = new RegExp(`</${rawTag}>`);
2539
+ const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
2540
+ if (!closeMatch) {
2541
+ if (stack.length) {
2542
+ const parent = stack[stack.length - 1];
2543
+ parent.children = parent.children ?? [];
2544
+ parent.children.push(node);
2545
+ } else {
2546
+ roots.push(node);
2547
+ }
2548
+ continue;
2549
+ }
2550
+ stack.push(node);
2551
+ const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
2552
+ const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
2553
+ if (!inner.includes("<")) {
2554
+ stack.pop();
2555
+ if (stack.length) {
2556
+ const parent = stack[stack.length - 1];
2557
+ parent.children = parent.children ?? [];
2558
+ parent.children.push(node);
2559
+ } else {
2560
+ roots.push(node);
2561
+ }
2562
+ }
2563
+ }
2564
+ return roots.length ? roots : stack;
2565
+ }
2566
+ function validateNodeIds(node, pathPrefix, issues) {
2567
+ for (const prop of ID_PROPS) {
2568
+ const value = node[prop];
2569
+ if (value === void 0) continue;
2570
+ const validated = validateId4(value, prop);
2571
+ if (!validated.ok) {
2572
+ issues.push({
2573
+ path: `${pathPrefix}.${prop}`,
2574
+ message: validated.issues[0]?.message ?? `invalid ${prop}`
2575
+ });
2576
+ }
2577
+ }
2578
+ node.children?.forEach((child, index) => {
2579
+ validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
2580
+ });
2581
+ }
2582
+ function validateBlockTreeIds(tree) {
2583
+ const issues = [];
2584
+ tree.blocks.forEach((block, index) => {
2585
+ validateNodeIds(block, `blocks[${index}]`, issues);
2586
+ });
2587
+ return issues;
2588
+ }
2589
+ function extractBlockTree(options) {
2590
+ const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
2591
+ const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
2592
+ const blocks = [];
2593
+ for (const rel of sources) {
2594
+ const abs = join10(options.projectRoot, rel);
2595
+ if (!existsSync4(abs)) continue;
2596
+ const source = readFileSync3(abs, "utf8");
2597
+ const parsed = parseJsxBlocks(source, blockTypes);
2598
+ blocks.push(...parsed);
2599
+ }
2600
+ return {
2601
+ schemaVersion: 1,
2602
+ sources,
2603
+ blocks
2604
+ };
2605
+ }
2606
+
2607
+ // src/lkcourse/export.ts
2608
+ import { mkdir as mkdir3, writeFile } from "fs/promises";
2609
+ import { createRequire as createRequire2 } from "module";
2610
+ import { dirname as dirname4, join as join11, resolve as resolve8 } from "path";
2611
+ import { parseLessonkitInterchange } from "@lxpack/validators";
2612
+ function resolveLessonkitVersion(explicit) {
2613
+ if (explicit?.trim()) return explicit.trim();
2614
+ try {
2615
+ const require2 = createRequire2(import.meta.url);
2616
+ const pkg = require2("../../package.json");
2617
+ return pkg.version ?? "0.0.0";
2618
+ } catch {
2619
+ return "0.0.0";
2620
+ }
2621
+ }
2622
+ async function exportLkcourse(options) {
2623
+ const projectRoot = resolve8(options.projectRoot);
2624
+ const manifest = options.manifest;
2625
+ const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
2626
+ try {
2627
+ assertRealPathUnderRoot(projectRoot, spaDistDir);
2628
+ await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
2629
+ } catch (err) {
2630
+ return {
2631
+ ok: false,
2632
+ issues: [
2633
+ {
2634
+ path: manifest.paths.spaDistDir,
2635
+ message: err instanceof Error ? err.message : String(err)
2636
+ }
2637
+ ]
2638
+ };
2639
+ }
2640
+ const interchange = descriptorToInterchange(manifest.course);
2641
+ const interchangeParsed = parseLessonkitInterchange(interchange);
2642
+ if (!interchangeParsed.ok) {
2643
+ return {
2644
+ ok: false,
2645
+ issues: interchangeParsed.issues.map((i) => ({
2646
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2647
+ message: i.message
2648
+ }))
2649
+ };
2650
+ }
2651
+ const validatedInterchange = interchangeParsed.data;
2652
+ const interchangeCourseId = validatedInterchange.course?.id;
2653
+ if (!interchangeCourseId) {
2654
+ return {
2655
+ ok: false,
2656
+ issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
2657
+ };
2658
+ }
2659
+ if (manifest.course.courseId !== interchangeCourseId) {
2660
+ return {
2661
+ ok: false,
2662
+ issues: [
2663
+ {
2664
+ path: "course.courseId",
2665
+ message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
2666
+ }
2667
+ ]
2668
+ };
2669
+ }
2670
+ const zipEntries = /* @__PURE__ */ new Map();
2671
+ const interchangeJson = JSON.stringify(interchange, null, 2);
2672
+ zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
2673
+ let blockTreeJson;
2674
+ if (options.includeBlockTree) {
2675
+ const blockTree = extractBlockTree({ projectRoot });
2676
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2677
+ if (blockTreeIssues.length) {
2678
+ return {
2679
+ ok: false,
2680
+ issues: blockTreeIssues.map((issue) => ({
2681
+ path: `block-tree.${issue.path}`,
2682
+ message: issue.message
2683
+ }))
2684
+ };
2685
+ }
2686
+ blockTreeJson = JSON.stringify(blockTree, null, 2);
2687
+ zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
2688
+ }
2689
+ let distEntries;
2690
+ try {
2691
+ distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
2692
+ } catch (err) {
2693
+ return {
2694
+ ok: false,
2695
+ issues: [
2696
+ {
2697
+ path: manifest.paths.spaDistDir,
2698
+ message: err instanceof Error ? err.message : String(err)
2699
+ }
2700
+ ]
2701
+ };
2702
+ }
2703
+ if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
2704
+ return {
2705
+ ok: false,
2706
+ issues: [
2707
+ {
2708
+ path: `${manifest.paths.spaDistDir}/index.html`,
2709
+ message: "dist must contain index.html before export"
2710
+ }
2711
+ ]
2712
+ };
2713
+ }
2714
+ for (const [path, data] of distEntries) {
2715
+ zipEntries.set(path, data);
2716
+ }
2717
+ const entryPaths = [...zipEntries.keys()].sort();
2718
+ const envelope = {
2719
+ format: "lkcourse",
2720
+ schemaVersion: 1,
2721
+ lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
2722
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2723
+ sourceManifest: manifest,
2724
+ entries: entryPaths
2725
+ };
2726
+ const envelopeCheck = parseLkcourseEnvelope(envelope);
2727
+ if (!envelopeCheck.ok) {
2728
+ return { ok: false, issues: envelopeCheck.issues };
2729
+ }
2730
+ zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
2731
+ const archivePath = resolve8(
2732
+ projectRoot,
2733
+ options.outPath ?? `${manifest.name}.lkcourse`
2734
+ );
2735
+ try {
2736
+ assertRealPathUnderRoot(projectRoot, archivePath);
2737
+ } catch (err) {
2738
+ return {
2739
+ ok: false,
2740
+ issues: [
2741
+ {
2742
+ path: options.outPath ?? `${manifest.name}.lkcourse`,
2743
+ message: err instanceof Error ? err.message : String(err)
2744
+ }
2745
+ ]
2746
+ };
2747
+ }
2748
+ if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
2749
+ return {
2750
+ ok: false,
2751
+ issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
2752
+ };
2753
+ }
2754
+ try {
2755
+ await mkdir3(dirname4(archivePath), { recursive: true });
2756
+ const zipped = createZip(zipEntries);
2757
+ await writeFile(archivePath, zipped);
2758
+ } catch (err) {
2759
+ return {
2760
+ ok: false,
2761
+ issues: [
2762
+ {
2763
+ path: archivePath,
2764
+ message: err instanceof Error ? err.message : String(err)
2765
+ }
2766
+ ]
2767
+ };
2768
+ }
2769
+ return {
2770
+ ok: true,
2771
+ archivePath,
2772
+ fileCount: zipEntries.size,
2773
+ includeBlockTree: Boolean(options.includeBlockTree)
2774
+ };
2775
+ }
2776
+
2777
+ // src/lkcourse/validate.ts
2778
+ import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
2779
+ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2780
+ const issues = [];
2781
+ const manifestData = entries.get("manifest.json");
2782
+ if (!manifestData) {
2783
+ return {
2784
+ ok: false,
2785
+ issues: [{ path: "manifest.json", message: "required file missing from archive" }]
2786
+ };
2787
+ }
2788
+ let envelopeRaw;
2789
+ try {
2790
+ envelopeRaw = JSON.parse(entryToUtf8(manifestData));
2791
+ } catch {
2792
+ return {
2793
+ ok: false,
2794
+ issues: [{ path: "manifest.json", message: "invalid JSON" }]
2795
+ };
2796
+ }
2797
+ const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
2798
+ if (!envelopeParsed.ok) {
2799
+ return { ok: false, issues: envelopeParsed.issues };
2800
+ }
2801
+ const envelope = envelopeParsed.envelope;
2802
+ const interchangeData = entries.get("interchange.json");
2803
+ if (!interchangeData) {
2804
+ issues.push({ path: "interchange.json", message: "required file missing from archive" });
2805
+ }
2806
+ const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
2807
+ const spaIndexPath = `${spaDistDir}/index.html`;
2808
+ if (!entries.has(spaIndexPath)) {
2809
+ issues.push({ path: spaIndexPath, message: "required file missing from archive" });
2810
+ }
2811
+ for (const entryPath of envelope.entries) {
2812
+ if (!entries.has(entryPath)) {
2813
+ issues.push({
2814
+ path: entryPath,
2815
+ message: "listed in manifest.entries but missing from archive"
2816
+ });
2817
+ }
2818
+ }
2819
+ if (issues.length) return { ok: false, issues };
2820
+ let interchangeRaw;
2821
+ try {
2822
+ interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
2823
+ } catch {
2824
+ return {
2825
+ ok: false,
2826
+ issues: [{ path: "interchange.json", message: "invalid JSON" }]
2827
+ };
2828
+ }
2829
+ const interchangeParsed = parseLessonkitInterchange2(interchangeRaw);
2830
+ if (!interchangeParsed.ok) {
2831
+ return {
2832
+ ok: false,
2833
+ issues: interchangeParsed.issues.map((i) => ({
2834
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2835
+ message: i.message
2836
+ }))
2837
+ };
2838
+ }
2839
+ const interchange = interchangeParsed.data;
2840
+ const interchangeCourseId = interchange.course?.id;
2841
+ if (!interchangeCourseId) {
2842
+ issues.push({
2843
+ path: "interchange.course.id",
2844
+ message: "missing course id in interchange"
2845
+ });
2846
+ } else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
2847
+ issues.push({
2848
+ path: "sourceManifest.course.courseId",
2849
+ message: `does not match interchange.course.id (${interchangeCourseId})`
2850
+ });
2851
+ }
2852
+ if (issues.length) return { ok: false, issues };
2853
+ const blockTreeData = entries.get("block-tree.json");
2854
+ if (blockTreeData) {
2855
+ let blockTreeRaw;
2856
+ try {
2857
+ blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
2858
+ } catch {
2859
+ return {
2860
+ ok: false,
2861
+ issues: [{ path: "block-tree.json", message: "invalid JSON" }]
2862
+ };
2863
+ }
2864
+ const blockTree = blockTreeRaw;
2865
+ if (Array.isArray(blockTree?.blocks)) {
2866
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2867
+ if (blockTreeIssues.length) {
2868
+ return {
2869
+ ok: false,
2870
+ issues: blockTreeIssues.map((issue) => ({
2871
+ path: `block-tree.${issue.path}`,
2872
+ message: issue.message
2873
+ }))
2874
+ };
2875
+ }
2876
+ }
2877
+ }
2878
+ return {
2879
+ ok: true,
2880
+ envelope,
2881
+ interchange
2882
+ };
2883
+ }
2884
+ function validateLkcourse(archivePath) {
2885
+ const read = readZip(archivePath);
2886
+ if (!read.ok) return read;
2887
+ return validateLkcourseArchiveEntries(read.entries, archivePath);
2888
+ }
2889
+
2890
+ // src/lkcourse/import.ts
2891
+ import { access as access3, cp as cp2, mkdir as mkdir4, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2892
+ import { dirname as dirname5, join as join12, resolve as resolve9 } from "path";
2893
+ var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
2894
+ async function pathExists2(path) {
2895
+ try {
2896
+ await access3(path);
2897
+ return true;
2898
+ } catch {
2899
+ return false;
2900
+ }
2901
+ }
2902
+ async function renameOrCopy2(from, to, opts) {
2903
+ const renameFn = opts?.renameFn ?? rename2;
2904
+ try {
2905
+ await renameFn(from, to);
2906
+ } catch (err) {
2907
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
2908
+ if (code !== "EXDEV") throw err;
2909
+ await cp2(from, to, { recursive: true });
2910
+ await rm4(from, { recursive: true, force: true });
2911
+ }
2912
+ }
2913
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
2914
+ let fileCount = 0;
2915
+ await writeFile2(
2916
+ join12(stagingDir, "lessonkit.json"),
2917
+ `${JSON.stringify(manifest, null, 2)}
2918
+ `,
2919
+ "utf8"
2920
+ );
2921
+ fileCount += 1;
2922
+ for (const [entryPath, data] of entries) {
2923
+ const normalized = entryPath.replace(/\\/g, "/");
2924
+ if (!normalized.startsWith(`${spaDistDir}/`)) continue;
2925
+ const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
2926
+ const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
2927
+ const resolvedOut = resolve9(outPath);
2928
+ assertRealPathUnderRoot(stagingDir, resolvedOut);
2929
+ if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
2930
+ throw new Error(`unsafe extraction path: ${entryPath}`);
2931
+ }
2932
+ await mkdir4(dirname5(resolvedOut), { recursive: true });
2933
+ await writeFile2(resolvedOut, data);
2934
+ fileCount += 1;
2935
+ }
2936
+ return fileCount;
2937
+ }
2938
+ async function backupImportArtifacts(targetDir) {
2939
+ const existing = [];
2940
+ for (const name of IMPORT_ARTIFACTS) {
2941
+ if (await pathExists2(join12(targetDir, name))) {
2942
+ existing.push(name);
2943
+ }
2944
+ }
2945
+ if (!existing.length) return void 0;
2946
+ const backupDir = await mkdtemp3(join12(targetDir, ".lkcourse-backup-"));
2947
+ for (const name of existing) {
2948
+ await renameOrCopy2(join12(targetDir, name), join12(backupDir, name));
2949
+ }
2950
+ return backupDir;
2951
+ }
2952
+ async function restoreImportBackup(targetDir, backupDir) {
2953
+ for (const name of IMPORT_ARTIFACTS) {
2954
+ const backupPath = join12(backupDir, name);
2955
+ if (!await pathExists2(backupPath)) continue;
2956
+ const destPath = join12(targetDir, name);
2957
+ if (await pathExists2(destPath)) {
2958
+ await rm4(destPath, { recursive: true, force: true });
2959
+ }
2960
+ await renameOrCopy2(backupPath, destPath);
2961
+ }
2962
+ }
2963
+ async function promoteImportStaging(stagingDir, targetDir) {
2964
+ const entries = await readdir3(stagingDir, { withFileTypes: true });
2965
+ for (const entry of entries) {
2966
+ const srcPath = join12(stagingDir, entry.name);
2967
+ const destPath = join12(targetDir, entry.name);
2968
+ if (entry.isDirectory()) {
2969
+ await cp2(srcPath, destPath, { recursive: true, force: true });
2970
+ } else if (entry.isFile()) {
2971
+ await mkdir4(dirname5(destPath), { recursive: true });
2972
+ await cp2(srcPath, destPath);
2973
+ }
2974
+ }
2975
+ }
2976
+ var promoteImportStagingImpl = promoteImportStaging;
2977
+ async function importLkcourse(options) {
2978
+ const archivePath = resolve9(options.archivePath);
2979
+ const targetDir = resolve9(options.targetDir);
2980
+ const validated = validateLkcourse(archivePath);
2981
+ if (!validated.ok) return validated;
2982
+ const { envelope, interchange } = validated;
2983
+ const manifest = envelope.sourceManifest;
2984
+ const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
2985
+ try {
2986
+ await mkdir4(targetDir, { recursive: true });
2987
+ assertRealPathUnderRoot(targetDir, targetDir);
2988
+ } catch (err) {
2989
+ return {
2990
+ ok: false,
2991
+ issues: [
2992
+ {
2993
+ path: targetDir,
2994
+ message: err instanceof Error ? err.message : String(err)
2995
+ }
2996
+ ]
2997
+ };
2998
+ }
2999
+ const read = readZip(archivePath);
3000
+ if (!read.ok) return read;
3001
+ let stagingDir;
3002
+ let backupDir;
3003
+ try {
3004
+ stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
3005
+ const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
3006
+ backupDir = await backupImportArtifacts(targetDir);
3007
+ try {
3008
+ await promoteImportStagingImpl(stagingDir, targetDir);
3009
+ } catch (promoteError) {
3010
+ if (backupDir) {
3011
+ await restoreImportBackup(targetDir, backupDir);
3012
+ }
3013
+ throw promoteError;
3014
+ }
3015
+ if (backupDir) {
3016
+ await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3017
+ backupDir = void 0;
3018
+ }
3019
+ await rm4(stagingDir, { recursive: true, force: true });
3020
+ stagingDir = void 0;
3021
+ return {
3022
+ ok: true,
3023
+ targetDir,
3024
+ manifest,
3025
+ interchange,
3026
+ fileCount
3027
+ };
3028
+ } catch (err) {
3029
+ if (backupDir) {
3030
+ await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3031
+ await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3032
+ }
3033
+ if (stagingDir) {
3034
+ await rm4(stagingDir, { recursive: true, force: true }).catch(() => void 0);
3035
+ }
3036
+ return {
3037
+ ok: false,
3038
+ issues: [
3039
+ {
3040
+ path: targetDir,
3041
+ message: err instanceof Error ? err.message : String(err)
3042
+ }
3043
+ ]
3044
+ };
3045
+ }
3046
+ }
2068
3047
  export {
2069
3048
  LESSONKIT_TELEMETRY_EVENTS,
3049
+ assertSpaDistContentsSafe,
2070
3050
  assessmentDescriptorToLxpack,
2071
3051
  buildLessonkitProject,
2072
3052
  buildStagingPackage,
2073
3053
  descriptorToInterchange,
2074
3054
  ensureOutDirParent,
2075
3055
  escapeShellText,
3056
+ exportLkcourse,
2076
3057
  extractAssessments,
3058
+ extractBlockTree,
3059
+ importLkcourse,
2077
3060
  lessonkitInterchangeSchema,
2078
3061
  loadLessonkitManifestFromFile,
2079
3062
  mapLessonkitIds,
@@ -2081,8 +3064,9 @@ export {
2081
3064
  mapLessonkitTelemetryToLxpack,
2082
3065
  materializeLessonkitProject2 as materializeLessonkitProject,
2083
3066
  packageLessonkitCourse,
2084
- parseLessonkitInterchange,
3067
+ parseLessonkitInterchange3 as parseLessonkitInterchange,
2085
3068
  parseLessonkitManifest,
3069
+ parseLkcourseEnvelope,
2086
3070
  promoteStagingToOutDir,
2087
3071
  remapArtifactPaths,
2088
3072
  resolveSafePackageOutputOverride,
@@ -2092,6 +3076,8 @@ export {
2092
3076
  validateDescriptor,
2093
3077
  validateDescriptorForTarget,
2094
3078
  validateLessonkitProject,
3079
+ validateLkcourse,
3080
+ validateLkcourseArchiveEntries,
2095
3081
  validatePackageInputs,
2096
3082
  validateProjectPaths,
2097
3083
  validateReactManifestParity,