@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.js
CHANGED
|
@@ -99,10 +99,18 @@ function parseAssessmentDescriptor(raw) {
|
|
|
99
99
|
};
|
|
100
100
|
const kind = raw.kind;
|
|
101
101
|
if (kind === "trueFalse") {
|
|
102
|
+
let answer;
|
|
103
|
+
if (typeof raw.answer === "boolean") {
|
|
104
|
+
answer = raw.answer;
|
|
105
|
+
} else if (raw.answer === "true") {
|
|
106
|
+
answer = true;
|
|
107
|
+
} else if (raw.answer === "false") {
|
|
108
|
+
answer = false;
|
|
109
|
+
}
|
|
102
110
|
return {
|
|
103
111
|
kind: "trueFalse",
|
|
104
112
|
...base,
|
|
105
|
-
answer
|
|
113
|
+
answer
|
|
106
114
|
};
|
|
107
115
|
}
|
|
108
116
|
if (kind === "fillInBlanks") {
|
|
@@ -280,6 +288,98 @@ function isResolvedPathUnderRoot(root, target) {
|
|
|
280
288
|
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
281
289
|
}
|
|
282
290
|
|
|
291
|
+
// src/validateProjectPaths.ts
|
|
292
|
+
import { existsSync as existsSync2, realpathSync as realpathSync2 } from "fs";
|
|
293
|
+
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
294
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
295
|
+
function isReservedOutputPath(value) {
|
|
296
|
+
let normalized = value.replace(/\\/g, "/");
|
|
297
|
+
while (normalized.startsWith("/")) normalized = normalized.slice(1);
|
|
298
|
+
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
299
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
300
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
301
|
+
}
|
|
302
|
+
function isReservedResolvedOutputPath(projectRoot, resolved) {
|
|
303
|
+
const rootResolved = resolveComparablePath(projectRoot);
|
|
304
|
+
const targetResolved = resolveComparablePath(resolved);
|
|
305
|
+
try {
|
|
306
|
+
const rootReal = existsSync2(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
|
|
307
|
+
const targetReal = existsSync2(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
|
|
308
|
+
const rel = relativePathUnderRoot(rootReal, targetReal);
|
|
309
|
+
return isReservedOutputPath(rel);
|
|
310
|
+
} catch {
|
|
311
|
+
return isReservedOutputPath(resolved);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
315
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
316
|
+
issues.push({
|
|
317
|
+
path: fieldPath,
|
|
318
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
323
|
+
issues.push({
|
|
324
|
+
path: fieldPath,
|
|
325
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
331
|
+
} catch {
|
|
332
|
+
issues.push({
|
|
333
|
+
path: fieldPath,
|
|
334
|
+
message: "path must resolve inside the project root"
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function validateProjectPaths(projectRoot, paths) {
|
|
339
|
+
const issues = [];
|
|
340
|
+
const root = resolve2(projectRoot);
|
|
341
|
+
if (paths.spaDistDir?.trim()) {
|
|
342
|
+
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
|
|
343
|
+
rejectReserved: true
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (paths.lxpackOutDir?.trim()) {
|
|
347
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
348
|
+
rejectReserved: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (paths.outputBaseDir?.trim()) {
|
|
352
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
353
|
+
rejectReserved: true
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return issues;
|
|
357
|
+
}
|
|
358
|
+
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
359
|
+
const root = resolve2(projectRoot);
|
|
360
|
+
const trimmed = override.trim();
|
|
361
|
+
if (!trimmed) {
|
|
362
|
+
throw new Error("output override must be a non-empty path");
|
|
363
|
+
}
|
|
364
|
+
if (isAbsolute2(trimmed)) {
|
|
365
|
+
const resolved2 = resolve2(trimmed);
|
|
366
|
+
assertRealPathUnderRoot(root, resolved2);
|
|
367
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
|
|
368
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
369
|
+
}
|
|
370
|
+
return resolved2;
|
|
371
|
+
}
|
|
372
|
+
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
373
|
+
throw new Error(`unsafe output path: ${override}`);
|
|
374
|
+
}
|
|
375
|
+
const resolved = resolve2(root, trimmed);
|
|
376
|
+
assertRealPathUnderRoot(root, resolved);
|
|
377
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
|
|
378
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
379
|
+
}
|
|
380
|
+
return resolved;
|
|
381
|
+
}
|
|
382
|
+
|
|
283
383
|
// src/theme.ts
|
|
284
384
|
import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
|
|
285
385
|
function themeToLxpackRuntime(input) {
|
|
@@ -356,8 +456,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
356
456
|
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
357
457
|
});
|
|
358
458
|
}
|
|
359
|
-
const explicitBlanks =
|
|
360
|
-
if (assessment.blanks !== void 0
|
|
459
|
+
const explicitBlanks = [];
|
|
460
|
+
if (assessment.blanks !== void 0) {
|
|
461
|
+
for (let i = 0; i < assessment.blanks.length; i++) {
|
|
462
|
+
const blank = assessment.blanks[i];
|
|
463
|
+
if (!blank || typeof blank !== "object") {
|
|
464
|
+
issues.push({
|
|
465
|
+
path: `${path}.blanks[${i}]`,
|
|
466
|
+
message: "blank entry must be an object with non-empty id and answer"
|
|
467
|
+
});
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const id = blank.id?.trim() ?? "";
|
|
471
|
+
const answer = blank.answer?.trim() ?? "";
|
|
472
|
+
if (!id || !answer) {
|
|
473
|
+
issues.push({
|
|
474
|
+
path: `${path}.blanks[${i}]`,
|
|
475
|
+
message: "blank entry must include non-empty id and answer"
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
explicitBlanks.push({ id, answer });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
|
|
361
483
|
issues.push({
|
|
362
484
|
path: `${path}.blanks`,
|
|
363
485
|
message: "blanks must include at least one entry with non-empty id and answer"
|
|
@@ -505,6 +627,20 @@ function validateCourseDescriptor(input) {
|
|
|
505
627
|
});
|
|
506
628
|
}
|
|
507
629
|
}
|
|
630
|
+
const descriptorSpaDistDir = input.spaDistDir?.trim();
|
|
631
|
+
if (descriptorSpaDistDir) {
|
|
632
|
+
if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
|
|
633
|
+
issues.push({
|
|
634
|
+
path: "spaDistDir",
|
|
635
|
+
message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
|
|
636
|
+
});
|
|
637
|
+
} else if (isReservedOutputPath(descriptorSpaDistDir)) {
|
|
638
|
+
issues.push({
|
|
639
|
+
path: "spaDistDir",
|
|
640
|
+
message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
508
644
|
if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
|
|
509
645
|
issues.push({
|
|
510
646
|
path: "lessons",
|
|
@@ -565,6 +701,7 @@ function validateCourseDescriptor(input) {
|
|
|
565
701
|
}
|
|
566
702
|
|
|
567
703
|
// src/assessments.ts
|
|
704
|
+
var DEFAULT_SHELL_PASSING_SCORE = 1;
|
|
568
705
|
function escapeShellText(text) {
|
|
569
706
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
570
707
|
}
|
|
@@ -573,7 +710,7 @@ function decodeShellEntities(text) {
|
|
|
573
710
|
}
|
|
574
711
|
function containsUnsafeShellMarkup(text) {
|
|
575
712
|
const decoded = decodeShellEntities(text);
|
|
576
|
-
return /<\/script/i.test(decoded) || /<!--/.test(decoded) ||
|
|
713
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
|
|
577
714
|
}
|
|
578
715
|
function sanitizeShellField(text) {
|
|
579
716
|
if (containsUnsafeShellMarkup(text)) return null;
|
|
@@ -588,6 +725,7 @@ function mcqToLxpack(assessment) {
|
|
|
588
725
|
const checkId = sanitizeShellField(assessment.checkId);
|
|
589
726
|
const prompt = sanitizeShellField(assessment.question);
|
|
590
727
|
if (!checkId || !prompt) return null;
|
|
728
|
+
const normalizedAnswer = assessment.answer.trim();
|
|
591
729
|
const choices = assessment.choices.map((text, index) => {
|
|
592
730
|
const sanitizedText = sanitizeShellField(text);
|
|
593
731
|
if (!sanitizedText) return null;
|
|
@@ -595,13 +733,13 @@ function mcqToLxpack(assessment) {
|
|
|
595
733
|
return {
|
|
596
734
|
id,
|
|
597
735
|
text: sanitizedText,
|
|
598
|
-
correct: text ===
|
|
736
|
+
correct: text.trim() === normalizedAnswer
|
|
599
737
|
};
|
|
600
738
|
});
|
|
601
739
|
if (choices.some((choice) => choice === null)) return null;
|
|
602
740
|
return {
|
|
603
741
|
id: checkId,
|
|
604
|
-
passingScore: assessment.passingScore ??
|
|
742
|
+
passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
|
|
605
743
|
questions: [
|
|
606
744
|
{
|
|
607
745
|
id: "q1",
|
|
@@ -646,11 +784,14 @@ function extractAssessments(descriptor) {
|
|
|
646
784
|
// src/descriptor/validateInjectableAssessments.ts
|
|
647
785
|
function validateInjectableAssessments(descriptor) {
|
|
648
786
|
const issues = [];
|
|
787
|
+
const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
|
|
649
788
|
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
650
789
|
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
790
|
+
const kind = assessment.kind ?? "mcq";
|
|
791
|
+
const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
|
|
651
792
|
issues.push({
|
|
652
793
|
path: `assessments[${index}]`,
|
|
653
|
-
message: `assessment kind "${
|
|
794
|
+
message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
|
|
654
795
|
});
|
|
655
796
|
}
|
|
656
797
|
});
|
|
@@ -666,7 +807,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
666
807
|
"cmi5"
|
|
667
808
|
]);
|
|
668
809
|
function appendActivityIriIssues(issues, descriptor, target) {
|
|
669
|
-
const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
|
|
810
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
|
|
670
811
|
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
671
812
|
if (!hasXapiTracking && !requiresForTarget) return;
|
|
672
813
|
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
@@ -721,12 +862,12 @@ function validateDescriptorForTarget(input, target) {
|
|
|
721
862
|
}
|
|
722
863
|
|
|
723
864
|
// src/validateReactParity.ts
|
|
724
|
-
import { readFileSync, existsSync as
|
|
865
|
+
import { readFileSync, existsSync as existsSync3, readdirSync, lstatSync } from "fs";
|
|
725
866
|
import { join as join2, relative as relative2 } from "path";
|
|
726
867
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
727
868
|
function collectSourceUnderSrc(projectRoot, issues) {
|
|
728
869
|
const srcDir = join2(projectRoot, "src");
|
|
729
|
-
if (!
|
|
870
|
+
if (!existsSync3(srcDir)) return [];
|
|
730
871
|
const results = [];
|
|
731
872
|
const walk = (dir) => {
|
|
732
873
|
for (const entry of readdirSync(dir)) {
|
|
@@ -790,7 +931,7 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
|
|
|
790
931
|
const abs = join2(projectRoot, rel);
|
|
791
932
|
try {
|
|
792
933
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
793
|
-
if (
|
|
934
|
+
if (existsSync3(abs) && lstatSync(abs).isSymbolicLink()) {
|
|
794
935
|
issues.push({
|
|
795
936
|
path: rel,
|
|
796
937
|
message: `appSources path is a symlink: ${rel}`,
|
|
@@ -806,7 +947,7 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
|
|
|
806
947
|
});
|
|
807
948
|
return null;
|
|
808
949
|
}
|
|
809
|
-
if (!
|
|
950
|
+
if (!existsSync3(abs)) return null;
|
|
810
951
|
return readFileSync(abs, "utf8");
|
|
811
952
|
}).filter((content) => content != null).join("\n");
|
|
812
953
|
}
|
|
@@ -882,9 +1023,20 @@ function courseConfigCourseIdPresent(source, courseId) {
|
|
|
882
1023
|
if (literalPattern.test(stripped)) return true;
|
|
883
1024
|
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
884
1025
|
}
|
|
1026
|
+
function courseMetaCourseIdPresent(source, courseId) {
|
|
1027
|
+
const constants = extractStringConstants(source);
|
|
1028
|
+
const stripped = stripComments(source);
|
|
1029
|
+
for (const [name, value] of constants) {
|
|
1030
|
+
if (value !== courseId) continue;
|
|
1031
|
+
if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
|
|
1032
|
+
if (/\blessons\s*:\s*\S/.test(stripped)) return true;
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
885
1036
|
function courseIdPresent(source, courseId) {
|
|
886
1037
|
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
887
1038
|
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
1039
|
+
if (courseMetaCourseIdPresent(source, courseId)) return true;
|
|
888
1040
|
return courseConfigCourseIdPresent(source, courseId);
|
|
889
1041
|
}
|
|
890
1042
|
function checkIdPresent(source, checkId) {
|
|
@@ -953,81 +1105,6 @@ function validateReactManifestParity(opts) {
|
|
|
953
1105
|
return issues;
|
|
954
1106
|
}
|
|
955
1107
|
|
|
956
|
-
// src/validateProjectPaths.ts
|
|
957
|
-
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
958
|
-
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
959
|
-
function isReservedOutputPath(value) {
|
|
960
|
-
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
961
|
-
const segments = normalized.split("/").filter(Boolean);
|
|
962
|
-
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
963
|
-
}
|
|
964
|
-
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
965
|
-
if (!isSafeRelativeSpaPath(value)) {
|
|
966
|
-
issues.push({
|
|
967
|
-
path: fieldPath,
|
|
968
|
-
message: "path must be relative without '..' segments or absolute prefixes"
|
|
969
|
-
});
|
|
970
|
-
return;
|
|
971
|
-
}
|
|
972
|
-
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
973
|
-
issues.push({
|
|
974
|
-
path: fieldPath,
|
|
975
|
-
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
976
|
-
});
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
try {
|
|
980
|
-
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
981
|
-
} catch {
|
|
982
|
-
issues.push({
|
|
983
|
-
path: fieldPath,
|
|
984
|
-
message: "path must resolve inside the project root"
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
function validateProjectPaths(projectRoot, paths) {
|
|
989
|
-
const issues = [];
|
|
990
|
-
const root = resolve2(projectRoot);
|
|
991
|
-
if (paths.spaDistDir?.trim()) {
|
|
992
|
-
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
993
|
-
}
|
|
994
|
-
if (paths.lxpackOutDir?.trim()) {
|
|
995
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
996
|
-
rejectReserved: true
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
if (paths.outputBaseDir?.trim()) {
|
|
1000
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1001
|
-
rejectReserved: true
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
return issues;
|
|
1005
|
-
}
|
|
1006
|
-
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
1007
|
-
const root = resolve2(projectRoot);
|
|
1008
|
-
const trimmed = override.trim();
|
|
1009
|
-
if (!trimmed) {
|
|
1010
|
-
throw new Error("output override must be a non-empty path");
|
|
1011
|
-
}
|
|
1012
|
-
if (isAbsolute2(trimmed)) {
|
|
1013
|
-
const resolved2 = resolve2(trimmed);
|
|
1014
|
-
assertRealPathUnderRoot(root, resolved2);
|
|
1015
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1016
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1017
|
-
}
|
|
1018
|
-
return resolved2;
|
|
1019
|
-
}
|
|
1020
|
-
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
1021
|
-
throw new Error(`unsafe output path: ${override}`);
|
|
1022
|
-
}
|
|
1023
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1024
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1025
|
-
}
|
|
1026
|
-
const resolved = resolve2(root, trimmed);
|
|
1027
|
-
assertRealPathUnderRoot(root, resolved);
|
|
1028
|
-
return resolved;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
1108
|
// src/mapIds.ts
|
|
1032
1109
|
import { assertValidId } from "@lessonkit/core";
|
|
1033
1110
|
function mapLessonkitIds(descriptor) {
|
|
@@ -1159,7 +1236,7 @@ async function resolveSpaDirs(options) {
|
|
|
1159
1236
|
|
|
1160
1237
|
// src/spaDistValidation.ts
|
|
1161
1238
|
import { lstat, readdir } from "fs/promises";
|
|
1162
|
-
import { realpathSync as
|
|
1239
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
1163
1240
|
import { join as join4 } from "path";
|
|
1164
1241
|
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
1165
1242
|
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
@@ -1170,7 +1247,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
|
1170
1247
|
}
|
|
1171
1248
|
let rootReal;
|
|
1172
1249
|
try {
|
|
1173
|
-
rootReal =
|
|
1250
|
+
rootReal = realpathSync3(dirResolved);
|
|
1174
1251
|
} catch {
|
|
1175
1252
|
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
1176
1253
|
}
|
|
@@ -1199,7 +1276,7 @@ async function walkDistDir(rootReal, current, label) {
|
|
|
1199
1276
|
}
|
|
1200
1277
|
let entryReal;
|
|
1201
1278
|
try {
|
|
1202
|
-
entryReal =
|
|
1279
|
+
entryReal = realpathSync3(entryPath);
|
|
1203
1280
|
} catch (err) {
|
|
1204
1281
|
throw new Error(
|
|
1205
1282
|
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
@@ -1224,7 +1301,9 @@ async function writeLxpackProject(options) {
|
|
|
1224
1301
|
const descriptor = validation.descriptor;
|
|
1225
1302
|
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1226
1303
|
if (injectableIssues.length > 0) {
|
|
1227
|
-
throw new Error(
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
|
|
1306
|
+
);
|
|
1228
1307
|
}
|
|
1229
1308
|
const outDir = resolve4(options.outDir);
|
|
1230
1309
|
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
@@ -1290,6 +1369,19 @@ function validatePackageInputs(options) {
|
|
|
1290
1369
|
]
|
|
1291
1370
|
};
|
|
1292
1371
|
}
|
|
1372
|
+
if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
|
|
1373
|
+
return {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
courseDir: outDir,
|
|
1376
|
+
target,
|
|
1377
|
+
issues: [
|
|
1378
|
+
{
|
|
1379
|
+
path: "outDir",
|
|
1380
|
+
message: "outDir must not target reserved directories (.git, node_modules, .github)"
|
|
1381
|
+
}
|
|
1382
|
+
]
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1293
1385
|
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
1294
1386
|
return {
|
|
1295
1387
|
ok: false,
|
|
@@ -1347,6 +1439,19 @@ function validatePackageInputs(options) {
|
|
|
1347
1439
|
]
|
|
1348
1440
|
};
|
|
1349
1441
|
}
|
|
1442
|
+
if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
|
|
1443
|
+
return {
|
|
1444
|
+
ok: false,
|
|
1445
|
+
courseDir: outDir,
|
|
1446
|
+
target,
|
|
1447
|
+
issues: [
|
|
1448
|
+
{
|
|
1449
|
+
path: "outputBaseDir",
|
|
1450
|
+
message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
|
|
1451
|
+
}
|
|
1452
|
+
]
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1350
1455
|
}
|
|
1351
1456
|
if (output) {
|
|
1352
1457
|
const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
|
|
@@ -1368,6 +1473,35 @@ function validatePackageInputs(options) {
|
|
|
1368
1473
|
]
|
|
1369
1474
|
};
|
|
1370
1475
|
}
|
|
1476
|
+
const outputRel = isAbsolute3(output) ? output : output;
|
|
1477
|
+
if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
|
|
1478
|
+
return {
|
|
1479
|
+
ok: false,
|
|
1480
|
+
courseDir: outDir,
|
|
1481
|
+
target,
|
|
1482
|
+
issues: [
|
|
1483
|
+
{
|
|
1484
|
+
path: "output",
|
|
1485
|
+
message: "output must not target reserved directories (.git, node_modules, .github)"
|
|
1486
|
+
}
|
|
1487
|
+
]
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
relativePathUnderRoot(outDir, resolvedOutput);
|
|
1492
|
+
} catch {
|
|
1493
|
+
return {
|
|
1494
|
+
ok: false,
|
|
1495
|
+
courseDir: outDir,
|
|
1496
|
+
target,
|
|
1497
|
+
issues: [
|
|
1498
|
+
{
|
|
1499
|
+
path: "output",
|
|
1500
|
+
message: "output must resolve inside outDir"
|
|
1501
|
+
}
|
|
1502
|
+
]
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1371
1505
|
}
|
|
1372
1506
|
return { ok: true, outDir, projectRoot };
|
|
1373
1507
|
}
|
|
@@ -1460,11 +1594,14 @@ async function isStalePromoteLock(lockPath) {
|
|
|
1460
1594
|
return true;
|
|
1461
1595
|
}
|
|
1462
1596
|
}
|
|
1597
|
+
var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
|
|
1463
1598
|
async function withPromoteLock(outDir, fn) {
|
|
1464
1599
|
const lockPath = promoteLockPath(outDir);
|
|
1465
1600
|
await fsp.mkdir(dirname(outDir), { recursive: true });
|
|
1466
1601
|
let lockHandle;
|
|
1467
|
-
|
|
1602
|
+
const maxAttempts = 400;
|
|
1603
|
+
const started = Date.now();
|
|
1604
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1468
1605
|
try {
|
|
1469
1606
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1470
1607
|
await lockHandle.writeFile(`${process.pid}
|
|
@@ -1482,7 +1619,9 @@ ${Date.now()}
|
|
|
1482
1619
|
);
|
|
1483
1620
|
continue;
|
|
1484
1621
|
}
|
|
1485
|
-
|
|
1622
|
+
if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
|
|
1623
|
+
const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
|
|
1624
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
|
|
1486
1625
|
}
|
|
1487
1626
|
}
|
|
1488
1627
|
if (!lockHandle) {
|
|
@@ -1750,6 +1889,12 @@ function isPackagingErrorIssue(issue) {
|
|
|
1750
1889
|
function findPackagingErrorIssues(issues) {
|
|
1751
1890
|
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1752
1891
|
}
|
|
1892
|
+
function isPackagingWarningIssue(issue) {
|
|
1893
|
+
return issue.severity?.toLowerCase() === "warning";
|
|
1894
|
+
}
|
|
1895
|
+
function findPackagingWarningIssues(issues) {
|
|
1896
|
+
return (issues ?? []).filter(isPackagingWarningIssue);
|
|
1897
|
+
}
|
|
1753
1898
|
|
|
1754
1899
|
// src/packageCourse.ts
|
|
1755
1900
|
async function validateLessonkitProject(options) {
|
|
@@ -1817,14 +1962,29 @@ async function packageLessonkitCourse(options) {
|
|
|
1817
1962
|
}))
|
|
1818
1963
|
};
|
|
1819
1964
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1965
|
+
let staged;
|
|
1966
|
+
try {
|
|
1967
|
+
staged = await buildStagingPackage({
|
|
1968
|
+
...writeOpts,
|
|
1969
|
+
descriptor,
|
|
1970
|
+
target,
|
|
1971
|
+
output,
|
|
1972
|
+
dir,
|
|
1973
|
+
outputBaseDir
|
|
1974
|
+
});
|
|
1975
|
+
} catch (err) {
|
|
1976
|
+
return {
|
|
1977
|
+
ok: false,
|
|
1978
|
+
courseDir: outDir,
|
|
1979
|
+
target,
|
|
1980
|
+
issues: [
|
|
1981
|
+
{
|
|
1982
|
+
path: "staging",
|
|
1983
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1984
|
+
}
|
|
1985
|
+
]
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1828
1988
|
if (!staged.ok) {
|
|
1829
1989
|
await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
|
|
1830
1990
|
/* v8 ignore next */
|
|
@@ -1879,6 +2039,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1879
2039
|
issues: artifactIssues
|
|
1880
2040
|
};
|
|
1881
2041
|
}
|
|
2042
|
+
const buildWarningIssues = findPackagingWarningIssues(build.issues);
|
|
2043
|
+
if (options.strictBuild && buildWarningIssues.length > 0) {
|
|
2044
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
2045
|
+
/* v8 ignore next */
|
|
2046
|
+
() => void 0
|
|
2047
|
+
);
|
|
2048
|
+
return {
|
|
2049
|
+
ok: false,
|
|
2050
|
+
courseDir: outDir,
|
|
2051
|
+
target,
|
|
2052
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
2053
|
+
build,
|
|
2054
|
+
issues: buildWarningIssues.map((i) => ({
|
|
2055
|
+
path: i.path ?? "build",
|
|
2056
|
+
message: i.message ?? "build warning",
|
|
2057
|
+
severity: i.severity
|
|
2058
|
+
}))
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
1882
2061
|
const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
|
|
1883
2062
|
const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
|
|
1884
2063
|
const validation = {
|
|
@@ -2021,7 +2200,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
2021
2200
|
path: `paths.${key}`,
|
|
2022
2201
|
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2023
2202
|
});
|
|
2024
|
-
} else if (
|
|
2203
|
+
} else if (isReservedOutputPath(value)) {
|
|
2025
2204
|
issues.push({
|
|
2026
2205
|
path: `paths.${key}`,
|
|
2027
2206
|
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
@@ -2063,17 +2242,821 @@ import {
|
|
|
2063
2242
|
import {
|
|
2064
2243
|
lessonkitInterchangeSchema,
|
|
2065
2244
|
materializeLessonkitProject as materializeLessonkitProject2,
|
|
2066
|
-
parseLessonkitInterchange
|
|
2245
|
+
parseLessonkitInterchange as parseLessonkitInterchange3
|
|
2067
2246
|
} from "@lxpack/validators";
|
|
2247
|
+
|
|
2248
|
+
// src/lkcourse/zip.ts
|
|
2249
|
+
import { readFileSync as readFileSync2, statSync } from "fs";
|
|
2250
|
+
import { dirname as dirname3, join as join9, normalize } from "path";
|
|
2251
|
+
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
|
2252
|
+
var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
2253
|
+
function canonicalZipEntryPath(entryPath) {
|
|
2254
|
+
const slashNormalized = entryPath.replace(/\\/g, "/");
|
|
2255
|
+
const canonical = normalize(slashNormalized).replace(/\\/g, "/");
|
|
2256
|
+
if (canonical !== slashNormalized) return null;
|
|
2257
|
+
return canonical;
|
|
2258
|
+
}
|
|
2259
|
+
function isSafeZipEntryPath(entryPath) {
|
|
2260
|
+
const canonical = canonicalZipEntryPath(entryPath);
|
|
2261
|
+
if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
const segments = canonical.split("/").filter((s) => s.length > 0);
|
|
2265
|
+
if (segments.some((s) => s === "..")) return false;
|
|
2266
|
+
return segments.length > 0;
|
|
2267
|
+
}
|
|
2268
|
+
function createZip(entries) {
|
|
2269
|
+
const zipped = {};
|
|
2270
|
+
for (const [path, data] of entries) {
|
|
2271
|
+
if (!isSafeZipEntryPath(path)) {
|
|
2272
|
+
throw new Error(`unsafe zip entry path: ${path}`);
|
|
2273
|
+
}
|
|
2274
|
+
zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2275
|
+
}
|
|
2276
|
+
return zipSync(zipped, { level: 6 });
|
|
2277
|
+
}
|
|
2278
|
+
function readZip(archivePath) {
|
|
2279
|
+
const issues = [];
|
|
2280
|
+
let raw;
|
|
2281
|
+
try {
|
|
2282
|
+
raw = readFileSync2(archivePath);
|
|
2283
|
+
} catch {
|
|
2284
|
+
return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
|
|
2285
|
+
}
|
|
2286
|
+
if (!raw.length) {
|
|
2287
|
+
return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
|
|
2288
|
+
}
|
|
2289
|
+
let unzipped;
|
|
2290
|
+
try {
|
|
2291
|
+
unzipped = unzipSync(raw);
|
|
2292
|
+
} catch {
|
|
2293
|
+
return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
|
|
2294
|
+
}
|
|
2295
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2296
|
+
let totalUncompressed = 0;
|
|
2297
|
+
for (const [path, data] of Object.entries(unzipped)) {
|
|
2298
|
+
const canonical = canonicalZipEntryPath(path);
|
|
2299
|
+
if (!canonical || !isSafeZipEntryPath(canonical)) {
|
|
2300
|
+
issues.push({ path, message: "unsafe zip entry path" });
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
if (entries.has(canonical)) {
|
|
2304
|
+
issues.push({ path: canonical, message: "duplicate zip entry path" });
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
totalUncompressed += data.byteLength;
|
|
2308
|
+
if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
|
|
2309
|
+
return {
|
|
2310
|
+
ok: false,
|
|
2311
|
+
issues: [
|
|
2312
|
+
{
|
|
2313
|
+
path: archivePath,
|
|
2314
|
+
message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
|
|
2315
|
+
}
|
|
2316
|
+
]
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
entries.set(canonical, data);
|
|
2320
|
+
}
|
|
2321
|
+
if (issues.length) return { ok: false, issues };
|
|
2322
|
+
return { ok: true, entries };
|
|
2323
|
+
}
|
|
2324
|
+
async function collectDistEntries(distDir, spaDistRelative) {
|
|
2325
|
+
const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
|
|
2326
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2327
|
+
const walk = async (absDir, relPrefix) => {
|
|
2328
|
+
const dirEntries = await readdir4(absDir, { withFileTypes: true });
|
|
2329
|
+
for (const entry of dirEntries) {
|
|
2330
|
+
const abs = join9(absDir, entry.name);
|
|
2331
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
2332
|
+
const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
|
|
2333
|
+
if (!isSafeRelativeSpaPath(zipPath)) {
|
|
2334
|
+
throw new Error(`unsafe dist path: ${zipPath}`);
|
|
2335
|
+
}
|
|
2336
|
+
const stat2 = await lstat2(abs);
|
|
2337
|
+
if (stat2.isSymbolicLink()) {
|
|
2338
|
+
throw new Error(`dist contains symlink: ${abs}`);
|
|
2339
|
+
}
|
|
2340
|
+
if (stat2.isDirectory()) {
|
|
2341
|
+
await walk(abs, rel);
|
|
2342
|
+
} else if (stat2.isFile()) {
|
|
2343
|
+
entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
await walk(distDir, "");
|
|
2348
|
+
return entries;
|
|
2349
|
+
}
|
|
2350
|
+
function entryToUtf8(data) {
|
|
2351
|
+
return strFromU8(data);
|
|
2352
|
+
}
|
|
2353
|
+
function utf8ToEntry(text) {
|
|
2354
|
+
return strToU8(text);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// src/lkcourse/parseEnvelope.ts
|
|
2358
|
+
function parseLkcourseEnvelope(raw, label = "manifest.json") {
|
|
2359
|
+
const issues = [];
|
|
2360
|
+
if (!raw || typeof raw !== "object") {
|
|
2361
|
+
return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
|
|
2362
|
+
}
|
|
2363
|
+
const obj = raw;
|
|
2364
|
+
if (obj.format !== "lkcourse") {
|
|
2365
|
+
issues.push({
|
|
2366
|
+
path: "format",
|
|
2367
|
+
message: `must be "lkcourse" (got ${String(obj.format)})`
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
let schemaVersion = obj.schemaVersion;
|
|
2371
|
+
if (schemaVersion === "1") schemaVersion = 1;
|
|
2372
|
+
if (schemaVersion !== 1) {
|
|
2373
|
+
issues.push({
|
|
2374
|
+
path: "schemaVersion",
|
|
2375
|
+
message: `must be 1 (got ${String(obj.schemaVersion)})`
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
|
|
2379
|
+
if (!lessonkitVersion) {
|
|
2380
|
+
issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
|
|
2381
|
+
}
|
|
2382
|
+
const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
|
|
2383
|
+
if (!exportedAt) {
|
|
2384
|
+
issues.push({ path: "exportedAt", message: "must be a non-empty string" });
|
|
2385
|
+
}
|
|
2386
|
+
const entriesRaw = obj.entries;
|
|
2387
|
+
const entries = [];
|
|
2388
|
+
if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
|
|
2389
|
+
issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
|
|
2390
|
+
} else {
|
|
2391
|
+
for (let i = 0; i < entriesRaw.length; i++) {
|
|
2392
|
+
const entry = entriesRaw[i];
|
|
2393
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
2394
|
+
issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
|
|
2395
|
+
} else {
|
|
2396
|
+
const trimmed = entry.trim();
|
|
2397
|
+
if (!isSafeZipEntryPath(trimmed)) {
|
|
2398
|
+
issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
|
|
2399
|
+
} else {
|
|
2400
|
+
entries.push(trimmed);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
if (issues.length) return { ok: false, issues };
|
|
2406
|
+
const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
|
|
2407
|
+
if (!manifestParsed.ok) {
|
|
2408
|
+
return {
|
|
2409
|
+
ok: false,
|
|
2410
|
+
issues: manifestParsed.issues.map((issue) => ({
|
|
2411
|
+
path: `sourceManifest.${issue.path}`,
|
|
2412
|
+
message: issue.message
|
|
2413
|
+
}))
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
return {
|
|
2417
|
+
ok: true,
|
|
2418
|
+
envelope: {
|
|
2419
|
+
format: "lkcourse",
|
|
2420
|
+
schemaVersion: 1,
|
|
2421
|
+
lessonkitVersion,
|
|
2422
|
+
exportedAt,
|
|
2423
|
+
sourceManifest: manifestParsed.manifest,
|
|
2424
|
+
entries
|
|
2425
|
+
}
|
|
2426
|
+
};
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// src/lkcourse/blockTree.ts
|
|
2430
|
+
import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
|
|
2431
|
+
import { createRequire } from "module";
|
|
2432
|
+
import { join as join10, relative as relative3 } from "path";
|
|
2433
|
+
import { validateId as validateId4 } from "@lessonkit/core";
|
|
2434
|
+
var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
|
|
2435
|
+
var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
|
|
2436
|
+
function stripComments2(source) {
|
|
2437
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
2438
|
+
}
|
|
2439
|
+
function collectSourceUnderSrc2(projectRoot) {
|
|
2440
|
+
const srcDir = join10(projectRoot, "src");
|
|
2441
|
+
if (!existsSync4(srcDir)) return [];
|
|
2442
|
+
const results = [];
|
|
2443
|
+
const walk = (dir) => {
|
|
2444
|
+
for (const entry of readdirSync2(dir)) {
|
|
2445
|
+
const abs = join10(dir, entry);
|
|
2446
|
+
try {
|
|
2447
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
2448
|
+
} catch {
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
const stat2 = lstatSync2(abs);
|
|
2452
|
+
if (stat2.isSymbolicLink()) continue;
|
|
2453
|
+
if (stat2.isDirectory()) {
|
|
2454
|
+
walk(abs);
|
|
2455
|
+
} else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
|
|
2456
|
+
results.push(relative3(projectRoot, abs));
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
walk(srcDir);
|
|
2461
|
+
return results;
|
|
2462
|
+
}
|
|
2463
|
+
function loadCatalogBlockTypes(blockTypes) {
|
|
2464
|
+
if (blockTypes?.length) return blockTypes;
|
|
2465
|
+
try {
|
|
2466
|
+
const require2 = createRequire(import.meta.url);
|
|
2467
|
+
const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
|
|
2468
|
+
const catalog = JSON.parse(readFileSync3(catalogPath, "utf8"));
|
|
2469
|
+
return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
|
|
2470
|
+
} catch {
|
|
2471
|
+
return [
|
|
2472
|
+
"Course",
|
|
2473
|
+
"Lesson",
|
|
2474
|
+
"Scenario",
|
|
2475
|
+
"Quiz",
|
|
2476
|
+
"KnowledgeCheck",
|
|
2477
|
+
"ProgressTracker",
|
|
2478
|
+
"Reflection",
|
|
2479
|
+
"TrueFalse",
|
|
2480
|
+
"MarkTheWords",
|
|
2481
|
+
"FillInTheBlanks",
|
|
2482
|
+
"DragTheWords",
|
|
2483
|
+
"DragAndDrop",
|
|
2484
|
+
"AssessmentSequence",
|
|
2485
|
+
"Text",
|
|
2486
|
+
"Heading",
|
|
2487
|
+
"Image",
|
|
2488
|
+
"Video",
|
|
2489
|
+
"Page",
|
|
2490
|
+
"InteractiveBook",
|
|
2491
|
+
"Slide",
|
|
2492
|
+
"SlideDeck",
|
|
2493
|
+
"TimedCue",
|
|
2494
|
+
"InteractiveVideo",
|
|
2495
|
+
"Summary",
|
|
2496
|
+
"BranchingScenario",
|
|
2497
|
+
"BranchNode",
|
|
2498
|
+
"BranchChoice",
|
|
2499
|
+
"Embed",
|
|
2500
|
+
"Chart"
|
|
2501
|
+
];
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
function extractIdProp(tagSource, prop) {
|
|
2505
|
+
const re = new RegExp(
|
|
2506
|
+
`\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
|
|
2507
|
+
);
|
|
2508
|
+
const match = tagSource.match(re);
|
|
2509
|
+
if (!match) return void 0;
|
|
2510
|
+
return match[1] ?? match[2] ?? match[3];
|
|
2511
|
+
}
|
|
2512
|
+
function parseJsxBlocks(source, blockTypes) {
|
|
2513
|
+
const stripped = stripComments2(source);
|
|
2514
|
+
const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
|
|
2515
|
+
const stack = [];
|
|
2516
|
+
const roots = [];
|
|
2517
|
+
for (const match of stripped.matchAll(tagRe)) {
|
|
2518
|
+
const rawTag = match[1];
|
|
2519
|
+
const attrs = match[2] ?? "";
|
|
2520
|
+
const selfClosing = match[3] === "/";
|
|
2521
|
+
if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
|
|
2522
|
+
const known = blockTypes.has(rawTag);
|
|
2523
|
+
const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
|
|
2524
|
+
for (const prop of ID_PROPS) {
|
|
2525
|
+
const value = extractIdProp(attrs, prop);
|
|
2526
|
+
if (value) node[prop] = value;
|
|
2527
|
+
}
|
|
2528
|
+
if (selfClosing) {
|
|
2529
|
+
if (stack.length) {
|
|
2530
|
+
const parent = stack[stack.length - 1];
|
|
2531
|
+
parent.children = parent.children ?? [];
|
|
2532
|
+
parent.children.push(node);
|
|
2533
|
+
} else {
|
|
2534
|
+
roots.push(node);
|
|
2535
|
+
}
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
const closeRe = new RegExp(`</${rawTag}>`);
|
|
2539
|
+
const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
|
|
2540
|
+
if (!closeMatch) {
|
|
2541
|
+
if (stack.length) {
|
|
2542
|
+
const parent = stack[stack.length - 1];
|
|
2543
|
+
parent.children = parent.children ?? [];
|
|
2544
|
+
parent.children.push(node);
|
|
2545
|
+
} else {
|
|
2546
|
+
roots.push(node);
|
|
2547
|
+
}
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
stack.push(node);
|
|
2551
|
+
const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
|
|
2552
|
+
const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
|
|
2553
|
+
if (!inner.includes("<")) {
|
|
2554
|
+
stack.pop();
|
|
2555
|
+
if (stack.length) {
|
|
2556
|
+
const parent = stack[stack.length - 1];
|
|
2557
|
+
parent.children = parent.children ?? [];
|
|
2558
|
+
parent.children.push(node);
|
|
2559
|
+
} else {
|
|
2560
|
+
roots.push(node);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
return roots.length ? roots : stack;
|
|
2565
|
+
}
|
|
2566
|
+
function validateNodeIds(node, pathPrefix, issues) {
|
|
2567
|
+
for (const prop of ID_PROPS) {
|
|
2568
|
+
const value = node[prop];
|
|
2569
|
+
if (value === void 0) continue;
|
|
2570
|
+
const validated = validateId4(value, prop);
|
|
2571
|
+
if (!validated.ok) {
|
|
2572
|
+
issues.push({
|
|
2573
|
+
path: `${pathPrefix}.${prop}`,
|
|
2574
|
+
message: validated.issues[0]?.message ?? `invalid ${prop}`
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
node.children?.forEach((child, index) => {
|
|
2579
|
+
validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
function validateBlockTreeIds(tree) {
|
|
2583
|
+
const issues = [];
|
|
2584
|
+
tree.blocks.forEach((block, index) => {
|
|
2585
|
+
validateNodeIds(block, `blocks[${index}]`, issues);
|
|
2586
|
+
});
|
|
2587
|
+
return issues;
|
|
2588
|
+
}
|
|
2589
|
+
function extractBlockTree(options) {
|
|
2590
|
+
const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
|
|
2591
|
+
const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
|
|
2592
|
+
const blocks = [];
|
|
2593
|
+
for (const rel of sources) {
|
|
2594
|
+
const abs = join10(options.projectRoot, rel);
|
|
2595
|
+
if (!existsSync4(abs)) continue;
|
|
2596
|
+
const source = readFileSync3(abs, "utf8");
|
|
2597
|
+
const parsed = parseJsxBlocks(source, blockTypes);
|
|
2598
|
+
blocks.push(...parsed);
|
|
2599
|
+
}
|
|
2600
|
+
return {
|
|
2601
|
+
schemaVersion: 1,
|
|
2602
|
+
sources,
|
|
2603
|
+
blocks
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// src/lkcourse/export.ts
|
|
2608
|
+
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
2609
|
+
import { createRequire as createRequire2 } from "module";
|
|
2610
|
+
import { dirname as dirname4, join as join11, resolve as resolve8 } from "path";
|
|
2611
|
+
import { parseLessonkitInterchange } from "@lxpack/validators";
|
|
2612
|
+
function resolveLessonkitVersion(explicit) {
|
|
2613
|
+
if (explicit?.trim()) return explicit.trim();
|
|
2614
|
+
try {
|
|
2615
|
+
const require2 = createRequire2(import.meta.url);
|
|
2616
|
+
const pkg = require2("../../package.json");
|
|
2617
|
+
return pkg.version ?? "0.0.0";
|
|
2618
|
+
} catch {
|
|
2619
|
+
return "0.0.0";
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
async function exportLkcourse(options) {
|
|
2623
|
+
const projectRoot = resolve8(options.projectRoot);
|
|
2624
|
+
const manifest = options.manifest;
|
|
2625
|
+
const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
|
|
2626
|
+
try {
|
|
2627
|
+
assertRealPathUnderRoot(projectRoot, spaDistDir);
|
|
2628
|
+
await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
|
|
2629
|
+
} catch (err) {
|
|
2630
|
+
return {
|
|
2631
|
+
ok: false,
|
|
2632
|
+
issues: [
|
|
2633
|
+
{
|
|
2634
|
+
path: manifest.paths.spaDistDir,
|
|
2635
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2636
|
+
}
|
|
2637
|
+
]
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
const interchange = descriptorToInterchange(manifest.course);
|
|
2641
|
+
const interchangeParsed = parseLessonkitInterchange(interchange);
|
|
2642
|
+
if (!interchangeParsed.ok) {
|
|
2643
|
+
return {
|
|
2644
|
+
ok: false,
|
|
2645
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2646
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2647
|
+
message: i.message
|
|
2648
|
+
}))
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
const validatedInterchange = interchangeParsed.data;
|
|
2652
|
+
const interchangeCourseId = validatedInterchange.course?.id;
|
|
2653
|
+
if (!interchangeCourseId) {
|
|
2654
|
+
return {
|
|
2655
|
+
ok: false,
|
|
2656
|
+
issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
if (manifest.course.courseId !== interchangeCourseId) {
|
|
2660
|
+
return {
|
|
2661
|
+
ok: false,
|
|
2662
|
+
issues: [
|
|
2663
|
+
{
|
|
2664
|
+
path: "course.courseId",
|
|
2665
|
+
message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
|
|
2666
|
+
}
|
|
2667
|
+
]
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
const zipEntries = /* @__PURE__ */ new Map();
|
|
2671
|
+
const interchangeJson = JSON.stringify(interchange, null, 2);
|
|
2672
|
+
zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
|
|
2673
|
+
let blockTreeJson;
|
|
2674
|
+
if (options.includeBlockTree) {
|
|
2675
|
+
const blockTree = extractBlockTree({ projectRoot });
|
|
2676
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2677
|
+
if (blockTreeIssues.length) {
|
|
2678
|
+
return {
|
|
2679
|
+
ok: false,
|
|
2680
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2681
|
+
path: `block-tree.${issue.path}`,
|
|
2682
|
+
message: issue.message
|
|
2683
|
+
}))
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
blockTreeJson = JSON.stringify(blockTree, null, 2);
|
|
2687
|
+
zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
|
|
2688
|
+
}
|
|
2689
|
+
let distEntries;
|
|
2690
|
+
try {
|
|
2691
|
+
distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
|
|
2692
|
+
} catch (err) {
|
|
2693
|
+
return {
|
|
2694
|
+
ok: false,
|
|
2695
|
+
issues: [
|
|
2696
|
+
{
|
|
2697
|
+
path: manifest.paths.spaDistDir,
|
|
2698
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2699
|
+
}
|
|
2700
|
+
]
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
|
|
2704
|
+
return {
|
|
2705
|
+
ok: false,
|
|
2706
|
+
issues: [
|
|
2707
|
+
{
|
|
2708
|
+
path: `${manifest.paths.spaDistDir}/index.html`,
|
|
2709
|
+
message: "dist must contain index.html before export"
|
|
2710
|
+
}
|
|
2711
|
+
]
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
for (const [path, data] of distEntries) {
|
|
2715
|
+
zipEntries.set(path, data);
|
|
2716
|
+
}
|
|
2717
|
+
const entryPaths = [...zipEntries.keys()].sort();
|
|
2718
|
+
const envelope = {
|
|
2719
|
+
format: "lkcourse",
|
|
2720
|
+
schemaVersion: 1,
|
|
2721
|
+
lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
|
|
2722
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2723
|
+
sourceManifest: manifest,
|
|
2724
|
+
entries: entryPaths
|
|
2725
|
+
};
|
|
2726
|
+
const envelopeCheck = parseLkcourseEnvelope(envelope);
|
|
2727
|
+
if (!envelopeCheck.ok) {
|
|
2728
|
+
return { ok: false, issues: envelopeCheck.issues };
|
|
2729
|
+
}
|
|
2730
|
+
zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
|
|
2731
|
+
const archivePath = resolve8(
|
|
2732
|
+
projectRoot,
|
|
2733
|
+
options.outPath ?? `${manifest.name}.lkcourse`
|
|
2734
|
+
);
|
|
2735
|
+
try {
|
|
2736
|
+
assertRealPathUnderRoot(projectRoot, archivePath);
|
|
2737
|
+
} catch (err) {
|
|
2738
|
+
return {
|
|
2739
|
+
ok: false,
|
|
2740
|
+
issues: [
|
|
2741
|
+
{
|
|
2742
|
+
path: options.outPath ?? `${manifest.name}.lkcourse`,
|
|
2743
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2744
|
+
}
|
|
2745
|
+
]
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
|
|
2749
|
+
return {
|
|
2750
|
+
ok: false,
|
|
2751
|
+
issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
try {
|
|
2755
|
+
await mkdir3(dirname4(archivePath), { recursive: true });
|
|
2756
|
+
const zipped = createZip(zipEntries);
|
|
2757
|
+
await writeFile(archivePath, zipped);
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
return {
|
|
2760
|
+
ok: false,
|
|
2761
|
+
issues: [
|
|
2762
|
+
{
|
|
2763
|
+
path: archivePath,
|
|
2764
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2765
|
+
}
|
|
2766
|
+
]
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
return {
|
|
2770
|
+
ok: true,
|
|
2771
|
+
archivePath,
|
|
2772
|
+
fileCount: zipEntries.size,
|
|
2773
|
+
includeBlockTree: Boolean(options.includeBlockTree)
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// src/lkcourse/validate.ts
|
|
2778
|
+
import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
|
|
2779
|
+
function validateLkcourseArchiveEntries(entries, _archiveLabel) {
|
|
2780
|
+
const issues = [];
|
|
2781
|
+
const manifestData = entries.get("manifest.json");
|
|
2782
|
+
if (!manifestData) {
|
|
2783
|
+
return {
|
|
2784
|
+
ok: false,
|
|
2785
|
+
issues: [{ path: "manifest.json", message: "required file missing from archive" }]
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
let envelopeRaw;
|
|
2789
|
+
try {
|
|
2790
|
+
envelopeRaw = JSON.parse(entryToUtf8(manifestData));
|
|
2791
|
+
} catch {
|
|
2792
|
+
return {
|
|
2793
|
+
ok: false,
|
|
2794
|
+
issues: [{ path: "manifest.json", message: "invalid JSON" }]
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
|
|
2798
|
+
if (!envelopeParsed.ok) {
|
|
2799
|
+
return { ok: false, issues: envelopeParsed.issues };
|
|
2800
|
+
}
|
|
2801
|
+
const envelope = envelopeParsed.envelope;
|
|
2802
|
+
const interchangeData = entries.get("interchange.json");
|
|
2803
|
+
if (!interchangeData) {
|
|
2804
|
+
issues.push({ path: "interchange.json", message: "required file missing from archive" });
|
|
2805
|
+
}
|
|
2806
|
+
const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
2807
|
+
const spaIndexPath = `${spaDistDir}/index.html`;
|
|
2808
|
+
if (!entries.has(spaIndexPath)) {
|
|
2809
|
+
issues.push({ path: spaIndexPath, message: "required file missing from archive" });
|
|
2810
|
+
}
|
|
2811
|
+
for (const entryPath of envelope.entries) {
|
|
2812
|
+
if (!entries.has(entryPath)) {
|
|
2813
|
+
issues.push({
|
|
2814
|
+
path: entryPath,
|
|
2815
|
+
message: "listed in manifest.entries but missing from archive"
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
if (issues.length) return { ok: false, issues };
|
|
2820
|
+
let interchangeRaw;
|
|
2821
|
+
try {
|
|
2822
|
+
interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
|
|
2823
|
+
} catch {
|
|
2824
|
+
return {
|
|
2825
|
+
ok: false,
|
|
2826
|
+
issues: [{ path: "interchange.json", message: "invalid JSON" }]
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
const interchangeParsed = parseLessonkitInterchange2(interchangeRaw);
|
|
2830
|
+
if (!interchangeParsed.ok) {
|
|
2831
|
+
return {
|
|
2832
|
+
ok: false,
|
|
2833
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2834
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2835
|
+
message: i.message
|
|
2836
|
+
}))
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
const interchange = interchangeParsed.data;
|
|
2840
|
+
const interchangeCourseId = interchange.course?.id;
|
|
2841
|
+
if (!interchangeCourseId) {
|
|
2842
|
+
issues.push({
|
|
2843
|
+
path: "interchange.course.id",
|
|
2844
|
+
message: "missing course id in interchange"
|
|
2845
|
+
});
|
|
2846
|
+
} else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
|
|
2847
|
+
issues.push({
|
|
2848
|
+
path: "sourceManifest.course.courseId",
|
|
2849
|
+
message: `does not match interchange.course.id (${interchangeCourseId})`
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
if (issues.length) return { ok: false, issues };
|
|
2853
|
+
const blockTreeData = entries.get("block-tree.json");
|
|
2854
|
+
if (blockTreeData) {
|
|
2855
|
+
let blockTreeRaw;
|
|
2856
|
+
try {
|
|
2857
|
+
blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
|
|
2858
|
+
} catch {
|
|
2859
|
+
return {
|
|
2860
|
+
ok: false,
|
|
2861
|
+
issues: [{ path: "block-tree.json", message: "invalid JSON" }]
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
const blockTree = blockTreeRaw;
|
|
2865
|
+
if (Array.isArray(blockTree?.blocks)) {
|
|
2866
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2867
|
+
if (blockTreeIssues.length) {
|
|
2868
|
+
return {
|
|
2869
|
+
ok: false,
|
|
2870
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2871
|
+
path: `block-tree.${issue.path}`,
|
|
2872
|
+
message: issue.message
|
|
2873
|
+
}))
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
return {
|
|
2879
|
+
ok: true,
|
|
2880
|
+
envelope,
|
|
2881
|
+
interchange
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function validateLkcourse(archivePath) {
|
|
2885
|
+
const read = readZip(archivePath);
|
|
2886
|
+
if (!read.ok) return read;
|
|
2887
|
+
return validateLkcourseArchiveEntries(read.entries, archivePath);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// src/lkcourse/import.ts
|
|
2891
|
+
import { access as access3, cp as cp2, mkdir as mkdir4, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2892
|
+
import { dirname as dirname5, join as join12, resolve as resolve9 } from "path";
|
|
2893
|
+
var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
|
|
2894
|
+
async function pathExists2(path) {
|
|
2895
|
+
try {
|
|
2896
|
+
await access3(path);
|
|
2897
|
+
return true;
|
|
2898
|
+
} catch {
|
|
2899
|
+
return false;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
async function renameOrCopy2(from, to, opts) {
|
|
2903
|
+
const renameFn = opts?.renameFn ?? rename2;
|
|
2904
|
+
try {
|
|
2905
|
+
await renameFn(from, to);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
2908
|
+
if (code !== "EXDEV") throw err;
|
|
2909
|
+
await cp2(from, to, { recursive: true });
|
|
2910
|
+
await rm4(from, { recursive: true, force: true });
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
|
|
2914
|
+
let fileCount = 0;
|
|
2915
|
+
await writeFile2(
|
|
2916
|
+
join12(stagingDir, "lessonkit.json"),
|
|
2917
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
2918
|
+
`,
|
|
2919
|
+
"utf8"
|
|
2920
|
+
);
|
|
2921
|
+
fileCount += 1;
|
|
2922
|
+
for (const [entryPath, data] of entries) {
|
|
2923
|
+
const normalized = entryPath.replace(/\\/g, "/");
|
|
2924
|
+
if (!normalized.startsWith(`${spaDistDir}/`)) continue;
|
|
2925
|
+
const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
|
|
2926
|
+
const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
|
|
2927
|
+
const resolvedOut = resolve9(outPath);
|
|
2928
|
+
assertRealPathUnderRoot(stagingDir, resolvedOut);
|
|
2929
|
+
if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
|
|
2930
|
+
throw new Error(`unsafe extraction path: ${entryPath}`);
|
|
2931
|
+
}
|
|
2932
|
+
await mkdir4(dirname5(resolvedOut), { recursive: true });
|
|
2933
|
+
await writeFile2(resolvedOut, data);
|
|
2934
|
+
fileCount += 1;
|
|
2935
|
+
}
|
|
2936
|
+
return fileCount;
|
|
2937
|
+
}
|
|
2938
|
+
async function backupImportArtifacts(targetDir) {
|
|
2939
|
+
const existing = [];
|
|
2940
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
2941
|
+
if (await pathExists2(join12(targetDir, name))) {
|
|
2942
|
+
existing.push(name);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
if (!existing.length) return void 0;
|
|
2946
|
+
const backupDir = await mkdtemp3(join12(targetDir, ".lkcourse-backup-"));
|
|
2947
|
+
for (const name of existing) {
|
|
2948
|
+
await renameOrCopy2(join12(targetDir, name), join12(backupDir, name));
|
|
2949
|
+
}
|
|
2950
|
+
return backupDir;
|
|
2951
|
+
}
|
|
2952
|
+
async function restoreImportBackup(targetDir, backupDir) {
|
|
2953
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
2954
|
+
const backupPath = join12(backupDir, name);
|
|
2955
|
+
if (!await pathExists2(backupPath)) continue;
|
|
2956
|
+
const destPath = join12(targetDir, name);
|
|
2957
|
+
if (await pathExists2(destPath)) {
|
|
2958
|
+
await rm4(destPath, { recursive: true, force: true });
|
|
2959
|
+
}
|
|
2960
|
+
await renameOrCopy2(backupPath, destPath);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
async function promoteImportStaging(stagingDir, targetDir) {
|
|
2964
|
+
const entries = await readdir3(stagingDir, { withFileTypes: true });
|
|
2965
|
+
for (const entry of entries) {
|
|
2966
|
+
const srcPath = join12(stagingDir, entry.name);
|
|
2967
|
+
const destPath = join12(targetDir, entry.name);
|
|
2968
|
+
if (entry.isDirectory()) {
|
|
2969
|
+
await cp2(srcPath, destPath, { recursive: true, force: true });
|
|
2970
|
+
} else if (entry.isFile()) {
|
|
2971
|
+
await mkdir4(dirname5(destPath), { recursive: true });
|
|
2972
|
+
await cp2(srcPath, destPath);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
var promoteImportStagingImpl = promoteImportStaging;
|
|
2977
|
+
async function importLkcourse(options) {
|
|
2978
|
+
const archivePath = resolve9(options.archivePath);
|
|
2979
|
+
const targetDir = resolve9(options.targetDir);
|
|
2980
|
+
const validated = validateLkcourse(archivePath);
|
|
2981
|
+
if (!validated.ok) return validated;
|
|
2982
|
+
const { envelope, interchange } = validated;
|
|
2983
|
+
const manifest = envelope.sourceManifest;
|
|
2984
|
+
const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
2985
|
+
try {
|
|
2986
|
+
await mkdir4(targetDir, { recursive: true });
|
|
2987
|
+
assertRealPathUnderRoot(targetDir, targetDir);
|
|
2988
|
+
} catch (err) {
|
|
2989
|
+
return {
|
|
2990
|
+
ok: false,
|
|
2991
|
+
issues: [
|
|
2992
|
+
{
|
|
2993
|
+
path: targetDir,
|
|
2994
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2995
|
+
}
|
|
2996
|
+
]
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
const read = readZip(archivePath);
|
|
3000
|
+
if (!read.ok) return read;
|
|
3001
|
+
let stagingDir;
|
|
3002
|
+
let backupDir;
|
|
3003
|
+
try {
|
|
3004
|
+
stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
|
|
3005
|
+
const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
|
|
3006
|
+
backupDir = await backupImportArtifacts(targetDir);
|
|
3007
|
+
try {
|
|
3008
|
+
await promoteImportStagingImpl(stagingDir, targetDir);
|
|
3009
|
+
} catch (promoteError) {
|
|
3010
|
+
if (backupDir) {
|
|
3011
|
+
await restoreImportBackup(targetDir, backupDir);
|
|
3012
|
+
}
|
|
3013
|
+
throw promoteError;
|
|
3014
|
+
}
|
|
3015
|
+
if (backupDir) {
|
|
3016
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3017
|
+
backupDir = void 0;
|
|
3018
|
+
}
|
|
3019
|
+
await rm4(stagingDir, { recursive: true, force: true });
|
|
3020
|
+
stagingDir = void 0;
|
|
3021
|
+
return {
|
|
3022
|
+
ok: true,
|
|
3023
|
+
targetDir,
|
|
3024
|
+
manifest,
|
|
3025
|
+
interchange,
|
|
3026
|
+
fileCount
|
|
3027
|
+
};
|
|
3028
|
+
} catch (err) {
|
|
3029
|
+
if (backupDir) {
|
|
3030
|
+
await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
|
|
3031
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3032
|
+
}
|
|
3033
|
+
if (stagingDir) {
|
|
3034
|
+
await rm4(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3035
|
+
}
|
|
3036
|
+
return {
|
|
3037
|
+
ok: false,
|
|
3038
|
+
issues: [
|
|
3039
|
+
{
|
|
3040
|
+
path: targetDir,
|
|
3041
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3042
|
+
}
|
|
3043
|
+
]
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2068
3047
|
export {
|
|
2069
3048
|
LESSONKIT_TELEMETRY_EVENTS,
|
|
3049
|
+
assertSpaDistContentsSafe,
|
|
2070
3050
|
assessmentDescriptorToLxpack,
|
|
2071
3051
|
buildLessonkitProject,
|
|
2072
3052
|
buildStagingPackage,
|
|
2073
3053
|
descriptorToInterchange,
|
|
2074
3054
|
ensureOutDirParent,
|
|
2075
3055
|
escapeShellText,
|
|
3056
|
+
exportLkcourse,
|
|
2076
3057
|
extractAssessments,
|
|
3058
|
+
extractBlockTree,
|
|
3059
|
+
importLkcourse,
|
|
2077
3060
|
lessonkitInterchangeSchema,
|
|
2078
3061
|
loadLessonkitManifestFromFile,
|
|
2079
3062
|
mapLessonkitIds,
|
|
@@ -2081,8 +3064,9 @@ export {
|
|
|
2081
3064
|
mapLessonkitTelemetryToLxpack,
|
|
2082
3065
|
materializeLessonkitProject2 as materializeLessonkitProject,
|
|
2083
3066
|
packageLessonkitCourse,
|
|
2084
|
-
parseLessonkitInterchange,
|
|
3067
|
+
parseLessonkitInterchange3 as parseLessonkitInterchange,
|
|
2085
3068
|
parseLessonkitManifest,
|
|
3069
|
+
parseLkcourseEnvelope,
|
|
2086
3070
|
promoteStagingToOutDir,
|
|
2087
3071
|
remapArtifactPaths,
|
|
2088
3072
|
resolveSafePackageOutputOverride,
|
|
@@ -2092,6 +3076,8 @@ export {
|
|
|
2092
3076
|
validateDescriptor,
|
|
2093
3077
|
validateDescriptorForTarget,
|
|
2094
3078
|
validateLessonkitProject,
|
|
3079
|
+
validateLkcourse,
|
|
3080
|
+
validateLkcourseArchiveEntries,
|
|
2095
3081
|
validatePackageInputs,
|
|
2096
3082
|
validateProjectPaths,
|
|
2097
3083
|
validateReactManifestParity,
|