@lessonkit/lxpack 1.3.0 → 1.4.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.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { CheckId, CourseId, LessonId } from '@lessonkit/core';
1
+ import { McqAssessmentProps, CheckId, CourseId, LessonId } from '@lessonkit/core';
2
+ export { LmsBridgeMode, McqAssessmentProps } from '@lessonkit/core';
2
3
  import { ThemePresetName, LessonkitThemeV1 } from '@lessonkit/themes';
3
4
  import { ExportTarget, BuildCourseResult, ValidateCourseResult } from '@lxpack/api';
4
5
  export { ExportTarget } from '@lxpack/api';
@@ -14,14 +15,8 @@ type LessonDescriptor = {
14
15
  /** Built SPA folder relative to the LXPack project root (`per-lesson-spa` only). */
15
16
  spaPath?: string;
16
17
  };
17
- type McqAssessmentDescriptor = {
18
- kind?: "mcq";
19
- checkId: CheckId;
20
- question: string;
21
- choices: string[];
22
- answer: string;
23
- passingScore?: number;
24
- };
18
+ /** @deprecated Use `McqAssessmentProps` from `@lessonkit/core`. */
19
+ type McqAssessmentDescriptor = McqAssessmentProps;
25
20
  type TrueFalseAssessmentDescriptor = {
26
21
  kind: "trueFalse";
27
22
  checkId: CheckId;
@@ -110,6 +105,23 @@ type DescriptorValidationResult = {
110
105
  declare function validateDescriptor(input: unknown): DescriptorValidationResult;
111
106
  declare function validateDescriptorForTarget(input: unknown, target?: ExportTarget): DescriptorValidationResult;
112
107
 
108
+ type ReactParityIssue = {
109
+ path: string;
110
+ message: string;
111
+ severity: "error" | "warning";
112
+ };
113
+ type ValidateReactManifestParityOptions = {
114
+ projectRoot: string;
115
+ descriptor: LessonkitCourseDescriptor;
116
+ /** Relative source files to scan (default: all `.tsx` under `src/`). */
117
+ appSources?: string[];
118
+ };
119
+ /**
120
+ * Validates that React app source references the same courseId and assessment checkIds
121
+ * as the lessonkit.json descriptor (prevents LMS/runtime ID drift at package time).
122
+ */
123
+ declare function validateReactManifestParity(opts: ValidateReactManifestParityOptions): ReactParityIssue[];
124
+
113
125
  type ProjectPathsInput = {
114
126
  spaDistDir?: string;
115
127
  lxpackOutDir?: string;
@@ -218,6 +230,8 @@ type BuildStagingPackageResult = {
218
230
  declare function buildStagingPackage(options: BuildStagingPackageOptions): Promise<BuildStagingPackageResult>;
219
231
  declare function ensureOutDirParent(outDir: string): Promise<void>;
220
232
 
233
+ /** LessonKit-owned alias for LMS export targets (maps to `@lxpack/api` `ExportTarget`). */
234
+ type LessonkitExportTarget = ExportTarget;
221
235
  type ValidateLessonkitProjectOptions = {
222
236
  courseDir: string;
223
237
  target?: ExportTarget;
@@ -308,4 +322,4 @@ type ParseManifestResult = {
308
322
  declare function parseLessonkitManifest(raw: unknown, label?: string, projectRoot?: string): ParseManifestResult;
309
323
  declare function loadLessonkitManifestFromFile(readJson: () => Promise<unknown>, label?: string, projectRoot?: string): Promise<ParseManifestResult>;
310
324
 
311
- export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, writeLxpackProject };
325
+ export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitExportTarget, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type ReactParityIssue, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, validateReactManifestParity, writeLxpackProject };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { CheckId, CourseId, LessonId } from '@lessonkit/core';
1
+ import { McqAssessmentProps, CheckId, CourseId, LessonId } from '@lessonkit/core';
2
+ export { LmsBridgeMode, McqAssessmentProps } from '@lessonkit/core';
2
3
  import { ThemePresetName, LessonkitThemeV1 } from '@lessonkit/themes';
3
4
  import { ExportTarget, BuildCourseResult, ValidateCourseResult } from '@lxpack/api';
4
5
  export { ExportTarget } from '@lxpack/api';
@@ -14,14 +15,8 @@ type LessonDescriptor = {
14
15
  /** Built SPA folder relative to the LXPack project root (`per-lesson-spa` only). */
15
16
  spaPath?: string;
16
17
  };
17
- type McqAssessmentDescriptor = {
18
- kind?: "mcq";
19
- checkId: CheckId;
20
- question: string;
21
- choices: string[];
22
- answer: string;
23
- passingScore?: number;
24
- };
18
+ /** @deprecated Use `McqAssessmentProps` from `@lessonkit/core`. */
19
+ type McqAssessmentDescriptor = McqAssessmentProps;
25
20
  type TrueFalseAssessmentDescriptor = {
26
21
  kind: "trueFalse";
27
22
  checkId: CheckId;
@@ -110,6 +105,23 @@ type DescriptorValidationResult = {
110
105
  declare function validateDescriptor(input: unknown): DescriptorValidationResult;
111
106
  declare function validateDescriptorForTarget(input: unknown, target?: ExportTarget): DescriptorValidationResult;
112
107
 
108
+ type ReactParityIssue = {
109
+ path: string;
110
+ message: string;
111
+ severity: "error" | "warning";
112
+ };
113
+ type ValidateReactManifestParityOptions = {
114
+ projectRoot: string;
115
+ descriptor: LessonkitCourseDescriptor;
116
+ /** Relative source files to scan (default: all `.tsx` under `src/`). */
117
+ appSources?: string[];
118
+ };
119
+ /**
120
+ * Validates that React app source references the same courseId and assessment checkIds
121
+ * as the lessonkit.json descriptor (prevents LMS/runtime ID drift at package time).
122
+ */
123
+ declare function validateReactManifestParity(opts: ValidateReactManifestParityOptions): ReactParityIssue[];
124
+
113
125
  type ProjectPathsInput = {
114
126
  spaDistDir?: string;
115
127
  lxpackOutDir?: string;
@@ -218,6 +230,8 @@ type BuildStagingPackageResult = {
218
230
  declare function buildStagingPackage(options: BuildStagingPackageOptions): Promise<BuildStagingPackageResult>;
219
231
  declare function ensureOutDirParent(outDir: string): Promise<void>;
220
232
 
233
+ /** LessonKit-owned alias for LMS export targets (maps to `@lxpack/api` `ExportTarget`). */
234
+ type LessonkitExportTarget = ExportTarget;
221
235
  type ValidateLessonkitProjectOptions = {
222
236
  courseDir: string;
223
237
  target?: ExportTarget;
@@ -308,4 +322,4 @@ type ParseManifestResult = {
308
322
  declare function parseLessonkitManifest(raw: unknown, label?: string, projectRoot?: string): ParseManifestResult;
309
323
  declare function loadLessonkitManifestFromFile(readJson: () => Promise<unknown>, label?: string, projectRoot?: string): Promise<ParseManifestResult>;
310
324
 
311
- export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, writeLxpackProject };
325
+ export { type AssessmentDescriptor, type BuildLessonkitProjectOptions, type BuildStagingPackageOptions, type BuildStagingPackageResult, type DescriptorValidationIssue, type DescriptorValidationResult, type FillInBlanksAssessmentDescriptor, type LessonDescriptor, type LessonkitCourseDescriptor, type LessonkitExportTarget, type LessonkitManifest, type LessonkitManifestPaths, type LxpackInjectedAssessment, type LxpackRuntimeTheme, type ManifestParseIssue, type MappedLessonkitIds, type McqAssessmentDescriptor, type PackageLessonkitCourseOptions, type PackageLessonkitCourseResult, type PackageValidationIssue, type ParseManifestResult, type ProjectPathsInput, type ReactParityIssue, type SpaLayout, type SpaLessonEntry, type TrueFalseAssessmentDescriptor, type ValidateLessonkitProjectOptions, type ValidatePackageInputsResult, type ValidationIssue, type WriteLxpackProjectOptions, type WriteLxpackProjectResult, assessmentDescriptorToLxpack, buildLessonkitProject, buildStagingPackage, descriptorToInterchange, ensureOutDirParent, extractAssessments, loadLessonkitManifestFromFile, mapLessonkitIds, packageLessonkitCourse, parseLessonkitManifest, promoteStagingToOutDir, remapArtifactPaths, resolveSafePackageOutputOverride, resolveSpaLessons, themeToLxpackRuntime, validateDescriptor, validateDescriptorForTarget, validateLessonkitProject, validatePackageInputs, validateProjectPaths, validateReactManifestParity, writeLxpackProject };
package/dist/index.js CHANGED
@@ -315,6 +315,10 @@ var validateMcqLike = (assessment, path, issues) => {
315
315
  } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
316
316
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
317
317
  }
318
+ const uniqueChoices = new Set(trimmedChoices);
319
+ if (trimmedChoices.length !== uniqueChoices.size) {
320
+ issues.push({ path: `${path}.choices`, message: "choices must be unique" });
321
+ }
318
322
  };
319
323
  function countStarDelimitedBlanks(template) {
320
324
  const matches = template.match(/\*[^*]+\*/g);
@@ -582,15 +586,8 @@ function assessmentDescriptorToLxpack(assessment) {
582
586
  if (kind === "fillInBlanks") {
583
587
  return null;
584
588
  }
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
- });
589
+ if (kind === "findHotspot") {
590
+ return null;
594
591
  }
595
592
  if (kind === "findMultipleHotspots") {
596
593
  return null;
@@ -604,6 +601,20 @@ function extractAssessments(descriptor) {
604
601
  return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
605
602
  }
606
603
 
604
+ // src/descriptor/validateInjectableAssessments.ts
605
+ function validateInjectableAssessments(descriptor) {
606
+ const issues = [];
607
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
608
+ if (assessmentDescriptorToLxpack(assessment) === null) {
609
+ issues.push({
610
+ path: `assessments[${index}]`,
611
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
612
+ });
613
+ }
614
+ });
615
+ return issues;
616
+ }
617
+
607
618
  // src/descriptor/validateForTarget.ts
608
619
  var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
609
620
  "scorm12",
@@ -618,20 +629,21 @@ function validateDescriptorForExportTarget(descriptor, target) {
618
629
  const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
619
630
  if (!activityIri) {
620
631
  issues.push({
621
- path: "course.tracking.xapi.activityIri",
632
+ path: "tracking.xapi.activityIri",
622
633
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
623
634
  });
635
+ } else if (!/^https:\/\/.+/i.test(activityIri)) {
636
+ issues.push({
637
+ path: "tracking.xapi.activityIri",
638
+ message: "tracking.xapi.activityIri must be an HTTPS URL for xapi and cmi5 export targets"
639
+ });
624
640
  }
625
641
  }
626
642
  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
- });
633
- }
634
- });
643
+ issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
644
+ ...issue,
645
+ message: `${issue.message} for target "${target}"`
646
+ })));
635
647
  }
