@lessonkit/lxpack 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -31,22 +31,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  LESSONKIT_TELEMETRY_EVENTS: () => import_tracking_schema2.LESSONKIT_TELEMETRY_EVENTS,
34
+ assertSpaDistContentsSafe: () => assertSpaDistContentsSafe,
34
35
  assessmentDescriptorToLxpack: () => assessmentDescriptorToLxpack,
35
36
  buildLessonkitProject: () => buildLessonkitProject,
36
37
  buildStagingPackage: () => buildStagingPackage,
37
38
  descriptorToInterchange: () => descriptorToInterchange,
38
39
  ensureOutDirParent: () => ensureOutDirParent,
39
40
  escapeShellText: () => escapeShellText,
41
+ exportLkcourse: () => exportLkcourse,
40
42
  extractAssessments: () => extractAssessments,
41
- lessonkitInterchangeSchema: () => import_validators2.lessonkitInterchangeSchema,
43
+ extractBlockTree: () => extractBlockTree,
44
+ importLkcourse: () => importLkcourse,
45
+ lessonkitInterchangeSchema: () => import_validators4.lessonkitInterchangeSchema,
42
46
  loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
43
47
  mapLessonkitIds: () => mapLessonkitIds,
44
48
  mapLessonkitTelemetryToBridgeAction: () => import_tracking_schema2.mapLessonkitTelemetryToBridgeAction,
45
49
  mapLessonkitTelemetryToLxpack: () => import_tracking_schema2.mapLessonkitTelemetryToLxpack,
46
- materializeLessonkitProject: () => import_validators2.materializeLessonkitProject,
50
+ materializeLessonkitProject: () => import_validators4.materializeLessonkitProject,
47
51
  packageLessonkitCourse: () => packageLessonkitCourse,
48
- parseLessonkitInterchange: () => import_validators2.parseLessonkitInterchange,
52
+ parseLessonkitInterchange: () => import_validators4.parseLessonkitInterchange,
49
53
  parseLessonkitManifest: () => parseLessonkitManifest,
54
+ parseLkcourseEnvelope: () => parseLkcourseEnvelope,
50
55
  promoteStagingToOutDir: () => promoteStagingToOutDir,
51
56
  remapArtifactPaths: () => remapArtifactPaths,
52
57
  resolveSafePackageOutputOverride: () => resolveSafePackageOutputOverride,
@@ -56,6 +61,9 @@ __export(index_exports, {
56
61
  validateDescriptor: () => validateDescriptor,
57
62
  validateDescriptorForTarget: () => validateDescriptorForTarget,
58
63
  validateLessonkitProject: () => validateLessonkitProject,
64
+ validateLkcourse: () => validateLkcourse,
65
+ validateLkcourseArchiveEntries: () => validateLkcourseArchiveEntries,
66
+ validateManifestName: () => validateManifestName,
59
67
  validatePackageInputs: () => validatePackageInputs,
60
68
  validateProjectPaths: () => validateProjectPaths,
61
69
  validateReactManifestParity: () => validateReactManifestParity,
@@ -123,13 +131,40 @@ function normalizeDescriptor(input) {
123
131
  correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
124
132
  };
125
133
  }
134
+ if (assessment.kind === "sortParagraphs") {
135
+ return {
136
+ ...assessment,
137
+ checkId: check.id,
138
+ question,
139
+ paragraphs: assessment.paragraphs.map((p) => p.trim()).filter((p) => p.length > 0),
140
+ correctOrder: [...assessment.correctOrder]
141
+ };
142
+ }
143
+ if (assessment.kind === "guessTheAnswer") {
144
+ return {
145
+ ...assessment,
146
+ checkId: check.id,
147
+ question,
148
+ answer: assessment.answer.trim()
149
+ };
150
+ }
151
+ if (assessment.kind === "multimediaChoice") {
152
+ return {
153
+ ...assessment,
154
+ checkId: check.id,
155
+ question,
156
+ choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
157
+ answer: assessment.answer.trim()
158
+ };
159
+ }
126
160
  const mcq = assessment;
127
161
  return {
128
162
  ...mcq,
129
163
  checkId: check.id,
130
164
  question,
131
165
  choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
132
- answer: mcq.answer.trim()
166
+ answer: mcq.answer.trim(),
167
+ answers: mcq.answers?.map((a) => a.trim()).filter((a) => a.length > 0)
133
168
  };
134
169
  })
135
170
  };
@@ -160,10 +195,18 @@ function parseAssessmentDescriptor(raw) {
160
195
  };
161
196
  const kind = raw.kind;
162
197
  if (kind === "trueFalse") {
198
+ let answer;
199
+ if (typeof raw.answer === "boolean") {
200
+ answer = raw.answer;
201
+ } else if (raw.answer === "true") {
202
+ answer = true;
203
+ } else if (raw.answer === "false") {
204
+ answer = false;
205
+ }
163
206
  return {
164
207
  kind: "trueFalse",
165
208
  ...base,
166
- answer: typeof raw.answer === "boolean" ? raw.answer : raw.answer === "true"
209
+ answer
167
210
  };
168
211
  }
169
212
  if (kind === "fillInBlanks") {
@@ -195,7 +238,30 @@ function parseAssessmentDescriptor(raw) {
195
238
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
196
239
  };
197
240
  }
198
- if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
241
+ if (kind === "sortParagraphs") {
242
+ return {
243
+ kind: "sortParagraphs",
244
+ ...base,
245
+ paragraphs: Array.isArray(raw.paragraphs) ? raw.paragraphs.filter((p) => typeof p === "string") : [],
246
+ correctOrder: Array.isArray(raw.correctOrder) ? raw.correctOrder.filter((n) => typeof n === "number" && Number.isFinite(n)) : []
247
+ };
248
+ }
249
+ if (kind === "guessTheAnswer") {
250
+ return {
251
+ kind: "guessTheAnswer",
252
+ ...base,
253
+ answer: typeof raw.answer === "string" ? raw.answer : ""
254
+ };
255
+ }
256
+ if (kind === "multimediaChoice") {
257
+ return {
258
+ kind: "multimediaChoice",
259
+ ...base,
260
+ choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
261
+ answer: typeof raw.answer === "string" ? raw.answer : ""
262
+ };
263
+ }
264
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots" && kind !== "sortParagraphs" && kind !== "guessTheAnswer" && kind !== "multimediaChoice") {
199
265
  return {
200
266
  kind,
201
267
  ...base,
@@ -207,7 +273,15 @@ function parseAssessmentDescriptor(raw) {
207
273
  kind: kind === "mcq" ? "mcq" : void 0,
208
274
  ...base,
209
275
  choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
210
- answer: typeof raw.answer === "string" ? raw.answer : ""
276
+ answer: typeof raw.answer === "string" ? raw.answer : "",
277
+ answers: Array.isArray(raw.answers) ? raw.answers.filter((a) => typeof a === "string") : void 0,
278
+ shuffleChoices: typeof raw.shuffleChoices === "boolean" ? raw.shuffleChoices : void 0,
279
+ shuffleSeed: typeof raw.shuffleSeed === "string" || typeof raw.shuffleSeed === "number" ? raw.shuffleSeed : void 0,
280
+ choiceFeedback: raw.choiceFeedback && typeof raw.choiceFeedback === "object" && !Array.isArray(raw.choiceFeedback) ? Object.fromEntries(
281
+ Object.entries(raw.choiceFeedback).filter(
282
+ (entry) => typeof entry[1] === "string"
283
+ )
284
+ ) : void 0
211
285
  };
212
286
  }
213
287
  function parseCourseDescriptorInput(input) {
@@ -251,7 +325,7 @@ function parseCourseDescriptorInput(input) {
251
325
  }
252
326
 
253
327
  // src/descriptor/validateCourse.ts
254
- var import_core3 = require("@lessonkit/core");
328
+ var import_core4 = require("@lessonkit/core");
255
329
  var import_themes2 = require("@lessonkit/themes");
256
330
 
257
331
  // src/spaPath.ts
@@ -282,43 +356,32 @@ function assertResolvedPathUnderRoot(root, target) {
282
356
  throw new Error(`unsafe path escapes project root: ${target}`);
283
357
  }
284
358
  }
285
- function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
286
- const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
287
- if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
288
- throw new Error(`unsafe path escapes project root: ${targetResolved}`);
289
- }
290
- const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
291
- let current = rootReal;
292
- for (const segment of segments) {
293
- const next = (0, import_node_path.join)(current, segment);
294
- if ((0, import_node_fs.existsSync)(next)) {
359
+ function resolvePhysicalPathForCheck(p) {
360
+ const resolved = resolveComparablePath(p);
361
+ try {
362
+ return import_node_fs.realpathSync.native(resolved);
363
+ } catch {
364
+ let probe = resolved;
365
+ let suffix = "";
366
+ while (true) {
295
367
  try {
296
- current = (0, import_node_fs.realpathSync)(next);
368
+ const physical = import_node_fs.realpathSync.native(probe);
369
+ return suffix ? (0, import_node_path.join)(physical, suffix) : physical;
297
370
  } catch {
298
- current = next;
371
+ if (probe === (0, import_node_path.dirname)(probe)) {
372
+ return resolved;
373
+ }
374
+ const segment = (0, import_node_path.basename)(probe);
375
+ suffix = suffix ? (0, import_node_path.join)(segment, suffix) : segment;
376
+ probe = (0, import_node_path.dirname)(probe);
299
377
  }
300
- } else {
301
- current = next;
302
378
  }
303
- assertResolvedPathUnderRoot(rootReal, current);
304
379
  }
305
- return current;
306
380
  }
307
381
  function assertRealPathUnderRoot(root, target) {
308
- const rootResolved = resolveComparablePath(root);
309
- const targetResolved = resolveComparablePath(target);
310
- let rootReal;
311
- try {
312
- rootReal = (0, import_node_fs.realpathSync)(rootResolved);
313
- } catch {
314
- rootReal = rootResolved;
315
- }
316
- try {
317
- const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
318
- assertResolvedPathUnderRoot(rootReal, targetCheck);
319
- } catch {
320
- resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
321
- }
382
+ const rootPhysical = resolvePhysicalPathForCheck(root);
383
+ const targetPhysical = resolvePhysicalPathForCheck(target);
384
+ assertResolvedPathUnderRoot(rootPhysical, targetPhysical);
322
385
  }
323
386
  function normalizePathForComparison(p) {
324
387
  const resolved = resolveComparablePath(p);
@@ -341,6 +404,113 @@ function isResolvedPathUnderRoot(root, target) {
341
404
  return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
342
405
  }
343
406
 
407
+ // src/validateProjectPaths.ts
408
+ var import_node_fs2 = require("fs");
409
+ var import_node_path2 = require("path");
410
+ var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
411
+ function isReservedOutputPath(value) {
412
+ let normalized = value.replace(/\\/g, "/");
413
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
414
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
415
+ const segments = normalized.split("/").filter(Boolean);
416
+ return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
417
+ }
418
+ function validateManifestName(name) {
419
+ if (!name.length) {
420
+ return "must be a non-empty string";
421
+ }
422
+ if (name.includes("/") || name.includes("\\")) {
423
+ return "must not contain path separators";
424
+ }
425
+ if (!isSafeRelativeSpaPath(name)) {
426
+ return "must be a safe relative name without '..' segments or absolute prefixes";
427
+ }
428
+ if (isReservedOutputPath(name) || isReservedOutputPath(`${name}.lkcourse`)) {
429
+ return "must not target reserved directories (.git, node_modules, .github)";
430
+ }
431
+ return null;
432
+ }
433
+ function isReservedResolvedOutputPath(projectRoot, resolved) {
434
+ const rootResolved = resolveComparablePath(projectRoot);
435
+ const targetResolved = resolveComparablePath(resolved);
436
+ try {
437
+ const rootReal = (0, import_node_fs2.existsSync)(rootResolved) ? (0, import_node_fs2.realpathSync)(rootResolved) : rootResolved;
438
+ const targetReal = (0, import_node_fs2.existsSync)(targetResolved) ? (0, import_node_fs2.realpathSync)(targetResolved) : targetResolved;
439
+ const rel = relativePathUnderRoot(rootReal, targetReal);
440
+ return isReservedOutputPath(rel);
441
+ } catch {
442
+ return isReservedOutputPath(resolved);
443
+ }
444
+ }
445
+ function validatePathField(value, fieldPath, projectRoot, issues, options) {
446
+ if (!isSafeRelativeSpaPath(value)) {
447
+ issues.push({
448
+ path: fieldPath,
449
+ message: "path must be relative without '..' segments or absolute prefixes"
450
+ });
451
+ return;
452
+ }
453
+ if (options?.rejectReserved && isReservedOutputPath(value)) {
454
+ issues.push({
455
+ path: fieldPath,
456
+ message: "path must not target reserved directories (.git, node_modules, .github)"
457
+ });
458
+ return;
459
+ }
460
+ try {
461
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
462
+ } catch {
463
+ issues.push({
464
+ path: fieldPath,
465
+ message: "path must resolve inside the project root"
466
+ });
467
+ }
468
+ }
469
+ function validateProjectPaths(projectRoot, paths) {
470
+ const issues = [];
471
+ const root = (0, import_node_path2.resolve)(projectRoot);
472
+ if (paths.spaDistDir?.trim()) {
473
+ validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
474
+ rejectReserved: true
475
+ });
476
+ }
477
+ if (paths.lxpackOutDir?.trim()) {
478
+ validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
479
+ rejectReserved: true
480
+ });
481
+ }
482
+ if (paths.outputBaseDir?.trim()) {
483
+ validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
484
+ rejectReserved: true
485
+ });
486
+ }
487
+ return issues;
488
+ }
489
+ function resolveSafePackageOutputOverride(projectRoot, override) {
490
+ const root = (0, import_node_path2.resolve)(projectRoot);
491
+ const trimmed = override.trim();
492
+ if (!trimmed) {
493
+ throw new Error("output override must be a non-empty path");
494
+ }
495
+ if ((0, import_node_path2.isAbsolute)(trimmed)) {
496
+ const resolved2 = (0, import_node_path2.resolve)(trimmed);
497
+ assertRealPathUnderRoot(root, resolved2);
498
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
499
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
500
+ }
501
+ return resolved2;
502
+ }
503
+ if (!isSafeRelativeSpaPath(trimmed)) {
504
+ throw new Error(`unsafe output path: ${override}`);
505
+ }
506
+ const resolved = (0, import_node_path2.resolve)(root, trimmed);
507
+ assertRealPathUnderRoot(root, resolved);
508
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
509
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
510
+ }
511
+ return resolved;
512
+ }
513
+
344
514
  // src/theme.ts
