@lessonkit/lxpack 1.2.0 → 1.3.1

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
@@ -134,6 +134,14 @@ function parseAssessmentDescriptor(raw) {
134
134
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
135
135
  };
136
136
  }
137
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
138
+ return {
139
+ kind,
140
+ ...base,
141
+ choices: [],
142
+ answer: ""
143
+ };
144
+ }
137
145
  return {
138
146
  kind: kind === "mcq" ? "mcq" : void 0,
139
147
  ...base,
@@ -183,10 +191,11 @@ function parseCourseDescriptorInput(input) {
183
191
 
184
192
  // src/descriptor/validateCourse.ts
185
193
  import { validateId as validateId3 } from "@lessonkit/core";
194
+ import { validateTheme } from "@lessonkit/themes";
186
195
 
187
196
  // src/spaPath.ts
188
- import { realpathSync } from "fs";
189
- import { isAbsolute, relative, resolve, sep, win32 } from "path";
197
+ import { existsSync, realpathSync } from "fs";
198
+ import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
190
199
  function resolveComparablePath(p) {
191
200
  if (/^[a-zA-Z]:[/\\]/.test(p)) {
192
201
  return win32.resolve(p);
@@ -212,6 +221,28 @@ function assertResolvedPathUnderRoot(root, target) {
212
221
  throw new Error(`unsafe path escapes project root: ${target}`);
213
222
  }
214
223
  }
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)) {
234
+ try {
235
+ current = realpathSync(next);
236
+ } catch {
237
+ current = next;
238
+ }
239
+ } else {
240
+ current = next;
241
+ }
242
+ assertResolvedPathUnderRoot(rootReal, current);
243
+ }
244
+ return current;
245
+ }
215
246
  function assertRealPathUnderRoot(root, target) {
216
247
  const rootResolved = resolveComparablePath(root);
217
248
  const targetResolved = resolveComparablePath(target);
@@ -221,17 +252,12 @@ function assertRealPathUnderRoot(root, target) {
221
252
  } catch {
222
253
  rootReal = rootResolved;
223
254
  }
224
- let targetCheck;
225
255
  try {
226
- targetCheck = realpathSync(targetResolved);
256
+ const targetCheck = realpathSync(targetResolved);
257
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
227
258
  } catch {
228
- const rel = relative(rootResolved, targetResolved);
229
- if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
230
- throw new Error(`unsafe path escapes project root: ${target}`);
231
- }
232
- targetCheck = resolve(rootReal, rel);
259
+ resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
233
260
  }
234
- assertResolvedPathUnderRoot(rootReal, targetCheck);
235
261
  }
236
262
  function normalizePathForComparison(p) {
237
263
  const resolved = resolveComparablePath(p);
@@ -272,7 +298,12 @@ function themeToLxpackRuntime(input) {
272
298
  // src/descriptor/validateAssessments.ts
273
299
  import { validateId as validateId2 } from "@lessonkit/core";
274
300
  var validateMcqLike = (assessment, path, issues) => {
275
- if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
301
+ if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
302
+ issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
303
+ return;
304
+ }
305
+ if (!("answer" in assessment) || typeof assessment.answer !== "string") {
306
+ issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
276
307
  return;
277
308
  }
278
309
  const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
@@ -285,6 +316,22 @@ var validateMcqLike = (assessment, path, issues) => {
285
316
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
286
317
  }
287
318
  };
319
+ function countStarDelimitedBlanks(template) {
320
+ const matches = template.match(/\*[^*]+\*/g);
321
+ return matches?.length ?? 0;
322
+ }
323
+ function maxAchievableAssessmentScore(assessment) {
324
+ const kind = assessment.kind ?? "mcq";
325
+ if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
326
+ const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
327
+ if (explicit > 0) return explicit;
328
+ return countStarDelimitedBlanks(assessment.template ?? "");
329
+ }
330
+ if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
331
+ return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
332
+ }
333
+ return 1;
334
+ }
288
335
  var ASSESSMENT_VALIDATORS = {
289
336
  mcq: validateMcqLike,
290
337
  trueFalse: (assessment, path, issues) => {
@@ -297,9 +344,33 @@ var ASSESSMENT_VALIDATORS = {
297
344
  issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
298
345
  }
299
346
  },
300
- findHotspot: () => {
347
+ findHotspot: (assessment, path, issues) => {
348
+ if (assessment.kind !== "findHotspot") return;
349
+ if (!assessment.src?.trim()) {
350
+ issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
351
+ }
352
+ if (!assessment.alt?.trim()) {
353
+ issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
354
+ }
355
+ if (!assessment.correctTargetId?.trim()) {
356
+ issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
357
+ }
301
358
  },
302
- findMultipleHotspots: () => {
359
+ findMultipleHotspots: (assessment, path, issues) => {
360
+ if (assessment.kind !== "findMultipleHotspots") return;
361
+ if (!assessment.src?.trim()) {
362
+ issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
363
+ }
364
+ if (!assessment.alt?.trim()) {
365
+ issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
366
+ }
367
+ const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
368
+ if (!ids.length) {
369
+ issues.push({
370
+ path: `${path}.correctTargetIds`,
371
+ message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
372
+ });
373
+ }
303
374
  }
304
375
  };
305
376
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
@@ -315,14 +386,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
315
386
  if (!assessment.question?.trim()) {
316
387
  issues.push({ path: `${path}.question`, message: "question is required" });
317
388
  }
389
+ const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
390
+ if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
391
+ issues.push({
392
+ path: `${path}.kind`,
393
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
394
+ });
395
+ return;
396
+ }
318
397
  const kind = assessment.kind ?? "mcq";
319
- ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
398
+ const validator = ASSESSMENT_VALIDATORS[kind];
399
+ if (!validator) {
400
+ issues.push({
401
+ path: `${path}.kind`,
402
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
403
+ });
404
+ return;
405
+ }
406
+ validator(assessment, path, issues);
320
407
  const passingScore = assessment.passingScore;
321
408
  if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
322
409
  issues.push({
323
410
  path: `${path}.passingScore`,
324
411
  message: "passingScore must be greater than 0 (absolute point threshold)"
325
412
  });
413
+ } else if (passingScore !== void 0) {
414
+ const maxAchievable = maxAchievableAssessmentScore(assessment);
415
+ if (maxAchievable > 0 && passingScore > maxAchievable) {
416
+ issues.push({
417
+ path: `${path}.passingScore`,
418
+ message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
419
+ });
420
+ }
326
421
  }