636
648
  return issues;
637
649
  }
@@ -659,6 +671,120 @@ function validateDescriptorForTarget(input, target) {
659
671
  return result;
660
672
  }
661
673
 
674
+ // src/validateReactParity.ts
675
+ import { readFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
676
+ import { join as join2, relative as relative2 } from "path";
677
+ var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
678
+ function collectSourceUnderSrc(projectRoot) {
679
+ const srcDir = join2(projectRoot, "src");
680
+ if (!existsSync2(srcDir)) return [];
681
+ const results = [];
682
+ const walk = (dir) => {
683
+ for (const entry of readdirSync(dir)) {
684
+ const abs = join2(dir, entry);
685
+ if (statSync(abs).isDirectory()) {
686
+ walk(abs);
687
+ } else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
688
+ results.push(relative2(projectRoot, abs));
689
+ }
690
+ }
691
+ };
692
+ walk(srcDir);
693
+ return results;
694
+ }
695
+ function readAppSources(projectRoot, appSources) {
696
+ return appSources.map((rel) => join2(projectRoot, rel)).filter((abs) => existsSync2(abs)).map((abs) => readFileSync(abs, "utf8")).join("\n");
697
+ }
698
+ function stripComments(source) {
699
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
700
+ }
701
+ function idPropPatterns(prop, id) {
702
+ return [
703
+ `${prop}="${id}"`,
704
+ `${prop}='${id}'`,
705
+ `${prop}={'${id}'}`,
706
+ `${prop}={"${id}"}`,
707
+ `${prop}={\`${id}\`}`
708
+ ];
709
+ }
710
+ function extractStringConstants(source) {
711
+ const stripped = stripComments(source);
712
+ const map = /* @__PURE__ */ new Map();
713
+ const re = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
714
+ for (const match of stripped.matchAll(re)) {
715
+ map.set(match[1], match[3]);
716
+ }
717
+ return map;
718
+ }
719
+ function idUsedViaConstant(stripped, prop, id, constants) {
720
+ for (const [name, value] of constants) {
721
+ if (value !== id) continue;
722
+ const jsxPatterns = [
723
+ `${prop}={${name}}`,
724
+ `${prop}={ ${name} }`,
725
+ `${prop}={${name} }`,
726
+ `${prop}={ ${name}}`
727
+ ];
728
+ if (jsxPatterns.some((p) => stripped.includes(p))) return true;
729
+ const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
730
+ if (objPatterns.some((p) => stripped.includes(p))) return true;
731
+ }
732
+ return false;
733
+ }
734
+ function courseIdPresent(source, courseId) {
735
+ const stripped = stripComments(source);
736
+ if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
737
+ return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
738
+ }
739
+ function checkIdPresent(source, checkId) {
740
+ const stripped = stripComments(source);
741
+ if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
742
+ return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
743
+ }
744
+ var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
745
+ function parityHint(message) {
746
+ return `${message} See ${ID_SYNC_DOC}`;
747
+ }
748
+ function validateReactManifestParity(opts) {
749
+ const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
750
+ const source = readAppSources(opts.projectRoot, appSources);
751
+ const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
752
+ if (!source.trim()) {
753
+ return [
754
+ {
755
+ path: appSources.length > 0 ? appSources.join(", ") : "src/",
756
+ message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
757
+ severity: hasDescriptorIds ? "error" : "warning"
758
+ }
759
+ ];
760
+ }
761
+ const issues = [];
762
+ const courseId = opts.descriptor.courseId;
763
+ if (!courseIdPresent(source, courseId)) {
764
+ issues.push({
765
+ path: "course.courseId",
766
+ message: parityHint(
767
+ `React app source does not reference courseId="${courseId}" from lessonkit.json.`
768
+ ),
769
+ severity: "error"
770
+ });
771
+ }
772
+ for (const assessment of opts.descriptor.assessments ?? []) {
773
+ const checkId = assessment.checkId;
774
+ if (!checkId) continue;
775
+ if (!checkIdPresent(source, checkId)) {
776
+ issues.push({
777
+ path: `assessments.checkId:${checkId}`,
778
+ message: parityHint(
779
+ `React app source missing checkId="${checkId}" declared in lessonkit.json.`
780
+ ),
781
+ severity: "error"
782
+ });
783
+ }
784
+ }
785
+ return issues;
786
+ }
787
+
662
788
  // src/validateProjectPaths.ts
