@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/README.md +2 -2
- package/dist/bridge.d.cts +3 -2
- package/dist/bridge.d.ts +3 -2
- package/dist/index.cjs +295 -88
- package/dist/index.d.cts +24 -10
- package/dist/index.d.ts +24 -10
- package/dist/index.js +264 -58
- package/package.json +8 -8
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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"
|
|
586
|
-
return
|
|
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: "
|
|
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
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
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
|
|
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(
|
|
933
|
+
await access(join3(srcDist, "index.html"));
|
|
808
934
|
} catch {
|
|
809
|
-
throw new Error(`spaDistDir must contain 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(
|
|
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}": ${
|
|
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:
|
|
873
|
-
lessonkitJsonPath:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
1217
|
-
const defaultOutput = output ?? (dir ?
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
+
"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.
|
|
59
|
-
"@lessonkit/themes": "1.
|
|
60
|
-
"@lxpack/api": "
|
|
61
|
-
"@lxpack/spa-bridge": "
|
|
62
|
-
"@lxpack/tracking-schema": "
|
|
63
|
-
"@lxpack/validators": "
|
|
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": "^
|
|
66
|
+
"@types/node": "^25.9.2",
|
|
67
67
|
"tsup": "^8.5.0",
|
|
68
68
|
"typescript": "^5.8.3",
|
|
69
69
|
"vitest": "^4.1.8"
|