327
422
  }
328
423
 
@@ -356,13 +451,23 @@ function validateCourseDescriptor(input) {
356
451
  });
357
452
  }
358
453
  if (input.theme?.theme) {
359
- try {
360
- themeToLxpackRuntime({ preset: themePreset, theme: input.theme.theme });
361
- } catch (err) {
362
- issues.push({
363
- path: "theme.theme",
364
- message: err instanceof Error ? err.message : "invalid custom theme"
365
- });
454
+ const themeResult = validateTheme(input.theme.theme);
455
+ if (!themeResult.ok) {
456
+ for (const issue of themeResult.issues) {
457
+ issues.push({
458
+ path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
459
+ message: issue.message
460
+ });
461
+ }
462
+ } else {
463
+ try {
464
+ themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
465
+ } catch (err) {
466
+ issues.push({
467
+ path: "theme.theme",
468
+ message: err instanceof Error ? err.message : "invalid custom theme"
469
+ });
470
+ }
366
471
  }
367
472
  }
368
473
  const completionThreshold = input.tracking?.completion?.threshold;
@@ -433,19 +538,102 @@ function validateCourseDescriptor(input) {
433
538
  return issues;
434
539
  }
435
540
 
541
+ // src/assessments.ts
542
+ function slugChoiceId(text, index) {
543
+ const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
544
+ const stem = base.length ? base : "choice";
545
+ return `${stem}-${index + 1}`;
546
+ }
547
+ function mcqToLxpack(assessment) {
548
+ const choices = assessment.choices.map((text, index) => {
549
+ const id = slugChoiceId(text, index);
550
+ return {
551
+ id,
552
+ text,
553
+ correct: text === assessment.answer
554
+ };
555
+ });
556
+ return {
557
+ id: assessment.checkId,
558
+ passingScore: assessment.passingScore ?? 1,
559
+ questions: [
560
+ {
561
+ id: "q1",
562
+ prompt: assessment.question,
563
+ choices
564
+ }
565
+ ]
566
+ };
567
+ }
568
+ function assessmentDescriptorToLxpack(assessment) {
569
+ const kind = assessment.kind ?? "mcq";
570
+ if (kind === "trueFalse" && assessment.kind === "trueFalse") {
571
+ const choices = ["True", "False"];
572
+ const answerText = assessment.answer ? "True" : "False";
573
+ return mcqToLxpack({
574
+ kind: "mcq",
575
+ checkId: assessment.checkId,
576
+ question: assessment.question,
577
+ choices,
578
+ answer: answerText,
579
+ passingScore: assessment.passingScore
580
+ });
581
+ }
582
+ if (kind === "fillInBlanks") {
583
+ return null;
584
+ }
585
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
586
+ return mcqToLxpack({
587
+ kind: "mcq",
588
+ checkId: assessment.checkId,
589
+ question: assessment.question,
590
+ choices: [assessment.correctTargetId, "other"],
591
+ answer: assessment.correctTargetId,
592
+ passingScore: assessment.passingScore
593
+ });
594
+ }
595
+ if (kind === "findMultipleHotspots") {
596
+ return null;
597
+ }
598
+ if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
599
+ return mcqToLxpack(assessment);
600
+ }
601
+ return null;
602
+ }
603
+ function extractAssessments(descriptor) {
604
+ return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
605
+ }
606
+
436
607
  // src/descriptor/validateForTarget.ts
