@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.js CHANGED
@@ -62,13 +62,40 @@ function normalizeDescriptor(input) {
62
62
  correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
63
63
  };
64
64
  }
65
+ if (assessment.kind === "sortParagraphs") {
66
+ return {
67
+ ...assessment,
68
+ checkId: check.id,
69
+ question,
70
+ paragraphs: assessment.paragraphs.map((p) => p.trim()).filter((p) => p.length > 0),
71
+ correctOrder: [...assessment.correctOrder]
72
+ };
73
+ }
74
+ if (assessment.kind === "guessTheAnswer") {
75
+ return {
76
+ ...assessment,
77
+ checkId: check.id,
78
+ question,
79
+ answer: assessment.answer.trim()
80
+ };
81
+ }
82
+ if (assessment.kind === "multimediaChoice") {
83
+ return {
84
+ ...assessment,
85
+ checkId: check.id,
86
+ question,
87
+ choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
88
+ answer: assessment.answer.trim()
89
+ };
90
+ }
65
91
  const mcq = assessment;
66
92
  return {
67
93
  ...mcq,
68
94
  checkId: check.id,
69
95
  question,
70
96
  choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
71
- answer: mcq.answer.trim()
97
+ answer: mcq.answer.trim(),
98
+ answers: mcq.answers?.map((a) => a.trim()).filter((a) => a.length > 0)
72
99
  };
73
100
  })
74
101
  };
@@ -99,10 +126,18 @@ function parseAssessmentDescriptor(raw) {
99
126
  };
100
127
  const kind = raw.kind;
101
128
  if (kind === "trueFalse") {
129
+ let answer;
130
+ if (typeof raw.answer === "boolean") {
131
+ answer = raw.answer;
132
+ } else if (raw.answer === "true") {
133
+ answer = true;
134
+ } else if (raw.answer === "false") {
135
+ answer = false;
136
+ }
102
137
  return {
103
138
  kind: "trueFalse",
104
139
  ...base,
105
- answer: typeof raw.answer === "boolean" ? raw.answer : raw.answer === "true"
140
+ answer
106
141
  };
107
142
  }
108
143
  if (kind === "fillInBlanks") {
@@ -134,7 +169,30 @@ function parseAssessmentDescriptor(raw) {
134
169
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
135
170
  };
136
171
  }
137
- if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
172
+ if (kind === "sortParagraphs") {
173
+ return {
174
+ kind: "sortParagraphs",
175
+ ...base,
176
+ paragraphs: Array.isArray(raw.paragraphs) ? raw.paragraphs.filter((p) => typeof p === "string") : [],
177
+ correctOrder: Array.isArray(raw.correctOrder) ? raw.correctOrder.filter((n) => typeof n === "number" && Number.isFinite(n)) : []
178
+ };
179
+ }
180
+ if (kind === "guessTheAnswer") {
181
+ return {
182
+ kind: "guessTheAnswer",
183
+ ...base,
184
+ answer: typeof raw.answer === "string" ? raw.answer : ""
185
+ };
186
+ }
187
+ if (kind === "multimediaChoice") {
188
+ return {
189
+ kind: "multimediaChoice",
190
+ ...base,
191
+ choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
192
+ answer: typeof raw.answer === "string" ? raw.answer : ""
193
+ };
194
+ }
195
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots" && kind !== "sortParagraphs" && kind !== "guessTheAnswer" && kind !== "multimediaChoice") {
138
196
  return {
139
197
  kind,
140
198
  ...base,
@@ -146,7 +204,15 @@ function parseAssessmentDescriptor(raw) {
146
204
  kind: kind === "mcq" ? "mcq" : void 0,
147
205
  ...base,
148
206
  choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
149
- answer: typeof raw.answer === "string" ? raw.answer : ""
207
+ answer: typeof raw.answer === "string" ? raw.answer : "",
208
+ answers: Array.isArray(raw.answers) ? raw.answers.filter((a) => typeof a === "string") : void 0,
209
+ shuffleChoices: typeof raw.shuffleChoices === "boolean" ? raw.shuffleChoices : void 0,
210
+ shuffleSeed: typeof raw.shuffleSeed === "string" || typeof raw.shuffleSeed === "number" ? raw.shuffleSeed : void 0,
211
+ choiceFeedback: raw.choiceFeedback && typeof raw.choiceFeedback === "object" && !Array.isArray(raw.choiceFeedback) ? Object.fromEntries(
212
+ Object.entries(raw.choiceFeedback).filter(
213
+ (entry) => typeof entry[1] === "string"
214
+ )
215
+ ) : void 0
150
216
  };
151
217
  }
152
218
  function parseCourseDescriptorInput(input) {
@@ -194,8 +260,8 @@ import { validateId as validateId3 } from "@lessonkit/core";
194
260
  import { validateTheme } from "@lessonkit/themes";
195
261
 
196
262
  // src/spaPath.ts
197
- import { existsSync, realpathSync } from "fs";
198
- import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
263
+ import { realpathSync } from "fs";
264
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from "path";
199
265
  function resolveComparablePath(p) {
200
266
  if (/^[a-zA-Z]:[/\\]/.test(p)) {
201
267
  return win32.resolve(p);
@@ -221,43 +287,32 @@ function assertResolvedPathUnderRoot(root, target) {
221
287
  throw new Error(`unsafe path escapes project root: ${target}`);
222
288
  }
223
289
  }
224
- function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
225
- const rel = relative(rootResolved, targetResolved);
226
- if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
227
- throw new Error(`unsafe path escapes project root: ${targetResolved}`);
228
- }
229
- const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
230
- let current = rootReal;
231
- for (const segment of segments) {
232
- const next = join(current, segment);
233
- if (existsSync(next)) {
290
+ function resolvePhysicalPathForCheck(p) {
291
+ const resolved = resolveComparablePath(p);
292
+ try {
293
+ return realpathSync.native(resolved);
294
+ } catch {
295
+ let probe = resolved;
296
+ let suffix = "";
297
+ while (true) {
234
298
  try {
235
- current = realpathSync(next);
299
+ const physical = realpathSync.native(probe);
300
+ return suffix ? join(physical, suffix) : physical;
236
301
  } catch {
237
- current = next;
302
+ if (probe === dirname(probe)) {
303
+ return resolved;
304
+ }
305
+ const segment = basename(probe);
306
+ suffix = suffix ? join(segment, suffix) : segment;
307
+ probe = dirname(probe);
238
308
  }
239
- } else {
240
- current = next;
241
309
  }
242
- assertResolvedPathUnderRoot(rootReal, current);
243
310
  }
244
- return current;
245
311
  }
246
312
  function assertRealPathUnderRoot(root, target) {
247
- const rootResolved = resolveComparablePath(root);
248
- const targetResolved = resolveComparablePath(target);
249
- let rootReal;
250
- try {
251
- rootReal = realpathSync(rootResolved);
252
- } catch {
253
- rootReal = rootResolved;
254
- }
255
- try {
256
- const targetCheck = realpathSync(targetResolved);
257
- assertResolvedPathUnderRoot(rootReal, targetCheck);
258
- } catch {
259
- resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
260
- }
313
+ const rootPhysical = resolvePhysicalPathForCheck(root);
314
+ const targetPhysical = resolvePhysicalPathForCheck(target);
315
+ assertResolvedPathUnderRoot(rootPhysical, targetPhysical);
261
316
  }
262
317
  function normalizePathForComparison(p) {
263
318
  const resolved = resolveComparablePath(p);
@@ -280,6 +335,113 @@ function isResolvedPathUnderRoot(root, target) {
280
335
  return !rel.startsWith("..") && !isAbsolute(rel);
281
336
  }
282
337
 
338
+ // src/validateProjectPaths.ts
339
+ import { existsSync, realpathSync as realpathSync2 } from "fs";
340
+ import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
341
+ var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
342
+ function isReservedOutputPath(value) {
343
+ let normalized = value.replace(/\\/g, "/");
344
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
345
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
346
+ const segments = normalized.split("/").filter(Boolean);
347
+ return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
348
+ }
349
+ function validateManifestName(name) {
350
+ if (!name.length) {
351
+ return "must be a non-empty string";
352
+ }
353
+ if (name.includes("/") || name.includes("\\")) {
354
+ return "must not contain path separators";
355
+ }
356
+ if (!isSafeRelativeSpaPath(name)) {
357
+ return "must be a safe relative name without '..' segments or absolute prefixes";
358
+ }
359
+ if (isReservedOutputPath(name) || isReservedOutputPath(`${name}.lkcourse`)) {
360
+ return "must not target reserved directories (.git, node_modules, .github)";
361
+ }
362
+ return null;
363
+ }
364
+ function isReservedResolvedOutputPath(projectRoot, resolved) {
365
+ const rootResolved = resolveComparablePath(projectRoot);
366
+ const targetResolved = resolveComparablePath(resolved);
367
+ try {
368
+ const rootReal = existsSync(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
369
+ const targetReal = existsSync(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
370
+ const rel = relativePathUnderRoot(rootReal, targetReal);
371
+ return isReservedOutputPath(rel);
372
+ } catch {
373
+ return isReservedOutputPath(resolved);
374
+ }
375
+ }
376
+ function validatePathField(value, fieldPath, projectRoot, issues, options) {
377
+ if (!isSafeRelativeSpaPath(value)) {
378
+ issues.push({
379
+ path: fieldPath,
380
+ message: "path must be relative without '..' segments or absolute prefixes"
381
+ });
382
+ return;
383
+ }
384
+ if (options?.rejectReserved && isReservedOutputPath(value)) {
385
+ issues.push({
386
+ path: fieldPath,
387
+ message: "path must not target reserved directories (.git, node_modules, .github)"
388
+ });
389
+ return;
390
+ }
391
+ try {
392
+ assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
393
+ } catch {
394
+ issues.push({
395
+ path: fieldPath,
396
+ message: "path must resolve inside the project root"
397
+ });
398
+ }
399
+ }
400
+ function validateProjectPaths(projectRoot, paths) {
401
+ const issues = [];
402
+ const root = resolve2(projectRoot);
403
+ if (paths.spaDistDir?.trim()) {
404
+ validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
405
+ rejectReserved: true
406
+ });
407
+ }
408
+ if (paths.lxpackOutDir?.trim()) {
409
+ validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
410
+ rejectReserved: true
411
+ });
412
+ }
413
+ if (paths.outputBaseDir?.trim()) {
414
+ validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
415
+ rejectReserved: true
416
+ });
417
+ }
418
+ return issues;
419
+ }
420
+ function resolveSafePackageOutputOverride(projectRoot, override) {
421
+ const root = resolve2(projectRoot);
422
+ const trimmed = override.trim();
423
+ if (!trimmed) {
424
+ throw new Error("output override must be a non-empty path");
425
+ }
426
+ if (isAbsolute2(trimmed)) {
427
+ const resolved2 = resolve2(trimmed);
428
+ assertRealPathUnderRoot(root, resolved2);
429
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
430
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
431
+ }
432
+ return resolved2;
433
+ }
434
+ if (!isSafeRelativeSpaPath(trimmed)) {
435
+ throw new Error(`unsafe output path: ${override}`);
436
+ }
437
+ const resolved = resolve2(root, trimmed);
438
+ assertRealPathUnderRoot(root, resolved);
439
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
440
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
441
+ }
442
+ return resolved;
443
+ }
444
+
283
445
  // src/theme.ts