345
515
  var import_themes = require("@lessonkit/themes");
346
516
  function themeToLxpackRuntime(input) {
@@ -358,6 +528,7 @@ function themeToLxpackRuntime(input) {
358
528
 
359
529
  // src/descriptor/validateAssessments.ts
360
530
  var import_core2 = require("@lessonkit/core");
531
+ var import_core3 = require("@lessonkit/core");
361
532
  var validateMcqLike = (assessment, path, issues) => {
362
533
  if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
363
534
  issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
@@ -373,9 +544,44 @@ var validateMcqLike = (assessment, path, issues) => {
373
544
  }
374
545
  if (!assessment.answer.trim()) {
375
546
  issues.push({ path: `${path}.answer`, message: "answer is required" });
376
- } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
547
+ } else if (!("answers" in assessment && (0, import_core3.isMultiSelectMcq)({ answers: assessment.answers })) && trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
377
548
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
378
549
  }
550
+ if ("answers" in assessment && assessment.answers !== void 0) {
551
+ if (!Array.isArray(assessment.answers)) {
552
+ issues.push({ path: `${path}.answers`, message: "answers must be an array when provided" });
553
+ } else {
554
+ const trimmedAnswers = assessment.answers.map((a) => a.trim()).filter((a) => a.length > 0);
555
+ if (assessment.answers.length > 0 && trimmedAnswers.length === 0) {
556
+ issues.push({ path: `${path}.answers`, message: "answers must include non-empty strings" });
557
+ }
558
+ const uniqueAnswers = new Set(trimmedAnswers);
559
+ if (trimmedAnswers.length !== uniqueAnswers.size) {
560
+ issues.push({ path: `${path}.answers`, message: "answers must be unique" });
561
+ }
562
+ for (const ans of trimmedAnswers) {
563
+ if (trimmedChoices.length && !trimmedChoices.includes(ans)) {
564
+ issues.push({ path: `${path}.answers`, message: "each answer must match a choice" });
565
+ break;
566
+ }
567
+ }
568
+ }
569
+ }
570
+ if ("choiceFeedback" in assessment && assessment.choiceFeedback !== void 0) {
571
+ if (typeof assessment.choiceFeedback !== "object" || assessment.choiceFeedback === null) {
572
+ issues.push({ path: `${path}.choiceFeedback`, message: "choiceFeedback must be an object" });
573
+ } else {
574
+ for (const key of Object.keys(assessment.choiceFeedback)) {
575
+ if (!trimmedChoices.includes(key.trim())) {
576
+ issues.push({
577
+ path: `${path}.choiceFeedback`,
578
+ message: "choiceFeedback keys must match choice labels"
579
+ });
580
+ break;
581
+ }
582
+ }
583
+ }
584
+ }
379
585
  const uniqueChoices = new Set(trimmedChoices);
380
586
  if (trimmedChoices.length !== uniqueChoices.size) {
381
587
  issues.push({ path: `${path}.choices`, message: "choices must be unique" });
@@ -395,6 +601,12 @@ function maxAchievableAssessmentScore(assessment) {
395
601
  if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
396
602
  return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
397
603
  }
604
+ if (kind === "sortParagraphs" && assessment.kind === "sortParagraphs") {
605
+ return assessment.paragraphs?.length ?? assessment.correctOrder?.length ?? 0;
606
+ }
607
+ if ("answers" in assessment && Array.isArray(assessment.answers) && assessment.answers.length > 1) {
608
+ return assessment.answers.filter((a) => a.trim().length > 0).length;
609
+ }
398
610
  return 1;
399
611
  }
400
612
  var ASSESSMENT_VALIDATORS = {
@@ -417,8 +629,30 @@ var ASSESSMENT_VALIDATORS = {
417
629
  message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
418
630
  });
419
631
  }
420
- const explicitBlanks = assessment.blanks?.map((b) => ({ id: b.id?.trim() ?? "", answer: b.answer?.trim() ?? "" })).filter((b) => b.id.length > 0 && b.answer.length > 0) ?? [];
421
- if (assessment.blanks !== void 0 && explicitBlanks.length === 0) {
632
+ const explicitBlanks = [];
633
+ if (assessment.blanks !== void 0) {
634
+ for (let i = 0; i < assessment.blanks.length; i++) {
635
+ const blank = assessment.blanks[i];
636
+ if (!blank || typeof blank !== "object") {
637
+ issues.push({
638
+ path: `${path}.blanks[${i}]`,
639
+ message: "blank entry must be an object with non-empty id and answer"
640
+ });
641
+ continue;
642
+ }
643
+ const id = blank.id?.trim() ?? "";
644
+ const answer = blank.answer?.trim() ?? "";
645
+ if (!id || !answer) {
646
+ issues.push({
647
+ path: `${path}.blanks[${i}]`,
648
+ message: "blank entry must include non-empty id and answer"
649
+ });
650
+ continue;
651
+ }
652
+ explicitBlanks.push({ id, answer });
653
+ }
654
+ }
655
+ if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
422
656
  issues.push({
423
657
  path: `${path}.blanks`,
424
658
  message: "blanks must include at least one entry with non-empty id and answer"
@@ -458,7 +692,31 @@ var ASSESSMENT_VALIDATORS = {
458
692
  message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
459
693
  });
460
694
  }
461
- }
695
+ },
696
+ sortParagraphs: (assessment, path, issues) => {
697
+ if (assessment.kind !== "sortParagraphs") return;
698
+ if (!Array.isArray(assessment.paragraphs) || assessment.paragraphs.length === 0) {
699
+ issues.push({ path: `${path}.paragraphs`, message: "paragraphs is required for sortParagraphs" });
700
+ return;
701
+ }
702
+ if (!Array.isArray(assessment.correctOrder) || assessment.correctOrder.length === 0) {
703
+ issues.push({ path: `${path}.correctOrder`, message: "correctOrder is required for sortParagraphs" });
704
+ return;
705
+ }
706
+ if (assessment.correctOrder.length !== assessment.paragraphs.length) {
707
+ issues.push({
708
+ path: `${path}.correctOrder`,
709
+ message: "correctOrder length must match paragraphs length for sortParagraphs"
710
+ });
711
+ }
712
+ },
713
+ guessTheAnswer: (assessment, path, issues) => {
714
+ if (assessment.kind !== "guessTheAnswer") return;
715
+ if (!assessment.answer?.trim()) {
716
+ issues.push({ path: `${path}.answer`, message: "answer is required for guessTheAnswer" });
717
+ }
718
+ },
719
+ multimediaChoice: validateMcqLike
462
720
  };
463
721
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
464
722
  const path = `assessments[${index}]`;
@@ -513,7 +771,7 @@ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
513
771
  var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
514
772
  function validateCourseDescriptor(input) {
515
773
  const issues = [];
516
- const course = (0, import_core3.validateId)(input.courseId, "courseId");
774
+ const course = (0, import_core4.validateId)(input.courseId, "courseId");
517
775
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
518
776
  if (!input.title?.trim()) {
519
777
  issues.push({ path: "title", message: "title is required" });
@@ -566,6 +824,20 @@ function validateCourseDescriptor(input) {
566
824
  });
567
825
  }
568
826
  }
827
+ const descriptorSpaDistDir = input.spaDistDir?.trim();
828
+ if (descriptorSpaDistDir) {
829
+ if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
830
+ issues.push({
831
+ path: "spaDistDir",
832
+ message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
833
+ });
834
+ } else if (isReservedOutputPath(descriptorSpaDistDir)) {
835
+ issues.push({
836
+ path: "spaDistDir",
837
+ message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
838
+ });
839
+ }
840
+ }
569
841
  if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