608
+ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
609
+ "scorm12",
610
+ "scorm2004",
611
+ "standalone",
612
+ "xapi",
613
+ "cmi5"
614
+ ]);
437
615
  function validateDescriptorForExportTarget(descriptor, target) {
438
- if (target !== "xapi" && target !== "cmi5") return [];
439
- const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
440
- if (!activityIri) {
441
- return [
442
- {
616
+ const issues = [];
617
+ if (target === "xapi" || target === "cmi5") {
618
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
619
+ if (!activityIri) {
620
+ issues.push({
443
621
  path: "course.tracking.xapi.activityIri",
444
622
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
623
+ });
624
+ }
625
+ }
626
+ if (LMS_SHELL_TARGETS.has(target)) {
627
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
628
+ if (assessmentDescriptorToLxpack(assessment) === null) {
629
+ issues.push({
630
+ path: `assessments[${index}]`,
631
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
632
+ });
445
633
  }
446
- ];
634
+ });
447
635
  }
448
- return [];
636
+ return issues;
449
637
  }
450
638
 
451
639
  // src/validateDescriptor.ts
@@ -471,6 +659,112 @@ function validateDescriptorForTarget(input, target) {
471
659
  return result;
472
660
  }
473
661
 
662
+ // src/validateReactParity.ts
663
+ import { readFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
664
+ import { join as join2, relative as relative2 } from "path";
665
+ var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
666
+ function collectSourceUnderSrc(projectRoot) {
667
+ const srcDir = join2(projectRoot, "src");
668
+ if (!existsSync2(srcDir)) return [];
669
+ const results = [];
670
+ const walk = (dir) => {
671
+ for (const entry of readdirSync(dir)) {
672
+ const abs = join2(dir, entry);
673
+ if (statSync(abs).isDirectory()) {
674
+ walk(abs);
675
+ } else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
676
+ results.push(relative2(projectRoot, abs));
677
+ }
678
+ }
679
+ };
680
+ walk(srcDir);
681
+ return results;
682
+ }
683
+ function readAppSources(projectRoot, appSources) {
684
+ return appSources.map((rel) => join2(projectRoot, rel)).filter((abs) => existsSync2(abs)).map((abs) => readFileSync(abs, "utf8")).join("\n");
685
+ }
686
+ function stripComments(source) {
687
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
688
+ }
689
+ function idPropPatterns(prop, id) {
690
+ return [
691
+ `${prop}="${id}"`,
692
+ `${prop}='${id}'`,
693
+ `${prop}={'${id}'}`,
694
+ `${prop}={"${id}"}`,
695
+ `${prop}={\`${id}\`}`
696
+ ];
697
+ }
698
+ function extractStringConstants(source) {
699
+ const stripped = stripComments(source);
700
+ const map = /* @__PURE__ */ new Map();
701
+ const re = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
702
+ for (const match of stripped.matchAll(re)) {
703
+ map.set(match[1], match[3]);
704
+ }
705
+ return map;
706
+ }
707
+ function idUsedViaConstant(stripped, prop, id, constants) {
708
+ for (const [name, value] of constants) {
709
+ if (value !== id) continue;
710
+ const jsxPatterns = [
711
+ `${prop}={${name}}`,
712
+ `${prop}={ ${name} }`,
713
+ `${prop}={${name} }`,
714
+ `${prop}={ ${name}}`
715
+ ];
716
+ if (jsxPatterns.some((p) => stripped.includes(p))) return true;
717
+ const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
718
+ if (objPatterns.some((p) => stripped.includes(p))) return true;
719
+ }
720
+ return false;
721
+ }
722
+ function courseIdPresent(source, courseId) {
723
+ const stripped = stripComments(source);
724
+ if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
725
+ return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
726
+ }
727
+ function checkIdPresent(source, checkId) {
728
+ const stripped = stripComments(source);
729
+ if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
730
+ return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
731
+ }
732
+ function validateReactManifestParity(opts) {
733
+ const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
734
+ const source = readAppSources(opts.projectRoot, appSources);
735
+ const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
736
+ if (!source.trim()) {
737
+ return [
738
+ {
739
+ path: appSources.length > 0 ? appSources.join(", ") : "src/",
740
+ message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
741
+ severity: hasDescriptorIds ? "error" : "warning"
742
+ }
743
+ ];
744
+ }
745
+ const issues = [];
746
+ const courseId = opts.descriptor.courseId;
747
+ if (!courseIdPresent(source, courseId)) {
748
+ issues.push({
749
+ path: "course.courseId",
750
+ message: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
751
+ severity: "error"
752
+ });
753
+ }
754
+ for (const assessment of opts.descriptor.assessments ?? []) {
755
+ const checkId = assessment.checkId;
756
+ if (!checkId) continue;
757
+ if (!checkIdPresent(source, checkId)) {
758
+ issues.push({
759
+ path: `assessments.checkId:${checkId}`,
760
+ message: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
761
+ severity: "error"
762
+ });
763
+ }
764
+ }
765
+ return issues;
766
+ }
767
+
474
768
  // src/validateProjectPaths.ts