284
446
  import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
285
447
  function themeToLxpackRuntime(input) {
@@ -297,6 +459,7 @@ function themeToLxpackRuntime(input) {
297
459
 
298
460
  // src/descriptor/validateAssessments.ts
299
461
  import { validateId as validateId2 } from "@lessonkit/core";
462
+ import { isMultiSelectMcq } from "@lessonkit/core";
300
463
  var validateMcqLike = (assessment, path, issues) => {
301
464
  if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
302
465
  issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
@@ -312,9 +475,44 @@ var validateMcqLike = (assessment, path, issues) => {
312
475
  }
313
476
  if (!assessment.answer.trim()) {
314
477
  issues.push({ path: `${path}.answer`, message: "answer is required" });
315
- } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
478
+ } else if (!("answers" in assessment && isMultiSelectMcq({ answers: assessment.answers })) && trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
316
479
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
317
480
  }
481
+ if ("answers" in assessment && assessment.answers !== void 0) {
482
+ if (!Array.isArray(assessment.answers)) {
483
+ issues.push({ path: `${path}.answers`, message: "answers must be an array when provided" });
484
+ } else {
485
+ const trimmedAnswers = assessment.answers.map((a) => a.trim()).filter((a) => a.length > 0);
486
+ if (assessment.answers.length > 0 && trimmedAnswers.length === 0) {
487
+ issues.push({ path: `${path}.answers`, message: "answers must include non-empty strings" });
488
+ }
489
+ const uniqueAnswers = new Set(trimmedAnswers);
490
+ if (trimmedAnswers.length !== uniqueAnswers.size) {
491
+ issues.push({ path: `${path}.answers`, message: "answers must be unique" });
492
+ }
493
+ for (const ans of trimmedAnswers) {
494
+ if (trimmedChoices.length && !trimmedChoices.includes(ans)) {
495
+ issues.push({ path: `${path}.answers`, message: "each answer must match a choice" });
496
+ break;
497
+ }
498
+ }
499
+ }
500
+ }
501
+ if ("choiceFeedback" in assessment && assessment.choiceFeedback !== void 0) {
502
+ if (typeof assessment.choiceFeedback !== "object" || assessment.choiceFeedback === null) {
503
+ issues.push({ path: `${path}.choiceFeedback`, message: "choiceFeedback must be an object" });
504
+ } else {
505
+ for (const key of Object.keys(assessment.choiceFeedback)) {
506
+ if (!trimmedChoices.includes(key.trim())) {
507
+ issues.push({
508
+ path: `${path}.choiceFeedback`,
509
+ message: "choiceFeedback keys must match choice labels"
510
+ });
511
+ break;
512
+ }
513
+ }
514
+ }
515
+ }
318
516
  const uniqueChoices = new Set(trimmedChoices);
319
517
  if (trimmedChoices.length !== uniqueChoices.size) {
320
518
  issues.push({ path: `${path}.choices`, message: "choices must be unique" });
@@ -334,6 +532,12 @@ function maxAchievableAssessmentScore(assessment) {
334
532
  if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
335
533
  return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
336
534
  }
535
+ if (kind === "sortParagraphs" && assessment.kind === "sortParagraphs") {
536
+ return assessment.paragraphs?.length ?? assessment.correctOrder?.length ?? 0;
537
+ }
538
+ if ("answers" in assessment && Array.isArray(assessment.answers) && assessment.answers.length > 1) {
539
+ return assessment.answers.filter((a) => a.trim().length > 0).length;
540
+ }
337
541
  return 1;
338
542
  }
339
543
  var ASSESSMENT_VALIDATORS = {
@@ -356,8 +560,30 @@ var ASSESSMENT_VALIDATORS = {
356
560
  message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
357
561
  });
358
562
  }
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) {
563
+ const explicitBlanks = [];
564
+ if (assessment.blanks !== void 0) {
565
+ for (let i = 0; i < assessment.blanks.length; i++) {
566
+ const blank = assessment.blanks[i];
567
+ if (!blank || typeof blank !== "object") {
568
+ issues.push({
569
+ path: `${path}.blanks[${i}]`,
570
+ message: "blank entry must be an object with non-empty id and answer"
571
+ });
572
+ continue;
573
+ }
574
+ const id = blank.id?.trim() ?? "";
575
+ const answer = blank.answer?.trim() ?? "";
576
+ if (!id || !answer) {
577
+ issues.push({
578
+ path: `${path}.blanks[${i}]`,
579
+ message: "blank entry must include non-empty id and answer"
580
+ });
581
+ continue;
582
+ }
583
+ explicitBlanks.push({ id, answer });
584
+ }
585
+ }
586
+ if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
361
587
  issues.push({
362
588
  path: `${path}.blanks`,
363
589
  message: "blanks must include at least one entry with non-empty id and answer"
@@ -397,7 +623,31 @@ var ASSESSMENT_VALIDATORS = {
397
623
  message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
398
624
  });
399
625
  }
400
- }
626
+ },
627
+ sortParagraphs: (assessment, path, issues) => {
628
+ if (assessment.kind !== "sortParagraphs") return;
629
+ if (!Array.isArray(assessment.paragraphs) || assessment.paragraphs.length === 0) {
630
+ issues.push({ path: `${path}.paragraphs`, message: "paragraphs is required for sortParagraphs" });
631
+ return;
632
+ }
633
+ if (!Array.isArray(assessment.correctOrder) || assessment.correctOrder.length === 0) {
634
+ issues.push({ path: `${path}.correctOrder`, message: "correctOrder is required for sortParagraphs" });
635
+ return;
636
+ }
637
+ if (assessment.correctOrder.length !== assessment.paragraphs.length) {
638
+ issues.push({
639
+ path: `${path}.correctOrder`,
640
+ message: "correctOrder length must match paragraphs length for sortParagraphs"
641
+ });
642
+ }
643
+ },
644
+ guessTheAnswer: (assessment, path, issues) => {
645
+ if (assessment.kind !== "guessTheAnswer") return;
646
+ if (!assessment.answer?.trim()) {
647
+ issues.push({ path: `${path}.answer`, message: "answer is required for guessTheAnswer" });
648
+ }
649
+ },
650
+ multimediaChoice: validateMcqLike
401
651
  };
402
652
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
403
653
  const path = `assessments[${index}]`;
@@ -505,6 +755,20 @@ function validateCourseDescriptor(input) {
505
755
  });
506
756
  }
507
757
  }
758
+ const descriptorSpaDistDir = input.spaDistDir?.trim();
759
+ if (descriptorSpaDistDir) {
760
+ if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
761
+ issues.push({
762
+ path: "spaDistDir",
763
+ message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
764
+ });
765
+ } else if (isReservedOutputPath(descriptorSpaDistDir)) {
766
+ issues.push({
767
+ path: "spaDistDir",
768
+ message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
769
+ });
770
+ }
771
+ }
508
772
  if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
