@lessonkit/lxpack 1.5.0 → 1.6.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 +14 -0
- package/block-tree.v1.json +40 -0
- package/dist/bridge.cjs +77 -22
- package/dist/bridge.d.cts +34 -4
- package/dist/bridge.d.ts +34 -4
- package/dist/bridge.js +74 -23
- package/dist/index.cjs +1115 -120
- package/dist/index.d.cts +162 -1
- package/dist/index.d.ts +162 -1
- package/dist/index.js +1090 -104
- package/lessonkit-manifest.v1.json +6 -3
- package/lkcourse-format.v1.json +21 -0
- package/package.json +12 -6
package/dist/index.cjs
CHANGED
|
@@ -31,22 +31,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
LESSONKIT_TELEMETRY_EVENTS: () => import_tracking_schema2.LESSONKIT_TELEMETRY_EVENTS,
|
|
34
|
+
assertSpaDistContentsSafe: () => assertSpaDistContentsSafe,
|
|
34
35
|
assessmentDescriptorToLxpack: () => assessmentDescriptorToLxpack,
|
|
35
36
|
buildLessonkitProject: () => buildLessonkitProject,
|
|
36
37
|
buildStagingPackage: () => buildStagingPackage,
|
|
37
38
|
descriptorToInterchange: () => descriptorToInterchange,
|
|
38
39
|
ensureOutDirParent: () => ensureOutDirParent,
|
|
39
40
|
escapeShellText: () => escapeShellText,
|
|
41
|
+
exportLkcourse: () => exportLkcourse,
|
|
40
42
|
extractAssessments: () => extractAssessments,
|
|
41
|
-
|
|
43
|
+
extractBlockTree: () => extractBlockTree,
|
|
44
|
+
importLkcourse: () => importLkcourse,
|
|
45
|
+
lessonkitInterchangeSchema: () => import_validators4.lessonkitInterchangeSchema,
|
|
42
46
|
loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
|
|
43
47
|
mapLessonkitIds: () => mapLessonkitIds,
|
|
44
48
|
mapLessonkitTelemetryToBridgeAction: () => import_tracking_schema2.mapLessonkitTelemetryToBridgeAction,
|
|
45
49
|
mapLessonkitTelemetryToLxpack: () => import_tracking_schema2.mapLessonkitTelemetryToLxpack,
|
|
46
|
-
materializeLessonkitProject: () =>
|
|
50
|
+
materializeLessonkitProject: () => import_validators4.materializeLessonkitProject,
|
|
47
51
|
packageLessonkitCourse: () => packageLessonkitCourse,
|
|
48
|
-
parseLessonkitInterchange: () =>
|
|
52
|
+
parseLessonkitInterchange: () => import_validators4.parseLessonkitInterchange,
|
|
49
53
|
parseLessonkitManifest: () => parseLessonkitManifest,
|
|
54
|
+
parseLkcourseEnvelope: () => parseLkcourseEnvelope,
|
|
50
55
|
promoteStagingToOutDir: () => promoteStagingToOutDir,
|
|
51
56
|
remapArtifactPaths: () => remapArtifactPaths,
|
|
52
57
|
resolveSafePackageOutputOverride: () => resolveSafePackageOutputOverride,
|
|
@@ -56,6 +61,8 @@ __export(index_exports, {
|
|
|
56
61
|
validateDescriptor: () => validateDescriptor,
|
|
57
62
|
validateDescriptorForTarget: () => validateDescriptorForTarget,
|
|
58
63
|
validateLessonkitProject: () => validateLessonkitProject,
|
|
64
|
+
validateLkcourse: () => validateLkcourse,
|
|
65
|
+
validateLkcourseArchiveEntries: () => validateLkcourseArchiveEntries,
|
|
59
66
|
validatePackageInputs: () => validatePackageInputs,
|
|
60
67
|
validateProjectPaths: () => validateProjectPaths,
|
|
61
68
|
validateReactManifestParity: () => validateReactManifestParity,
|
|
@@ -160,10 +167,18 @@ function parseAssessmentDescriptor(raw) {
|
|
|
160
167
|
};
|
|
161
168
|
const kind = raw.kind;
|
|
162
169
|
if (kind === "trueFalse") {
|
|
170
|
+
let answer;
|
|
171
|
+
if (typeof raw.answer === "boolean") {
|
|
172
|
+
answer = raw.answer;
|
|
173
|
+
} else if (raw.answer === "true") {
|
|
174
|
+
answer = true;
|
|
175
|
+
} else if (raw.answer === "false") {
|
|
176
|
+
answer = false;
|
|
177
|
+
}
|
|
163
178
|
return {
|
|
164
179
|
kind: "trueFalse",
|
|
165
180
|
...base,
|
|
166
|
-
answer
|
|
181
|
+
answer
|
|
167
182
|
};
|
|
168
183
|
}
|
|
169
184
|
if (kind === "fillInBlanks") {
|
|
@@ -341,6 +356,98 @@ function isResolvedPathUnderRoot(root, target) {
|
|
|
341
356
|
return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
|
|
342
357
|
}
|
|
343
358
|
|
|
359
|
+
// src/validateProjectPaths.ts
|
|
360
|
+
var import_node_fs2 = require("fs");
|
|
361
|
+
var import_node_path2 = require("path");
|
|
362
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
363
|
+
function isReservedOutputPath(value) {
|
|
364
|
+
let normalized = value.replace(/\\/g, "/");
|
|
365
|
+
while (normalized.startsWith("/")) normalized = normalized.slice(1);
|
|
366
|
+
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
367
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
368
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
369
|
+
}
|
|
370
|
+
function isReservedResolvedOutputPath(projectRoot, resolved) {
|
|
371
|
+
const rootResolved = resolveComparablePath(projectRoot);
|
|
372
|
+
const targetResolved = resolveComparablePath(resolved);
|
|
373
|
+
try {
|
|
374
|
+
const rootReal = (0, import_node_fs2.existsSync)(rootResolved) ? (0, import_node_fs2.realpathSync)(rootResolved) : rootResolved;
|
|
375
|
+
const targetReal = (0, import_node_fs2.existsSync)(targetResolved) ? (0, import_node_fs2.realpathSync)(targetResolved) : targetResolved;
|
|
376
|
+
const rel = relativePathUnderRoot(rootReal, targetReal);
|
|
377
|
+
return isReservedOutputPath(rel);
|
|
378
|
+
} catch {
|
|
379
|
+
return isReservedOutputPath(resolved);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
383
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
384
|
+
issues.push({
|
|
385
|
+
path: fieldPath,
|
|
386
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
391
|
+
issues.push({
|
|
392
|
+
path: fieldPath,
|
|
393
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
|
|
399
|
+
} catch {
|
|
400
|
+
issues.push({
|
|
401
|
+
path: fieldPath,
|
|
402
|
+
message: "path must resolve inside the project root"
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function validateProjectPaths(projectRoot, paths) {
|
|
407
|
+
const issues = [];
|
|
408
|
+
const root = (0, import_node_path2.resolve)(projectRoot);
|
|
409
|
+
if (paths.spaDistDir?.trim()) {
|
|
410
|
+
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
|
|
411
|
+
rejectReserved: true
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (paths.lxpackOutDir?.trim()) {
|
|
415
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
416
|
+
rejectReserved: true
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (paths.outputBaseDir?.trim()) {
|
|
420
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
421
|
+
rejectReserved: true
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return issues;
|
|
425
|
+
}
|
|
426
|
+
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
427
|
+
const root = (0, import_node_path2.resolve)(projectRoot);
|
|
428
|
+
const trimmed = override.trim();
|
|
429
|
+
if (!trimmed) {
|
|
430
|
+
throw new Error("output override must be a non-empty path");
|
|
431
|
+
}
|
|
432
|
+
if ((0, import_node_path2.isAbsolute)(trimmed)) {
|
|
433
|
+
const resolved2 = (0, import_node_path2.resolve)(trimmed);
|
|
434
|
+
assertRealPathUnderRoot(root, resolved2);
|
|
435
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
|
|
436
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
437
|
+
}
|
|
438
|
+
return resolved2;
|
|
439
|
+
}
|
|
440
|
+
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
441
|
+
throw new Error(`unsafe output path: ${override}`);
|
|
442
|
+
}
|
|
443
|
+
const resolved = (0, import_node_path2.resolve)(root, trimmed);
|
|
444
|
+
assertRealPathUnderRoot(root, resolved);
|
|
445
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
|
|
446
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
447
|
+
}
|
|
448
|
+
return resolved;
|
|
449
|
+
}
|
|
450
|
+
|
|
344
451
|
// src/theme.ts
|
|
345
452
|
var import_themes = require("@lessonkit/themes");
|
|
346
453
|
function themeToLxpackRuntime(input) {
|
|
@@ -417,8 +524,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
417
524
|
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
418
525
|
});
|
|
419
526
|
}
|
|
420
|
-
const explicitBlanks =
|
|
421
|
-
if (assessment.blanks !== void 0
|
|
527
|
+
const explicitBlanks = [];
|
|
528
|
+
if (assessment.blanks !== void 0) {
|
|
529
|
+
for (let i = 0; i < assessment.blanks.length; i++) {
|
|
530
|
+
const blank = assessment.blanks[i];
|
|
531
|
+
if (!blank || typeof blank !== "object") {
|
|
532
|
+
issues.push({
|
|
533
|
+
path: `${path}.blanks[${i}]`,
|
|
534
|
+
message: "blank entry must be an object with non-empty id and answer"
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const id = blank.id?.trim() ?? "";
|
|
539
|
+
const answer = blank.answer?.trim() ?? "";
|
|
540
|
+
if (!id || !answer) {
|
|
541
|
+
issues.push({
|
|
542
|
+
path: `${path}.blanks[${i}]`,
|
|
543
|
+
message: "blank entry must include non-empty id and answer"
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
explicitBlanks.push({ id, answer });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
|
|
422
551
|
issues.push({
|
|
423
552
|
path: `${path}.blanks`,
|
|
424
553
|
message: "blanks must include at least one entry with non-empty id and answer"
|
|
@@ -566,6 +695,20 @@ function validateCourseDescriptor(input) {
|
|
|
566
695
|
});
|
|
567
696
|
}
|
|
568
697
|
}
|
|
698
|
+
const descriptorSpaDistDir = input.spaDistDir?.trim();
|
|
699
|
+
if (descriptorSpaDistDir) {
|
|
700
|
+
if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
|
|
701
|
+
issues.push({
|
|
702
|
+
path: "spaDistDir",
|
|
703
|
+
message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
|
|
704
|
+
});
|
|
705
|
+
} else if (isReservedOutputPath(descriptorSpaDistDir)) {
|
|
706
|
+
issues.push({
|
|
707
|
+
path: "spaDistDir",
|
|
708
|
+
message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
569
712
|
if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
|
|
570
713
|
issues.push({
|
|
571
714
|
path: "lessons",
|
|
@@ -626,6 +769,7 @@ function validateCourseDescriptor(input) {
|
|
|
626
769
|
}
|
|
627
770
|
|
|
628
771
|
// src/assessments.ts
|
|
772
|
+
var DEFAULT_SHELL_PASSING_SCORE = 1;
|
|
629
773
|
function escapeShellText(text) {
|
|
630
774
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
631
775
|
}
|
|
@@ -634,7 +778,7 @@ function decodeShellEntities(text) {
|
|
|
634
778
|
}
|
|
635
779
|
function containsUnsafeShellMarkup(text) {
|
|
636
780
|
const decoded = decodeShellEntities(text);
|
|
637
|
-
return /<\/script/i.test(decoded) || /<!--/.test(decoded) ||
|
|
781
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
|
|
638
782
|
}
|
|
639
783
|
function sanitizeShellField(text) {
|
|
640
784
|
if (containsUnsafeShellMarkup(text)) return null;
|
|
@@ -649,6 +793,7 @@ function mcqToLxpack(assessment) {
|
|
|
649
793
|
const checkId = sanitizeShellField(assessment.checkId);
|
|
650
794
|
const prompt = sanitizeShellField(assessment.question);
|
|
651
795
|
if (!checkId || !prompt) return null;
|
|
796
|
+
const normalizedAnswer = assessment.answer.trim();
|
|
652
797
|
const choices = assessment.choices.map((text, index) => {
|
|
653
798
|
const sanitizedText = sanitizeShellField(text);
|
|
654
799
|
if (!sanitizedText) return null;
|
|
@@ -656,13 +801,13 @@ function mcqToLxpack(assessment) {
|
|
|
656
801
|
return {
|
|
657
802
|
id,
|
|
658
803
|
text: sanitizedText,
|
|
659
|
-
correct: text ===
|
|
804
|
+
correct: text.trim() === normalizedAnswer
|
|
660
805
|
};
|
|
661
806
|
});
|
|
662
807
|
if (choices.some((choice) => choice === null)) return null;
|
|
663
808
|
return {
|
|
664
809
|
id: checkId,
|
|
665
|
-
passingScore: assessment.passingScore ??
|
|
810
|
+
passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
|
|
666
811
|
questions: [
|
|
667
812
|
{
|
|
668
813
|
id: "q1",
|
|
@@ -707,11 +852,14 @@ function extractAssessments(descriptor) {
|
|
|
707
852
|
// src/descriptor/validateInjectableAssessments.ts
|
|
708
853
|
function validateInjectableAssessments(descriptor) {
|
|
709
854
|
const issues = [];
|
|
855
|
+
const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
|
|
710
856
|
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
711
857
|
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
858
|
+
const kind = assessment.kind ?? "mcq";
|
|
859
|
+
const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
|
|
712
860
|
issues.push({
|
|
713
861
|
path: `assessments[${index}]`,
|
|
714
|
-
message: `assessment kind "${
|
|
862
|
+
message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
|
|
715
863
|
});
|
|
716
864
|
}
|
|
717
865
|
});
|
|
@@ -727,7 +875,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
727
875
|
"cmi5"
|
|
728
876
|
]);
|
|
729
877
|
function appendActivityIriIssues(issues, descriptor, target) {
|
|
730
|
-
const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
|
|
878
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
|
|
731
879
|
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
732
880
|
if (!hasXapiTracking && !requiresForTarget) return;
|
|
733
881
|
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
@@ -782,26 +930,26 @@ function validateDescriptorForTarget(input, target) {
|
|
|
782
930
|
}
|
|
783
931
|
|
|
784
932
|
// src/validateReactParity.ts
|
|
785
|
-
var
|
|
786
|
-
var
|
|
933
|
+
var import_node_fs3 = require("fs");
|
|
934
|
+
var import_node_path3 = require("path");
|
|
787
935
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
788
936
|
function collectSourceUnderSrc(projectRoot, issues) {
|
|
789
|
-
const srcDir = (0,
|
|
790
|
-
if (!(0,
|
|
937
|
+
const srcDir = (0, import_node_path3.join)(projectRoot, "src");
|
|
938
|
+
if (!(0, import_node_fs3.existsSync)(srcDir)) return [];
|
|
791
939
|
const results = [];
|
|
792
940
|
const walk = (dir) => {
|
|
793
|
-
for (const entry of (0,
|
|
794
|
-
const abs = (0,
|
|
941
|
+
for (const entry of (0, import_node_fs3.readdirSync)(dir)) {
|
|
942
|
+
const abs = (0, import_node_path3.join)(dir, entry);
|
|
795
943
|
let stat2;
|
|
796
944
|
try {
|
|
797
|
-
stat2 = (0,
|
|
945
|
+
stat2 = (0, import_node_fs3.lstatSync)(abs);
|
|
798
946
|
} catch {
|
|
799
947
|
continue;
|
|
800
948
|
}
|
|
801
949
|
if (stat2.isSymbolicLink()) {
|
|
802
950
|
issues.push({
|
|
803
|
-
path: (0,
|
|
804
|
-
message: `Source tree contains symlink (rejected for parity scan): ${(0,
|
|
951
|
+
path: (0, import_node_path3.relative)(projectRoot, abs),
|
|
952
|
+
message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path3.relative)(projectRoot, abs)}`,
|
|
805
953
|
severity: "error"
|
|
806
954
|
});
|
|
807
955
|
continue;
|
|
@@ -811,8 +959,8 @@ function collectSourceUnderSrc(projectRoot, issues) {
|
|
|
811
959
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
812
960
|
} catch {
|
|
813
961
|
issues.push({
|
|
814
|
-
path: (0,
|
|
815
|
-
message: `Source directory escapes project root: ${(0,
|
|
962
|
+
path: (0, import_node_path3.relative)(projectRoot, abs),
|
|
963
|
+
message: `Source directory escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
|
|
816
964
|
severity: "error"
|
|
817
965
|
});
|
|
818
966
|
continue;
|
|
@@ -823,13 +971,13 @@ function collectSourceUnderSrc(projectRoot, issues) {
|
|
|
823
971
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
824
972
|
} catch {
|
|
825
973
|
issues.push({
|
|
826
|
-
path: (0,
|
|
827
|
-
message: `Source file escapes project root: ${(0,
|
|
974
|
+
path: (0, import_node_path3.relative)(projectRoot, abs),
|
|
975
|
+
message: `Source file escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
|
|
828
976
|
severity: "error"
|
|
829
977
|
});
|
|
830
978
|
continue;
|
|
831
979
|
}
|
|
832
|
-
results.push((0,
|
|
980
|
+
results.push((0, import_node_path3.relative)(projectRoot, abs));
|
|
833
981
|
}
|
|
834
982
|
}
|
|
835
983
|
};
|
|
@@ -848,10 +996,10 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
|
|
|
848
996
|
}
|
|
849
997
|
return null;
|
|
850
998
|
}
|
|
851
|
-
const abs = (0,
|
|
999
|
+
const abs = (0, import_node_path3.join)(projectRoot, rel);
|
|
852
1000
|
try {
|
|
853
1001
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
854
|
-
if ((0,
|
|
1002
|
+
if ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.lstatSync)(abs).isSymbolicLink()) {
|
|
855
1003
|
issues.push({
|
|
856
1004
|
path: rel,
|
|
857
1005
|
message: `appSources path is a symlink: ${rel}`,
|
|
@@ -867,8 +1015,8 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
|
|
|
867
1015
|
});
|
|
868
1016
|
return null;
|
|
869
1017
|
}
|
|
870
|
-
if (!(0,
|
|
871
|
-
return (0,
|
|
1018
|
+
if (!(0, import_node_fs3.existsSync)(abs)) return null;
|
|
1019
|
+
return (0, import_node_fs3.readFileSync)(abs, "utf8");
|
|
872
1020
|
}).filter((content) => content != null).join("\n");
|
|
873
1021
|
}
|
|
874
1022
|
function stripComments(source) {
|
|
@@ -943,9 +1091,20 @@ function courseConfigCourseIdPresent(source, courseId) {
|
|
|
943
1091
|
if (literalPattern.test(stripped)) return true;
|
|
944
1092
|
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
945
1093
|
}
|
|
1094
|
+
function courseMetaCourseIdPresent(source, courseId) {
|
|
1095
|
+
const constants = extractStringConstants(source);
|
|
1096
|
+
const stripped = stripComments(source);
|
|
1097
|
+
for (const [name, value] of constants) {
|
|
1098
|
+
if (value !== courseId) continue;
|
|
1099
|
+
if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
|
|
1100
|
+
if (/\blessons\s*:\s*\S/.test(stripped)) return true;
|
|
1101
|
+
}
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
946
1104
|
function courseIdPresent(source, courseId) {
|
|
947
1105
|
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
948
1106
|
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
1107
|
+
if (courseMetaCourseIdPresent(source, courseId)) return true;
|
|
949
1108
|
return courseConfigCourseIdPresent(source, courseId);
|
|
950
1109
|
}
|
|
951
1110
|
function checkIdPresent(source, checkId) {
|
|
@@ -1014,81 +1173,6 @@ function validateReactManifestParity(opts) {
|
|
|
1014
1173
|
return issues;
|
|
1015
1174
|
}
|
|
1016
1175
|
|
|
1017
|
-
// src/validateProjectPaths.ts
|
|
1018
|
-
var import_node_path3 = require("path");
|
|
1019
|
-
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
1020
|
-
function isReservedOutputPath(value) {
|
|
1021
|
-
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
1022
|
-
const segments = normalized.split("/").filter(Boolean);
|
|
1023
|
-
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
1024
|
-
}
|
|
1025
|
-
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
1026
|
-
if (!isSafeRelativeSpaPath(value)) {
|
|
1027
|
-
issues.push({
|
|
1028
|
-
path: fieldPath,
|
|
1029
|
-
message: "path must be relative without '..' segments or absolute prefixes"
|
|
1030
|
-
});
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
1034
|
-
issues.push({
|
|
1035
|
-
path: fieldPath,
|
|
1036
|
-
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
1037
|
-
});
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
try {
|
|
1041
|
-
assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
|
|
1042
|
-
} catch {
|
|
1043
|
-
issues.push({
|
|
1044
|
-
path: fieldPath,
|
|
1045
|
-
message: "path must resolve inside the project root"
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
function validateProjectPaths(projectRoot, paths) {
|
|
1050
|
-
const issues = [];
|
|
1051
|
-
const root = (0, import_node_path3.resolve)(projectRoot);
|
|
1052
|
-
if (paths.spaDistDir?.trim()) {
|
|
1053
|
-
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
1054
|
-
}
|
|
1055
|
-
if (paths.lxpackOutDir?.trim()) {
|
|
1056
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
1057
|
-
rejectReserved: true
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
if (paths.outputBaseDir?.trim()) {
|
|
1061
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1062
|
-
rejectReserved: true
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
return issues;
|
|
1066
|
-
}
|
|
1067
|
-
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
1068
|
-
const root = (0, import_node_path3.resolve)(projectRoot);
|
|
1069
|
-
const trimmed = override.trim();
|
|
1070
|
-
if (!trimmed) {
|
|
1071
|
-
throw new Error("output override must be a non-empty path");
|
|
1072
|
-
}
|
|
1073
|
-
if ((0, import_node_path3.isAbsolute)(trimmed)) {
|
|
1074
|
-
const resolved2 = (0, import_node_path3.resolve)(trimmed);
|
|
1075
|
-
assertRealPathUnderRoot(root, resolved2);
|
|
1076
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1077
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1078
|
-
}
|
|
1079
|
-
return resolved2;
|
|
1080
|
-
}
|
|
1081
|
-
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
1082
|
-
throw new Error(`unsafe output path: ${override}`);
|
|
1083
|
-
}
|
|
1084
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1085
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1086
|
-
}
|
|
1087
|
-
const resolved = (0, import_node_path3.resolve)(root, trimmed);
|
|
1088
|
-
assertRealPathUnderRoot(root, resolved);
|
|
1089
|
-
return resolved;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1176
|
// src/mapIds.ts
|
|
1093
1177
|
var import_core4 = require("@lessonkit/core");
|
|
1094
1178
|
function mapLessonkitIds(descriptor) {
|
|
@@ -1220,7 +1304,7 @@ async function resolveSpaDirs(options) {
|
|
|
1220
1304
|
|
|
1221
1305
|
// src/spaDistValidation.ts
|
|
1222
1306
|
var import_promises2 = require("fs/promises");
|
|
1223
|
-
var
|
|
1307
|
+
var import_node_fs4 = require("fs");
|
|
1224
1308
|
var import_node_path5 = require("path");
|
|
1225
1309
|
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
1226
1310
|
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
@@ -1231,7 +1315,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
|
1231
1315
|
}
|
|
1232
1316
|
let rootReal;
|
|
1233
1317
|
try {
|
|
1234
|
-
rootReal = (0,
|
|
1318
|
+
rootReal = (0, import_node_fs4.realpathSync)(dirResolved);
|
|
1235
1319
|
} catch {
|
|
1236
1320
|
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
1237
1321
|
}
|
|
@@ -1260,7 +1344,7 @@ async function walkDistDir(rootReal, current, label) {
|
|
|
1260
1344
|
}
|
|
1261
1345
|
let entryReal;
|
|
1262
1346
|
try {
|
|
1263
|
-
entryReal = (0,
|
|
1347
|
+
entryReal = (0, import_node_fs4.realpathSync)(entryPath);
|
|
1264
1348
|
} catch (err) {
|
|
1265
1349
|
throw new Error(
|
|
1266
1350
|
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
@@ -1285,7 +1369,9 @@ async function writeLxpackProject(options) {
|
|
|
1285
1369
|
const descriptor = validation.descriptor;
|
|
1286
1370
|
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1287
1371
|
if (injectableIssues.length > 0) {
|
|
1288
|
-
throw new Error(
|
|
1372
|
+
throw new Error(
|
|
1373
|
+
injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
|
|
1374
|
+
);
|
|
1289
1375
|
}
|
|
1290
1376
|
const outDir = (0, import_node_path6.resolve)(options.outDir);
|
|
1291
1377
|
assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
|
|
@@ -1348,6 +1434,19 @@ function validatePackageInputs(options) {
|
|
|
1348
1434
|
]
|
|
1349
1435
|
};
|
|
1350
1436
|
}
|
|
1437
|
+
if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
|
|
1438
|
+
return {
|
|
1439
|
+
ok: false,
|
|
1440
|
+
courseDir: outDir,
|
|
1441
|
+
target,
|
|
1442
|
+
issues: [
|
|
1443
|
+
{
|
|
1444
|
+
path: "outDir",
|
|
1445
|
+
message: "outDir must not target reserved directories (.git, node_modules, .github)"
|
|
1446
|
+
}
|
|
1447
|
+
]
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1351
1450
|
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
1352
1451
|
return {
|
|
1353
1452
|
ok: false,
|
|
@@ -1405,6 +1504,19 @@ function validatePackageInputs(options) {
|
|
|
1405
1504
|
]
|
|
1406
1505
|
};
|
|
1407
1506
|
}
|
|
1507
|
+
if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
|
|
1508
|
+
return {
|
|
1509
|
+
ok: false,
|
|
1510
|
+
courseDir: outDir,
|
|
1511
|
+
target,
|
|
1512
|
+
issues: [
|
|
1513
|
+
{
|
|
1514
|
+
path: "outputBaseDir",
|
|
1515
|
+
message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
|
|
1516
|
+
}
|
|
1517
|
+
]
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1408
1520
|
}
|
|
1409
1521
|
if (output) {
|
|
1410
1522
|
const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
|
|
@@ -1426,6 +1538,35 @@ function validatePackageInputs(options) {
|
|
|
1426
1538
|
]
|
|
1427
1539
|
};
|
|
1428
1540
|
}
|
|
1541
|
+
const outputRel = (0, import_node_path7.isAbsolute)(output) ? output : output;
|
|
1542
|
+
if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
|
|
1543
|
+
return {
|
|
1544
|
+
ok: false,
|
|
1545
|
+
courseDir: outDir,
|
|
1546
|
+
target,
|
|
1547
|
+
issues: [
|
|
1548
|
+
{
|
|
1549
|
+
path: "output",
|
|
1550
|
+
message: "output must not target reserved directories (.git, node_modules, .github)"
|
|
1551
|
+
}
|
|
1552
|
+
]
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
relativePathUnderRoot(outDir, resolvedOutput);
|
|
1557
|
+
} catch {
|
|
1558
|
+
return {
|
|
1559
|
+
ok: false,
|
|
1560
|
+
courseDir: outDir,
|
|
1561
|
+
target,
|
|
1562
|
+
issues: [
|
|
1563
|
+
{
|
|
1564
|
+
path: "output",
|
|
1565
|
+
message: "output must resolve inside outDir"
|
|
1566
|
+
}
|
|
1567
|
+
]
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1429
1570
|
}
|
|
1430
1571
|
return { ok: true, outDir, projectRoot };
|
|
1431
1572
|
}
|
|
@@ -1518,11 +1659,14 @@ async function isStalePromoteLock(lockPath) {
|
|
|
1518
1659
|
return true;
|
|
1519
1660
|
}
|
|
1520
1661
|
}
|
|
1662
|
+
var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
|
|
1521
1663
|
async function withPromoteLock(outDir, fn) {
|
|
1522
1664
|
const lockPath = promoteLockPath(outDir);
|
|
1523
1665
|
await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
|
|
1524
1666
|
let lockHandle;
|
|
1525
|
-
|
|
1667
|
+
const maxAttempts = 400;
|
|
1668
|
+
const started = Date.now();
|
|
1669
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1526
1670
|
try {
|
|
1527
1671
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1528
1672
|
await lockHandle.writeFile(`${process.pid}
|
|
@@ -1540,7 +1684,9 @@ ${Date.now()}
|
|
|
1540
1684
|
);
|
|
1541
1685
|
continue;
|
|
1542
1686
|
}
|
|
1543
|
-
|
|
1687
|
+
if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
|
|
1688
|
+
const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
|
|
1689
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
|
|
1544
1690
|
}
|
|
1545
1691
|
}
|
|
1546
1692
|
if (!lockHandle) {
|
|
@@ -1808,6 +1954,12 @@ function isPackagingErrorIssue(issue) {
|
|
|
1808
1954
|
function findPackagingErrorIssues(issues) {
|
|
1809
1955
|
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1810
1956
|
}
|
|
1957
|
+
function isPackagingWarningIssue(issue) {
|
|
1958
|
+
return issue.severity?.toLowerCase() === "warning";
|
|
1959
|
+
}
|
|
1960
|
+
function findPackagingWarningIssues(issues) {
|
|
1961
|
+
return (issues ?? []).filter(isPackagingWarningIssue);
|
|
1962
|
+
}
|
|
1811
1963
|
|
|
1812
1964
|
// src/packageCourse.ts
|
|
1813
1965
|
async function validateLessonkitProject(options) {
|
|
@@ -1875,14 +2027,29 @@ async function packageLessonkitCourse(options) {
|
|
|
1875
2027
|
}))
|
|
1876
2028
|
};
|
|
1877
2029
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2030
|
+
let staged;
|
|
2031
|
+
try {
|
|
2032
|
+
staged = await buildStagingPackage({
|
|
2033
|
+
...writeOpts,
|
|
2034
|
+
descriptor,
|
|
2035
|
+
target,
|
|
2036
|
+
output,
|
|
2037
|
+
dir,
|
|
2038
|
+
outputBaseDir
|
|
2039
|
+
});
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
return {
|
|
2042
|
+
ok: false,
|
|
2043
|
+
courseDir: outDir,
|
|
2044
|
+
target,
|
|
2045
|
+
issues: [
|
|
2046
|
+
{
|
|
2047
|
+
path: "staging",
|
|
2048
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2049
|
+
}
|
|
2050
|
+
]
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
1886
2053
|
if (!staged.ok) {
|
|
1887
2054
|
await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
|
|
1888
2055
|
/* v8 ignore next */
|
|
@@ -1937,6 +2104,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1937
2104
|
issues: artifactIssues
|
|
1938
2105
|
};
|
|
1939
2106
|
}
|
|
2107
|
+
const buildWarningIssues = findPackagingWarningIssues(build.issues);
|
|
2108
|
+
if (options.strictBuild && buildWarningIssues.length > 0) {
|
|
2109
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
2110
|
+
/* v8 ignore next */
|
|
2111
|
+
() => void 0
|
|
2112
|
+
);
|
|
2113
|
+
return {
|
|
2114
|
+
ok: false,
|
|
2115
|
+
courseDir: outDir,
|
|
2116
|
+
target,
|
|
2117
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
2118
|
+
build,
|
|
2119
|
+
issues: buildWarningIssues.map((i) => ({
|
|
2120
|
+
path: i.path ?? "build",
|
|
2121
|
+
message: i.message ?? "build warning",
|
|
2122
|
+
severity: i.severity
|
|
2123
|
+
}))
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
1940
2126
|
const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
|
|
1941
2127
|
const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
|
|
1942
2128
|
const validation = {
|
|
@@ -2079,7 +2265,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
2079
2265
|
path: `paths.${key}`,
|
|
2080
2266
|
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2081
2267
|
});
|
|
2082
|
-
} else if (
|
|
2268
|
+
} else if (isReservedOutputPath(value)) {
|
|
2083
2269
|
issues.push({
|
|
2084
2270
|
path: `paths.${key}`,
|
|
2085
2271
|
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
@@ -2173,17 +2359,823 @@ function telemetryEventToLessonkit(event) {
|
|
|
2173
2359
|
}
|
|
2174
2360
|
|
|
2175
2361
|
// src/index.ts
|
|
2176
|
-
var
|
|
2362
|
+
var import_validators4 = require("@lxpack/validators");
|
|
2363
|
+
|
|
2364
|
+
// src/lkcourse/zip.ts
|
|
2365
|
+
var import_node_fs5 = require("fs");
|
|
2366
|
+
var import_node_path11 = require("path");
|
|
2367
|
+
var import_fflate = require("fflate");
|
|
2368
|
+
var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
2369
|
+
function canonicalZipEntryPath(entryPath) {
|
|
2370
|
+
const slashNormalized = entryPath.replace(/\\/g, "/");
|
|
2371
|
+
const canonical = (0, import_node_path11.normalize)(slashNormalized).replace(/\\/g, "/");
|
|
2372
|
+
if (canonical !== slashNormalized) return null;
|
|
2373
|
+
return canonical;
|
|
2374
|
+
}
|
|
2375
|
+
function isSafeZipEntryPath(entryPath) {
|
|
2376
|
+
const canonical = canonicalZipEntryPath(entryPath);
|
|
2377
|
+
if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
|
|
2378
|
+
return false;
|
|
2379
|
+
}
|
|
2380
|
+
const segments = canonical.split("/").filter((s) => s.length > 0);
|
|
2381
|
+
if (segments.some((s) => s === "..")) return false;
|
|
2382
|
+
return segments.length > 0;
|
|
2383
|
+
}
|
|
2384
|
+
function createZip(entries) {
|
|
2385
|
+
const zipped = {};
|
|
2386
|
+
for (const [path, data] of entries) {
|
|
2387
|
+
if (!isSafeZipEntryPath(path)) {
|
|
2388
|
+
throw new Error(`unsafe zip entry path: ${path}`);
|
|
2389
|
+
}
|
|
2390
|
+
zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2391
|
+
}
|
|
2392
|
+
return (0, import_fflate.zipSync)(zipped, { level: 6 });
|
|
2393
|
+
}
|
|
2394
|
+
function readZip(archivePath) {
|
|
2395
|
+
const issues = [];
|
|
2396
|
+
let raw;
|
|
2397
|
+
try {
|
|
2398
|
+
raw = (0, import_node_fs5.readFileSync)(archivePath);
|
|
2399
|
+
} catch {
|
|
2400
|
+
return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
|
|
2401
|
+
}
|
|
2402
|
+
if (!raw.length) {
|
|
2403
|
+
return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
|
|
2404
|
+
}
|
|
2405
|
+
let unzipped;
|
|
2406
|
+
try {
|
|
2407
|
+
unzipped = (0, import_fflate.unzipSync)(raw);
|
|
2408
|
+
} catch {
|
|
2409
|
+
return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
|
|
2410
|
+
}
|
|
2411
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2412
|
+
let totalUncompressed = 0;
|
|
2413
|
+
for (const [path, data] of Object.entries(unzipped)) {
|
|
2414
|
+
const canonical = canonicalZipEntryPath(path);
|
|
2415
|
+
if (!canonical || !isSafeZipEntryPath(canonical)) {
|
|
2416
|
+
issues.push({ path, message: "unsafe zip entry path" });
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
if (entries.has(canonical)) {
|
|
2420
|
+
issues.push({ path: canonical, message: "duplicate zip entry path" });
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
totalUncompressed += data.byteLength;
|
|
2424
|
+
if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
|
|
2425
|
+
return {
|
|
2426
|
+
ok: false,
|
|
2427
|
+
issues: [
|
|
2428
|
+
{
|
|
2429
|
+
path: archivePath,
|
|
2430
|
+
message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
|
|
2431
|
+
}
|
|
2432
|
+
]
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
entries.set(canonical, data);
|
|
2436
|
+
}
|
|
2437
|
+
if (issues.length) return { ok: false, issues };
|
|
2438
|
+
return { ok: true, entries };
|
|
2439
|
+
}
|
|
2440
|
+
async function collectDistEntries(distDir, spaDistRelative) {
|
|
2441
|
+
const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
|
|
2442
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2443
|
+
const walk = async (absDir, relPrefix) => {
|
|
2444
|
+
const dirEntries = await readdir4(absDir, { withFileTypes: true });
|
|
2445
|
+
for (const entry of dirEntries) {
|
|
2446
|
+
const abs = (0, import_node_path11.join)(absDir, entry.name);
|
|
2447
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
2448
|
+
const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
|
|
2449
|
+
if (!isSafeRelativeSpaPath(zipPath)) {
|
|
2450
|
+
throw new Error(`unsafe dist path: ${zipPath}`);
|
|
2451
|
+
}
|
|
2452
|
+
const stat2 = await lstat2(abs);
|
|
2453
|
+
if (stat2.isSymbolicLink()) {
|
|
2454
|
+
throw new Error(`dist contains symlink: ${abs}`);
|
|
2455
|
+
}
|
|
2456
|
+
if (stat2.isDirectory()) {
|
|
2457
|
+
await walk(abs, rel);
|
|
2458
|
+
} else if (stat2.isFile()) {
|
|
2459
|
+
entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
await walk(distDir, "");
|
|
2464
|
+
return entries;
|
|
2465
|
+
}
|
|
2466
|
+
function entryToUtf8(data) {
|
|
2467
|
+
return (0, import_fflate.strFromU8)(data);
|
|
2468
|
+
}
|
|
2469
|
+
function utf8ToEntry(text) {
|
|
2470
|
+
return (0, import_fflate.strToU8)(text);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// src/lkcourse/parseEnvelope.ts
|
|
2474
|
+
function parseLkcourseEnvelope(raw, label = "manifest.json") {
|
|
2475
|
+
const issues = [];
|
|
2476
|
+
if (!raw || typeof raw !== "object") {
|
|
2477
|
+
return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
|
|
2478
|
+
}
|
|
2479
|
+
const obj = raw;
|
|
2480
|
+
if (obj.format !== "lkcourse") {
|
|
2481
|
+
issues.push({
|
|
2482
|
+
path: "format",
|
|
2483
|
+
message: `must be "lkcourse" (got ${String(obj.format)})`
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
let schemaVersion = obj.schemaVersion;
|
|
2487
|
+
if (schemaVersion === "1") schemaVersion = 1;
|
|
2488
|
+
if (schemaVersion !== 1) {
|
|
2489
|
+
issues.push({
|
|
2490
|
+
path: "schemaVersion",
|
|
2491
|
+
message: `must be 1 (got ${String(obj.schemaVersion)})`
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
|
|
2495
|
+
if (!lessonkitVersion) {
|
|
2496
|
+
issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
|
|
2497
|
+
}
|
|
2498
|
+
const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
|
|
2499
|
+
if (!exportedAt) {
|
|
2500
|
+
issues.push({ path: "exportedAt", message: "must be a non-empty string" });
|
|
2501
|
+
}
|
|
2502
|
+
const entriesRaw = obj.entries;
|
|
2503
|
+
const entries = [];
|
|
2504
|
+
if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
|
|
2505
|
+
issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
|
|
2506
|
+
} else {
|
|
2507
|
+
for (let i = 0; i < entriesRaw.length; i++) {
|
|
2508
|
+
const entry = entriesRaw[i];
|
|
2509
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
2510
|
+
issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
|
|
2511
|
+
} else {
|
|
2512
|
+
const trimmed = entry.trim();
|
|
2513
|
+
if (!isSafeZipEntryPath(trimmed)) {
|
|
2514
|
+
issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
|
|
2515
|
+
} else {
|
|
2516
|
+
entries.push(trimmed);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
if (issues.length) return { ok: false, issues };
|
|
2522
|
+
const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
|
|
2523
|
+
if (!manifestParsed.ok) {
|
|
2524
|
+
return {
|
|
2525
|
+
ok: false,
|
|
2526
|
+
issues: manifestParsed.issues.map((issue) => ({
|
|
2527
|
+
path: `sourceManifest.${issue.path}`,
|
|
2528
|
+
message: issue.message
|
|
2529
|
+
}))
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
return {
|
|
2533
|
+
ok: true,
|
|
2534
|
+
envelope: {
|
|
2535
|
+
format: "lkcourse",
|
|
2536
|
+
schemaVersion: 1,
|
|
2537
|
+
lessonkitVersion,
|
|
2538
|
+
exportedAt,
|
|
2539
|
+
sourceManifest: manifestParsed.manifest,
|
|
2540
|
+
entries
|
|
2541
|
+
}
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// src/lkcourse/blockTree.ts
|
|
2546
|
+
var import_node_fs6 = require("fs");
|
|
2547
|
+
var import_node_module = require("module");
|
|
2548
|
+
var import_node_path12 = require("path");
|
|
2549
|
+
var import_core5 = require("@lessonkit/core");
|
|
2550
|
+
var import_meta = {};
|
|
2551
|
+
var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
|
|
2552
|
+
var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
|
|
2553
|
+
function stripComments2(source) {
|
|
2554
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
2555
|
+
}
|
|
2556
|
+
function collectSourceUnderSrc2(projectRoot) {
|
|
2557
|
+
const srcDir = (0, import_node_path12.join)(projectRoot, "src");
|
|
2558
|
+
if (!(0, import_node_fs6.existsSync)(srcDir)) return [];
|
|
2559
|
+
const results = [];
|
|
2560
|
+
const walk = (dir) => {
|
|
2561
|
+
for (const entry of (0, import_node_fs6.readdirSync)(dir)) {
|
|
2562
|
+
const abs = (0, import_node_path12.join)(dir, entry);
|
|
2563
|
+
try {
|
|
2564
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
2565
|
+
} catch {
|
|
2566
|
+
continue;
|
|
2567
|
+
}
|
|
2568
|
+
const stat2 = (0, import_node_fs6.lstatSync)(abs);
|
|
2569
|
+
if (stat2.isSymbolicLink()) continue;
|
|
2570
|
+
if (stat2.isDirectory()) {
|
|
2571
|
+
walk(abs);
|
|
2572
|
+
} else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
|
|
2573
|
+
results.push((0, import_node_path12.relative)(projectRoot, abs));
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
};
|
|
2577
|
+
walk(srcDir);
|
|
2578
|
+
return results;
|
|
2579
|
+
}
|
|
2580
|
+
function loadCatalogBlockTypes(blockTypes) {
|
|
2581
|
+
if (blockTypes?.length) return blockTypes;
|
|
2582
|
+
try {
|
|
2583
|
+
const require2 = (0, import_node_module.createRequire)(import_meta.url);
|
|
2584
|
+
const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
|
|
2585
|
+
const catalog = JSON.parse((0, import_node_fs6.readFileSync)(catalogPath, "utf8"));
|
|
2586
|
+
return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
|
|
2587
|
+
} catch {
|
|
2588
|
+
return [
|
|
2589
|
+
"Course",
|
|
2590
|
+
"Lesson",
|
|
2591
|
+
"Scenario",
|
|
2592
|
+
"Quiz",
|
|
2593
|
+
"KnowledgeCheck",
|
|
2594
|
+
"ProgressTracker",
|
|
2595
|
+
"Reflection",
|
|
2596
|
+
"TrueFalse",
|
|
2597
|
+
"MarkTheWords",
|
|
2598
|
+
"FillInTheBlanks",
|
|
2599
|
+
"DragTheWords",
|
|
2600
|
+
"DragAndDrop",
|
|
2601
|
+
"AssessmentSequence",
|
|
2602
|
+
"Text",
|
|
2603
|
+
"Heading",
|
|
2604
|
+
"Image",
|
|
2605
|
+
"Video",
|
|
2606
|
+
"Page",
|
|
2607
|
+
"InteractiveBook",
|
|
2608
|
+
"Slide",
|
|
2609
|
+
"SlideDeck",
|
|
2610
|
+
"TimedCue",
|
|
2611
|
+
"InteractiveVideo",
|
|
2612
|
+
"Summary",
|
|
2613
|
+
"BranchingScenario",
|
|
2614
|
+
"BranchNode",
|
|
2615
|
+
"BranchChoice",
|
|
2616
|
+
"Embed",
|
|
2617
|
+
"Chart"
|
|
2618
|
+
];
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
function extractIdProp(tagSource, prop) {
|
|
2622
|
+
const re = new RegExp(
|
|
2623
|
+
`\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
|
|
2624
|
+
);
|
|
2625
|
+
const match = tagSource.match(re);
|
|
2626
|
+
if (!match) return void 0;
|
|
2627
|
+
return match[1] ?? match[2] ?? match[3];
|
|
2628
|
+
}
|
|
2629
|
+
function parseJsxBlocks(source, blockTypes) {
|
|
2630
|
+
const stripped = stripComments2(source);
|
|
2631
|
+
const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
|
|
2632
|
+
const stack = [];
|
|
2633
|
+
const roots = [];
|
|
2634
|
+
for (const match of stripped.matchAll(tagRe)) {
|
|
2635
|
+
const rawTag = match[1];
|
|
2636
|
+
const attrs = match[2] ?? "";
|
|
2637
|
+
const selfClosing = match[3] === "/";
|
|
2638
|
+
if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
|
|
2639
|
+
const known = blockTypes.has(rawTag);
|
|
2640
|
+
const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
|
|
2641
|
+
for (const prop of ID_PROPS) {
|
|
2642
|
+
const value = extractIdProp(attrs, prop);
|
|
2643
|
+
if (value) node[prop] = value;
|
|
2644
|
+
}
|
|
2645
|
+
if (selfClosing) {
|
|
2646
|
+
if (stack.length) {
|
|
2647
|
+
const parent = stack[stack.length - 1];
|
|
2648
|
+
parent.children = parent.children ?? [];
|
|
2649
|
+
parent.children.push(node);
|
|
2650
|
+
} else {
|
|
2651
|
+
roots.push(node);
|
|
2652
|
+
}
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
const closeRe = new RegExp(`</${rawTag}>`);
|
|
2656
|
+
const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
|
|
2657
|
+
if (!closeMatch) {
|
|
2658
|
+
if (stack.length) {
|
|
2659
|
+
const parent = stack[stack.length - 1];
|
|
2660
|
+
parent.children = parent.children ?? [];
|
|
2661
|
+
parent.children.push(node);
|
|
2662
|
+
} else {
|
|
2663
|
+
roots.push(node);
|
|
2664
|
+
}
|
|
2665
|
+
continue;
|
|
2666
|
+
}
|
|
2667
|
+
stack.push(node);
|
|
2668
|
+
const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
|
|
2669
|
+
const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
|
|
2670
|
+
if (!inner.includes("<")) {
|
|
2671
|
+
stack.pop();
|
|
2672
|
+
if (stack.length) {
|
|
2673
|
+
const parent = stack[stack.length - 1];
|
|
2674
|
+
parent.children = parent.children ?? [];
|
|
2675
|
+
parent.children.push(node);
|
|
2676
|
+
} else {
|
|
2677
|
+
roots.push(node);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
return roots.length ? roots : stack;
|
|
2682
|
+
}
|
|
2683
|
+
function validateNodeIds(node, pathPrefix, issues) {
|
|
2684
|
+
for (const prop of ID_PROPS) {
|
|
2685
|
+
const value = node[prop];
|
|
2686
|
+
if (value === void 0) continue;
|
|
2687
|
+
const validated = (0, import_core5.validateId)(value, prop);
|
|
2688
|
+
if (!validated.ok) {
|
|
2689
|
+
issues.push({
|
|
2690
|
+
path: `${pathPrefix}.${prop}`,
|
|
2691
|
+
message: validated.issues[0]?.message ?? `invalid ${prop}`
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
node.children?.forEach((child, index) => {
|
|
2696
|
+
validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
function validateBlockTreeIds(tree) {
|
|
2700
|
+
const issues = [];
|
|
2701
|
+
tree.blocks.forEach((block, index) => {
|
|
2702
|
+
validateNodeIds(block, `blocks[${index}]`, issues);
|
|
2703
|
+
});
|
|
2704
|
+
return issues;
|
|
2705
|
+
}
|
|
2706
|
+
function extractBlockTree(options) {
|
|
2707
|
+
const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
|
|
2708
|
+
const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
|
|
2709
|
+
const blocks = [];
|
|
2710
|
+
for (const rel of sources) {
|
|
2711
|
+
const abs = (0, import_node_path12.join)(options.projectRoot, rel);
|
|
2712
|
+
if (!(0, import_node_fs6.existsSync)(abs)) continue;
|
|
2713
|
+
const source = (0, import_node_fs6.readFileSync)(abs, "utf8");
|
|
2714
|
+
const parsed = parseJsxBlocks(source, blockTypes);
|
|
2715
|
+
blocks.push(...parsed);
|
|
2716
|
+
}
|
|
2717
|
+
return {
|
|
2718
|
+
schemaVersion: 1,
|
|
2719
|
+
sources,
|
|
2720
|
+
blocks
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// src/lkcourse/export.ts
|
|
2725
|
+
var import_promises3 = require("fs/promises");
|
|
2726
|
+
var import_node_module2 = require("module");
|
|
2727
|
+
var import_node_path13 = require("path");
|
|
2728
|
+
var import_validators2 = require("@lxpack/validators");
|
|
2729
|
+
var import_meta2 = {};
|
|
2730
|
+
function resolveLessonkitVersion(explicit) {
|
|
2731
|
+
if (explicit?.trim()) return explicit.trim();
|
|
2732
|
+
try {
|
|
2733
|
+
const require2 = (0, import_node_module2.createRequire)(import_meta2.url);
|
|
2734
|
+
const pkg = require2("../../package.json");
|
|
2735
|
+
return pkg.version ?? "0.0.0";
|
|
2736
|
+
} catch {
|
|
2737
|
+
return "0.0.0";
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
async function exportLkcourse(options) {
|
|
2741
|
+
const projectRoot = (0, import_node_path13.resolve)(options.projectRoot);
|
|
2742
|
+
const manifest = options.manifest;
|
|
2743
|
+
const spaDistDir = (0, import_node_path13.join)(projectRoot, manifest.paths.spaDistDir);
|
|
2744
|
+
try {
|
|
2745
|
+
assertRealPathUnderRoot(projectRoot, spaDistDir);
|
|
2746
|
+
await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
|
|
2747
|
+
} catch (err) {
|
|
2748
|
+
return {
|
|
2749
|
+
ok: false,
|
|
2750
|
+
issues: [
|
|
2751
|
+
{
|
|
2752
|
+
path: manifest.paths.spaDistDir,
|
|
2753
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2754
|
+
}
|
|
2755
|
+
]
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
const interchange = descriptorToInterchange(manifest.course);
|
|
2759
|
+
const interchangeParsed = (0, import_validators2.parseLessonkitInterchange)(interchange);
|
|
2760
|
+
if (!interchangeParsed.ok) {
|
|
2761
|
+
return {
|
|
2762
|
+
ok: false,
|
|
2763
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2764
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2765
|
+
message: i.message
|
|
2766
|
+
}))
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
const validatedInterchange = interchangeParsed.data;
|
|
2770
|
+
const interchangeCourseId = validatedInterchange.course?.id;
|
|
2771
|
+
if (!interchangeCourseId) {
|
|
2772
|
+
return {
|
|
2773
|
+
ok: false,
|
|
2774
|
+
issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
if (manifest.course.courseId !== interchangeCourseId) {
|
|
2778
|
+
return {
|
|
2779
|
+
ok: false,
|
|
2780
|
+
issues: [
|
|
2781
|
+
{
|
|
2782
|
+
path: "course.courseId",
|
|
2783
|
+
message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
|
|
2784
|
+
}
|
|
2785
|
+
]
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
const zipEntries = /* @__PURE__ */ new Map();
|
|
2789
|
+
const interchangeJson = JSON.stringify(interchange, null, 2);
|
|
2790
|
+
zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
|
|
2791
|
+
let blockTreeJson;
|
|
2792
|
+
if (options.includeBlockTree) {
|
|
2793
|
+
const blockTree = extractBlockTree({ projectRoot });
|
|
2794
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2795
|
+
if (blockTreeIssues.length) {
|
|
2796
|
+
return {
|
|
2797
|
+
ok: false,
|
|
2798
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2799
|
+
path: `block-tree.${issue.path}`,
|
|
2800
|
+
message: issue.message
|
|
2801
|
+
}))
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
blockTreeJson = JSON.stringify(blockTree, null, 2);
|
|
2805
|
+
zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
|
|
2806
|
+
}
|
|
2807
|
+
let distEntries;
|
|
2808
|
+
try {
|
|
2809
|
+
distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
|
|
2810
|
+
} catch (err) {
|
|
2811
|
+
return {
|
|
2812
|
+
ok: false,
|
|
2813
|
+
issues: [
|
|
2814
|
+
{
|
|
2815
|
+
path: manifest.paths.spaDistDir,
|
|
2816
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2817
|
+
}
|
|
2818
|
+
]
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
|
|
2822
|
+
return {
|
|
2823
|
+
ok: false,
|
|
2824
|
+
issues: [
|
|
2825
|
+
{
|
|
2826
|
+
path: `${manifest.paths.spaDistDir}/index.html`,
|
|
2827
|
+
message: "dist must contain index.html before export"
|
|
2828
|
+
}
|
|
2829
|
+
]
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
for (const [path, data] of distEntries) {
|
|
2833
|
+
zipEntries.set(path, data);
|
|
2834
|
+
}
|
|
2835
|
+
const entryPaths = [...zipEntries.keys()].sort();
|
|
2836
|
+
const envelope = {
|
|
2837
|
+
format: "lkcourse",
|
|
2838
|
+
schemaVersion: 1,
|
|
2839
|
+
lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
|
|
2840
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2841
|
+
sourceManifest: manifest,
|
|
2842
|
+
entries: entryPaths
|
|
2843
|
+
};
|
|
2844
|
+
const envelopeCheck = parseLkcourseEnvelope(envelope);
|
|
2845
|
+
if (!envelopeCheck.ok) {
|
|
2846
|
+
return { ok: false, issues: envelopeCheck.issues };
|
|
2847
|
+
}
|
|
2848
|
+
zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
|
|
2849
|
+
const archivePath = (0, import_node_path13.resolve)(
|
|
2850
|
+
projectRoot,
|
|
2851
|
+
options.outPath ?? `${manifest.name}.lkcourse`
|
|
2852
|
+
);
|
|
2853
|
+
try {
|
|
2854
|
+
assertRealPathUnderRoot(projectRoot, archivePath);
|
|
2855
|
+
} catch (err) {
|
|
2856
|
+
return {
|
|
2857
|
+
ok: false,
|
|
2858
|
+
issues: [
|
|
2859
|
+
{
|
|
2860
|
+
path: options.outPath ?? `${manifest.name}.lkcourse`,
|
|
2861
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2862
|
+
}
|
|
2863
|
+
]
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
|
|
2867
|
+
return {
|
|
2868
|
+
ok: false,
|
|
2869
|
+
issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
try {
|
|
2873
|
+
await (0, import_promises3.mkdir)((0, import_node_path13.dirname)(archivePath), { recursive: true });
|
|
2874
|
+
const zipped = createZip(zipEntries);
|
|
2875
|
+
await (0, import_promises3.writeFile)(archivePath, zipped);
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
return {
|
|
2878
|
+
ok: false,
|
|
2879
|
+
issues: [
|
|
2880
|
+
{
|
|
2881
|
+
path: archivePath,
|
|
2882
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2883
|
+
}
|
|
2884
|
+
]
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
return {
|
|
2888
|
+
ok: true,
|
|
2889
|
+
archivePath,
|
|
2890
|
+
fileCount: zipEntries.size,
|
|
2891
|
+
includeBlockTree: Boolean(options.includeBlockTree)
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// src/lkcourse/validate.ts
|
|
2896
|
+
var import_validators3 = require("@lxpack/validators");
|
|
2897
|
+
function validateLkcourseArchiveEntries(entries, _archiveLabel) {
|
|
2898
|
+
const issues = [];
|
|
2899
|
+
const manifestData = entries.get("manifest.json");
|
|
2900
|
+
if (!manifestData) {
|
|
2901
|
+
return {
|
|
2902
|
+
ok: false,
|
|
2903
|
+
issues: [{ path: "manifest.json", message: "required file missing from archive" }]
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
let envelopeRaw;
|
|
2907
|
+
try {
|
|
2908
|
+
envelopeRaw = JSON.parse(entryToUtf8(manifestData));
|
|
2909
|
+
} catch {
|
|
2910
|
+
return {
|
|
2911
|
+
ok: false,
|
|
2912
|
+
issues: [{ path: "manifest.json", message: "invalid JSON" }]
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
|
|
2916
|
+
if (!envelopeParsed.ok) {
|
|
2917
|
+
return { ok: false, issues: envelopeParsed.issues };
|
|
2918
|
+
}
|
|
2919
|
+
const envelope = envelopeParsed.envelope;
|
|
2920
|
+
const interchangeData = entries.get("interchange.json");
|
|
2921
|
+
if (!interchangeData) {
|
|
2922
|
+
issues.push({ path: "interchange.json", message: "required file missing from archive" });
|
|
2923
|
+
}
|
|
2924
|
+
const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
2925
|
+
const spaIndexPath = `${spaDistDir}/index.html`;
|
|
2926
|
+
if (!entries.has(spaIndexPath)) {
|
|
2927
|
+
issues.push({ path: spaIndexPath, message: "required file missing from archive" });
|
|
2928
|
+
}
|
|
2929
|
+
for (const entryPath of envelope.entries) {
|
|
2930
|
+
if (!entries.has(entryPath)) {
|
|
2931
|
+
issues.push({
|
|
2932
|
+
path: entryPath,
|
|
2933
|
+
message: "listed in manifest.entries but missing from archive"
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
if (issues.length) return { ok: false, issues };
|
|
2938
|
+
let interchangeRaw;
|
|
2939
|
+
try {
|
|
2940
|
+
interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
|
|
2941
|
+
} catch {
|
|
2942
|
+
return {
|
|
2943
|
+
ok: false,
|
|
2944
|
+
issues: [{ path: "interchange.json", message: "invalid JSON" }]
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
const interchangeParsed = (0, import_validators3.parseLessonkitInterchange)(interchangeRaw);
|
|
2948
|
+
if (!interchangeParsed.ok) {
|
|
2949
|
+
return {
|
|
2950
|
+
ok: false,
|
|
2951
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2952
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2953
|
+
message: i.message
|
|
2954
|
+
}))
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
const interchange = interchangeParsed.data;
|
|
2958
|
+
const interchangeCourseId = interchange.course?.id;
|
|
2959
|
+
if (!interchangeCourseId) {
|
|
2960
|
+
issues.push({
|
|
2961
|
+
path: "interchange.course.id",
|
|
2962
|
+
message: "missing course id in interchange"
|
|
2963
|
+
});
|
|
2964
|
+
} else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
|
|
2965
|
+
issues.push({
|
|
2966
|
+
path: "sourceManifest.course.courseId",
|
|
2967
|
+
message: `does not match interchange.course.id (${interchangeCourseId})`
|
|
2968
|
+
});
|
|
2969
|
+
}
|
|
2970
|
+
if (issues.length) return { ok: false, issues };
|
|
2971
|
+
const blockTreeData = entries.get("block-tree.json");
|
|
2972
|
+
if (blockTreeData) {
|
|
2973
|
+
let blockTreeRaw;
|
|
2974
|
+
try {
|
|
2975
|
+
blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
|
|
2976
|
+
} catch {
|
|
2977
|
+
return {
|
|
2978
|
+
ok: false,
|
|
2979
|
+
issues: [{ path: "block-tree.json", message: "invalid JSON" }]
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
const blockTree = blockTreeRaw;
|
|
2983
|
+
if (Array.isArray(blockTree?.blocks)) {
|
|
2984
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2985
|
+
if (blockTreeIssues.length) {
|
|
2986
|
+
return {
|
|
2987
|
+
ok: false,
|
|
2988
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2989
|
+
path: `block-tree.${issue.path}`,
|
|
2990
|
+
message: issue.message
|
|
2991
|
+
}))
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
return {
|
|
2997
|
+
ok: true,
|
|
2998
|
+
envelope,
|
|
2999
|
+
interchange
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
function validateLkcourse(archivePath) {
|
|
3003
|
+
const read = readZip(archivePath);
|
|
3004
|
+
if (!read.ok) return read;
|
|
3005
|
+
return validateLkcourseArchiveEntries(read.entries, archivePath);
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// src/lkcourse/import.ts
|
|
3009
|
+
var import_promises4 = require("fs/promises");
|
|
3010
|
+
var import_node_path14 = require("path");
|
|
3011
|
+
var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
|
|
3012
|
+
async function pathExists2(path) {
|
|
3013
|
+
try {
|
|
3014
|
+
await (0, import_promises4.access)(path);
|
|
3015
|
+
return true;
|
|
3016
|
+
} catch {
|
|
3017
|
+
return false;
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
async function renameOrCopy2(from, to, opts) {
|
|
3021
|
+
const renameFn = opts?.renameFn ?? import_promises4.rename;
|
|
3022
|
+
try {
|
|
3023
|
+
await renameFn(from, to);
|
|
3024
|
+
} catch (err) {
|
|
3025
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
3026
|
+
if (code !== "EXDEV") throw err;
|
|
3027
|
+
await (0, import_promises4.cp)(from, to, { recursive: true });
|
|
3028
|
+
await (0, import_promises4.rm)(from, { recursive: true, force: true });
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
|
|
3032
|
+
let fileCount = 0;
|
|
3033
|
+
await (0, import_promises4.writeFile)(
|
|
3034
|
+
(0, import_node_path14.join)(stagingDir, "lessonkit.json"),
|
|
3035
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
3036
|
+
`,
|
|
3037
|
+
"utf8"
|
|
3038
|
+
);
|
|
3039
|
+
fileCount += 1;
|
|
3040
|
+
for (const [entryPath, data] of entries) {
|
|
3041
|
+
const normalized = entryPath.replace(/\\/g, "/");
|
|
3042
|
+
if (!normalized.startsWith(`${spaDistDir}/`)) continue;
|
|
3043
|
+
const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
|
|
3044
|
+
const outPath = (0, import_node_path14.join)(stagingDir, spaDistDir, relativeUnderSpa);
|
|
3045
|
+
const resolvedOut = (0, import_node_path14.resolve)(outPath);
|
|
3046
|
+
assertRealPathUnderRoot(stagingDir, resolvedOut);
|
|
3047
|
+
if (!isSafeZipEntryPath((0, import_node_path14.join)(spaDistDir, relativeUnderSpa))) {
|
|
3048
|
+
throw new Error(`unsafe extraction path: ${entryPath}`);
|
|
3049
|
+
}
|
|
3050
|
+
await (0, import_promises4.mkdir)((0, import_node_path14.dirname)(resolvedOut), { recursive: true });
|
|
3051
|
+
await (0, import_promises4.writeFile)(resolvedOut, data);
|
|
3052
|
+
fileCount += 1;
|
|
3053
|
+
}
|
|
3054
|
+
return fileCount;
|
|
3055
|
+
}
|
|
3056
|
+
async function backupImportArtifacts(targetDir) {
|
|
3057
|
+
const existing = [];
|
|
3058
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3059
|
+
if (await pathExists2((0, import_node_path14.join)(targetDir, name))) {
|
|
3060
|
+
existing.push(name);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
if (!existing.length) return void 0;
|
|
3064
|
+
const backupDir = await (0, import_promises4.mkdtemp)((0, import_node_path14.join)(targetDir, ".lkcourse-backup-"));
|
|
3065
|
+
for (const name of existing) {
|
|
3066
|
+
await renameOrCopy2((0, import_node_path14.join)(targetDir, name), (0, import_node_path14.join)(backupDir, name));
|
|
3067
|
+
}
|
|
3068
|
+
return backupDir;
|
|
3069
|
+
}
|
|
3070
|
+
async function restoreImportBackup(targetDir, backupDir) {
|
|
3071
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3072
|
+
const backupPath = (0, import_node_path14.join)(backupDir, name);
|
|
3073
|
+
if (!await pathExists2(backupPath)) continue;
|
|
3074
|
+
const destPath = (0, import_node_path14.join)(targetDir, name);
|
|
3075
|
+
if (await pathExists2(destPath)) {
|
|
3076
|
+
await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
|
|
3077
|
+
}
|
|
3078
|
+
await renameOrCopy2(backupPath, destPath);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
async function promoteImportStaging(stagingDir, targetDir) {
|
|
3082
|
+
const entries = await (0, import_promises4.readdir)(stagingDir, { withFileTypes: true });
|
|
3083
|
+
for (const entry of entries) {
|
|
3084
|
+
const srcPath = (0, import_node_path14.join)(stagingDir, entry.name);
|
|
3085
|
+
const destPath = (0, import_node_path14.join)(targetDir, entry.name);
|
|
3086
|
+
if (entry.isDirectory()) {
|
|
3087
|
+
await (0, import_promises4.cp)(srcPath, destPath, { recursive: true, force: true });
|
|
3088
|
+
} else if (entry.isFile()) {
|
|
3089
|
+
await (0, import_promises4.mkdir)((0, import_node_path14.dirname)(destPath), { recursive: true });
|
|
3090
|
+
await (0, import_promises4.cp)(srcPath, destPath);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
var promoteImportStagingImpl = promoteImportStaging;
|
|
3095
|
+
async function importLkcourse(options) {
|
|
3096
|
+
const archivePath = (0, import_node_path14.resolve)(options.archivePath);
|
|
3097
|
+
const targetDir = (0, import_node_path14.resolve)(options.targetDir);
|
|
3098
|
+
const validated = validateLkcourse(archivePath);
|
|
3099
|
+
if (!validated.ok) return validated;
|
|
3100
|
+
const { envelope, interchange } = validated;
|
|
3101
|
+
const manifest = envelope.sourceManifest;
|
|
3102
|
+
const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
3103
|
+
try {
|
|
3104
|
+
await (0, import_promises4.mkdir)(targetDir, { recursive: true });
|
|
3105
|
+
assertRealPathUnderRoot(targetDir, targetDir);
|
|
3106
|
+
} catch (err) {
|
|
3107
|
+
return {
|
|
3108
|
+
ok: false,
|
|
3109
|
+
issues: [
|
|
3110
|
+
{
|
|
3111
|
+
path: targetDir,
|
|
3112
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3113
|
+
}
|
|
3114
|
+
]
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
const read = readZip(archivePath);
|
|
3118
|
+
if (!read.ok) return read;
|
|
3119
|
+
let stagingDir;
|
|
3120
|
+
let backupDir;
|
|
3121
|
+
try {
|
|
3122
|
+
stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path14.join)(targetDir, ".lkcourse-import-"));
|
|
3123
|
+
const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
|
|
3124
|
+
backupDir = await backupImportArtifacts(targetDir);
|
|
3125
|
+
try {
|
|
3126
|
+
await promoteImportStagingImpl(stagingDir, targetDir);
|
|
3127
|
+
} catch (promoteError) {
|
|
3128
|
+
if (backupDir) {
|
|
3129
|
+
await restoreImportBackup(targetDir, backupDir);
|
|
3130
|
+
}
|
|
3131
|
+
throw promoteError;
|
|
3132
|
+
}
|
|
3133
|
+
if (backupDir) {
|
|
3134
|
+
await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3135
|
+
backupDir = void 0;
|
|
3136
|
+
}
|
|
3137
|
+
await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true });
|
|
3138
|
+
stagingDir = void 0;
|
|
3139
|
+
return {
|
|
3140
|
+
ok: true,
|
|
3141
|
+
targetDir,
|
|
3142
|
+
manifest,
|
|
3143
|
+
interchange,
|
|
3144
|
+
fileCount
|
|
3145
|
+
};
|
|
3146
|
+
} catch (err) {
|
|
3147
|
+
if (backupDir) {
|
|
3148
|
+
await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
|
|
3149
|
+
await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3150
|
+
}
|
|
3151
|
+
if (stagingDir) {
|
|
3152
|
+
await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3153
|
+
}
|
|
3154
|
+
return {
|
|
3155
|
+
ok: false,
|
|
3156
|
+
issues: [
|
|
3157
|
+
{
|
|
3158
|
+
path: targetDir,
|
|
3159
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3160
|
+
}
|
|
3161
|
+
]
|
|
3162
|
+
};
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
2177
3165
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2178
3166
|
0 && (module.exports = {
|
|
2179
3167
|
LESSONKIT_TELEMETRY_EVENTS,
|
|
3168
|
+
assertSpaDistContentsSafe,
|
|
2180
3169
|
assessmentDescriptorToLxpack,
|
|
2181
3170
|
buildLessonkitProject,
|
|
2182
3171
|
buildStagingPackage,
|
|
2183
3172
|
descriptorToInterchange,
|
|
2184
3173
|
ensureOutDirParent,
|
|
2185
3174
|
escapeShellText,
|
|
3175
|
+
exportLkcourse,
|
|
2186
3176
|
extractAssessments,
|
|
3177
|
+
extractBlockTree,
|
|
3178
|
+
importLkcourse,
|
|
2187
3179
|
lessonkitInterchangeSchema,
|
|
2188
3180
|
loadLessonkitManifestFromFile,
|
|
2189
3181
|
mapLessonkitIds,
|
|
@@ -2193,6 +3185,7 @@ var import_validators2 = require("@lxpack/validators");
|
|
|
2193
3185
|
packageLessonkitCourse,
|
|
2194
3186
|
parseLessonkitInterchange,
|
|
2195
3187
|
parseLessonkitManifest,
|
|
3188
|
+
parseLkcourseEnvelope,
|
|
2196
3189
|
promoteStagingToOutDir,
|
|
2197
3190
|
remapArtifactPaths,
|
|
2198
3191
|
resolveSafePackageOutputOverride,
|
|
@@ -2202,6 +3195,8 @@ var import_validators2 = require("@lxpack/validators");
|
|
|
2202
3195
|
validateDescriptor,
|
|
2203
3196
|
validateDescriptorForTarget,
|
|
2204
3197
|
validateLessonkitProject,
|
|
3198
|
+
validateLkcourse,
|
|
3199
|
+
validateLkcourseArchiveEntries,
|
|
2205
3200
|
validatePackageInputs,
|
|
2206
3201
|
validateProjectPaths,
|
|
2207
3202
|
validateReactManifestParity,
|