475
769
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
476
770
  function validatePathField(value, fieldPath, projectRoot, issues) {
@@ -534,72 +828,6 @@ function mapLessonkitIds(descriptor) {
534
828
  return { courseId, lessonIds, checkIds };
535
829
  }
536
830
 
537
- // src/assessments.ts
538
- function slugChoiceId(text, index) {
539
- const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
540
- const stem = base.length ? base : "choice";
541
- return `${stem}-${index + 1}`;
542
- }
543
- function mcqToLxpack(assessment) {
544
- const choices = assessment.choices.map((text, index) => {
545
- const id = slugChoiceId(text, index);
546
- return {
547
- id,
548
- text,
549
- correct: text === assessment.answer
550
- };
551
- });
552
- return {
553
- id: assessment.checkId,
554
- passingScore: assessment.passingScore ?? 1,
555
- questions: [
556
- {
557
- id: "q1",
558
- prompt: assessment.question,
559
- choices
560
- }
561
- ]
562
- };
563
- }
564
- function assessmentDescriptorToLxpack(assessment) {
565
- const kind = assessment.kind ?? "mcq";
566
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
567
- const choices = ["True", "False"];
568
- const answerText = assessment.answer ? "True" : "False";
569
- return mcqToLxpack({
570
- kind: "mcq",
571
- checkId: assessment.checkId,
572
- question: assessment.question,
573
- choices,
574
- answer: answerText,
575
- passingScore: assessment.passingScore
576
- });
577
- }
578
- if (kind === "fillInBlanks") {
579
- return null;
580
- }
581
- if (kind === "findHotspot" && assessment.kind === "findHotspot") {
582
- return mcqToLxpack({
583
- kind: "mcq",
584
- checkId: assessment.checkId,
585
- question: assessment.question,
586
- choices: [assessment.correctTargetId, "other"],
587
- answer: assessment.correctTargetId,
588
- passingScore: assessment.passingScore
589
- });
590
- }
591
- if (kind === "findMultipleHotspots") {
592
- return null;
593
- }
594
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
595
- return mcqToLxpack(assessment);
596
- }
597
- return null;
598
- }
599
- function extractAssessments(descriptor) {
600
- return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
601
- }
602
-
603
831
  // src/interchange.ts