570
842
  issues.push({
571
843
  path: "lessons",
@@ -576,7 +848,7 @@ function validateCourseDescriptor(input) {
576
848
  const spaPaths = /* @__PURE__ */ new Set();
577
849
  for (const [index, lesson] of (input.lessons ?? []).entries()) {
578
850
  const path = `lessons[${index}]`;
579
- const lessonResult = (0, import_core3.validateId)(lesson.id, `${path}.id`);
851
+ const lessonResult = (0, import_core4.validateId)(lesson.id, `${path}.id`);
580
852
  if (!lessonResult.ok) {
581
853
  issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
582
854
  } else if (lessonIds.has(lessonResult.id)) {
@@ -608,7 +880,7 @@ function validateCourseDescriptor(input) {
608
880
  }
609
881
  if (layout === "single-spa" && input.spaLessonId?.trim()) {
610
882
  const spaId = input.spaLessonId.trim();
611
- const spaResult = (0, import_core3.validateId)(spaId, "spaLessonId");
883
+ const spaResult = (0, import_core4.validateId)(spaId, "spaLessonId");
612
884
  if (!spaResult.ok) {
613
885
  issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
614
886
  } else if (!lessonIds.has(spaResult.id)) {
@@ -626,6 +898,8 @@ function validateCourseDescriptor(input) {
626
898
  }
627
899
 
628
900
  // src/assessments.ts
901
+ var import_core5 = require("@lessonkit/core");
902
+ var DEFAULT_SHELL_PASSING_SCORE = 1;
629
903
  function escapeShellText(text) {
630
904
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
631
905
  }
@@ -634,7 +908,7 @@ function decodeShellEntities(text) {
634
908
  }
635
909
  function containsUnsafeShellMarkup(text) {
636
910
  const decoded = decodeShellEntities(text);
637
- return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /</.test(decoded);
911
+ return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
638
912
  }
639
913
  function sanitizeShellField(text) {
640
914
  if (containsUnsafeShellMarkup(text)) return null;
@@ -649,6 +923,8 @@ function mcqToLxpack(assessment) {
649
923
  const checkId = sanitizeShellField(assessment.checkId);
650
924
  const prompt = sanitizeShellField(assessment.question);
651
925
  if (!checkId || !prompt) return null;
926
+ const normalizedAnswer = assessment.answer.trim();
927
+ const multiCorrect = assessment.answers && assessment.answers.length > 1 ? new Set(assessment.answers.map((a) => a.trim())) : /* @__PURE__ */ new Set([normalizedAnswer]);
652
928
  const choices = assessment.choices.map((text, index) => {
653
929
  const sanitizedText = sanitizeShellField(text);
654
930
  if (!sanitizedText) return null;
@@ -656,17 +932,21 @@ function mcqToLxpack(assessment) {
656
932
  return {
657
933
  id,
658
934
  text: sanitizedText,
659
- correct: text === assessment.answer
935
+ correct: multiCorrect.has(text.trim())
660
936
  };
661
937
  });
662
938
  if (choices.some((choice) => choice === null)) return null;
939
+ const multiSelect = (0, import_core5.isMultiSelectMcq)(assessment);
663
940
  return {
664
941
  id: checkId,
665
- passingScore: assessment.passingScore ?? 1,
942
+ passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
943
+ shuffleChoices: assessment.shuffleChoices === true ? true : void 0,
944
+ showFeedback: assessment.choiceFeedback && Object.keys(assessment.choiceFeedback).length > 0 ? "immediate" : void 0,
666
945
  questions: [
667
946
  {
668
947
  id: "q1",
669
948
  prompt,
949
+ ...multiSelect ? { selectionMode: "multiple" } : {},
670
950
  choices
671
951
  }
672
952
  ]
@@ -695,7 +975,20 @@ function assessmentDescriptorToLxpack(assessment) {
695
975
  if (kind === "findMultipleHotspots") {
696
976
  return null;
697
977
  }
698
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
978
+ if (kind === "sortParagraphs" || kind === "guessTheAnswer") {
979
+ return null;
980
+ }
981
+ if (kind === "multimediaChoice" && assessment.kind === "multimediaChoice") {
982
+ return mcqToLxpack({
983
+ kind: "mcq",
984
+ checkId: assessment.checkId,
985
+ question: assessment.question,
986
+ choices: assessment.choices,
987
+ answer: assessment.answer,
988
+ passingScore: assessment.passingScore
989
+ });
990
+ }
991
+ if ((kind === "mcq" || assessment.kind === void 0) && "choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
699
992
  return mcqToLxpack(assessment);
700
993
  }
701
994
  return null;
@@ -707,11 +1000,20 @@ function extractAssessments(descriptor) {
707
1000
  // src/descriptor/validateInjectableAssessments.ts
708
1001
  function validateInjectableAssessments(descriptor) {
709
1002
  const issues = [];
1003
+ const spaOnlyKinds = /* @__PURE__ */ new Set([
1004
+ "fillInBlanks",
1005
+ "findHotspot",
1006
+ "findMultipleHotspots",
1007
+ "sortParagraphs",
1008
+ "guessTheAnswer"
1009
+ ]);
710
1010
  (descriptor.assessments ?? []).forEach((assessment, index) => {
711
1011
  if (assessmentDescriptorToLxpack(assessment) === null) {
1012
+ const kind = assessment.kind ?? "mcq";
1013
+ const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
712
1014
  issues.push({
713
1015
  path: `assessments[${index}]`,
714
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
1016
+ message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
715
1017
  });
716
1018
  }
717
1019
  });
@@ -727,7 +1029,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
727
1029
  "cmi5"
728
1030
  ]);
729
1031
  function appendActivityIriIssues(issues, descriptor, target) {
730
- const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
1032
+ const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
731
1033
  const requiresForTarget = target === "xapi" || target === "cmi5";
732
1034
  if (!hasXapiTracking && !requiresForTarget) return;
733
1035
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
@@ -782,26 +1084,26 @@ function validateDescriptorForTarget(input, target) {
782
1084
  }
783
1085
 
784
1086
  // src/validateReactParity.ts
785
- var import_node_fs2 = require("fs");
786
- var import_node_path2 = require("path");
1087
+ var import_node_fs3 = require("fs");
1088
+ var import_node_path3 = require("path");
787
1089
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
788
1090
  function collectSourceUnderSrc(projectRoot, issues) {
789
- const srcDir = (0, import_node_path2.join)(projectRoot, "src");
790
- if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
1091
+ const srcDir = (0, import_node_path3.join)(projectRoot, "src");
1092
+ if (!(0, import_node_fs3.existsSync)(srcDir)) return [];
791
1093
  const results = [];
792
1094
  const walk = (dir) => {
793
- for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
794
- const abs = (0, import_node_path2.join)(dir, entry);
1095
+ for (const entry of (0, import_node_fs3.readdirSync)(dir)) {
1096
+ const abs = (0, import_node_path3.join)(dir, entry);
795
1097
  let stat2;
796
1098
  try {
797
- stat2 = (0, import_node_fs2.lstatSync)(abs);
1099
+ stat2 = (0, import_node_fs3.lstatSync)(abs);
798
1100
  } catch {
799
1101
  continue;
800
1102
  }
801
1103
  if (stat2.isSymbolicLink()) {
802
1104
  issues.push({
803
- path: (0, import_node_path2.relative)(projectRoot, abs),
804
- message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path2.relative)(projectRoot, abs)}`,
1105
+ path: (0, import_node_path3.relative)(projectRoot, abs),
1106
+ message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path3.relative)(projectRoot, abs)}`,
805
1107
  severity: "error"
806
1108
  });
807
1109
  continue;
@@ -811,8 +1113,8 @@ function collectSourceUnderSrc(projectRoot, issues) {
811
1113
  assertRealPathUnderRoot(projectRoot, abs);
812
1114
  } catch {
813
1115
  issues.push({
814
- path: (0, import_node_path2.relative)(projectRoot, abs),
815
- message: `Source directory escapes project root: ${(0, import_node_path2.relative)(projectRoot, abs)}`,
1116
+ path: (0, import_node_path3.relative)(projectRoot, abs),
1117
+ message: `Source directory escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
816
1118
  severity: "error"
817
1119
  });
818
1120
  continue;
@@ -823,13 +1125,13 @@ function collectSourceUnderSrc(projectRoot, issues) {
823
1125
  assertRealPathUnderRoot(projectRoot, abs);
824
1126
  } catch {
825
1127
  issues.push({
826
- path: (0, import_node_path2.relative)(projectRoot, abs),
827
- message: `Source file escapes project root: ${(0, import_node_path2.relative)(projectRoot, abs)}`,
1128
+ path: (0, import_node_path3.relative)(projectRoot, abs),
1129
+ message: `Source file escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
828
1130
  severity: "error"
829
1131
  });
830
1132
  continue;
831
1133
  }
832
- results.push((0, import_node_path2.relative)(projectRoot, abs));
1134
+ results.push((0, import_node_path3.relative)(projectRoot, abs));
833
1135
  }
834
1136
  }
835
1137
  };
@@ -848,10 +1150,10 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
848
1150
  }
849
1151
  return null;
850
1152
  }
851
- const abs = (0, import_node_path2.join)(projectRoot, rel);
1153
+ const abs = (0, import_node_path3.join)(projectRoot, rel);
852
1154
  try {
853
1155
  assertRealPathUnderRoot(projectRoot, abs);
854
- if ((0, import_node_fs2.existsSync)(abs) && (0, import_node_fs2.lstatSync)(abs).isSymbolicLink()) {
1156
+ if ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.lstatSync)(abs).isSymbolicLink()) {
855
1157
  issues.push({
856
1158
  path: rel,
857
1159
  message: `appSources path is a symlink: ${rel}`,
@@ -867,8 +1169,8 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
867
1169
  });
868
1170
  return null;
869
1171
  }
870
- if (!(0, import_node_fs2.existsSync)(abs)) return null;
871
- return (0, import_node_fs2.readFileSync)(abs, "utf8");
1172
+ if (!(0, import_node_fs3.existsSync)(abs)) return null;
1173
+ return (0, import_node_fs3.readFileSync)(abs, "utf8");
872
1174
  }).filter((content) => content != null).join("\n");
873
1175
  }
874
1176
  function stripComments(source) {
@@ -943,9 +1245,20 @@ function courseConfigCourseIdPresent(source, courseId) {
943
1245
  if (literalPattern.test(stripped)) return true;
944
1246
  return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
945
1247
  }
1248
+ function courseMetaCourseIdPresent(source, courseId) {
1249
+ const constants = extractStringConstants(source);
1250
+ const stripped = stripComments(source);
1251
+ for (const [name, value] of constants) {
1252
+ if (value !== courseId) continue;
1253
+ if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
1254
+ if (/\blessons\s*:\s*\S/.test(stripped)) return true;
1255
+ }
1256
+ return false;
1257
+ }
946
1258
  function courseIdPresent(source, courseId) {
947
1259
  if (idPropPresent(source, "courseId", courseId)) return true;
948
1260
  if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
1261
+ if (courseMetaCourseIdPresent(source, courseId)) return true;
949
1262
  return courseConfigCourseIdPresent(source, courseId);
950
1263
  }
951
1264
  function checkIdPresent(source, checkId) {
@@ -1014,88 +1327,13 @@ function validateReactManifestParity(opts) {
1014
1327
  return issues;
1015
1328
  }
1016
1329
 
1017
- // src/validateProjectPaths.ts
1018
- var import_node_path3 = require("path");
1019
- var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
1020
- function isReservedOutputPath(value) {
1021
- const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
1022
- const segments = normalized.split("/").filter(Boolean);
1023
- return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
1024
- }
1025
- function validatePathField(value, fieldPath, projectRoot, issues, options) {
1026
- if (!isSafeRelativeSpaPath(value)) {
1027
- issues.push({
1028
- path: fieldPath,
1029
- message: "path must be relative without '..' segments or absolute prefixes"
1030
- });
1031
- return;
1032
- }
1033
- if (options?.rejectReserved && isReservedOutputPath(value)) {
1034
- issues.push({
1035
- path: fieldPath,
1036
- message: "path must not target reserved directories (.git, node_modules, .github)"
1037
- });
1038
- return;
1039
- }
1040
- try {
1041
- assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
1042
- } catch {
1043
- issues.push({
1044
- path: fieldPath,
1045
- message: "path must resolve inside the project root"
1046
- });
1047
- }
1048
- }
1049
- function validateProjectPaths(projectRoot, paths) {
1050
- const issues = [];
1051
- const root = (0, import_node_path3.resolve)(projectRoot);
1052
- if (paths.spaDistDir?.trim()) {
1053
- validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
1054
- }
1055
- if (paths.lxpackOutDir?.trim()) {
1056
- validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
1057
- rejectReserved: true
1058
- });
1059
- }
1060
- if (paths.outputBaseDir?.trim()) {
1061
- validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
1062
- rejectReserved: true
1063
- });
1064
- }
1065
- return issues;
1066
- }
1067
- function resolveSafePackageOutputOverride(projectRoot, override) {
1068
- const root = (0, import_node_path3.resolve)(projectRoot);
1069
- const trimmed = override.trim();
1070
- if (!trimmed) {
1071
- throw new Error("output override must be a non-empty path");
1072
- }
1073
- if ((0, import_node_path3.isAbsolute)(trimmed)) {
1074
- const resolved2 = (0, import_node_path3.resolve)(trimmed);
1075
- assertRealPathUnderRoot(root, resolved2);
1076
- if (isReservedOutputPath(trimmed)) {
1077
- throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1078
- }
1079
- return resolved2;
1080
- }
1081
- if (!isSafeRelativeSpaPath(trimmed)) {
1082
- throw new Error(`unsafe output path: ${override}`);
1083
- }
1084
- if (isReservedOutputPath(trimmed)) {
1085
- throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1086
- }
1087
- const resolved = (0, import_node_path3.resolve)(root, trimmed);
1088
- assertRealPathUnderRoot(root, resolved);
1089
- return resolved;
1090
- }
1091
-
1092
1330
  // src/mapIds.ts
1093
- var import_core4 = require("@lessonkit/core");
1331
+ var import_core6 = require("@lessonkit/core");
1094
1332
  function mapLessonkitIds(descriptor) {
1095
- const courseId = (0, import_core4.assertValidId)(descriptor.courseId, "courseId");
1096
- const lessonIds = descriptor.lessons.map((l) => (0, import_core4.assertValidId)(l.id, "lessonId"));
1333
+ const courseId = (0, import_core6.assertValidId)(descriptor.courseId, "courseId");
1334
+ const lessonIds = descriptor.lessons.map((l) => (0, import_core6.assertValidId)(l.id, "lessonId"));
1097
1335
  const checkIds = (descriptor.assessments ?? []).map(
1098
- (a) => (0, import_core4.assertValidId)(a.checkId, "checkId")
1336
+ (a) => (0, import_core6.assertValidId)(a.checkId, "checkId")
1099
1337
  );
1100
1338
  return { courseId, lessonIds, checkIds };
1101
1339
  }
@@ -1220,7 +1458,7 @@ async function resolveSpaDirs(options) {
1220
1458
 
1221
1459
  // src/spaDistValidation.ts
1222
1460
  var import_promises2 = require("fs/promises");
1223
- var import_node_fs3 = require("fs");
1461
+ var import_node_fs4 = require("fs");
1224
1462
  var import_node_path5 = require("path");
1225
1463
  async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1226
1464
  for (const [label, dir] of Object.entries(spaDirs)) {
@@ -1231,7 +1469,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1231
1469
  }
1232
1470
  let rootReal;
1233
1471
  try {
1234
- rootReal = (0, import_node_fs3.realpathSync)(dirResolved);
1472
+ rootReal = (0, import_node_fs4.realpathSync)(dirResolved);
1235
1473
  } catch {
1236
1474
  throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
1237
1475
  }
@@ -1260,7 +1498,7 @@ async function walkDistDir(rootReal, current, label) {
1260
1498
  }
1261
1499
  let entryReal;
1262
1500
  try {
1263
- entryReal = (0, import_node_fs3.realpathSync)(entryPath);
1501
+ entryReal = (0, import_node_fs4.realpathSync)(entryPath);
1264
1502
  } catch (err) {
1265
1503
  throw new Error(
1266
1504
  `spa dist for "${label}" could not resolve path: ${entryPath}`,
@@ -1285,8 +1523,10 @@ async function writeLxpackProject(options) {
1285
1523
  const descriptor = validation.descriptor;
1286
1524
  const injectableIssues = validateInjectableAssessments(descriptor);
1287
1525
  if (injectableIssues.length > 0) {
1288
- throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1289
- }
1526
+ throw new Error(
1527
+ injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
1528
+ );
1529
+ }
1290
1530
  const outDir = (0, import_node_path6.resolve)(options.outDir);
1291
1531
  assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
1292
1532
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
@@ -1312,8 +1552,8 @@ async function writeLxpackProject(options) {
1312
1552
  }
1313
1553
 
1314
1554
  // src/packageCourse.ts
1315
- var import_node_path10 = require("path");
1316
- var fsp3 = __toESM(require("fs/promises"), 1);
1555
+ var import_node_path11 = require("path");
1556
+ var fsp4 = __toESM(require("fs/promises"), 1);
1317
1557
  var import_api2 = require("@lxpack/api");
1318
1558
 
1319
1559
  // src/packaging/validateInputs.ts
@@ -1348,6 +1588,19 @@ function validatePackageInputs(options) {
1348
1588
  ]
1349
1589
  };
1350
1590
  }
1591
+ if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
1592
+ return {
1593
+ ok: false,
1594
+ courseDir: outDir,
1595
+ target,
1596
+ issues: [
1597
+ {
1598
+ path: "outDir",
1599
+ message: "outDir must not target reserved directories (.git, node_modules, .github)"
1600
+ }
1601
+ ]
1602
+ };
1603
+ }
1351
1604
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1352
1605
  return {
1353
1606
  ok: false,
@@ -1405,6 +1658,19 @@ function validatePackageInputs(options) {
1405
1658
  ]
1406
1659
  };
1407
1660
  }
1661
+ if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
1662
+ return {
1663
+ ok: false,
1664
+ courseDir: outDir,
1665
+ target,
1666
+ issues: [
1667
+ {
1668
+ path: "outputBaseDir",
1669
+ message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
1670
+ }
1671
+ ]
1672
+ };
1673
+ }
1408
1674
  }
1409
1675
  if (output) {
1410
1676
  const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
@@ -1426,6 +1692,20 @@ function validatePackageInputs(options) {
1426
1692
  ]
1427
1693
  };
1428
1694
  }
1695
+ const outputRel = (0, import_node_path7.isAbsolute)(output) ? output : output;
1696
+ if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
1697
+ return {
1698
+ ok: false,
1699
+ courseDir: outDir,
1700
+ target,
1701
+ issues: [
1702
+ {
1703
+ path: "output",
1704
+ message: "output must not target reserved directories (.git, node_modules, .github)"
1705
+ }
1706
+ ]
1707
+ };
1708
+ }
1429
1709
  }