663
789
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
664
790
  function validatePathField(value, fieldPath, projectRoot, issues) {
@@ -782,12 +908,12 @@ function descriptorToInterchange(descriptor) {
782
908
  }
783
909
 
784
910
  // src/writeProject.ts
785
- import { join as join3, resolve as resolve4 } from "path";
911
+ import { join as join5, resolve as resolve4 } from "path";
786
912
  import { materializeLessonkitProject } from "@lxpack/validators";
787
913
 
788
914
  // src/spaDirs.ts
789
915
  import { access } from "fs/promises";
790
- import { join as join2, resolve as resolve3 } from "path";
916
+ import { join as join3, resolve as resolve3 } from "path";
791
917
  async function resolveSpaDirs(options) {
792
918
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
793
919
  const spaLessons = resolveSpaLessons(descriptor);
@@ -804,9 +930,9 @@ async function resolveSpaDirs(options) {
804
930
  throw new Error(`spaDistDir not found: ${srcDist}`);
805
931
  }
806
932
  try {
807
- await access(join2(srcDist, "index.html"));
933
+ await access(join3(srcDist, "index.html"));
808
934
  } catch {
809
- throw new Error(`spaDistDir must contain index.html: ${join2(srcDist, "index.html")}`);
935
+ throw new Error(`spaDistDir must contain index.html: ${join3(srcDist, "index.html")}`);
810
936
  }
811
937
  const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
812
938
  "main";
@@ -829,10 +955,10 @@ async function resolveSpaDirs(options) {
829
955
  throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
830
956
  }
831
957
  try {
832
- await access(join2(resolved, "index.html"));
958
+ await access(join3(resolved, "index.html"));
833
959
  } catch {
834
960
  throw new Error(
835
- `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join2(resolved, "index.html")}`
961
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join3(resolved, "index.html")}`
836
962
  );
837
963
  }
838
964
  dirs[lesson.id] = resolved;
@@ -840,6 +966,59 @@ async function resolveSpaDirs(options) {
840
966
  return dirs;
841
967
  }
842
968
 
969
+ // src/spaDistValidation.ts
970
+ import { lstat, readdir } from "fs/promises";
971
+ import { realpathSync as realpathSync2 } from "fs";
972
+ import { join as join4 } from "path";
973
+ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
974
+ for (const [label, dir] of Object.entries(spaDirs)) {
975
+ const dirResolved = resolveComparablePath(dir);
976
+ const dirStat = await lstat(dirResolved);
977
+ if (dirStat.isSymbolicLink()) {
978
+ throw new Error(`spa dist for "${label}" cannot be a symlink: ${dir}`);
979
+ }
980
+ let rootReal;
981
+ try {
982
+ rootReal = realpathSync2(dirResolved);
983
+ } catch {
984
+ throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
985
+ }
986
+ if (projectRoot) {
987
+ assertRealPathUnderRoot(projectRoot, dir);
988
+ }
989
+ assertResolvedPathUnderRoot(rootReal, rootReal);
990
+ await walkDistDir(rootReal, rootReal, label);
991
+ }
992
+ }
993
+ async function walkDistDir(rootReal, current, label) {
994
+ let entries;
995
+ try {
996
+ entries = await readdir(current, { withFileTypes: true });
997
+ } catch (err) {
998
+ throw new Error(
999
+ `spa dist for "${label}" is not readable: ${err instanceof Error ? err.message : String(err)}`,
1000
+ { cause: err }
1001
+ );
1002
+ }
1003
+ for (const entry of entries) {
1004
+ const entryPath = join4(current, entry.name);
1005
+ const stat2 = await lstat(entryPath);
1006
+ if (stat2.isSymbolicLink()) {
1007
+ throw new Error(`spa dist for "${label}" contains symlink: ${entryPath}`);
1008
+ }
1009
+ let entryReal;
1010
+ try {
1011
+ entryReal = realpathSync2(entryPath);
1012
+ } catch {
1013
+ entryReal = entryPath;
1014
+ }
1015
+ assertResolvedPathUnderRoot(rootReal, entryReal);
1016
+ if (stat2.isDirectory()) {
1017
+ await walkDistDir(rootReal, entryPath, label);
1018
+ }
1019
+ }
1020
+ }
1021
+
843
1022
  // src/writeProject.ts
844
1023
  async function writeLxpackProject(options) {
845
1024
  const validation = validateDescriptor(options.descriptor);
@@ -849,11 +1028,16 @@ async function writeLxpackProject(options) {
849
1028
  );
850
1029
  }
851
1030
  const descriptor = validation.descriptor;
1031
+ const injectableIssues = validateInjectableAssessments(descriptor);
1032
+ if (injectableIssues.length > 0) {
1033
+ throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1034
+ }
852
1035
  const outDir = resolve4(options.outDir);
853
1036
  if (options.projectRoot) {
854
1037
  assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
855
1038
  }
856
1039
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1040
+ await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
857
1041
  const interchange = descriptorToInterchange(descriptor);
858
1042
  const materialized = await materializeLessonkitProject({
859
1043
  interchange,
@@ -869,8 +1053,8 @@ async function writeLxpackProject(options) {
869
1053
  const courseDir = materialized.courseDir;
870
1054
  return {
871
1055
  outDir: courseDir,
872
- courseYamlPath: join3(courseDir, "course.yaml"),
873
- lessonkitJsonPath: join3(courseDir, "lessonkit.json")
1056
+ courseYamlPath: join5(courseDir, "course.yaml"),
1057
+ lessonkitJsonPath: join5(courseDir, "lessonkit.json")
874
1058
  };
875
1059
  }
876
1060
 
@@ -883,7 +1067,7 @@ import {
883
1067
  } from "@lxpack/api";
884
1068
 
885
1069
  // src/packaging/validateInputs.ts
886
- import { isAbsolute as isAbsolute3, join as join4, resolve as resolve5, win32 as win322 } from "path";
1070
+ import { isAbsolute as isAbsolute3, join as join6, resolve as resolve5, win32 as win322 } from "path";
887
1071
  function validatePackageInputs(options) {
888
1072
  const { target, output, outputBaseDir } = options;
889
1073
  const outDir = resolve5(options.outDir);
@@ -1020,13 +1204,13 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1020
1204
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
1021
1205
  return win322.join(outDir, rel.replace(/\//g, win322.sep));
1022
1206
  }
1023
- return join4(outDir, rel);
1207
+ return join6(outDir, rel);
1024
1208
  }
1025
1209
 
1026
1210
  // src/packaging/promote.ts
1027
1211
  import * as fsp from "fs/promises";
1028
1212
  import { createHash, randomUUID } from "crypto";
1029
- import { dirname, join as join5, resolve as resolve6 } from "path";
1213
+ import { dirname, join as join7, resolve as resolve6 } from "path";
1030
1214
  async function pathExists(path) {
1031
1215
  try {
1032
1216
  await fsp.access(path);
@@ -1048,22 +1232,23 @@ async function renameOrCopy(from, to) {
1048
1232
  function promoteLockPath(outDir) {
1049
1233
  const parent = dirname(outDir);
1050
1234
  const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1051
- return join5(parent, `.lk-promote-lock-${hash}`);
1235
+ return join7(parent, `.lk-promote-lock-${hash}`);
1052
1236
  }
1053
1237
  var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1054
1238
  async function isStalePromoteLock(lockPath) {
1055
1239
  try {
1056
- const stat2 = await fsp.stat(lockPath);
1057
- if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
1058
1240
  const content = await fsp.readFile(lockPath, "utf8");
1059
1241
  const pid = Number.parseInt(content.trim(), 10);
1060
- if (!Number.isFinite(pid) || pid <= 0) return true;
1061
- try {
1062
- process.kill(pid, 0);
1063
- return false;
1064
- } catch {
1065
- return true;
1242
+ if (Number.isFinite(pid) && pid > 0) {
1243
+ try {
1244
+ process.kill(pid, 0);
1245
+ return false;
1246
+ } catch {
1247
+ return true;
1248
+ }
1066
1249
  }
1250
+ const stat2 = await fsp.stat(lockPath);
1251
+ return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
1067
1252
  } catch {
1068
1253
  return true;
1069
1254
  }
@@ -1123,10 +1308,10 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1123
1308
  return withPromoteLock(outDir, async () => {
1124
1309
  await assertNoLegacyPromoteArtifacts(outDir);
1125
1310
  const parent = dirname(outDir);
1126
- const tmpPromote = await fsp.mkdtemp(join5(parent, ".lk-promote-"));
1311
+ const tmpPromote = await fsp.mkdtemp(join7(parent, ".lk-promote-"));
1127
1312
  await renameOrCopy(stagingDir, tmpPromote);
1128
1313
  const hadOutDir = await pathExists(outDir);
1129
- const backup = hadOutDir ? await fsp.mkdtemp(join5(parent, ".lk-backup-")) : void 0;
1314
+ const backup = hadOutDir ? await fsp.mkdtemp(join7(parent, ".lk-backup-")) : void 0;
1130
1315
  if (hadOutDir && backup) {
1131
1316
  await renameOrCopy(outDir, backup);
1132
1317
  }
@@ -1137,7 +1322,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1137
1322
  try {
1138
1323
  await renameOrCopy(backup, outDir);
1139
1324
  } catch (restoreError) {
1140
- const failedPromote2 = join5(parent, `.lk-failed-promote-${randomUUID()}`);
1325
+ const failedPromote2 = join7(parent, `.lk-failed-promote-${randomUUID()}`);
1141
1326
  try {
1142
1327
  await renameOrCopy(tmpPromote, failedPromote2);
1143
1328
  } catch {
@@ -1149,7 +1334,8 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1149
1334
  const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1150
1335
  const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1151
1336
  throw new Error(
1152
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1337
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`,
1338
+ { cause: restoreError }
1153
1339
  );
1154
1340
  }
1155
1341
  } else {
@@ -1167,7 +1353,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1167
1353
  }