604
832
  function mapDescriptorTracking(tracking) {
605
833
  if (!tracking) return void 0;
@@ -660,12 +888,12 @@ function descriptorToInterchange(descriptor) {
660
888
  }
661
889
 
662
890
  // src/writeProject.ts
663
- import { join as join2, resolve as resolve4 } from "path";
891
+ import { join as join4, resolve as resolve4 } from "path";
664
892
  import { materializeLessonkitProject } from "@lxpack/validators";
665
893
 
666
894
  // src/spaDirs.ts
667
895
  import { access } from "fs/promises";
668
- import { join, resolve as resolve3 } from "path";
896
+ import { join as join3, resolve as resolve3 } from "path";
669
897
  async function resolveSpaDirs(options) {
670
898
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
671
899
  const spaLessons = resolveSpaLessons(descriptor);
@@ -682,9 +910,9 @@ async function resolveSpaDirs(options) {
682
910
  throw new Error(`spaDistDir not found: ${srcDist}`);
683
911
  }
684
912
  try {
685
- await access(join(srcDist, "index.html"));
913
+ await access(join3(srcDist, "index.html"));
686
914
  } catch {
687
- throw new Error(`spaDistDir must contain index.html: ${join(srcDist, "index.html")}`);
915
+ throw new Error(`spaDistDir must contain index.html: ${join3(srcDist, "index.html")}`);
688
916
  }
689
917
  const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
690
918
  "main";
@@ -707,10 +935,10 @@ async function resolveSpaDirs(options) {
707
935
  throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
708
936
  }
709
937
  try {
710
- await access(join(resolved, "index.html"));
938
+ await access(join3(resolved, "index.html"));
711
939
  } catch {
712
940
  throw new Error(
713
- `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join(resolved, "index.html")}`
941
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join3(resolved, "index.html")}`
714
942
  );
715
943
  }
716
944
  dirs[lesson.id] = resolved;
@@ -747,13 +975,13 @@ async function writeLxpackProject(options) {
747
975
  const courseDir = materialized.courseDir;
748
976
  return {
749
977
  outDir: courseDir,
750
- courseYamlPath: join2(courseDir, "course.yaml"),
751
- lessonkitJsonPath: join2(courseDir, "lessonkit.json")
978
+ courseYamlPath: join4(courseDir, "course.yaml"),
979
+ lessonkitJsonPath: join4(courseDir, "lessonkit.json")
752
980
  };
753
981
  }
754
982
 
755
983
  // src/packageCourse.ts
756
- import { resolve as resolve6 } from "path";
984
+ import { resolve as resolve7 } from "path";
757
985
  import * as fsp3 from "fs/promises";
758
986
  import {
759
987
  buildCourse,
@@ -761,48 +989,75 @@ import {
761
989
  } from "@lxpack/api";
762
990
 
763
991
  // src/packaging/validateInputs.ts
764
- import { isAbsolute as isAbsolute3, join as join3, resolve as resolve5, win32 as win322 } from "path";
992
+ import { isAbsolute as isAbsolute3, join as join5, resolve as resolve5, win32 as win322 } from "path";
765
993
  function validatePackageInputs(options) {
766
994
  const { target, output, outputBaseDir } = options;
767
995
  const outDir = resolve5(options.outDir);
768
- const projectRoot = options.projectRoot ? resolve5(options.projectRoot) : void 0;
769
- if (projectRoot) {
770
- try {
771
- assertRealPathUnderRoot(projectRoot, outDir);
772
- } catch (err) {
773
- return {
774
- ok: false,
775
- courseDir: outDir,
776
- target,
777
- issues: [
778
- {
779
- path: "outDir",
780
- message: (
781
- /* v8 ignore next */
782
- err instanceof Error ? err.message : String(err)
783
- )
784
- }
785
- ]
786
- };
787
- }
996
+ if (!options.projectRoot) {
997
+ return {
998
+ ok: false,
999
+ courseDir: outDir,
1000
+ target,
1001
+ issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
1002
+ };
788
1003
  }
789
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1004
+ const projectRoot = resolve5(options.projectRoot);
1005
+ try {
1006
+ assertRealPathUnderRoot(projectRoot, outDir);
1007
+ } catch (err) {
790
1008
  return {
791
1009
  ok: false,
792
1010
  courseDir: outDir,
793
1011
  target,
794
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
1012
+ issues: [
1013
+ {
1014
+ path: "outDir",
1015
+ message: (
1016
+ /* v8 ignore next */
1017
+ err instanceof Error ? err.message : String(err)
1018
+ )
1019
+ }
1020
+ ]
795
1021
  };
796
1022
  }
797
- if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
1023
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
798
1024
  return {
799
1025
  ok: false,
800
1026
  courseDir: outDir,
801
1027
  target,
802
- issues: [{ path: "output", message: `unsafe output: ${output}` }]
1028
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
803
1029
  };
804
1030
  }
805
- if (projectRoot && outputBaseDir) {
1031
+ if (output && !isSafeRelativeSpaPath(output)) {
1032
+ if (isAbsolute3(output)) {
1033
+ try {
1034
+ assertRealPathUnderRoot(projectRoot, resolve5(output));
1035
+ } catch (err) {
1036
+ return {
1037
+ ok: false,
1038
+ courseDir: outDir,
1039
+ target,
1040
+ issues: [
1041
+ {
1042
+ path: "output",
1043
+ message: (
1044
+ /* v8 ignore next */
1045
+ err instanceof Error ? err.message : `unsafe output: ${output}`
1046
+ )
1047
+ }
1048
+ ]
1049
+ };
1050
+ }
1051
+ } else {
1052
+ return {
1053
+ ok: false,
1054
+ courseDir: outDir,
1055
+ target,
1056
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
1057
+ };
1058
+ }
1059
+ }
1060
+ if (outputBaseDir) {
806
1061
  const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
807
1062
  try {
808
1063
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
@@ -823,8 +1078,8 @@ function validatePackageInputs(options) {
823
1078
  };
824
1079
  }
825
1080
  }
826
- if (projectRoot && output) {
827
- const resolvedOutput = resolve5(projectRoot, output);
1081
+ if (output) {
1082
+ const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
828
1083
  try {
829
1084
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
830
1085
  } catch (err) {
@@ -861,23 +1116,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
861
1116
  if (!artifactPath) return void 0;
862
1117
  const resolved = resolveComparablePath(artifactPath);
863
1118
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
864
- return artifactPath;
1119
+ throw new Error(`${artifactPath} is outside the staging directory`);
865
1120
  }
866
1121
  const rel = relativePathUnderRoot(stagingRoot, resolved);
867
1122
  if (rel.startsWith("..") || isAbsolute3(rel)) {
868
- return artifactPath;
1123
+ throw new Error(`${artifactPath} is outside the staging directory`);
869
1124
  }
870
1125
  if (!rel) return outDir;
871
1126
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
872
1127
  return win322.join(outDir, rel.replace(/\//g, win322.sep));
873
1128
  }
874
- return join3(outDir, rel);
1129
+ return join5(outDir, rel);
875
1130
  }
876
1131
 
877
1132
  // src/packaging/promote.ts
878
1133
  import * as fsp from "fs/promises";
879
- import { randomUUID } from "crypto";
880
- import { dirname, join as join4 } from "path";
1134
+ import { createHash, randomUUID } from "crypto";
1135
+ import { dirname, join as join6, resolve as resolve6 } from "path";
881
1136
  async function pathExists(path) {
882
1137
  try {
883
1138
  await fsp.access(path);
@@ -896,6 +1151,69 @@ async function renameOrCopy(from, to) {
896
1151
  await fsp.rm(from, { recursive: true, force: true });
897
1152
  }
898
1153
  }
1154
+ function promoteLockPath(outDir) {
1155
+ const parent = dirname(outDir);
1156
+ const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1157
+ return join6(parent, `.lk-promote-lock-${hash}`);
1158
+ }
1159
+ var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1160
+ async function isStalePromoteLock(lockPath) {
1161
+ try {
1162
+ const content = await fsp.readFile(lockPath, "utf8");
1163
+ const pid = Number.parseInt(content.trim(), 10);
1164
+ if (Number.isFinite(pid) && pid > 0) {
1165
+ try {
1166
+ process.kill(pid, 0);
1167
+ return false;
1168
+ } catch {
1169
+ return true;
1170
+ }
1171
+ }
1172
+ const stat2 = await fsp.stat(lockPath);
1173
+ return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
1174
+ } catch {
1175
+ return true;
1176
+ }
1177
+ }
1178
+ async function withPromoteLock(outDir, fn) {
1179
+ const lockPath = promoteLockPath(outDir);
1180
+ await fsp.mkdir(dirname(outDir), { recursive: true });
1181
+ let lockHandle;
1182
+ for (let attempt = 0; attempt < 200; attempt++) {
1183
+ try {
1184
+ lockHandle = await fsp.open(lockPath, "wx");
1185
+ await lockHandle.writeFile(`${process.pid}
1186
+ `, "utf8");
1187
+ break;
1188
+ } catch (err) {
1189
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
1190
+ if (code !== "EEXIST") throw err;
1191
+ if (await isStalePromoteLock(lockPath)) {
1192
+ await fsp.rm(lockPath, { force: true }).catch(
1193
+ /* v8 ignore next */
1194
+ () => void 0
1195
+ );
1196
+ continue;
1197
+ }
1198
+ await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1199
+ }
1200
+ }
1201
+ if (!lockHandle) {
1202
+ throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
1203
+ }
1204
+ try {
1205
+ return await fn();
1206
+ } finally {
1207
+ await lockHandle.close().catch(
1208
+ /* v8 ignore next */
1209
+ () => void 0
1210
+ );
1211
+ await fsp.rm(lockPath, { force: true }).catch(
1212
+ /* v8 ignore next */
1213
+ () => void 0
1214
+ );
1215
+ }
1216
+ }
899
1217
  async function assertNoLegacyPromoteArtifacts(outDir) {
900
1218
  const legacyTmp = `${outDir}.tmp-promote`;
901
1219
  const legacyBak = `${outDir}.bak`;
@@ -909,45 +1227,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
909
1227
  }
910
1228
  }
911
1229
  async function promoteStagingToOutDir(stagingDir, outDir) {
912
- await assertNoLegacyPromoteArtifacts(outDir);
913
- const parent = dirname(outDir);
914
- const tmpPromote = await fsp.mkdtemp(join4(parent, ".lk-promote-"));
915
- await renameOrCopy(stagingDir, tmpPromote);
916
- const hadOutDir = await pathExists(outDir);
917
- const backup = hadOutDir ? await fsp.mkdtemp(join4(parent, ".lk-backup-")) : void 0;
918
- if (hadOutDir && backup) {
919
- await renameOrCopy(outDir, backup);
920
- }
921
- try {
922
- await renameOrCopy(tmpPromote, outDir);
923
- } catch (promoteError) {
1230
+ return withPromoteLock(outDir, async () => {
1231
+ await assertNoLegacyPromoteArtifacts(outDir);
1232
+ const parent = dirname(outDir);
1233
+ const tmpPromote = await fsp.mkdtemp(join6(parent, ".lk-promote-"));
1234
+ await renameOrCopy(stagingDir, tmpPromote);
1235
+ const hadOutDir = await pathExists(outDir);
1236
+ const backup = hadOutDir ? await fsp.mkdtemp(join6(parent, ".lk-backup-")) : void 0;
924
1237
  if (hadOutDir && backup) {
925
- try {
926
- await renameOrCopy(backup, outDir);
927
- } catch (restoreError) {
928
- const failedPromote2 = join4(parent, `.lk-failed-promote-${randomUUID()}`);
1238
+ await renameOrCopy(outDir, backup);
1239
+ }
1240
+ try {
1241
+ await renameOrCopy(tmpPromote, outDir);
1242
+ } catch (promoteError) {
1243
+ if (hadOutDir && backup) {
1244
+ try {
1245
+ await renameOrCopy(backup, outDir);
1246
+ } catch (restoreError) {
1247
+ const failedPromote2 = join6(parent, `.lk-failed-promote-${randomUUID()}`);
1248
+ try {
1249
+ await renameOrCopy(tmpPromote, failedPromote2);
1250
+ } catch {
1251
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1252
+ /* v8 ignore next */
1253
+ () => void 0
1254
+ );
1255
+ }
1256
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1257
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1258
+ throw new Error(
1259
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1260
+ );
1261
+ }
1262
+ } else {
929
1263
  try {
930
- await renameOrCopy(tmpPromote, failedPromote2);
931
- } catch {
1264
+ await renameOrCopy(tmpPromote, stagingDir);
1265
+ } catch (restoreError) {
1266
+ console.warn(
1267
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1268
+ restoreError instanceof Error ? restoreError.message : restoreError
1269
+ );
932
1270
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
933
1271
  /* v8 ignore next */
934
1272
  () => void 0
935
1273
  );
936
1274
  }
937
- const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
938
- const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
939
- throw new Error(
940
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
941
- );
1275
+ throw promoteError;
942
1276
  }
943
- } else {
1277
+ const failedPromote = join6(parent, `.lk-failed-promote-${randomUUID()}`);
944
1278
  try {
945
- await renameOrCopy(tmpPromote, stagingDir);
946
- } catch (restoreError) {
947
- console.warn(
948
- `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
949
- restoreError instanceof Error ? restoreError.message : restoreError
950
- );
1279
+ await renameOrCopy(tmpPromote, failedPromote);
1280
+ } catch {
951
1281
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
952
1282
  /* v8 ignore next */
953
1283
  () => void 0
@@ -955,33 +1285,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
955
1285
  }
956
1286
  throw promoteError;
957
1287
  }