509
773
  issues.push({
510
774
  path: "lessons",
@@ -565,6 +829,8 @@ function validateCourseDescriptor(input) {
565
829
  }
566
830
 
567
831
  // src/assessments.ts
832
+ import { isMultiSelectMcq as isMultiSelectMcq2 } from "@lessonkit/core";
833
+ var DEFAULT_SHELL_PASSING_SCORE = 1;
568
834
  function escapeShellText(text) {
569
835
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
570
836
  }
@@ -573,7 +839,7 @@ function decodeShellEntities(text) {
573
839
  }
574
840
  function containsUnsafeShellMarkup(text) {
575
841
  const decoded = decodeShellEntities(text);
576
- return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /</.test(decoded);
842
+ return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
577
843
  }
578
844
  function sanitizeShellField(text) {
579
845
  if (containsUnsafeShellMarkup(text)) return null;
@@ -588,6 +854,8 @@ function mcqToLxpack(assessment) {
588
854
  const checkId = sanitizeShellField(assessment.checkId);
589
855
  const prompt = sanitizeShellField(assessment.question);
590
856
  if (!checkId || !prompt) return null;
857
+ const normalizedAnswer = assessment.answer.trim();
858
+ const multiCorrect = assessment.answers && assessment.answers.length > 1 ? new Set(assessment.answers.map((a) => a.trim())) : /* @__PURE__ */ new Set([normalizedAnswer]);
591
859
  const choices = assessment.choices.map((text, index) => {
592
860
  const sanitizedText = sanitizeShellField(text);
593
861
  if (!sanitizedText) return null;
@@ -595,17 +863,21 @@ function mcqToLxpack(assessment) {
595
863
  return {
596
864
  id,
597
865
  text: sanitizedText,
598
- correct: text === assessment.answer
866
+ correct: multiCorrect.has(text.trim())
599
867
  };
600
868
  });
601
869
  if (choices.some((choice) => choice === null)) return null;
870
+ const multiSelect = isMultiSelectMcq2(assessment);
602
871
  return {
603
872
  id: checkId,
604
- passingScore: assessment.passingScore ?? 1,
873
+ passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
874
+ shuffleChoices: assessment.shuffleChoices === true ? true : void 0,
875
+ showFeedback: assessment.choiceFeedback && Object.keys(assessment.choiceFeedback).length > 0 ? "immediate" : void 0,
605
876
  questions: [
606
877
  {
607
878
  id: "q1",
608
879
  prompt,
880
+ ...multiSelect ? { selectionMode: "multiple" } : {},
609
881
  choices
610
882
  }
611
883
  ]
@@ -634,7 +906,20 @@ function assessmentDescriptorToLxpack(assessment) {
634
906
  if (kind === "findMultipleHotspots") {
635
907
  return null;
636
908
  }
637
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
909
+ if (kind === "sortParagraphs" || kind === "guessTheAnswer") {
910
+ return null;
911
+ }
912
+ if (kind === "multimediaChoice" && assessment.kind === "multimediaChoice") {
913
+ return mcqToLxpack({
914
+ kind: "mcq",
915
+ checkId: assessment.checkId,
916
+ question: assessment.question,
917
+ choices: assessment.choices,
918
+ answer: assessment.answer,
919
+ passingScore: assessment.passingScore
920
+ });
921
+ }
922
+ if ((kind === "mcq" || assessment.kind === void 0) && "choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
638
923
  return mcqToLxpack(assessment);
639
924
  }
640
925
  return null;
@@ -646,11 +931,20 @@ function extractAssessments(descriptor) {
646
931
  // src/descriptor/validateInjectableAssessments.ts
647
932
  function validateInjectableAssessments(descriptor) {
648
933
  const issues = [];
934
+ const spaOnlyKinds = /* @__PURE__ */ new Set([
935
+ "fillInBlanks",
936
+ "findHotspot",
937
+ "findMultipleHotspots",
938
+ "sortParagraphs",
939
+ "guessTheAnswer"
940
+ ]);
649
941
  (descriptor.assessments ?? []).forEach((assessment, index) => {
650
942
  if (assessmentDescriptorToLxpack(assessment) === null) {
943
+ const kind = assessment.kind ?? "mcq";
944
+ 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
945
  issues.push({
652
946
  path: `assessments[${index}]`,
653
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
947
+ message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
654
948
  });
655
949
  }
656
950
  });
@@ -666,7 +960,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
666
960
  "cmi5"
667
961
  ]);
668
962
  function appendActivityIriIssues(issues, descriptor, target) {
669
- const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
963
+ const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
670
964
  const requiresForTarget = target === "xapi" || target === "cmi5";
671
965
  if (!hasXapiTracking && !requiresForTarget) return;
672
966
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
@@ -882,9 +1176,20 @@ function courseConfigCourseIdPresent(source, courseId) {
882
1176
  if (literalPattern.test(stripped)) return true;
883
1177
  return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
884
1178
  }
1179
+ function courseMetaCourseIdPresent(source, courseId) {
1180
+ const constants = extractStringConstants(source);
1181
+ const stripped = stripComments(source);
1182
+ for (const [name, value] of constants) {
1183
+ if (value !== courseId) continue;
1184
+ if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
1185
+ if (/\blessons\s*:\s*\S/.test(stripped)) return true;
1186
+ }
1187
+ return false;
1188
+ }
885
1189
  function courseIdPresent(source, courseId) {
886
1190
  if (idPropPresent(source, "courseId", courseId)) return true;
887
1191
  if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
1192
+ if (courseMetaCourseIdPresent(source, courseId)) return true;
888
1193
  return courseConfigCourseIdPresent(source, courseId);
889
1194
  }
890
1195
  function checkIdPresent(source, checkId) {
@@ -953,117 +1258,42 @@ function validateReactManifestParity(opts) {
953
1258
  return issues;
954
1259
  }
955
1260
 
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));
1261
+ // src/mapIds.ts
1262
+ import { assertValidId } from "@lessonkit/core";
1263
+ function mapLessonkitIds(descriptor) {
1264
+ const courseId = assertValidId(descriptor.courseId, "courseId");
1265
+ const lessonIds = descriptor.lessons.map((l) => assertValidId(l.id, "lessonId"));
1266
+ const checkIds = (descriptor.assessments ?? []).map(
1267
+ (a) => assertValidId(a.checkId, "checkId")
1268
+ );
1269
+ return { courseId, lessonIds, checkIds };
963
1270
  }
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;
1271
+
1272
+ // src/interchange.ts
1273
+ function mapDescriptorTracking(tracking) {
1274
+ if (!tracking) return void 0;
1275
+ const mapped = {};
1276
+ if (tracking.completion?.threshold !== void 0) {
1277
+ mapped.completion = { threshold: tracking.completion.threshold };
978
1278
  }
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
- });
1279
+ const activityIri = tracking.xapi?.activityIri?.trim();
1280
+ if (activityIri) {
1281
+ mapped.xapi = { activityIri };
986
1282
  }
1283
+ return Object.keys(mapped).length > 0 ? mapped : void 0;
987
1284
  }
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
- // src/mapIds.ts
1032
- import { assertValidId } from "@lessonkit/core";
1033
- function mapLessonkitIds(descriptor) {
1034
- const courseId = assertValidId(descriptor.courseId, "courseId");
1035
- const lessonIds = descriptor.lessons.map((l) => assertValidId(l.id, "lessonId"));
1036
- const checkIds = (descriptor.assessments ?? []).map(
1037
- (a) => assertValidId(a.checkId, "checkId")
1038
- );
1039
- return { courseId, lessonIds, checkIds };
1040
- }
1041
-
1042
- // src/interchange.ts
1043
- function mapDescriptorTracking(tracking) {
1044
- if (!tracking) return void 0;
1045
- const mapped = {};
1046
- if (tracking.completion?.threshold !== void 0) {
1047
- mapped.completion = { threshold: tracking.completion.threshold };
1048
- }
1049
- const activityIri = tracking.xapi?.activityIri?.trim();
1050
- if (activityIri) {
1051
- mapped.xapi = { activityIri };
1052
- }
1053
- return Object.keys(mapped).length > 0 ? mapped : void 0;
1054
- }
1055
- function resolveSpaLessons(descriptor) {
1056
- const mapped = mapLessonkitIds(descriptor);
1057
- if (descriptor.layout === "single-spa") {
1058
- const spaLessonId = descriptor.spaLessonId ?? mapped.lessonIds[0] ?? "main";
1059
- const firstLesson = descriptor.lessons.find((l) => l.id === spaLessonId);
1060
- return [
1061
- {
1062
- id: spaLessonId,
1063
- title: firstLesson?.title ?? descriptor.title,
1064
- path: "dist"
1065
- }
1066
- ];
1285
+ function resolveSpaLessons(descriptor) {
1286
+ const mapped = mapLessonkitIds(descriptor);
1287
+ if (descriptor.layout === "single-spa") {
1288
+ const spaLessonId = descriptor.spaLessonId ?? mapped.lessonIds[0] ?? "main";
1289
+ const firstLesson = descriptor.lessons.find((l) => l.id === spaLessonId);
1290
+ return [
1291
+ {
1292
+ id: spaLessonId,
1293
+ title: firstLesson?.title ?? descriptor.title,
1294
+ path: "dist"
1295
+ }
1296
+ ];
1067
1297
  }
1068
1298
  return descriptor.lessons.map((lesson) => ({
1069
1299
  id: lesson.id,
@@ -1159,7 +1389,7 @@ async function resolveSpaDirs(options) {
1159
1389
 
1160
1390
  // src/spaDistValidation.ts
1161
1391
  import { lstat, readdir } from "fs/promises";
1162
- import { realpathSync as realpathSync2 } from "fs";
1392
+ import { realpathSync as realpathSync3 } from "fs";
1163
1393
  import { join as join4 } from "path";
1164
1394
  async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1165
1395
  for (const [label, dir] of Object.entries(spaDirs)) {
@@ -1170,7 +1400,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
1170
1400
  }
1171
1401
  let rootReal;
1172
1402
  try {
1173
- rootReal = realpathSync2(dirResolved);
1403
+ rootReal = realpathSync3(dirResolved);
1174
1404
  } catch {
1175
1405
  throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
1176
1406
  }
@@ -1199,7 +1429,7 @@ async function walkDistDir(rootReal, current, label) {
1199
1429
  }
1200
1430
  let entryReal;
1201
1431
  try {
1202
- entryReal = realpathSync2(entryPath);
1432
+ entryReal = realpathSync3(entryPath);
1203
1433
  } catch (err) {
1204
1434
  throw new Error(
1205
1435
  `spa dist for "${label}" could not resolve path: ${entryPath}`,
@@ -1224,7 +1454,9 @@ async function writeLxpackProject(options) {
1224
1454
  const descriptor = validation.descriptor;
1225
1455
  const injectableIssues = validateInjectableAssessments(descriptor);
1226
1456
  if (injectableIssues.length > 0) {
1227
- throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1457
+ throw new Error(
1458
+ injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
1459
+ );
1228
1460
  }
1229
1461
  const outDir = resolve4(options.outDir);
1230
1462
  assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
@@ -1251,8 +1483,8 @@ async function writeLxpackProject(options) {
1251
1483
  }
1252
1484
 
1253
1485
  // src/packageCourse.ts
1254
- import { resolve as resolve7 } from "path";
1255
- import * as fsp3 from "fs/promises";
1486
+ import { resolve as resolve9 } from "path";
1487
+ import * as fsp4 from "fs/promises";
1256
1488
  import {
1257
1489
  buildCourse,
1258
1490
  validateCourse
@@ -1290,6 +1522,19 @@ function validatePackageInputs(options) {
1290
1522
  ]
1291
1523
  };
1292
1524
  }
1525
+ if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
1526
+ return {
1527
+ ok: false,
1528
+ courseDir: outDir,
1529
+ target,
1530
+ issues: [
1531
+ {
1532
+ path: "outDir",
1533
+ message: "outDir must not target reserved directories (.git, node_modules, .github)"
1534
+ }
1535
+ ]
1536
+ };
1537
+ }
1293
1538
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1294
1539
  return {
1295
1540
  ok: false,
@@ -1347,6 +1592,19 @@ function validatePackageInputs(options) {
1347
1592
  ]
1348
1593
  };
1349
1594
  }
1595
+ if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
1596
+ return {
1597
+ ok: false,
1598
+ courseDir: outDir,
1599
+ target,
1600
+ issues: [
1601
+ {
1602
+ path: "outputBaseDir",
1603
+ message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
1604
+ }
1605
+ ]
1606
+ };
1607
+ }
1350
1608
  }
1351
1609
  if (output) {
1352
1610
  const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
@@ -1368,6 +1626,20 @@ function validatePackageInputs(options) {
1368
1626
  ]
1369
1627
  };
1370
1628
  }
1629
+ const outputRel = isAbsolute3(output) ? output : output;
1630
+ if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
1631
+ return {
1632
+ ok: false,
1633
+ courseDir: outDir,
1634
+ target,
1635
+ issues: [
1636
+ {
1637
+ path: "output",
1638
+ message: "output must not target reserved directories (.git, node_modules, .github)"
1639
+ }
1640
+ ]
1641
+ };
1642
+ }
1371
1643
  }
1372
1644
  return { ok: true, outDir, projectRoot };
1373
1645
  }
@@ -1402,7 +1674,7 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1402
1674
  // src/packaging/promote.ts
1403
1675
  import * as fsp from "fs/promises";
1404
1676
  import { createHash, randomUUID } from "crypto";
1405
- import { dirname, join as join7, resolve as resolve6 } from "path";
1677
+ import { dirname as dirname2, join as join7, resolve as resolve6 } from "path";
1406
1678
  async function pathExists(path) {
1407
1679
  try {
1408
1680
  await fsp.access(path);
@@ -1422,7 +1694,7 @@ async function renameOrCopy(from, to) {
1422
1694
  }
1423
1695
  }
1424
1696
  function promoteLockPath(outDir) {
1425
- const parent = dirname(outDir);
1697
+ const parent = dirname2(outDir);
1426
1698
  const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1427
1699
  return join7(parent, `.lk-promote-lock-${hash}`);
1428
1700
  }
@@ -1460,11 +1732,14 @@ async function isStalePromoteLock(lockPath) {
1460
1732
  return true;
1461
1733
  }
1462
1734
  }
1735
+ var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1463
1736
  async function withPromoteLock(outDir, fn) {
1464
1737
  const lockPath = promoteLockPath(outDir);
1465
- await fsp.mkdir(dirname(outDir), { recursive: true });
1738
+ await fsp.mkdir(dirname2(outDir), { recursive: true });
1466
1739
  let lockHandle;
1467
- for (let attempt = 0; attempt < 200; attempt++) {
1740
+ const maxAttempts = 400;
1741
+ const started = Date.now();
1742
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1468
1743
  try {
1469
1744
  lockHandle = await fsp.open(lockPath, "wx");
1470
1745
  await lockHandle.writeFile(`${process.pid}
@@ -1482,7 +1757,9 @@ ${Date.now()}
1482
1757
  );
1483
1758
  continue;
1484
1759
  }
1485
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1760
+ if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
1761
+ const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
1762
+ await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
1486
1763
  }
1487
1764
  }
1488
1765
  if (!lockHandle) {
@@ -1547,7 +1824,7 @@ async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, n
1547
1824
  if (newArtifactPaths.has(rel)) continue;
1548
1825
  const src = join7(priorArtifactsDir, rel);
1549
1826
  const dest = join7(destArtifactsDir, rel);
1550
- await fsp.mkdir(dirname(dest), { recursive: true });
1827
+ await fsp.mkdir(dirname2(dest), { recursive: true });
1551
1828
  await fsp.cp(src, dest, { force: true });
1552
1829
  }
1553
1830
  }
@@ -1565,7 +1842,7 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1565
1842
  newArtifactPaths.add(rel);
1566
1843
  }
1567
1844
  }
1568
- const parent = dirname(outDir);
1845
+ const parent = dirname2(outDir);
1569
1846
  let priorArtifactsBackup;
1570
1847
  const existingArtifactsDir = join7(outDir, outputBaseDir);
1571
1848
  if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
@@ -1651,14 +1928,29 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1651
1928
  });
1652
1929
  }
1653
1930
 
1654
- // src/packaging/staging.ts
1931
+ // src/packaging/relocateOutput.ts
1655
1932
  import * as fsp2 from "fs/promises";
1656
- import { dirname as dirname2, join as join8 } from "path";
1933
+ import { dirname as dirname3, resolve as resolve7 } from "path";
1934
+ async function relocatePackageOutput(builtOutputPath, requestedOutputPath, projectRoot) {
1935
+ if (!builtOutputPath || !requestedOutputPath) return builtOutputPath;
1936
+ const resolvedBuilt = resolveComparablePath(builtOutputPath);
1937
+ const resolvedRequested = resolveComparablePath(requestedOutputPath);
1938
+ if (resolvedBuilt === resolvedRequested) return builtOutputPath;
1939
+ const root = resolve7(projectRoot);
1940
+ assertRealPathUnderRoot(root, resolvedRequested);
1941
+ await fsp2.mkdir(dirname3(resolvedRequested), { recursive: true });
1942
+ await renameOrCopy(resolvedBuilt, resolvedRequested);
1943
+ return resolvedRequested;
1944
+ }
1945
+
1946
+ // src/packaging/staging.ts
1947
+ import * as fsp3 from "fs/promises";
1948
+ import { dirname as dirname4, isAbsolute as isAbsolute4, join as join8, resolve as resolve8 } from "path";
1657
1949
  import { tmpdir } from "os";
1658
1950
  import { packageLessonkit } from "@lxpack/api";
1659
1951
  async function buildStagingPackage(options) {
1660
1952
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1661
- const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1953
+ const stagingDir = await fsp3.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1662
1954
  let succeeded = false;
1663
1955
  try {
1664
1956
  let spaDirs;
@@ -1690,14 +1982,28 @@ async function buildStagingPackage(options) {
1690
1982
  }
1691
1983
  const interchange = descriptorToInterchange(descriptor);
1692
1984
  const outputBase = outputBaseDir ?? ".lxpack/out";
1693
- await fsp2.mkdir(join8(stagingDir, outputBase), { recursive: true });
1694
- const defaultOutput = output ?? (dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`));
1985
+ await fsp3.mkdir(join8(stagingDir, outputBase), { recursive: true });
1986
+ const defaultOutput = dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`);
1987
+ let packageOutput = output ?? defaultOutput;
1988
+ let requestedOutputPath;
1989
+ let requestedOutputDir;
1990
+ if (output) {
1991
+ const projectRoot = resolve8(writeOpts.projectRoot);
1992
+ const requested = isAbsolute4(output) ? resolve8(output) : resolve8(projectRoot, output);
1993
+ if (dir) {
1994
+ requestedOutputDir = requested;
1995
+ packageOutput = defaultOutput;
1996
+ } else {
1997
+ requestedOutputPath = requested;
1998
+ packageOutput = defaultOutput;
1999
+ }
2000
+ }
1695
2001
  const build = await packageLessonkit({
1696
2002
  interchange,
1697
2003
  spaDirs,
1698
2004
  target,
1699
2005
  courseDir: stagingDir,
1700
- output: defaultOutput,
2006
+ output: packageOutput,
1701
2007
  dir,
1702
2008
  outputBaseDir,
1703
2009
  outputAnchorDir: stagingDir,
@@ -1721,17 +2027,19 @@ async function buildStagingPackage(options) {
1721
2027
  stagingDir,
1722
2028
  build,
1723
2029
  outputPath: "outputPath" in build ? build.outputPath : void 0,
1724
- outputDir: "outputDir" in build ? build.outputDir : void 0
2030
+ outputDir: "outputDir" in build ? build.outputDir : void 0,
2031
+ requestedOutputPath,
2032
+ requestedOutputDir
1725
2033
  };
1726
2034
  } catch (err) {
1727
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2035
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1728
2036
  /* v8 ignore next */
1729
2037
  () => void 0
1730
2038
  );
1731
2039
  throw err;
1732
2040
  } finally {
1733
2041
  if (!succeeded) {
1734
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2042
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1735
2043
  /* v8 ignore next */
1736
2044
  () => void 0
1737
2045
  );
@@ -1739,7 +2047,7 @@ async function buildStagingPackage(options) {
1739
2047
  }
1740
2048
  }
1741
2049
  async function ensureOutDirParent(outDir) {
1742
- await fsp2.mkdir(dirname2(outDir), { recursive: true });
2050
+ await fsp3.mkdir(dirname4(outDir), { recursive: true });
1743
2051
  }
1744
2052
 
1745
2053
  // src/packaging/issueSeverity.ts
@@ -1750,17 +2058,23 @@ function isPackagingErrorIssue(issue) {
1750
2058
  function findPackagingErrorIssues(issues) {
1751
2059
  return (issues ?? []).filter(isPackagingErrorIssue);
1752
2060
  }
2061
+ function isPackagingWarningIssue(issue) {
2062
+ return issue.severity?.toLowerCase() === "warning";
2063
+ }
2064
+ function findPackagingWarningIssues(issues) {
2065
+ return (issues ?? []).filter(isPackagingWarningIssue);
2066
+ }
1753
2067
 
1754
2068
  // src/packageCourse.ts
1755
2069
  async function validateLessonkitProject(options) {
1756
2070
  return validateCourse({
1757
- courseDir: resolve7(options.courseDir),
2071
+ courseDir: resolve9(options.courseDir),
1758
2072
  target: options.target
1759
2073
  });
1760
2074
  }
1761
2075
  async function buildLessonkitProject(options) {
1762
2076
  const buildOptions = {
1763
- courseDir: resolve7(options.courseDir),
2077
+ courseDir: resolve9(options.courseDir),
1764
2078
  target: options.target,
1765
2079
  output: options.output,
1766
2080
  dir: options.dir,
@@ -1791,7 +2105,7 @@ async function packageLessonkitCourse(options) {
1791
2105
  if (!descriptorValidation.ok) {
1792
2106
  return {
1793
2107
  ok: false,
1794
- courseDir: resolve7(writeOpts.outDir),
2108
+ courseDir: resolve9(writeOpts.outDir),
1795
2109
  target,
1796
2110
  issues: descriptorValidation.issues.map((i) => ({
1797
2111
  path: i.path,
@@ -1817,16 +2131,31 @@ async function packageLessonkitCourse(options) {
1817
2131
  }))
1818
2132
  };
1819
2133
  }
1820
- const staged = await buildStagingPackage({
1821
- ...writeOpts,
1822
- descriptor,
1823
- target,
1824
- output,
1825
- dir,
1826
- outputBaseDir
1827
- });
2134
+ let staged;
2135
+ try {
2136
+ staged = await buildStagingPackage({
2137
+ ...writeOpts,
2138
+ descriptor,
2139
+ target,
2140
+ output,
2141
+ dir,
2142
+ outputBaseDir
2143
+ });
2144
+ } catch (err) {
2145
+ return {
2146
+ ok: false,
2147
+ courseDir: outDir,
2148
+ target,
2149
+ issues: [
2150
+ {
2151
+ path: "staging",
2152
+ message: err instanceof Error ? err.message : String(err)
2153
+ }
2154
+ ]
2155
+ };
2156
+ }
1828
2157
  if (!staged.ok) {
1829
- await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
2158
+ await fsp4.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1830
2159
  /* v8 ignore next */
1831
2160
  () => void 0
1832
2161
  );
@@ -1843,7 +2172,7 @@ async function packageLessonkitCourse(options) {
1843
2172
  const { stagingDir, build } = staged;
1844
2173
  const buildErrorIssues = findPackagingErrorIssues(build.issues);
1845
2174
  if (buildErrorIssues.length > 0) {
1846
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2175
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
1847
2176
  /* v8 ignore next */
1848
2177
  () => void 0
1849
2178
  );
@@ -1860,13 +2189,13 @@ async function packageLessonkitCourse(options) {
1860
2189
  }))
1861
2190
  };
1862
2191
  }
1863
- const stagingRoot = await fsp3.realpath(stagingDir);
2192
+ const stagingRoot = await fsp4.realpath(stagingDir);
1864
2193
  const artifactIssues = [
1865
2194
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
1866
2195
  validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
1867
2196
  ].filter((issue) => issue != null);
1868
2197
  if (artifactIssues.length > 0) {
1869
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2198
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
1870
2199
  /* v8 ignore next */
1871
2200
  () => void 0
1872
2201
  );
@@ -1879,8 +2208,27 @@ async function packageLessonkitCourse(options) {
1879
2208
  issues: artifactIssues
1880
2209
  };
1881
2210
  }
1882
- const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
1883
- const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
2211
+ const buildWarningIssues = findPackagingWarningIssues(build.issues);
2212
+ if (options.strictBuild && buildWarningIssues.length > 0) {
2213
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2214
+ /* v8 ignore next */
2215
+ () => void 0
2216
+ );
2217
+ return {
2218
+ ok: false,
2219
+ courseDir: outDir,
2220
+ target,
2221
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
2222
+ build,
2223
+ issues: buildWarningIssues.map((i) => ({
2224
+ path: i.path ?? "build",
2225
+ message: i.message ?? "build warning",
2226
+ severity: i.severity
2227
+ }))
2228
+ };
2229
+ }
2230
+ let remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
2231
+ let remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
1884
2232
  const validation = {
1885
2233
  ok: true,
1886
2234
  manifest: build.manifest,
@@ -1893,7 +2241,7 @@ async function packageLessonkitCourse(options) {
1893
2241
  projectRoot: writeOpts.projectRoot
1894
2242
  });
1895
2243
  } catch (err) {
1896
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2244
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
1897
2245
  /* v8 ignore next */
1898
2246
  () => void 0
1899
2247
  );
@@ -1911,6 +2259,32 @@ async function packageLessonkitCourse(options) {
1911
2259
  ]
1912
2260
  };
1913
2261
  }
2262
+ try {
2263
+ remappedOutputPath = await relocatePackageOutput(
2264
+ remappedOutputPath,
2265
+ staged.requestedOutputPath,
2266
+ writeOpts.projectRoot
2267
+ );
2268
+ remappedOutputDir = await relocatePackageOutput(
2269
+ remappedOutputDir,
2270
+ staged.requestedOutputDir,
2271
+ writeOpts.projectRoot
2272
+ );
2273
+ } catch (err) {
2274
+ return {
2275
+ ok: false,
2276
+ courseDir: outDir,
2277
+ target,
2278
+ validation,
2279
+ build,
2280
+ issues: [
2281
+ {
2282
+ path: "output",
2283
+ message: err instanceof Error ? err.message : String(err)
2284
+ }
2285
+ ]
2286
+ };
2287
+ }
1914
2288
  const remappedBuild = { ...build };
1915
2289
  if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
1916
2290
  remappedBuild.outputPath = remappedOutputPath;
@@ -1954,8 +2328,9 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
1954
2328
  }
1955
2329
  const nameRaw = config.name;
1956
2330
  const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
1957
- if (!name) {
1958
- issues.push({ path: "name", message: "must be a non-empty string" });
2331
+ const nameIssue = validateManifestName(name);
2332
+ if (nameIssue) {
2333
+ issues.push({ path: "name", message: nameIssue });
1959
2334
  }
1960
2335
  const courseRaw = config.course;
1961
2336
  if (Array.isArray(courseRaw)) {
@@ -2021,7 +2396,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2021
2396
  path: `paths.${key}`,
2022
2397
  message: "path must be relative without '..' segments or absolute prefixes"
2023
2398
  });
2024
- } else if ((key === "lxpackOutDir" || key === "outputBaseDir") && isReservedOutputPath(value)) {
2399
+ } else if (isReservedOutputPath(value)) {
2025
2400
  issues.push({
2026
2401
  path: `paths.${key}`,
2027
2402
  message: "path must not target reserved directories (.git, node_modules, .github)"
@@ -2063,17 +2438,920 @@ import {
2063
2438
  import {
2064
2439
  lessonkitInterchangeSchema,
2065
2440
  materializeLessonkitProject as materializeLessonkitProject2,
2066
- parseLessonkitInterchange
2441
+ parseLessonkitInterchange as parseLessonkitInterchange3
2067
2442
  } from "@lxpack/validators";
2443
+
2444
+ // src/lkcourse/zip.ts
2445
+ import { readFileSync as readFileSync2, statSync } from "fs";
2446
+ import { dirname as dirname5, join as join9, normalize } from "path";
2447
+ import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
2448
+ var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2449
+ function canonicalZipEntryPath(entryPath) {
2450
+ const slashNormalized = entryPath.replace(/\\/g, "/");
2451
+ const canonical = normalize(slashNormalized).replace(/\\/g, "/");
2452
+ if (canonical !== slashNormalized) return null;
2453
+ return canonical;
2454
+ }
2455
+ function isSafeZipEntryPath(entryPath) {
2456
+ const canonical = canonicalZipEntryPath(entryPath);
2457
+ if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
2458
+ return false;
2459
+ }
2460
+ const segments = canonical.split("/").filter((s) => s.length > 0);
2461
+ if (segments.some((s) => s === "..")) return false;
2462
+ return segments.length > 0;
2463
+ }
2464
+ function createZip(entries) {
2465
+ const zipped = {};
2466
+ for (const [path, data] of entries) {
2467
+ if (!isSafeZipEntryPath(path)) {
2468
+ throw new Error(`unsafe zip entry path: ${path}`);
2469
+ }
2470
+ zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
2471
+ }
2472
+ return zipSync(zipped, { level: 6 });
2473
+ }
2474
+ function readZip(archivePath) {
2475
+ const issues = [];
2476
+ let raw;
2477
+ try {
2478
+ raw = readFileSync2(archivePath);
2479
+ } catch {
2480
+ return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
2481
+ }
2482
+ if (!raw.length) {
2483
+ return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
2484
+ }
2485
+ let unzipped;
2486
+ try {
2487
+ unzipped = unzipSync(raw);
2488
+ } catch {
2489
+ return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
2490
+ }
2491
+ const entries = /* @__PURE__ */ new Map();
2492
+ let totalUncompressed = 0;
2493
+ for (const [path, data] of Object.entries(unzipped)) {
2494
+ const canonical = canonicalZipEntryPath(path);
2495
+ if (!canonical || !isSafeZipEntryPath(canonical)) {
2496
+ issues.push({ path, message: "unsafe zip entry path" });
2497
+ continue;
2498
+ }
2499
+ if (entries.has(canonical)) {
2500
+ issues.push({ path: canonical, message: "duplicate zip entry path" });
2501
+ continue;
2502
+ }
2503
+ totalUncompressed += data.byteLength;
2504
+ if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
2505
+ return {
2506
+ ok: false,
2507
+ issues: [
2508
+ {
2509
+ path: archivePath,
2510
+ message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
2511
+ }
2512
+ ]
2513
+ };
2514
+ }
2515
+ entries.set(canonical, data);
2516
+ }
2517
+ if (issues.length) return { ok: false, issues };
2518
+ return { ok: true, entries };
2519
+ }
2520
+ async function collectDistEntries(distDir, spaDistRelative) {
2521
+ const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
2522
+ const entries = /* @__PURE__ */ new Map();
2523
+ const walk = async (absDir, relPrefix) => {
2524
+ const dirEntries = await readdir4(absDir, { withFileTypes: true });
2525
+ for (const entry of dirEntries) {
2526
+ const abs = join9(absDir, entry.name);
2527
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
2528
+ const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
2529
+ if (!isSafeRelativeSpaPath(zipPath)) {
2530
+ throw new Error(`unsafe dist path: ${zipPath}`);
2531
+ }
2532
+ const stat2 = await lstat2(abs);
2533
+ if (stat2.isSymbolicLink()) {
2534
+ throw new Error(`dist contains symlink: ${abs}`);
2535
+ }
2536
+ if (stat2.isDirectory()) {
2537
+ await walk(abs, rel);
2538
+ } else if (stat2.isFile()) {
2539
+ entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
2540
+ }
2541
+ }
2542
+ };
2543
+ await walk(distDir, "");
2544
+ return entries;
2545
+ }
2546
+ function entryToUtf8(data) {
2547
+ return strFromU8(data);
2548
+ }
2549
+ function utf8ToEntry(text) {
2550
+ return strToU8(text);
2551
+ }
2552
+
2553
+ // src/lkcourse/parseEnvelope.ts
2554
+ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2555
+ const issues = [];
2556
+ if (!raw || typeof raw !== "object") {
2557
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
2558
+ }
2559
+ const obj = raw;
2560
+ if (obj.format !== "lkcourse") {
2561
+ issues.push({
2562
+ path: "format",
2563
+ message: `must be "lkcourse" (got ${String(obj.format)})`
2564
+ });
2565
+ }
2566
+ let schemaVersion = obj.schemaVersion;
2567
+ if (schemaVersion === "1") schemaVersion = 1;
2568
+ if (schemaVersion !== 1) {
2569
+ issues.push({
2570
+ path: "schemaVersion",
2571
+ message: `must be 1 (got ${String(obj.schemaVersion)})`
2572
+ });
2573
+ }
2574
+ const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
2575
+ if (!lessonkitVersion) {
2576
+ issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
2577
+ }
2578
+ const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
2579
+ if (!exportedAt) {
2580
+ issues.push({ path: "exportedAt", message: "must be a non-empty string" });
2581
+ }
2582
+ const entriesRaw = obj.entries;
2583
+ const entries = [];
2584
+ if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
2585
+ issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
2586
+ } else {
2587
+ for (let i = 0; i < entriesRaw.length; i++) {
2588
+ const entry = entriesRaw[i];
2589
+ if (typeof entry !== "string" || !entry.trim()) {
2590
+ issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
2591
+ } else {
2592
+ const trimmed = entry.trim();
2593
+ if (!isSafeZipEntryPath(trimmed)) {
2594
+ issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
2595
+ } else {
2596
+ entries.push(trimmed);
2597
+ }
2598
+ }
2599
+ }
2600
+ }
2601
+ if (issues.length) return { ok: false, issues };
2602
+ const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
2603
+ if (!manifestParsed.ok) {
2604
+ return {
2605
+ ok: false,
2606
+ issues: manifestParsed.issues.map((issue) => ({
2607
+ path: `sourceManifest.${issue.path}`,
2608
+ message: issue.message
2609
+ }))
2610
+ };
2611
+ }
2612
+ return {
2613
+ ok: true,
2614
+ envelope: {
2615
+ format: "lkcourse",
2616
+ schemaVersion: 1,
2617
+ lessonkitVersion,
2618
+ exportedAt,
2619
+ sourceManifest: manifestParsed.manifest,
2620
+ entries
2621
+ }
2622
+ };
2623
+ }
2624
+
2625
+ // src/lkcourse/blockTree.ts
2626
+ import { existsSync as existsSync3, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
2627
+ import { createRequire } from "module";
2628
+ import { join as join10, relative as relative3 } from "path";
2629
+ import { validateId as validateId4 } from "@lessonkit/core";
2630
+ var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
2631
+ var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
2632
+ function stripComments2(source) {
2633
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
2634
+ }
2635
+ function collectSourceUnderSrc2(projectRoot) {
2636
+ const srcDir = join10(projectRoot, "src");
2637
+ if (!existsSync3(srcDir)) return [];
2638
+ const results = [];
2639
+ const walk = (dir) => {
2640
+ for (const entry of readdirSync2(dir)) {
2641
+ const abs = join10(dir, entry);
2642
+ try {
2643
+ assertRealPathUnderRoot(projectRoot, abs);
2644
+ } catch {
2645
+ continue;
2646
+ }
2647
+ const stat2 = lstatSync2(abs);
2648
+ if (stat2.isSymbolicLink()) continue;
2649
+ if (stat2.isDirectory()) {
2650
+ walk(abs);
2651
+ } else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
2652
+ results.push(relative3(projectRoot, abs));
2653
+ }
2654
+ }
2655
+ };
2656
+ walk(srcDir);
2657
+ return results;
2658
+ }
2659
+ function loadCatalogBlockTypes(blockTypes) {
2660
+ if (blockTypes?.length) return blockTypes;
2661
+ try {
2662
+ const require2 = createRequire(import.meta.url);
2663
+ const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
2664
+ const catalog = JSON.parse(readFileSync3(catalogPath, "utf8"));
2665
+ return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
2666
+ } catch {
2667
+ return [
2668
+ "Course",
2669
+ "Lesson",
2670
+ "Scenario",
2671
+ "Quiz",
2672
+ "KnowledgeCheck",
2673
+ "ProgressTracker",
2674
+ "Reflection",
2675
+ "TrueFalse",
2676
+ "MarkTheWords",
2677
+ "FillInTheBlanks",
2678
+ "DragTheWords",
2679
+ "DragAndDrop",
2680
+ "AssessmentSequence",
2681
+ "Text",
2682
+ "Heading",
2683
+ "Image",
2684
+ "Video",
2685
+ "Page",
2686
+ "InteractiveBook",
2687
+ "Slide",
2688
+ "SlideDeck",
2689
+ "TimedCue",
2690
+ "InteractiveVideo",
2691
+ "Summary",
2692
+ "BranchingScenario",
2693
+ "BranchNode",
2694
+ "BranchChoice",
2695
+ "Embed",
2696
+ "Chart"
2697
+ ];
2698
+ }
2699
+ }
2700
+ function extractIdProp(tagSource, prop) {
2701
+ const re = new RegExp(
2702
+ `\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
2703
+ );
2704
+ const match = tagSource.match(re);
2705
+ if (!match) return void 0;
2706
+ return match[1] ?? match[2] ?? match[3];
2707
+ }
2708
+ function parseJsxBlocks(source, blockTypes) {
2709
+ const stripped = stripComments2(source);
2710
+ const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
2711
+ const stack = [];
2712
+ const roots = [];
2713
+ for (const match of stripped.matchAll(tagRe)) {
2714
+ const rawTag = match[1];
2715
+ const attrs = match[2] ?? "";
2716
+ const selfClosing = match[3] === "/";
2717
+ if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
2718
+ const known = blockTypes.has(rawTag);
2719
+ const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
2720
+ for (const prop of ID_PROPS) {
2721
+ const value = extractIdProp(attrs, prop);
2722
+ if (value) node[prop] = value;
2723
+ }
2724
+ if (selfClosing) {
2725
+ if (stack.length) {
2726
+ const parent = stack[stack.length - 1];
2727
+ parent.children = parent.children ?? [];
2728
+ parent.children.push(node);
2729
+ } else {
2730
+ roots.push(node);
2731
+ }
2732
+ continue;
2733
+ }
2734
+ const closeRe = new RegExp(`</${rawTag}>`);
2735
+ const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
2736
+ if (!closeMatch) {
2737
+ if (stack.length) {
2738
+ const parent = stack[stack.length - 1];
2739
+ parent.children = parent.children ?? [];
2740
+ parent.children.push(node);
2741
+ } else {
2742
+ roots.push(node);
2743
+ }
2744
+ continue;
2745
+ }
2746
+ stack.push(node);
2747
+ const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
2748
+ const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
2749
+ if (!inner.includes("<")) {
2750
+ stack.pop();
2751
+ if (stack.length) {
2752
+ const parent = stack[stack.length - 1];
2753
+ parent.children = parent.children ?? [];
2754
+ parent.children.push(node);
2755
+ } else {
2756
+ roots.push(node);
2757
+ }
2758
+ }
2759
+ }
2760
+ return roots.length ? roots : stack;
2761
+ }
2762
+ function validateNodeIds(node, pathPrefix, issues) {
2763
+ for (const prop of ID_PROPS) {
2764
+ const value = node[prop];
2765
+ if (value === void 0) continue;
2766
+ const validated = validateId4(value, prop);
2767
+ if (!validated.ok) {
2768
+ issues.push({
2769
+ path: `${pathPrefix}.${prop}`,
2770
+ message: validated.issues[0]?.message ?? `invalid ${prop}`
2771
+ });
2772
+ }
2773
+ }
2774
+ node.children?.forEach((child, index) => {
2775
+ validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
2776
+ });
2777
+ }
2778
+ function validateBlockTreeIds(tree) {
2779
+ const issues = [];
2780
+ tree.blocks.forEach((block, index) => {
2781
+ validateNodeIds(block, `blocks[${index}]`, issues);
2782
+ });
2783
+ return issues;
2784
+ }
2785
+ function extractBlockTree(options) {
2786
+ const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
2787
+ const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
2788
+ const blocks = [];
2789
+ for (const rel of sources) {
2790
+ const abs = join10(options.projectRoot, rel);
2791
+ if (!existsSync3(abs)) continue;
2792
+ const source = readFileSync3(abs, "utf8");
2793
+ const parsed = parseJsxBlocks(source, blockTypes);
2794
+ blocks.push(...parsed);
2795
+ }
2796
+ return {
2797
+ schemaVersion: 1,
2798
+ sources,
2799
+ blocks
2800
+ };
2801
+ }
2802
+
2803
+ // src/lkcourse/export.ts
2804
+ import { mkdir as mkdir4, writeFile } from "fs/promises";
2805
+ import { createRequire as createRequire2 } from "module";
2806
+ import { dirname as dirname6, join as join11, resolve as resolve10 } from "path";
2807
+ import { parseLessonkitInterchange } from "@lxpack/validators";
2808
+ function resolveLessonkitVersion(explicit) {
2809
+ if (explicit?.trim()) return explicit.trim();
2810
+ try {
2811
+ const require2 = createRequire2(import.meta.url);
2812
+ const pkg = require2("../../package.json");
2813
+ return pkg.version ?? "0.0.0";
2814
+ } catch {
2815
+ return "0.0.0";
2816
+ }
2817
+ }
2818
+ async function exportLkcourse(options) {
2819
+ const projectRoot = resolve10(options.projectRoot);
2820
+ const manifest = options.manifest;
2821
+ const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
2822
+ try {
2823
+ assertRealPathUnderRoot(projectRoot, spaDistDir);
2824
+ await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
2825
+ } catch (err) {
2826
+ return {
2827
+ ok: false,
2828
+ issues: [
2829
+ {
2830
+ path: manifest.paths.spaDistDir,
2831
+ message: err instanceof Error ? err.message : String(err)
2832
+ }
2833
+ ]
2834
+ };
2835
+ }
2836
+ const injectableIssues = validateInjectableAssessments(manifest.course);
2837
+ if (injectableIssues.length > 0) {
2838
+ return {
2839
+ ok: false,
2840
+ issues: injectableIssues.map((issue) => ({
2841
+ path: issue.path,
2842
+ message: issue.message
2843
+ }))
2844
+ };
2845
+ }
2846
+ const interchange = descriptorToInterchange(manifest.course);
2847
+ const interchangeParsed = parseLessonkitInterchange(interchange);
2848
+ if (!interchangeParsed.ok) {
2849
+ return {
2850
+ ok: false,
2851
+ issues: interchangeParsed.issues.map((i) => ({
2852
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2853
+ message: i.message
2854
+ }))
2855
+ };
2856
+ }
2857
+ const validatedInterchange = interchangeParsed.data;
2858
+ const interchangeCourseId = validatedInterchange.course?.id;
2859
+ if (!interchangeCourseId) {
2860
+ return {
2861
+ ok: false,
2862
+ issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
2863
+ };
2864
+ }
2865
+ if (manifest.course.courseId !== interchangeCourseId) {
2866
+ return {
2867
+ ok: false,
2868
+ issues: [
2869
+ {
2870
+ path: "course.courseId",
2871
+ message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
2872
+ }
2873
+ ]
2874
+ };
2875
+ }
2876
+ const zipEntries = /* @__PURE__ */ new Map();
2877
+ const interchangeJson = JSON.stringify(interchange, null, 2);
2878
+ zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
2879
+ let blockTreeJson;
2880
+ if (options.includeBlockTree) {
2881
+ const blockTree = extractBlockTree({ projectRoot });
2882
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2883
+ if (blockTreeIssues.length) {
2884
+ return {
2885
+ ok: false,
2886
+ issues: blockTreeIssues.map((issue) => ({
2887
+ path: `block-tree.${issue.path}`,
2888
+ message: issue.message
2889
+ }))
2890
+ };
2891
+ }
2892
+ blockTreeJson = JSON.stringify(blockTree, null, 2);
2893
+ zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
2894
+ }
2895
+ let distEntries;
2896
+ try {
2897
+ distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
2898
+ } catch (err) {
2899
+ return {
2900
+ ok: false,
2901
+ issues: [
2902
+ {
2903
+ path: manifest.paths.spaDistDir,
2904
+ message: err instanceof Error ? err.message : String(err)
2905
+ }
2906
+ ]
2907
+ };
2908
+ }
2909
+ if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
2910
+ return {
2911
+ ok: false,
2912
+ issues: [
2913
+ {
2914
+ path: `${manifest.paths.spaDistDir}/index.html`,
2915
+ message: "dist must contain index.html before export"
2916
+ }
2917
+ ]
2918
+ };
2919
+ }
2920
+ for (const [path, data] of distEntries) {
2921
+ zipEntries.set(path, data);
2922
+ }
2923
+ const entryPaths = [...zipEntries.keys()].sort();
2924
+ const envelope = {
2925
+ format: "lkcourse",
2926
+ schemaVersion: 1,
2927
+ lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
2928
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2929
+ sourceManifest: manifest,
2930
+ entries: entryPaths
2931
+ };
2932
+ const envelopeCheck = parseLkcourseEnvelope(envelope);
2933
+ if (!envelopeCheck.ok) {
2934
+ return { ok: false, issues: envelopeCheck.issues };
2935
+ }
2936
+ zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
2937
+ const archivePath = resolve10(
2938
+ projectRoot,
2939
+ options.outPath ?? `${manifest.name}.lkcourse`
2940
+ );
2941
+ const archiveRel = options.outPath ?? `${manifest.name}.lkcourse`;
2942
+ try {
2943
+ assertRealPathUnderRoot(projectRoot, archivePath);
2944
+ } catch (err) {
2945
+ return {
2946
+ ok: false,
2947
+ issues: [
2948
+ {
2949
+ path: archiveRel,
2950
+ message: err instanceof Error ? err.message : String(err)
2951
+ }
2952
+ ]
2953
+ };
2954
+ }
2955
+ if (isReservedResolvedOutputPath(projectRoot, archivePath)) {
2956
+ return {
2957
+ ok: false,
2958
+ issues: [
2959
+ {
2960
+ path: archiveRel,
2961
+ message: "output path must not target reserved directories (.git, node_modules, .github)"
2962
+ }
2963
+ ]
2964
+ };
2965
+ }
2966
+ if (!isSafeZipEntryPath(archiveRel)) {
2967
+ return {
2968
+ ok: false,
2969
+ issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
2970
+ };
2971
+ }
2972
+ try {
2973
+ await mkdir4(dirname6(archivePath), { recursive: true });
2974
+ const zipped = createZip(zipEntries);
2975
+ await writeFile(archivePath, zipped);
2976
+ } catch (err) {
2977
+ return {
2978
+ ok: false,
2979
+ issues: [
2980
+ {
2981
+ path: archivePath,
2982
+ message: err instanceof Error ? err.message : String(err)
2983
+ }
2984
+ ]
2985
+ };
2986
+ }
2987
+ return {
2988
+ ok: true,
2989
+ archivePath,
2990
+ fileCount: zipEntries.size,
2991
+ includeBlockTree: Boolean(options.includeBlockTree)
2992
+ };
2993
+ }
2994
+
2995
+ // src/lkcourse/validate.ts
2996
+ import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
2997
+
2998
+ // src/lkcourse/assessmentParity.ts
2999
+ function validateLkcourseAssessmentConsistency(descriptor, interchange) {
3000
+ const issues = [];
3001
+ for (const issue of validateInjectableAssessments(descriptor)) {
3002
+ issues.push({
3003
+ path: `sourceManifest.course.${issue.path}`,
3004
+ message: issue.message
3005
+ });
3006
+ }
3007
+ const expectedIds = extractAssessments(descriptor).map((a) => a.id).sort();
3008
+ const interchangeIds = (interchange.assessments ?? []).map((a) => a.id).sort();
3009
+ const matches = expectedIds.length === interchangeIds.length && expectedIds.every((id, index) => id === interchangeIds[index]);
3010
+ if (!matches) {
3011
+ issues.push({
3012
+ path: "interchange.assessments",
3013
+ message: `injectable assessment ids [${expectedIds.join(", ")}] do not match interchange [${interchangeIds.join(", ")}]`
3014
+ });
3015
+ }
3016
+ return issues;
3017
+ }
3018
+
3019
+ // src/lkcourse/validate.ts
3020
+ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
3021
+ const issues = [];
3022
+ const manifestData = entries.get("manifest.json");
3023
+ if (!manifestData) {
3024
+ return {
3025
+ ok: false,
3026
+ issues: [{ path: "manifest.json", message: "required file missing from archive" }]
3027
+ };
3028
+ }
3029
+ let envelopeRaw;
3030
+ try {
3031
+ envelopeRaw = JSON.parse(entryToUtf8(manifestData));
3032
+ } catch {
3033
+ return {
3034
+ ok: false,
3035
+ issues: [{ path: "manifest.json", message: "invalid JSON" }]
3036
+ };
3037
+ }
3038
+ const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
3039
+ if (!envelopeParsed.ok) {
3040
+ return { ok: false, issues: envelopeParsed.issues };
3041
+ }
3042
+ const envelope = envelopeParsed.envelope;
3043
+ const interchangeData = entries.get("interchange.json");
3044
+ if (!interchangeData) {
3045
+ issues.push({ path: "interchange.json", message: "required file missing from archive" });
3046
+ }
3047
+ const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
3048
+ const spaIndexPath = `${spaDistDir}/index.html`;
3049
+ if (!entries.has(spaIndexPath)) {
3050
+ issues.push({ path: spaIndexPath, message: "required file missing from archive" });
3051
+ }
3052
+ const allowlisted = new Set(envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")));
3053
+ const spaDistPrefix = `${spaDistDir}/`;
3054
+ for (const entryPath of envelope.entries) {
3055
+ if (!entries.has(entryPath)) {
3056
+ issues.push({
3057
+ path: entryPath,
3058
+ message: "listed in manifest.entries but missing from archive"
3059
+ });
3060
+ }
3061
+ }
3062
+ for (const zipPath of entries.keys()) {
3063
+ const normalized = zipPath.replace(/\\/g, "/");
3064
+ if (!normalized.startsWith(spaDistPrefix)) continue;
3065
+ if (!allowlisted.has(normalized)) {
3066
+ issues.push({
3067
+ path: zipPath,
3068
+ message: "unlisted file under spaDistDir; not in manifest.entries"
3069
+ });
3070
+ }
3071
+ }
3072
+ if (issues.length) return { ok: false, issues };
3073
+ let interchangeRaw;
3074
+ try {
3075
+ interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
3076
+ } catch {
3077
+ return {
3078
+ ok: false,
3079
+ issues: [{ path: "interchange.json", message: "invalid JSON" }]
3080
+ };
3081
+ }
3082
+ const interchangeParsed = parseLessonkitInterchange2(interchangeRaw);
3083
+ if (!interchangeParsed.ok) {
3084
+ return {
3085
+ ok: false,
3086
+ issues: interchangeParsed.issues.map((i) => ({
3087
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
3088
+ message: i.message
3089
+ }))
3090
+ };
3091
+ }
3092
+ const interchange = interchangeParsed.data;
3093
+ const interchangeCourseId = interchange.course?.id;
3094
+ if (!interchangeCourseId) {
3095
+ issues.push({
3096
+ path: "interchange.course.id",
3097
+ message: "missing course id in interchange"
3098
+ });
3099
+ } else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
3100
+ issues.push({
3101
+ path: "sourceManifest.course.courseId",
3102
+ message: `does not match interchange.course.id (${interchangeCourseId})`
3103
+ });
3104
+ }
3105
+ issues.push(
3106
+ ...validateLkcourseAssessmentConsistency(
3107
+ envelope.sourceManifest.course,
3108
+ interchange
3109
+ )
3110
+ );
3111
+ if (issues.length) return { ok: false, issues };
3112
+ const blockTreeData = entries.get("block-tree.json");
3113
+ if (blockTreeData) {
3114
+ let blockTreeRaw;
3115
+ try {
3116
+ blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
3117
+ } catch {
3118
+ return {
3119
+ ok: false,
3120
+ issues: [{ path: "block-tree.json", message: "invalid JSON" }]
3121
+ };
3122
+ }
3123
+ const blockTree = blockTreeRaw;
3124
+ if (Array.isArray(blockTree?.blocks)) {
3125
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
3126
+ if (blockTreeIssues.length) {
3127
+ return {
3128
+ ok: false,
3129
+ issues: blockTreeIssues.map((issue) => ({
3130
+ path: `block-tree.${issue.path}`,
3131
+ message: issue.message
3132
+ }))
3133
+ };
3134
+ }
3135
+ }
3136
+ }
3137
+ return {
3138
+ ok: true,
3139
+ envelope,
3140
+ interchange
3141
+ };
3142
+ }
3143
+ function validateLkcourse(archivePath) {
3144
+ const read = readZip(archivePath);
3145
+ if (!read.ok) return read;
3146
+ return validateLkcourseArchiveEntries(read.entries, archivePath);
3147
+ }
3148
+
3149
+ // src/lkcourse/import.ts
3150
+ import { access as access3, cp as cp2, mkdir as mkdir5, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
3151
+ import { dirname as dirname7, join as join12, resolve as resolve11 } from "path";
3152
+ var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
3153
+ async function pathExists2(path) {
3154
+ try {
3155
+ await access3(path);
3156
+ return true;
3157
+ } catch {
3158
+ return false;
3159
+ }
3160
+ }
3161
+ async function renameOrCopy2(from, to, opts) {
3162
+ const renameFn = opts?.renameFn ?? rename2;
3163
+ try {
3164
+ await renameFn(from, to);
3165
+ } catch (err) {
3166
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
3167
+ if (code !== "EXDEV") throw err;
3168
+ await cp2(from, to, { recursive: true });
3169
+ await rm4(from, { recursive: true, force: true });
3170
+ }
3171
+ }
3172
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir, allowlistedSpaPaths) {
3173
+ let fileCount = 0;
3174
+ await writeFile2(
3175
+ join12(stagingDir, "lessonkit.json"),
3176
+ `${JSON.stringify(manifest, null, 2)}
3177
+ `,
3178
+ "utf8"
3179
+ );
3180
+ fileCount += 1;
3181
+ for (const [entryPath, data] of entries) {
3182
+ const normalized = entryPath.replace(/\\/g, "/");
3183
+ if (!normalized.startsWith(`${spaDistDir}/`)) continue;
3184
+ if (!allowlistedSpaPaths.has(normalized)) {
3185
+ throw new Error(`unlisted spaDist entry rejected: ${entryPath}`);
3186
+ }
3187
+ const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
3188
+ const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
3189
+ const resolvedOut = resolve11(outPath);
3190
+ assertRealPathUnderRoot(stagingDir, resolvedOut);
3191
+ if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
3192
+ throw new Error(`unsafe extraction path: ${entryPath}`);
3193
+ }
3194
+ await mkdir5(dirname7(resolvedOut), { recursive: true });
3195
+ await writeFile2(resolvedOut, data);
3196
+ fileCount += 1;
3197
+ }
3198
+ return fileCount;
3199
+ }
3200
+ async function backupImportArtifacts(targetDir) {
3201
+ const existing = [];
3202
+ for (const name of IMPORT_ARTIFACTS) {
3203
+ if (await pathExists2(join12(targetDir, name))) {
3204
+ existing.push(name);
3205
+ }
3206
+ }
3207
+ if (!existing.length) return void 0;
3208
+ const backupDir = await mkdtemp3(join12(targetDir, ".lkcourse-backup-"));
3209
+ for (const name of existing) {
3210
+ await renameOrCopy2(join12(targetDir, name), join12(backupDir, name));
3211
+ }
3212
+ return backupDir;
3213
+ }
3214
+ async function restoreImportBackup(targetDir, backupDir) {
3215
+ for (const name of IMPORT_ARTIFACTS) {
3216
+ const backupPath = join12(backupDir, name);
3217
+ if (!await pathExists2(backupPath)) continue;
3218
+ const destPath = join12(targetDir, name);
3219
+ if (await pathExists2(destPath)) {
3220
+ await rm4(destPath, { recursive: true, force: true });
3221
+ }
3222
+ await renameOrCopy2(backupPath, destPath);
3223
+ }
3224
+ }
3225
+ async function snapshotPreExistingImportArtifacts(targetDir) {
3226
+ const existing = /* @__PURE__ */ new Set();
3227
+ for (const name of IMPORT_ARTIFACTS) {
3228
+ if (await pathExists2(join12(targetDir, name))) {
3229
+ existing.add(name);
3230
+ }
3231
+ }
3232
+ return existing;
3233
+ }
3234
+ async function rollbackFailedImport(targetDir, backupDir, preExisting) {
3235
+ if (backupDir) {
3236
+ await restoreImportBackup(targetDir, backupDir);
3237
+ }
3238
+ for (const name of IMPORT_ARTIFACTS) {
3239
+ if (preExisting.has(name)) continue;
3240
+ const destPath = join12(targetDir, name);
3241
+ if (await pathExists2(destPath)) {
3242
+ await rm4(destPath, { recursive: true, force: true });
3243
+ }
3244
+ }
3245
+ }
3246
+ async function promoteImportStaging(stagingDir, targetDir) {
3247
+ await mkdir5(targetDir, { recursive: true });
3248
+ const entries = await readdir3(stagingDir, { withFileTypes: true });
3249
+ for (const entry of entries) {
3250
+ const srcPath = join12(stagingDir, entry.name);
3251
+ const destPath = join12(targetDir, entry.name);
3252
+ if (entry.isDirectory() || entry.isFile()) {
3253
+ await renameOrCopy2(srcPath, destPath);
3254
+ }
3255
+ }
3256
+ }
3257
+ var promoteImportStagingImpl = promoteImportStaging;
3258
+ async function importLkcourse(options) {
3259
+ const archivePath = resolve11(options.archivePath);
3260
+ const targetDir = resolve11(options.targetDir);
3261
+ const validated = validateLkcourse(archivePath);
3262
+ if (!validated.ok) return validated;
3263
+ const { envelope, interchange } = validated;
3264
+ const manifest = envelope.sourceManifest;
3265
+ const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
3266
+ try {
3267
+ await mkdir5(targetDir, { recursive: true });
3268
+ assertRealPathUnderRoot(targetDir, targetDir);
3269
+ } catch (err) {
3270
+ return {
3271
+ ok: false,
3272
+ issues: [
3273
+ {
3274
+ path: targetDir,
3275
+ message: err instanceof Error ? err.message : String(err)
3276
+ }
3277
+ ]
3278
+ };
3279
+ }
3280
+ const read = readZip(archivePath);
3281
+ if (!read.ok) return read;
3282
+ let stagingDir;
3283
+ let backupDir;
3284
+ let preExisting;
3285
+ try {
3286
+ stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
3287
+ const allowlistedSpaPaths = new Set(
3288
+ envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")).filter((entryPath) => entryPath.startsWith(`${spaDistDir}/`))
3289
+ );
3290
+ const fileCount = await writeImportTree(
3291
+ stagingDir,
3292
+ manifest,
3293
+ read.entries,
3294
+ spaDistDir,
3295
+ allowlistedSpaPaths
3296
+ );
3297
+ preExisting = await snapshotPreExistingImportArtifacts(targetDir);
3298
+ backupDir = await backupImportArtifacts(targetDir);
3299
+ try {
3300
+ await promoteImportStagingImpl(stagingDir, targetDir);
3301
+ } catch (promoteError) {
3302
+ await rollbackFailedImport(targetDir, backupDir, preExisting);
3303
+ backupDir = void 0;
3304
+ throw promoteError;
3305
+ }
3306
+ if (backupDir) {
3307
+ await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3308
+ backupDir = void 0;
3309
+ }
3310
+ await rm4(stagingDir, { recursive: true, force: true });
3311
+ stagingDir = void 0;
3312
+ return {
3313
+ ok: true,
3314
+ targetDir,
3315
+ manifest,
3316
+ interchange,
3317
+ fileCount
3318
+ };
3319
+ } catch (err) {
3320
+ if (preExisting) {
3321
+ await rollbackFailedImport(targetDir, backupDir, preExisting).catch(() => void 0);
3322
+ } else if (backupDir) {
3323
+ await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3324
+ }
3325
+ if (backupDir) {
3326
+ await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3327
+ }
3328
+ if (stagingDir) {
3329
+ await rm4(stagingDir, { recursive: true, force: true }).catch(() => void 0);
3330
+ }
3331
+ return {
3332
+ ok: false,
3333
+ issues: [
3334
+ {
3335
+ path: targetDir,
3336
+ message: err instanceof Error ? err.message : String(err)
3337
+ }
3338
+ ]
3339
+ };
3340
+ }
3341
+ }
2068
3342
  export {
2069
3343
  LESSONKIT_TELEMETRY_EVENTS,
3344
+ assertSpaDistContentsSafe,
2070
3345
  assessmentDescriptorToLxpack,
2071
3346
  buildLessonkitProject,
2072
3347
  buildStagingPackage,
2073
3348
  descriptorToInterchange,
2074
3349
  ensureOutDirParent,
2075
3350
  escapeShellText,
3351
+ exportLkcourse,
2076
3352
  extractAssessments,
3353
+ extractBlockTree,
3354
+ importLkcourse,
2077
3355
  lessonkitInterchangeSchema,
2078
3356
  loadLessonkitManifestFromFile,
2079
3357
  mapLessonkitIds,
@@ -2081,8 +3359,9 @@ export {
2081
3359
  mapLessonkitTelemetryToLxpack,
2082
3360
  materializeLessonkitProject2 as materializeLessonkitProject,
2083
3361
  packageLessonkitCourse,
2084
- parseLessonkitInterchange,
3362
+ parseLessonkitInterchange3 as parseLessonkitInterchange,
2085
3363
  parseLessonkitManifest,
3364
+ parseLkcourseEnvelope,
2086
3365
  promoteStagingToOutDir,
2087
3366
  remapArtifactPaths,
2088
3367
  resolveSafePackageOutputOverride,
@@ -2092,6 +3371,9 @@ export {
2092
3371
  validateDescriptor,
2093
3372
  validateDescriptorForTarget,
2094
3373
  validateLessonkitProject,
3374
+ validateLkcourse,
3375
+ validateLkcourseArchiveEntries,
3376
+ validateManifestName,
2095
3377
  validatePackageInputs,
2096
3378
  validateProjectPaths,
2097
3379
  validateReactManifestParity,