1168
1354
  throw promoteError;
1169
1355
  }
1170
- const failedPromote = join5(parent, `.lk-failed-promote-${randomUUID()}`);
1356
+ const failedPromote = join7(parent, `.lk-failed-promote-${randomUUID()}`);
1171
1357
  try {
1172
1358
  await renameOrCopy(tmpPromote, failedPromote);
1173
1359
  } catch {
@@ -1189,16 +1375,17 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1189
1375
 
1190
1376
  // src/packaging/staging.ts
1191
1377
  import * as fsp2 from "fs/promises";
1192
- import { dirname as dirname2, join as join6 } from "path";
1378
+ import { dirname as dirname2, join as join8 } from "path";
1193
1379
  import { tmpdir } from "os";
1194
1380
  import { packageLessonkit } from "@lxpack/api";
1195
1381
  async function buildStagingPackage(options) {
1196
1382
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1197
- const stagingDir = await fsp2.mkdtemp(join6(tmpdir(), "lessonkit-lxpack-"));
1383
+ const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1198
1384
  try {
1199
1385
  let spaDirs;
1200
1386
  try {
1201
1387
  spaDirs = await resolveSpaDirs({ ...writeOpts, descriptor });
1388
+ await assertSpaDistContentsSafe(spaDirs, writeOpts.projectRoot);
1202
1389
  } catch (err) {
1203
1390
  return {
1204
1391
  ok: false,
@@ -1211,10 +1398,21 @@ async function buildStagingPackage(options) {
1211
1398
  ]
1212
1399
  };
1213
1400
  }
1401
+ const injectableIssues = validateInjectableAssessments(descriptor);
1402
+ if (injectableIssues.length > 0) {
1403
+ return {
1404
+ ok: false,
1405
+ stagingDir,
1406
+ issues: injectableIssues.map((i) => ({
1407
+ path: i.path,
1408
+ message: i.message
1409
+ }))
1410
+ };
1411
+ }
1214
1412
  const interchange = descriptorToInterchange(descriptor);
1215
1413
  const outputBase = outputBaseDir ?? ".lxpack/out";
1216
- await fsp2.mkdir(join6(stagingDir, outputBase), { recursive: true });
1217
- const defaultOutput = output ?? (dir ? join6(outputBase, target) : join6(outputBase, `course-${target}.zip`));
1414
+ await fsp2.mkdir(join8(stagingDir, outputBase), { recursive: true });
1415
+ const defaultOutput = output ?? (dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`));
1218
1416
  const build = await packageLessonkit({
1219
1417
  interchange,
1220
1418
  spaDirs,
@@ -1315,17 +1513,24 @@ async function packageLessonkitCourse(options) {
1315
1513
  };
1316
1514
  }
1317
1515
  const descriptor = descriptorValidation.descriptor;
1318
- const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1319
- if (nonInjectableAssessments.length > 0) {
1320
- return {
1321
- ok: false,
1322
- courseDir: outDir,
1323
- target,
1324
- issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1325
- path: `assessments[${index}]`,
1326
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1327
- }))
1328
- };
1516
+ if (writeOpts.projectRoot) {
1517
+ const parityIssues = validateReactManifestParity({
1518
+ projectRoot: writeOpts.projectRoot,
1519
+ descriptor
1520
+ });
1521
+ const parityErrors = parityIssues.filter((i) => i.severity === "error");
1522
+ if (parityErrors.length > 0) {
1523
+ return {
1524
+ ok: false,
1525
+ courseDir: outDir,
1526
+ target,
1527
+ issues: parityErrors.map((i) => ({
1528
+ path: i.path,
1529
+ message: i.message,
1530
+ severity: i.severity
1531
+ }))
1532
+ };
1533
+ }
1329
1534
  }
1330
1535
  const staged = await buildStagingPackage({
1331
1536
  ...writeOpts,
@@ -1586,5 +1791,6 @@ export {
1586
1791
  validateLessonkitProject,
1587
1792
  validatePackageInputs,
1588
1793
  validateProjectPaths,
1794
+ validateReactManifestParity,
1589
1795
  writeLxpackProject
1590
1796
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/lxpack",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "description": "LXPack export adapter for LessonKit courses (SCORM, standalone, xAPI, cmi5).",
6
6
  "license": "Apache-2.0",
@@ -55,15 +55,15 @@
55
55
  "lint": "echo \"(no lint configured yet)\""
56
56
  },
57
57
  "dependencies": {
58
- "@lessonkit/core": "1.3.0",
59
- "@lessonkit/themes": "1.3.0",
60
- "@lxpack/api": "^0.6.2",
61
- "@lxpack/spa-bridge": "^0.6.2",
62
- "@lxpack/tracking-schema": "^0.6.2",
63
- "@lxpack/validators": "^0.6.2"
58
+ "@lessonkit/core": "1.4.0",
59
+ "@lessonkit/themes": "1.4.0",
60
+ "@lxpack/api": "0.6.4",
61
+ "@lxpack/spa-bridge": "0.6.4",
62
+ "@lxpack/tracking-schema": "0.6.4",
63
+ "@lxpack/validators": "0.6.4"
64
64
  },
65
65
  "devDependencies": {
66
- "@types/node": "^22.13.10",
66
+ "@types/node": "^25.9.2",
67
67
  "tsup": "^8.5.0",
68
68
  "typescript": "^5.8.3",
69
69
  "vitest": "^4.1.8"