958
- const failedPromote = join4(parent, `.lk-failed-promote-${randomUUID()}`);
959
- try {
960
- await renameOrCopy(tmpPromote, failedPromote);
961
- } catch {
962
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1288
+ if (backup) {
1289
+ await fsp.rm(backup, { recursive: true, force: true }).catch(
963
1290
  /* v8 ignore next */
964
1291
  () => void 0
965
1292
  );
966
1293
  }
967
- throw promoteError;
968
- }
969
- if (backup) {
970
- await fsp.rm(backup, { recursive: true, force: true }).catch(
971
- /* v8 ignore next */
972
- () => void 0
973
- );
974
- }
1294
+ });
975
1295
  }
976
1296
 
977
1297
  // src/packaging/staging.ts
978
1298
  import * as fsp2 from "fs/promises";
979
- import { dirname as dirname2, join as join5 } from "path";
1299
+ import { dirname as dirname2, join as join7 } from "path";
980
1300
  import { tmpdir } from "os";
981
1301
  import { packageLessonkit } from "@lxpack/api";
982
1302
  async function buildStagingPackage(options) {
983
1303
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
984
- const stagingDir = await fsp2.mkdtemp(join5(tmpdir(), "lessonkit-lxpack-"));
1304
+ const stagingDir = await fsp2.mkdtemp(join7(tmpdir(), "lessonkit-lxpack-"));
985
1305
  try {
986
1306
  let spaDirs;
987
1307
  try {
@@ -1000,8 +1320,8 @@ async function buildStagingPackage(options) {
1000
1320
  }
1001
1321
  const interchange = descriptorToInterchange(descriptor);
1002
1322
  const outputBase = outputBaseDir ?? ".lxpack/out";
1003
- await fsp2.mkdir(join5(stagingDir, outputBase), { recursive: true });
1004
- const defaultOutput = output ?? (dir ? join5(outputBase, target) : join5(outputBase, `course-${target}.zip`));
1323
+ await fsp2.mkdir(join7(stagingDir, outputBase), { recursive: true });
1324
+ const defaultOutput = output ?? (dir ? join7(outputBase, target) : join7(outputBase, `course-${target}.zip`));
1005
1325
  const build = await packageLessonkit({
1006
1326
  interchange,
1007
1327
  spaDirs,
@@ -1044,16 +1364,25 @@ async function ensureOutDirParent(outDir) {
1044
1364
  await fsp2.mkdir(dirname2(outDir), { recursive: true });
1045
1365
  }
1046
1366
 
1367
+ // src/packaging/issueSeverity.ts
1368
+ function isPackagingErrorIssue(issue) {
1369
+ const severity = issue.severity?.toLowerCase();
1370
+ return severity === "error" || severity === "fatal";
1371
+ }
1372
+ function findPackagingErrorIssues(issues) {
1373
+ return (issues ?? []).filter(isPackagingErrorIssue);
1374
+ }
1375
+
1047
1376
  // src/packageCourse.ts
1048
1377
  async function validateLessonkitProject(options) {
1049
1378
  return validateCourse({
1050
- courseDir: resolve6(options.courseDir),
1379
+ courseDir: resolve7(options.courseDir),
1051
1380
  target: options.target
1052
1381
  });
1053
1382
  }
1054
1383
  async function buildLessonkitProject(options) {
1055
1384
  const buildOptions = {
1056
- courseDir: resolve6(options.courseDir),
1385
+ courseDir: resolve7(options.courseDir),
1057
1386
  target: options.target,
1058
1387
  output: options.output,
1059
1388
  dir: options.dir,
@@ -1084,7 +1413,7 @@ async function packageLessonkitCourse(options) {
1084
1413
  if (!descriptorValidation.ok) {
1085
1414
  return {
1086
1415
  ok: false,
1087
- courseDir: resolve6(writeOpts.outDir),
1416
+ courseDir: resolve7(writeOpts.outDir),
1088
1417
  target,
1089
1418
  issues: descriptorValidation.issues.map((i) => ({
1090
1419
  path: i.path,
@@ -1093,6 +1422,37 @@ async function packageLessonkitCourse(options) {
1093
1422
  };
1094
1423
  }
1095
1424
  const descriptor = descriptorValidation.descriptor;
1425
+ if (writeOpts.projectRoot) {
1426
+ const parityIssues = validateReactManifestParity({
1427
+ projectRoot: writeOpts.projectRoot,
1428
+ descriptor
1429
+ });
1430
+ const parityErrors = parityIssues.filter((i) => i.severity === "error");
1431
+ if (parityErrors.length > 0) {
1432
+ return {
1433
+ ok: false,
1434
+ courseDir: outDir,
1435
+ target,
1436
+ issues: parityErrors.map((i) => ({
1437
+ path: i.path,
1438
+ message: i.message,
1439
+ severity: i.severity
1440
+ }))
1441
+ };
1442
+ }
1443
+ }
1444
+ const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1445
+ if (nonInjectableAssessments.length > 0) {
1446
+ return {
1447
+ ok: false,
1448
+ courseDir: outDir,
1449
+ target,
1450
+ issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1451
+ path: `assessments[${index}]`,
1452
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1453
+ }))
1454
+ };
1455
+ }
1096
1456
  const staged = await buildStagingPackage({
1097
1457
  ...writeOpts,
1098
1458
  descriptor,
@@ -1117,6 +1477,25 @@ async function packageLessonkitCourse(options) {
1117
1477
  };
1118
1478
  }
1119
1479
  const { stagingDir, build } = staged;
1480
+ const buildErrorIssues = findPackagingErrorIssues(build.issues);
1481
+ if (buildErrorIssues.length > 0) {
1482
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1483
+ /* v8 ignore next */
1484
+ () => void 0
1485
+ );
1486
+ return {
1487
+ ok: false,
1488
+ courseDir: outDir,
1489
+ target,
1490
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1491
+ build,
1492
+ issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
1493
+ path: i.path ?? "build",
1494
+ message: i.message,
1495
+ severity: i.severity
1496
+ }))
1497
+ };
1498
+ }
1120
1499
  const stagingRoot = await fsp3.realpath(stagingDir);
1121
1500
  const artifactIssues = [
1122
1501
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
@@ -1147,6 +1526,10 @@ async function packageLessonkitCourse(options) {
1147
1526
  await ensureOutDirParent(outDir);
1148
1527
  await promoteStagingToOutDir(stagingDir, outDir);
1149
1528
  } catch (err) {
1529
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1530
+ /* v8 ignore next */
1531
+ () => void 0
1532
+ );
1150
1533
  return {
1151
1534
  ok: false,
1152
1535
  courseDir: outDir,
@@ -1329,5 +1712,6 @@ export {
1329
1712
  validateLessonkitProject,
1330
1713
  validatePackageInputs,
1331
1714
  validateProjectPaths,
1715
+ validateReactManifestParity,
1332
1716
  writeLxpackProject
1333
1717
  };