1430
1710
  return { ok: true, outDir, projectRoot };
1431
1711
  }
@@ -1518,11 +1798,14 @@ async function isStalePromoteLock(lockPath) {
1518
1798
  return true;
1519
1799
  }
1520
1800
  }
1801
+ var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1521
1802
  async function withPromoteLock(outDir, fn) {
1522
1803
  const lockPath = promoteLockPath(outDir);
1523
1804
  await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
1524
1805
  let lockHandle;
1525
- for (let attempt = 0; attempt < 200; attempt++) {
1806
+ const maxAttempts = 400;
1807
+ const started = Date.now();
1808
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1526
1809
  try {
1527
1810
  lockHandle = await fsp.open(lockPath, "wx");
1528
1811
  await lockHandle.writeFile(`${process.pid}
@@ -1540,7 +1823,9 @@ ${Date.now()}
1540
1823
  );
1541
1824
  continue;
1542
1825
  }
1543
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1826
+ if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
1827
+ const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
1828
+ await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
1544
1829
  }
1545
1830
  }
1546
1831
  if (!lockHandle) {
@@ -1709,14 +1994,29 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1709
1994
  });
1710
1995
  }
1711
1996
 
1712
- // src/packaging/staging.ts
1997
+ // src/packaging/relocateOutput.ts
1713
1998
  var fsp2 = __toESM(require("fs/promises"), 1);
1714
1999
  var import_node_path9 = require("path");
2000
+ async function relocatePackageOutput(builtOutputPath, requestedOutputPath, projectRoot) {
2001
+ if (!builtOutputPath || !requestedOutputPath) return builtOutputPath;
2002
+ const resolvedBuilt = resolveComparablePath(builtOutputPath);
2003
+ const resolvedRequested = resolveComparablePath(requestedOutputPath);
2004
+ if (resolvedBuilt === resolvedRequested) return builtOutputPath;
2005
+ const root = (0, import_node_path9.resolve)(projectRoot);
2006
+ assertRealPathUnderRoot(root, resolvedRequested);
2007
+ await fsp2.mkdir((0, import_node_path9.dirname)(resolvedRequested), { recursive: true });
2008
+ await renameOrCopy(resolvedBuilt, resolvedRequested);
2009
+ return resolvedRequested;
2010
+ }
2011
+
2012
+ // src/packaging/staging.ts
2013
+ var fsp3 = __toESM(require("fs/promises"), 1);
2014
+ var import_node_path10 = require("path");
1715
2015
  var import_node_os = require("os");
1716
2016
  var import_api = require("@lxpack/api");
1717
2017
  async function buildStagingPackage(options) {
1718
2018
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1719
- const stagingDir = await fsp2.mkdtemp((0, import_node_path9.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
2019
+ const stagingDir = await fsp3.mkdtemp((0, import_node_path10.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1720
2020
  let succeeded = false;
1721
2021
  try {
1722
2022
  let spaDirs;
@@ -1748,14 +2048,28 @@ async function buildStagingPackage(options) {
1748
2048
  }
1749
2049
  const interchange = descriptorToInterchange(descriptor);
1750
2050
  const outputBase = outputBaseDir ?? ".lxpack/out";
1751
- await fsp2.mkdir((0, import_node_path9.join)(stagingDir, outputBase), { recursive: true });
1752
- const defaultOutput = output ?? (dir ? (0, import_node_path9.join)(outputBase, target) : (0, import_node_path9.join)(outputBase, `course-${target}.zip`));
2051
+ await fsp3.mkdir((0, import_node_path10.join)(stagingDir, outputBase), { recursive: true });
2052
+ const defaultOutput = dir ? (0, import_node_path10.join)(outputBase, target) : (0, import_node_path10.join)(outputBase, `course-${target}.zip`);
2053
+ let packageOutput = output ?? defaultOutput;
2054
+ let requestedOutputPath;
2055
+ let requestedOutputDir;
2056
+ if (output) {
2057
+ const projectRoot = (0, import_node_path10.resolve)(writeOpts.projectRoot);
2058
+ const requested = (0, import_node_path10.isAbsolute)(output) ? (0, import_node_path10.resolve)(output) : (0, import_node_path10.resolve)(projectRoot, output);
2059
+ if (dir) {
2060
+ requestedOutputDir = requested;
2061
+ packageOutput = defaultOutput;
2062
+ } else {
2063
+ requestedOutputPath = requested;
2064
+ packageOutput = defaultOutput;
2065
+ }
2066
+ }
1753
2067
  const build = await (0, import_api.packageLessonkit)({
1754
2068
  interchange,
1755
2069
  spaDirs,
1756
2070
  target,
1757
2071
  courseDir: stagingDir,
1758
- output: defaultOutput,
2072
+ output: packageOutput,
1759
2073
  dir,
1760
2074
  outputBaseDir,
1761
2075
  outputAnchorDir: stagingDir,
@@ -1779,17 +2093,19 @@ async function buildStagingPackage(options) {
1779
2093
  stagingDir,
1780
2094
  build,
1781
2095
  outputPath: "outputPath" in build ? build.outputPath : void 0,
1782
- outputDir: "outputDir" in build ? build.outputDir : void 0
2096
+ outputDir: "outputDir" in build ? build.outputDir : void 0,
2097
+ requestedOutputPath,
2098
+ requestedOutputDir
1783
2099
  };
1784
2100
  } catch (err) {
1785
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2101
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1786
2102
  /* v8 ignore next */
1787
2103
  () => void 0
1788
2104
  );
1789
2105
  throw err;
1790
2106
  } finally {
1791
2107
  if (!succeeded) {
1792
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2108
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1793
2109
  /* v8 ignore next */
1794
2110
  () => void 0
1795
2111
  );
@@ -1797,7 +2113,7 @@ async function buildStagingPackage(options) {
1797
2113
  }
1798
2114
  }
1799
2115
  async function ensureOutDirParent(outDir) {
1800
- await fsp2.mkdir((0, import_node_path9.dirname)(outDir), { recursive: true });
2116
+ await fsp3.mkdir((0, import_node_path10.dirname)(outDir), { recursive: true });
1801
2117
  }
1802
2118
 
1803
2119
  // src/packaging/issueSeverity.ts
@@ -1808,17 +2124,23 @@ function isPackagingErrorIssue(issue) {
1808
2124
  function findPackagingErrorIssues(issues) {
1809
2125
  return (issues ?? []).filter(isPackagingErrorIssue);
1810
2126
  }
2127
+ function isPackagingWarningIssue(issue) {
2128
+ return issue.severity?.toLowerCase() === "warning";
2129
+ }
2130
+ function findPackagingWarningIssues(issues) {
2131
+ return (issues ?? []).filter(isPackagingWarningIssue);
2132
+ }
1811
2133
 
1812
2134
  // src/packageCourse.ts
1813
2135
  async function validateLessonkitProject(options) {
1814
2136
  return (0, import_api2.validateCourse)({
1815
- courseDir: (0, import_node_path10.resolve)(options.courseDir),
2137
+ courseDir: (0, import_node_path11.resolve)(options.courseDir),
1816
2138
  target: options.target
1817
2139
  });
1818
2140
  }
1819
2141
  async function buildLessonkitProject(options) {
1820
2142
  const buildOptions = {
1821
- courseDir: (0, import_node_path10.resolve)(options.courseDir),
2143
+ courseDir: (0, import_node_path11.resolve)(options.courseDir),
1822
2144
  target: options.target,
1823
2145
  output: options.output,
1824
2146
  dir: options.dir,
@@ -1849,7 +2171,7 @@ async function packageLessonkitCourse(options) {
1849
2171
  if (!descriptorValidation.ok) {
1850
2172
  return {
1851
2173
  ok: false,
1852
- courseDir: (0, import_node_path10.resolve)(writeOpts.outDir),
2174
+ courseDir: (0, import_node_path11.resolve)(writeOpts.outDir),
1853
2175
  target,
1854
2176
  issues: descriptorValidation.issues.map((i) => ({
1855
2177
  path: i.path,
@@ -1875,16 +2197,31 @@ async function packageLessonkitCourse(options) {
1875
2197
  }))
1876
2198
  };
1877
2199
  }
1878
- const staged = await buildStagingPackage({
1879
- ...writeOpts,
1880
- descriptor,
1881
- target,
1882
- output,
1883
- dir,
1884
- outputBaseDir
1885
- });
2200
+ let staged;
2201
+ try {
2202
+ staged = await buildStagingPackage({
2203
+ ...writeOpts,
2204
+ descriptor,
2205
+ target,
2206
+ output,
2207
+ dir,
2208
+ outputBaseDir
2209
+ });
2210
+ } catch (err) {
2211
+ return {
2212
+ ok: false,
2213
+ courseDir: outDir,
2214
+ target,
2215
+ issues: [
2216
+ {
2217
+ path: "staging",
2218
+ message: err instanceof Error ? err.message : String(err)
2219
+ }
2220
+ ]
2221
+ };
2222
+ }
1886
2223
  if (!staged.ok) {
1887
- await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
2224
+ await fsp4.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1888
2225
  /* v8 ignore next */
1889
2226
  () => void 0
1890
2227
  );
@@ -1901,7 +2238,7 @@ async function packageLessonkitCourse(options) {
1901
2238
  const { stagingDir, build } = staged;
1902
2239
  const buildErrorIssues = findPackagingErrorIssues(build.issues);
1903
2240
  if (buildErrorIssues.length > 0) {
1904
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2241
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
1905
2242
  /* v8 ignore next */
1906
2243
  () => void 0
1907
2244
  );
@@ -1918,13 +2255,13 @@ async function packageLessonkitCourse(options) {
1918
2255
  }))
1919
2256
  };
1920
2257
  }
1921
- const stagingRoot = await fsp3.realpath(stagingDir);
2258
+ const stagingRoot = await fsp4.realpath(stagingDir);
1922
2259
  const artifactIssues = [
1923
2260
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
1924
2261
  validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
1925
2262
  ].filter((issue) => issue != null);
1926
2263
  if (artifactIssues.length > 0) {
1927
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2264
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
1928
2265
  /* v8 ignore next */
1929
2266
  () => void 0
1930
2267
  );
@@ -1937,8 +2274,27 @@ async function packageLessonkitCourse(options) {
1937
2274
  issues: artifactIssues
1938
2275
  };
1939
2276
  }
1940
- const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
1941
- const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
2277
+ const buildWarningIssues = findPackagingWarningIssues(build.issues);
2278
+ if (options.strictBuild && buildWarningIssues.length > 0) {
2279
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2280
+ /* v8 ignore next */
2281
+ () => void 0
2282
+ );
2283
+ return {
2284
+ ok: false,
2285
+ courseDir: outDir,
2286
+ target,
2287
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
2288
+ build,
2289
+ issues: buildWarningIssues.map((i) => ({
2290
+ path: i.path ?? "build",
2291
+ message: i.message ?? "build warning",
2292
+ severity: i.severity
2293
+ }))
2294
+ };
2295
+ }
2296
+ let remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
2297
+ let remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
1942
2298
  const validation = {
1943
2299
  ok: true,
1944
2300
  manifest: build.manifest,
@@ -1951,7 +2307,7 @@ async function packageLessonkitCourse(options) {
1951
2307
  projectRoot: writeOpts.projectRoot
1952
2308
  });
1953
2309
  } catch (err) {
1954
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2310
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
1955
2311
  /* v8 ignore next */
1956
2312
  () => void 0
1957
2313
  );
@@ -1969,6 +2325,32 @@ async function packageLessonkitCourse(options) {
1969
2325
  ]
1970
2326
  };
1971
2327
  }
2328
+ try {
2329
+ remappedOutputPath = await relocatePackageOutput(
2330
+ remappedOutputPath,
2331
+ staged.requestedOutputPath,
2332
+ writeOpts.projectRoot
2333
+ );
2334
+ remappedOutputDir = await relocatePackageOutput(
2335
+ remappedOutputDir,
2336
+ staged.requestedOutputDir,
2337
+ writeOpts.projectRoot
2338
+ );
2339
+ } catch (err) {
2340
+ return {
2341
+ ok: false,
2342
+ courseDir: outDir,
2343
+ target,
2344
+ validation,
2345
+ build,
2346
+ issues: [
2347
+ {
2348
+ path: "output",
2349
+ message: err instanceof Error ? err.message : String(err)
2350
+ }
2351
+ ]
2352
+ };
2353
+ }
1972
2354
  const remappedBuild = { ...build };
1973
2355
  if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
1974
2356
  remappedBuild.outputPath = remappedOutputPath;
@@ -2012,8 +2394,9 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2012
2394
  }
2013
2395
  const nameRaw = config.name;
2014
2396
  const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
2015
- if (!name) {
2016
- issues.push({ path: "name", message: "must be a non-empty string" });
2397
+ const nameIssue = validateManifestName(name);
2398
+ if (nameIssue) {
2399
+ issues.push({ path: "name", message: nameIssue });
2017
2400
  }
2018
2401
  const courseRaw = config.course;
2019
2402
  if (Array.isArray(courseRaw)) {
@@ -2079,7 +2462,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2079
2462
  path: `paths.${key}`,
2080
2463
  message: "path must be relative without '..' segments or absolute prefixes"
2081
2464
  });
2082
- } else if ((key === "lxpackOutDir" || key === "outputBaseDir") && isReservedOutputPath(value)) {
2465
+ } else if (isReservedOutputPath(value)) {
2083
2466
  issues.push({
2084
2467
  path: `paths.${key}`,
2085
2468
  message: "path must not target reserved directories (.git, node_modules, .github)"
@@ -2173,17 +2556,922 @@ function telemetryEventToLessonkit(event) {
2173
2556
  }
2174
2557
 
2175
2558
  // src/index.ts
2559
+ var import_validators4 = require("@lxpack/validators");
2560
+
2561
+ // src/lkcourse/zip.ts
2562
+ var import_node_fs5 = require("fs");
2563
+ var import_node_path12 = require("path");
2564
+ var import_fflate = require("fflate");
2565
+ var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2566
+ function canonicalZipEntryPath(entryPath) {
2567
+ const slashNormalized = entryPath.replace(/\\/g, "/");
2568
+ const canonical = (0, import_node_path12.normalize)(slashNormalized).replace(/\\/g, "/");
2569
+ if (canonical !== slashNormalized) return null;
2570
+ return canonical;
2571
+ }
2572
+ function isSafeZipEntryPath(entryPath) {
2573
+ const canonical = canonicalZipEntryPath(entryPath);
2574
+ if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
2575
+ return false;
2576
+ }
2577
+ const segments = canonical.split("/").filter((s) => s.length > 0);
2578
+ if (segments.some((s) => s === "..")) return false;
2579
+ return segments.length > 0;
2580
+ }
2581
+ function createZip(entries) {
2582
+ const zipped = {};
2583
+ for (const [path, data] of entries) {
2584
+ if (!isSafeZipEntryPath(path)) {
2585
+ throw new Error(`unsafe zip entry path: ${path}`);
2586
+ }
2587
+ zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
2588
+ }
2589
+ return (0, import_fflate.zipSync)(zipped, { level: 6 });
2590
+ }
2591
+ function readZip(archivePath) {
2592
+ const issues = [];
2593
+ let raw;
2594
+ try {
2595
+ raw = (0, import_node_fs5.readFileSync)(archivePath);
2596
+ } catch {
2597
+ return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
2598
+ }
2599
+ if (!raw.length) {
2600
+ return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
2601
+ }
2602
+ let unzipped;
2603
+ try {
2604
+ unzipped = (0, import_fflate.unzipSync)(raw);
2605
+ } catch {
2606
+ return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
2607
+ }
2608
+ const entries = /* @__PURE__ */ new Map();
2609
+ let totalUncompressed = 0;
2610
+ for (const [path, data] of Object.entries(unzipped)) {
2611
+ const canonical = canonicalZipEntryPath(path);
2612
+ if (!canonical || !isSafeZipEntryPath(canonical)) {
2613
+ issues.push({ path, message: "unsafe zip entry path" });
2614
+ continue;
2615
+ }
2616
+ if (entries.has(canonical)) {
2617
+ issues.push({ path: canonical, message: "duplicate zip entry path" });
2618
+ continue;
2619
+ }
2620
+ totalUncompressed += data.byteLength;
2621
+ if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
2622
+ return {
2623
+ ok: false,
2624
+ issues: [
2625
+ {
2626
+ path: archivePath,
2627
+ message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
2628
+ }
2629
+ ]
2630
+ };
2631
+ }
2632
+ entries.set(canonical, data);
2633
+ }
2634
+ if (issues.length) return { ok: false, issues };
2635
+ return { ok: true, entries };
2636
+ }
2637
+ async function collectDistEntries(distDir, spaDistRelative) {
2638
+ const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
2639
+ const entries = /* @__PURE__ */ new Map();
2640
+ const walk = async (absDir, relPrefix) => {
2641
+ const dirEntries = await readdir4(absDir, { withFileTypes: true });
2642
+ for (const entry of dirEntries) {
2643
+ const abs = (0, import_node_path12.join)(absDir, entry.name);
2644
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
2645
+ const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
2646
+ if (!isSafeRelativeSpaPath(zipPath)) {
2647
+ throw new Error(`unsafe dist path: ${zipPath}`);
2648
+ }
2649
+ const stat2 = await lstat2(abs);
2650
+ if (stat2.isSymbolicLink()) {
2651
+ throw new Error(`dist contains symlink: ${abs}`);
2652
+ }
2653
+ if (stat2.isDirectory()) {
2654
+ await walk(abs, rel);
2655
+ } else if (stat2.isFile()) {
2656
+ entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
2657
+ }
2658
+ }
2659
+ };
2660
+ await walk(distDir, "");
2661
+ return entries;
2662
+ }
2663
+ function entryToUtf8(data) {
2664
+ return (0, import_fflate.strFromU8)(data);
2665
+ }
2666
+ function utf8ToEntry(text) {
2667
+ return (0, import_fflate.strToU8)(text);
2668
+ }
2669
+
2670
+ // src/lkcourse/parseEnvelope.ts
2671
+ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2672
+ const issues = [];
2673
+ if (!raw || typeof raw !== "object") {
2674
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
2675
+ }
2676
+ const obj = raw;
2677
+ if (obj.format !== "lkcourse") {
2678
+ issues.push({
2679
+ path: "format",
2680
+ message: `must be "lkcourse" (got ${String(obj.format)})`
2681
+ });
2682
+ }
2683
+ let schemaVersion = obj.schemaVersion;
2684
+ if (schemaVersion === "1") schemaVersion = 1;
2685
+ if (schemaVersion !== 1) {
2686
+ issues.push({
2687
+ path: "schemaVersion",
2688
+ message: `must be 1 (got ${String(obj.schemaVersion)})`
2689
+ });
2690
+ }
2691
+ const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
2692
+ if (!lessonkitVersion) {
2693
+ issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
2694
+ }
2695
+ const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
2696
+ if (!exportedAt) {
2697
+ issues.push({ path: "exportedAt", message: "must be a non-empty string" });
2698
+ }
2699
+ const entriesRaw = obj.entries;
2700
+ const entries = [];
2701
+ if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
2702
+ issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
2703
+ } else {
2704
+ for (let i = 0; i < entriesRaw.length; i++) {
2705
+ const entry = entriesRaw[i];
2706
+ if (typeof entry !== "string" || !entry.trim()) {
2707
+ issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
2708
+ } else {
2709
+ const trimmed = entry.trim();
2710
+ if (!isSafeZipEntryPath(trimmed)) {
2711
+ issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
2712
+ } else {
2713
+ entries.push(trimmed);
2714
+ }
2715
+ }
2716
+ }
2717
+ }
2718
+ if (issues.length) return { ok: false, issues };
2719
+ const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
2720
+ if (!manifestParsed.ok) {
2721
+ return {
2722
+ ok: false,
2723
+ issues: manifestParsed.issues.map((issue) => ({
2724
+ path: `sourceManifest.${issue.path}`,
2725
+ message: issue.message
2726
+ }))
2727
+ };
2728
+ }
2729
+ return {
2730
+ ok: true,
2731
+ envelope: {
2732
+ format: "lkcourse",
2733
+ schemaVersion: 1,
2734
+ lessonkitVersion,
2735
+ exportedAt,
2736
+ sourceManifest: manifestParsed.manifest,
2737
+ entries
2738
+ }
2739
+ };
2740
+ }
2741
+
2742
+ // src/lkcourse/blockTree.ts
2743
+ var import_node_fs6 = require("fs");
2744
+ var import_node_module = require("module");
2745
+ var import_node_path13 = require("path");
2746
+ var import_core7 = require("@lessonkit/core");
2747
+ var import_meta = {};
2748
+ var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
2749
+ var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
2750
+ function stripComments2(source) {
2751
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
2752
+ }
2753
+ function collectSourceUnderSrc2(projectRoot) {
2754
+ const srcDir = (0, import_node_path13.join)(projectRoot, "src");
2755
+ if (!(0, import_node_fs6.existsSync)(srcDir)) return [];
2756
+ const results = [];
2757
+ const walk = (dir) => {
2758
+ for (const entry of (0, import_node_fs6.readdirSync)(dir)) {
2759
+ const abs = (0, import_node_path13.join)(dir, entry);
2760
+ try {
2761
+ assertRealPathUnderRoot(projectRoot, abs);
2762
+ } catch {
2763
+ continue;
2764
+ }
2765
+ const stat2 = (0, import_node_fs6.lstatSync)(abs);
2766
+ if (stat2.isSymbolicLink()) continue;
2767
+ if (stat2.isDirectory()) {
2768
+ walk(abs);
2769
+ } else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
2770
+ results.push((0, import_node_path13.relative)(projectRoot, abs));
2771
+ }
2772
+ }
2773
+ };
2774
+ walk(srcDir);
2775
+ return results;
2776
+ }
2777
+ function loadCatalogBlockTypes(blockTypes) {
2778
+ if (blockTypes?.length) return blockTypes;
2779
+ try {
2780
+ const require2 = (0, import_node_module.createRequire)(import_meta.url);
2781
+ const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
2782
+ const catalog = JSON.parse((0, import_node_fs6.readFileSync)(catalogPath, "utf8"));
2783
+ return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
2784
+ } catch {
2785
+ return [
2786
+ "Course",
2787
+ "Lesson",
2788
+ "Scenario",
2789
+ "Quiz",
2790
+ "KnowledgeCheck",
2791
+ "ProgressTracker",
2792
+ "Reflection",
2793
+ "TrueFalse",
2794
+ "MarkTheWords",
2795
+ "FillInTheBlanks",
2796
+ "DragTheWords",
2797
+ "DragAndDrop",
2798
+ "AssessmentSequence",
2799
+ "Text",
2800
+ "Heading",
2801
+ "Image",
2802
+ "Video",
2803
+ "Page",
2804
+ "InteractiveBook",
2805
+ "Slide",
2806
+ "SlideDeck",
2807
+ "TimedCue",
2808
+ "InteractiveVideo",
2809
+ "Summary",
2810
+ "BranchingScenario",
2811
+ "BranchNode",
2812
+ "BranchChoice",
2813
+ "Embed",
2814
+ "Chart"
2815
+ ];
2816
+ }
2817
+ }
2818
+ function extractIdProp(tagSource, prop) {
2819
+ const re = new RegExp(
2820
+ `\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
2821
+ );
2822
+ const match = tagSource.match(re);
2823
+ if (!match) return void 0;
2824
+ return match[1] ?? match[2] ?? match[3];
2825
+ }
2826
+ function parseJsxBlocks(source, blockTypes) {
2827
+ const stripped = stripComments2(source);
2828
+ const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
2829
+ const stack = [];
2830
+ const roots = [];
2831
+ for (const match of stripped.matchAll(tagRe)) {
2832
+ const rawTag = match[1];
2833
+ const attrs = match[2] ?? "";
2834
+ const selfClosing = match[3] === "/";
2835
+ if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
2836
+ const known = blockTypes.has(rawTag);
2837
+ const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
2838
+ for (const prop of ID_PROPS) {
2839
+ const value = extractIdProp(attrs, prop);
2840
+ if (value) node[prop] = value;
2841
+ }
2842
+ if (selfClosing) {
2843
+ if (stack.length) {
2844
+ const parent = stack[stack.length - 1];
2845
+ parent.children = parent.children ?? [];
2846
+ parent.children.push(node);
2847
+ } else {
2848
+ roots.push(node);
2849
+ }
2850
+ continue;
2851
+ }
2852
+ const closeRe = new RegExp(`</${rawTag}>`);
2853
+ const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
2854
+ if (!closeMatch) {
2855
+ if (stack.length) {
2856
+ const parent = stack[stack.length - 1];
2857
+ parent.children = parent.children ?? [];
2858
+ parent.children.push(node);
2859
+ } else {
2860
+ roots.push(node);
2861
+ }
2862
+ continue;
2863
+ }
2864
+ stack.push(node);
2865
+ const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
2866
+ const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
2867
+ if (!inner.includes("<")) {
2868
+ stack.pop();
2869
+ if (stack.length) {
2870
+ const parent = stack[stack.length - 1];
2871
+ parent.children = parent.children ?? [];
2872
+ parent.children.push(node);
2873
+ } else {
2874
+ roots.push(node);
2875
+ }
2876
+ }
2877
+ }
2878
+ return roots.length ? roots : stack;
2879
+ }
2880
+ function validateNodeIds(node, pathPrefix, issues) {
2881
+ for (const prop of ID_PROPS) {
2882
+ const value = node[prop];
2883
+ if (value === void 0) continue;
2884
+ const validated = (0, import_core7.validateId)(value, prop);
2885
+ if (!validated.ok) {
2886
+ issues.push({
2887
+ path: `${pathPrefix}.${prop}`,
2888
+ message: validated.issues[0]?.message ?? `invalid ${prop}`
2889
+ });
2890
+ }
2891
+ }
2892
+ node.children?.forEach((child, index) => {
2893
+ validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
2894
+ });
2895
+ }
2896
+ function validateBlockTreeIds(tree) {
2897
+ const issues = [];
2898
+ tree.blocks.forEach((block, index) => {
2899
+ validateNodeIds(block, `blocks[${index}]`, issues);
2900
+ });
2901
+ return issues;
2902
+ }
2903
+ function extractBlockTree(options) {
2904
+ const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
2905
+ const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
2906
+ const blocks = [];
2907
+ for (const rel of sources) {
2908
+ const abs = (0, import_node_path13.join)(options.projectRoot, rel);
2909
+ if (!(0, import_node_fs6.existsSync)(abs)) continue;
2910
+ const source = (0, import_node_fs6.readFileSync)(abs, "utf8");
2911
+ const parsed = parseJsxBlocks(source, blockTypes);
2912
+ blocks.push(...parsed);
2913
+ }
2914
+ return {
2915
+ schemaVersion: 1,
2916
+ sources,
2917
+ blocks
2918
+ };
2919
+ }
2920
+
2921
+ // src/lkcourse/export.ts
2922
+ var import_promises3 = require("fs/promises");
2923
+ var import_node_module2 = require("module");
2924
+ var import_node_path14 = require("path");
2176
2925
  var import_validators2 = require("@lxpack/validators");
2926
+ var import_meta2 = {};
2927
+ function resolveLessonkitVersion(explicit) {
2928
+ if (explicit?.trim()) return explicit.trim();
2929
+ try {
2930
+ const require2 = (0, import_node_module2.createRequire)(import_meta2.url);
2931
+ const pkg = require2("../../package.json");
2932
+ return pkg.version ?? "0.0.0";
2933
+ } catch {
2934
+ return "0.0.0";
2935
+ }
2936
+ }
2937
+ async function exportLkcourse(options) {
2938
+ const projectRoot = (0, import_node_path14.resolve)(options.projectRoot);
2939
+ const manifest = options.manifest;
2940
+ const spaDistDir = (0, import_node_path14.join)(projectRoot, manifest.paths.spaDistDir);
2941
+ try {
2942
+ assertRealPathUnderRoot(projectRoot, spaDistDir);
2943
+ await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
2944
+ } catch (err) {
2945
+ return {
2946
+ ok: false,
2947
+ issues: [
2948
+ {
2949
+ path: manifest.paths.spaDistDir,
2950
+ message: err instanceof Error ? err.message : String(err)
2951
+ }
2952
+ ]
2953
+ };
2954
+ }
2955
+ const injectableIssues = validateInjectableAssessments(manifest.course);
2956
+ if (injectableIssues.length > 0) {
2957
+ return {
2958
+ ok: false,
2959
+ issues: injectableIssues.map((issue) => ({
2960
+ path: issue.path,
2961
+ message: issue.message
2962
+ }))
2963
+ };
2964
+ }
2965
+ const interchange = descriptorToInterchange(manifest.course);
2966
+ const interchangeParsed = (0, import_validators2.parseLessonkitInterchange)(interchange);
2967
+ if (!interchangeParsed.ok) {
2968
+ return {
2969
+ ok: false,
2970
+ issues: interchangeParsed.issues.map((i) => ({
2971
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2972
+ message: i.message
2973
+ }))
2974
+ };
2975
+ }
2976
+ const validatedInterchange = interchangeParsed.data;
2977
+ const interchangeCourseId = validatedInterchange.course?.id;
2978
+ if (!interchangeCourseId) {
2979
+ return {
2980
+ ok: false,
2981
+ issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
2982
+ };
2983
+ }
2984
+ if (manifest.course.courseId !== interchangeCourseId) {
2985
+ return {
2986
+ ok: false,
2987
+ issues: [
2988
+ {
2989
+ path: "course.courseId",
2990
+ message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
2991
+ }
2992
+ ]
2993
+ };
2994
+ }
2995
+ const zipEntries = /* @__PURE__ */ new Map();
2996
+ const interchangeJson = JSON.stringify(interchange, null, 2);
2997
+ zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
2998
+ let blockTreeJson;
2999
+ if (options.includeBlockTree) {
3000
+ const blockTree = extractBlockTree({ projectRoot });
3001
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
3002
+ if (blockTreeIssues.length) {
3003
+ return {
3004
+ ok: false,
3005
+ issues: blockTreeIssues.map((issue) => ({
3006
+ path: `block-tree.${issue.path}`,
3007
+ message: issue.message
3008
+ }))
3009
+ };
3010
+ }
3011
+ blockTreeJson = JSON.stringify(blockTree, null, 2);
3012
+ zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
3013
+ }
3014
+ let distEntries;
3015
+ try {
3016
+ distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
3017
+ } catch (err) {
3018
+ return {
3019
+ ok: false,
3020
+ issues: [
3021
+ {
3022
+ path: manifest.paths.spaDistDir,
3023
+ message: err instanceof Error ? err.message : String(err)
3024
+ }
3025
+ ]
3026
+ };
3027
+ }
3028
+ if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
3029
+ return {
3030
+ ok: false,
3031
+ issues: [
3032
+ {
3033
+ path: `${manifest.paths.spaDistDir}/index.html`,
3034
+ message: "dist must contain index.html before export"
3035
+ }
3036
+ ]
3037
+ };
3038
+ }
3039
+ for (const [path, data] of distEntries) {
3040
+ zipEntries.set(path, data);
3041
+ }
3042
+ const entryPaths = [...zipEntries.keys()].sort();
3043
+ const envelope = {
3044
+ format: "lkcourse",
3045
+ schemaVersion: 1,
3046
+ lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
3047
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
3048
+ sourceManifest: manifest,
3049
+ entries: entryPaths
3050
+ };
3051
+ const envelopeCheck = parseLkcourseEnvelope(envelope);
3052
+ if (!envelopeCheck.ok) {
3053
+ return { ok: false, issues: envelopeCheck.issues };
3054
+ }
3055
+ zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
3056
+ const archivePath = (0, import_node_path14.resolve)(
3057
+ projectRoot,
3058
+ options.outPath ?? `${manifest.name}.lkcourse`
3059
+ );
3060
+ const archiveRel = options.outPath ?? `${manifest.name}.lkcourse`;
3061
+ try {
3062
+ assertRealPathUnderRoot(projectRoot, archivePath);
3063
+ } catch (err) {
3064
+ return {
3065
+ ok: false,
3066
+ issues: [
3067
+ {
3068
+ path: archiveRel,
3069
+ message: err instanceof Error ? err.message : String(err)
3070
+ }
3071
+ ]
3072
+ };
3073
+ }
3074
+ if (isReservedResolvedOutputPath(projectRoot, archivePath)) {
3075
+ return {
3076
+ ok: false,
3077
+ issues: [
3078
+ {
3079
+ path: archiveRel,
3080
+ message: "output path must not target reserved directories (.git, node_modules, .github)"
3081
+ }
3082
+ ]
3083
+ };
3084
+ }
3085
+ if (!isSafeZipEntryPath(archiveRel)) {
3086
+ return {
3087
+ ok: false,
3088
+ issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
3089
+ };
3090
+ }
3091
+ try {
3092
+ await (0, import_promises3.mkdir)((0, import_node_path14.dirname)(archivePath), { recursive: true });
3093
+ const zipped = createZip(zipEntries);
3094
+ await (0, import_promises3.writeFile)(archivePath, zipped);
3095
+ } catch (err) {
3096
+ return {
3097
+ ok: false,
3098
+ issues: [
3099
+ {
3100
+ path: archivePath,
3101
+ message: err instanceof Error ? err.message : String(err)
3102
+ }
3103
+ ]
3104
+ };
3105
+ }
3106
+ return {
3107
+ ok: true,
3108
+ archivePath,
3109
+ fileCount: zipEntries.size,
3110
+ includeBlockTree: Boolean(options.includeBlockTree)
3111
+ };
3112
+ }
3113
+
3114
+ // src/lkcourse/validate.ts
3115
+ var import_validators3 = require("@lxpack/validators");
3116
+
3117
+ // src/lkcourse/assessmentParity.ts
3118
+ function validateLkcourseAssessmentConsistency(descriptor, interchange) {
3119
+ const issues = [];
3120
+ for (const issue of validateInjectableAssessments(descriptor)) {
3121
+ issues.push({
3122
+ path: `sourceManifest.course.${issue.path}`,
3123
+ message: issue.message
3124
+ });
3125
+ }
3126
+ const expectedIds = extractAssessments(descriptor).map((a) => a.id).sort();
3127
+ const interchangeIds = (interchange.assessments ?? []).map((a) => a.id).sort();
3128
+ const matches = expectedIds.length === interchangeIds.length && expectedIds.every((id, index) => id === interchangeIds[index]);
3129
+ if (!matches) {
3130
+ issues.push({
3131
+ path: "interchange.assessments",
3132
+ message: `injectable assessment ids [${expectedIds.join(", ")}] do not match interchange [${interchangeIds.join(", ")}]`
3133
+ });
3134
+ }
3135
+ return issues;
3136
+ }
3137
+
3138
+ // src/lkcourse/validate.ts
3139
+ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
3140
+ const issues = [];
3141
+ const manifestData = entries.get("manifest.json");
3142
+ if (!manifestData) {
3143
+ return {
3144
+ ok: false,
3145
+ issues: [{ path: "manifest.json", message: "required file missing from archive" }]
3146
+ };
3147
+ }
3148
+ let envelopeRaw;
3149
+ try {
3150
+ envelopeRaw = JSON.parse(entryToUtf8(manifestData));
3151
+ } catch {
3152
+ return {
3153
+ ok: false,
3154
+ issues: [{ path: "manifest.json", message: "invalid JSON" }]
3155
+ };
3156
+ }
3157
+ const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
3158
+ if (!envelopeParsed.ok) {
3159
+ return { ok: false, issues: envelopeParsed.issues };
3160
+ }
3161
+ const envelope = envelopeParsed.envelope;
3162
+ const interchangeData = entries.get("interchange.json");
3163
+ if (!interchangeData) {
3164
+ issues.push({ path: "interchange.json", message: "required file missing from archive" });
3165
+ }
3166
+ const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
3167
+ const spaIndexPath = `${spaDistDir}/index.html`;
3168
+ if (!entries.has(spaIndexPath)) {
3169
+ issues.push({ path: spaIndexPath, message: "required file missing from archive" });
3170
+ }
3171
+ const allowlisted = new Set(envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")));
3172
+ const spaDistPrefix = `${spaDistDir}/`;
3173
+ for (const entryPath of envelope.entries) {
3174
+ if (!entries.has(entryPath)) {
3175
+ issues.push({
3176
+ path: entryPath,
3177
+ message: "listed in manifest.entries but missing from archive"
3178
+ });
3179
+ }
3180
+ }
3181
+ for (const zipPath of entries.keys()) {
3182
+ const normalized = zipPath.replace(/\\/g, "/");
3183
+ if (!normalized.startsWith(spaDistPrefix)) continue;
3184
+ if (!allowlisted.has(normalized)) {
3185
+ issues.push({
3186
+ path: zipPath,
3187
+ message: "unlisted file under spaDistDir; not in manifest.entries"
3188
+ });
3189
+ }
3190
+ }
3191
+ if (issues.length) return { ok: false, issues };
3192
+ let interchangeRaw;
3193
+ try {
3194
+ interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
3195
+ } catch {
3196
+ return {
3197
+ ok: false,
3198
+ issues: [{ path: "interchange.json", message: "invalid JSON" }]
3199
+ };
3200
+ }
3201
+ const interchangeParsed = (0, import_validators3.parseLessonkitInterchange)(interchangeRaw);
3202
+ if (!interchangeParsed.ok) {
3203
+ return {
3204
+ ok: false,
3205
+ issues: interchangeParsed.issues.map((i) => ({
3206
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
3207
+ message: i.message
3208
+ }))
3209
+ };
3210
+ }
3211
+ const interchange = interchangeParsed.data;
3212
+ const interchangeCourseId = interchange.course?.id;
3213
+ if (!interchangeCourseId) {
3214
+ issues.push({
3215
+ path: "interchange.course.id",
3216
+ message: "missing course id in interchange"
3217
+ });
3218
+ } else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
3219
+ issues.push({
3220
+ path: "sourceManifest.course.courseId",
3221
+ message: `does not match interchange.course.id (${interchangeCourseId})`
3222
+ });
3223
+ }
3224
+ issues.push(
3225
+ ...validateLkcourseAssessmentConsistency(
3226
+ envelope.sourceManifest.course,
3227
+ interchange
3228
+ )
3229
+ );
3230
+ if (issues.length) return { ok: false, issues };
3231
+ const blockTreeData = entries.get("block-tree.json");
3232
+ if (blockTreeData) {
3233
+ let blockTreeRaw;
3234
+ try {
3235
+ blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
3236
+ } catch {
3237
+ return {
3238
+ ok: false,
3239
+ issues: [{ path: "block-tree.json", message: "invalid JSON" }]
3240
+ };
3241
+ }
3242
+ const blockTree = blockTreeRaw;
3243
+ if (Array.isArray(blockTree?.blocks)) {
3244
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
3245
+ if (blockTreeIssues.length) {
3246
+ return {
3247
+ ok: false,
3248
+ issues: blockTreeIssues.map((issue) => ({
3249
+ path: `block-tree.${issue.path}`,
3250
+ message: issue.message
3251
+ }))
3252
+ };
3253
+ }
3254
+ }
3255
+ }
3256
+ return {
3257
+ ok: true,
3258
+ envelope,
3259
+ interchange
3260
+ };
3261
+ }
3262
+ function validateLkcourse(archivePath) {
3263
+ const read = readZip(archivePath);
3264
+ if (!read.ok) return read;
3265
+ return validateLkcourseArchiveEntries(read.entries, archivePath);
3266
+ }
3267
+
3268
+ // src/lkcourse/import.ts
3269
+ var import_promises4 = require("fs/promises");
3270
+ var import_node_path15 = require("path");
3271
+ var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
3272
+ async function pathExists2(path) {
3273
+ try {
3274
+ await (0, import_promises4.access)(path);
3275
+ return true;
3276
+ } catch {
3277
+ return false;
3278
+ }
3279
+ }
3280
+ async function renameOrCopy2(from, to, opts) {
3281
+ const renameFn = opts?.renameFn ?? import_promises4.rename;
3282
+ try {
3283
+ await renameFn(from, to);
3284
+ } catch (err) {
3285
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
3286
+ if (code !== "EXDEV") throw err;
3287
+ await (0, import_promises4.cp)(from, to, { recursive: true });
3288
+ await (0, import_promises4.rm)(from, { recursive: true, force: true });
3289
+ }
3290
+ }
3291
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir, allowlistedSpaPaths) {
3292
+ let fileCount = 0;
3293
+ await (0, import_promises4.writeFile)(
3294
+ (0, import_node_path15.join)(stagingDir, "lessonkit.json"),
3295
+ `${JSON.stringify(manifest, null, 2)}
3296
+ `,
3297
+ "utf8"
3298
+ );
3299
+ fileCount += 1;
3300
+ for (const [entryPath, data] of entries) {
3301
+ const normalized = entryPath.replace(/\\/g, "/");
3302
+ if (!normalized.startsWith(`${spaDistDir}/`)) continue;
3303
+ if (!allowlistedSpaPaths.has(normalized)) {
3304
+ throw new Error(`unlisted spaDist entry rejected: ${entryPath}`);
3305
+ }
3306
+ const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
3307
+ const outPath = (0, import_node_path15.join)(stagingDir, spaDistDir, relativeUnderSpa);
3308
+ const resolvedOut = (0, import_node_path15.resolve)(outPath);
3309
+ assertRealPathUnderRoot(stagingDir, resolvedOut);
3310
+ if (!isSafeZipEntryPath((0, import_node_path15.join)(spaDistDir, relativeUnderSpa))) {
3311
+ throw new Error(`unsafe extraction path: ${entryPath}`);
3312
+ }
3313
+ await (0, import_promises4.mkdir)((0, import_node_path15.dirname)(resolvedOut), { recursive: true });
3314
+ await (0, import_promises4.writeFile)(resolvedOut, data);
3315
+ fileCount += 1;
3316
+ }
3317
+ return fileCount;
3318
+ }
3319
+ async function backupImportArtifacts(targetDir) {
3320
+ const existing = [];
3321
+ for (const name of IMPORT_ARTIFACTS) {
3322
+ if (await pathExists2((0, import_node_path15.join)(targetDir, name))) {
3323
+ existing.push(name);
3324
+ }
3325
+ }
3326
+ if (!existing.length) return void 0;
3327
+ const backupDir = await (0, import_promises4.mkdtemp)((0, import_node_path15.join)(targetDir, ".lkcourse-backup-"));
3328
+ for (const name of existing) {
3329
+ await renameOrCopy2((0, import_node_path15.join)(targetDir, name), (0, import_node_path15.join)(backupDir, name));
3330
+ }
3331
+ return backupDir;
3332
+ }
3333
+ async function restoreImportBackup(targetDir, backupDir) {
3334
+ for (const name of IMPORT_ARTIFACTS) {
3335
+ const backupPath = (0, import_node_path15.join)(backupDir, name);
3336
+ if (!await pathExists2(backupPath)) continue;
3337
+ const destPath = (0, import_node_path15.join)(targetDir, name);
3338
+ if (await pathExists2(destPath)) {
3339
+ await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
3340
+ }
3341
+ await renameOrCopy2(backupPath, destPath);
3342
+ }
3343
+ }
3344
+ async function snapshotPreExistingImportArtifacts(targetDir) {
3345
+ const existing = /* @__PURE__ */ new Set();
3346
+ for (const name of IMPORT_ARTIFACTS) {
3347
+ if (await pathExists2((0, import_node_path15.join)(targetDir, name))) {
3348
+ existing.add(name);
3349
+ }
3350
+ }
3351
+ return existing;
3352
+ }
3353
+ async function rollbackFailedImport(targetDir, backupDir, preExisting) {
3354
+ if (backupDir) {
3355
+ await restoreImportBackup(targetDir, backupDir);
3356
+ }
3357
+ for (const name of IMPORT_ARTIFACTS) {
3358
+ if (preExisting.has(name)) continue;
3359
+ const destPath = (0, import_node_path15.join)(targetDir, name);
3360
+ if (await pathExists2(destPath)) {
3361
+ await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
3362
+ }
3363
+ }
3364
+ }
3365
+ async function promoteImportStaging(stagingDir, targetDir) {
3366
+ await (0, import_promises4.mkdir)(targetDir, { recursive: true });
3367
+ const entries = await (0, import_promises4.readdir)(stagingDir, { withFileTypes: true });
3368
+ for (const entry of entries) {
3369
+ const srcPath = (0, import_node_path15.join)(stagingDir, entry.name);
3370
+ const destPath = (0, import_node_path15.join)(targetDir, entry.name);
3371
+ if (entry.isDirectory() || entry.isFile()) {
3372
+ await renameOrCopy2(srcPath, destPath);
3373
+ }
3374
+ }
3375
+ }
3376
+ var promoteImportStagingImpl = promoteImportStaging;
3377
+ async function importLkcourse(options) {
3378
+ const archivePath = (0, import_node_path15.resolve)(options.archivePath);
3379
+ const targetDir = (0, import_node_path15.resolve)(options.targetDir);
3380
+ const validated = validateLkcourse(archivePath);
3381
+ if (!validated.ok) return validated;
3382
+ const { envelope, interchange } = validated;
3383
+ const manifest = envelope.sourceManifest;
3384
+ const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
3385
+ try {
3386
+ await (0, import_promises4.mkdir)(targetDir, { recursive: true });
3387
+ assertRealPathUnderRoot(targetDir, targetDir);
3388
+ } catch (err) {
3389
+ return {
3390
+ ok: false,
3391
+ issues: [
3392
+ {
3393
+ path: targetDir,
3394
+ message: err instanceof Error ? err.message : String(err)
3395
+ }
3396
+ ]
3397
+ };
3398
+ }
3399
+ const read = readZip(archivePath);
3400
+ if (!read.ok) return read;
3401
+ let stagingDir;
3402
+ let backupDir;
3403
+ let preExisting;
3404
+ try {
3405
+ stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path15.join)(targetDir, ".lkcourse-import-"));
3406
+ const allowlistedSpaPaths = new Set(
3407
+ envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")).filter((entryPath) => entryPath.startsWith(`${spaDistDir}/`))
3408
+ );
3409
+ const fileCount = await writeImportTree(
3410
+ stagingDir,
3411
+ manifest,
3412
+ read.entries,
3413
+ spaDistDir,
3414
+ allowlistedSpaPaths
3415
+ );
3416
+ preExisting = await snapshotPreExistingImportArtifacts(targetDir);
3417
+ backupDir = await backupImportArtifacts(targetDir);
3418
+ try {
3419
+ await promoteImportStagingImpl(stagingDir, targetDir);
3420
+ } catch (promoteError) {
3421
+ await rollbackFailedImport(targetDir, backupDir, preExisting);
3422
+ backupDir = void 0;
3423
+ throw promoteError;
3424
+ }
3425
+ if (backupDir) {
3426
+ await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
3427
+ backupDir = void 0;
3428
+ }
3429
+ await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true });
3430
+ stagingDir = void 0;
3431
+ return {
3432
+ ok: true,
3433
+ targetDir,
3434
+ manifest,
3435
+ interchange,
3436
+ fileCount
3437
+ };
3438
+ } catch (err) {
3439
+ if (preExisting) {
3440
+ await rollbackFailedImport(targetDir, backupDir, preExisting).catch(() => void 0);
3441
+ } else if (backupDir) {
3442
+ await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3443
+ }
3444
+ if (backupDir) {
3445
+ await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
3446
+ }
3447
+ if (stagingDir) {
3448
+ await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true }).catch(() => void 0);
3449
+ }
3450
+ return {
3451
+ ok: false,
3452
+ issues: [
3453
+ {
3454
+ path: targetDir,
3455
+ message: err instanceof Error ? err.message : String(err)
3456
+ }
3457
+ ]
3458
+ };
3459
+ }
3460
+ }
2177
3461
  // Annotate the CommonJS export names for ESM import in node:
2178
3462
  0 && (module.exports = {
2179
3463
  LESSONKIT_TELEMETRY_EVENTS,
3464
+ assertSpaDistContentsSafe,
2180
3465
  assessmentDescriptorToLxpack,
2181
3466
  buildLessonkitProject,
2182
3467
  buildStagingPackage,
2183
3468
  descriptorToInterchange,
2184
3469
  ensureOutDirParent,
2185
3470
  escapeShellText,
3471
+ exportLkcourse,
2186
3472
  extractAssessments,
3473
+ extractBlockTree,
3474
+ importLkcourse,
2187
3475
  lessonkitInterchangeSchema,
2188
3476
  loadLessonkitManifestFromFile,
2189
3477
  mapLessonkitIds,
@@ -2193,6 +3481,7 @@ var import_validators2 = require("@lxpack/validators");
2193
3481
  packageLessonkitCourse,
2194
3482
  parseLessonkitInterchange,
2195
3483
  parseLessonkitManifest,
3484
+ parseLkcourseEnvelope,
2196
3485
  promoteStagingToOutDir,
2197
3486
  remapArtifactPaths,
2198
3487
  resolveSafePackageOutputOverride,
@@ -2202,6 +3491,9 @@ var import_validators2 = require("@lxpack/validators");
2202
3491
  validateDescriptor,
2203
3492
  validateDescriptorForTarget,
2204
3493
  validateLessonkitProject,
3494
+ validateLkcourse,
3495
+ validateLkcourseArchiveEntries,
3496
+ validateManifestName,
2205
3497
  validatePackageInputs,
2206
3498
  validateProjectPaths,
2207
3499
  validateReactManifestParity,