@lessonkit/lxpack 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +586 -201
- package/dist/index.d.cts +19 -2
- package/dist/index.d.ts +19 -2
- package/dist/index.js +570 -186
- package/lessonkit-manifest.v1.json +99 -7
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -57,6 +57,7 @@ __export(index_exports, {
|
|
|
57
57
|
validateLessonkitProject: () => validateLessonkitProject,
|
|
58
58
|
validatePackageInputs: () => validatePackageInputs,
|
|
59
59
|
validateProjectPaths: () => validateProjectPaths,
|
|
60
|
+
validateReactManifestParity: () => validateReactManifestParity,
|
|
60
61
|
writeLxpackProject: () => writeLxpackProject
|
|
61
62
|
});
|
|
62
63
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -193,6 +194,14 @@ function parseAssessmentDescriptor(raw) {
|
|
|
193
194
|
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
194
195
|
};
|
|
195
196
|
}
|
|
197
|
+
if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
|
|
198
|
+
return {
|
|
199
|
+
kind,
|
|
200
|
+
...base,
|
|
201
|
+
choices: [],
|
|
202
|
+
answer: ""
|
|
203
|
+
};
|
|
204
|
+
}
|
|
196
205
|
return {
|
|
197
206
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
198
207
|
...base,
|
|
@@ -242,6 +251,7 @@ function parseCourseDescriptorInput(input) {
|
|
|
242
251
|
|
|
243
252
|
// src/descriptor/validateCourse.ts
|
|
244
253
|
var import_core3 = require("@lessonkit/core");
|
|
254
|
+
var import_themes2 = require("@lessonkit/themes");
|
|
245
255
|
|
|
246
256
|
// src/spaPath.ts
|
|
247
257
|
var import_node_fs = require("fs");
|
|
@@ -271,6 +281,28 @@ function assertResolvedPathUnderRoot(root, target) {
|
|
|
271
281
|
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
272
282
|
}
|
|
273
283
|
}
|
|
284
|
+
function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
|
|
285
|
+
const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
286
|
+
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
287
|
+
throw new Error(`unsafe path escapes project root: ${targetResolved}`);
|
|
288
|
+
}
|
|
289
|
+
const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
290
|
+
let current = rootReal;
|
|
291
|
+
for (const segment of segments) {
|
|
292
|
+
const next = (0, import_node_path.join)(current, segment);
|
|
293
|
+
if ((0, import_node_fs.existsSync)(next)) {
|
|
294
|
+
try {
|
|
295
|
+
current = (0, import_node_fs.realpathSync)(next);
|
|
296
|
+
} catch {
|
|
297
|
+
current = next;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
current = next;
|
|
301
|
+
}
|
|
302
|
+
assertResolvedPathUnderRoot(rootReal, current);
|
|
303
|
+
}
|
|
304
|
+
return current;
|
|
305
|
+
}
|
|
274
306
|
function assertRealPathUnderRoot(root, target) {
|
|
275
307
|
const rootResolved = resolveComparablePath(root);
|
|
276
308
|
const targetResolved = resolveComparablePath(target);
|
|
@@ -280,17 +312,12 @@ function assertRealPathUnderRoot(root, target) {
|
|
|
280
312
|
} catch {
|
|
281
313
|
rootReal = rootResolved;
|
|
282
314
|
}
|
|
283
|
-
let targetCheck;
|
|
284
315
|
try {
|
|
285
|
-
targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
316
|
+
const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
317
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
286
318
|
} catch {
|
|
287
|
-
|
|
288
|
-
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
289
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
290
|
-
}
|
|
291
|
-
targetCheck = (0, import_node_path.resolve)(rootReal, rel);
|
|
319
|
+
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
292
320
|
}
|
|
293
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
294
321
|
}
|
|
295
322
|
function normalizePathForComparison(p) {
|
|
296
323
|
const resolved = resolveComparablePath(p);
|
|
@@ -331,7 +358,12 @@ function themeToLxpackRuntime(input) {
|
|
|
331
358
|
// src/descriptor/validateAssessments.ts
|
|
332
359
|
var import_core2 = require("@lessonkit/core");
|
|
333
360
|
var validateMcqLike = (assessment, path, issues) => {
|
|
334
|
-
if (!("choices" in assessment) || !(
|
|
361
|
+
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
362
|
+
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
366
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
|
|
335
367
|
return;
|
|
336
368
|
}
|
|
337
369
|
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
@@ -344,6 +376,22 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
344
376
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
345
377
|
}
|
|
346
378
|
};
|
|
379
|
+
function countStarDelimitedBlanks(template) {
|
|
380
|
+
const matches = template.match(/\*[^*]+\*/g);
|
|
381
|
+
return matches?.length ?? 0;
|
|
382
|
+
}
|
|
383
|
+
function maxAchievableAssessmentScore(assessment) {
|
|
384
|
+
const kind = assessment.kind ?? "mcq";
|
|
385
|
+
if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
|
|
386
|
+
const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
|
|
387
|
+
if (explicit > 0) return explicit;
|
|
388
|
+
return countStarDelimitedBlanks(assessment.template ?? "");
|
|
389
|
+
}
|
|
390
|
+
if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
|
|
391
|
+
return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
|
|
392
|
+
}
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
347
395
|
var ASSESSMENT_VALIDATORS = {
|
|
348
396
|
mcq: validateMcqLike,
|
|
349
397
|
trueFalse: (assessment, path, issues) => {
|
|
@@ -356,9 +404,33 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
356
404
|
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
357
405
|
}
|
|
358
406
|
},
|
|
359
|
-
findHotspot: () => {
|
|
407
|
+
findHotspot: (assessment, path, issues) => {
|
|
408
|
+
if (assessment.kind !== "findHotspot") return;
|
|
409
|
+
if (!assessment.src?.trim()) {
|
|
410
|
+
issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
|
|
411
|
+
}
|
|
412
|
+
if (!assessment.alt?.trim()) {
|
|
413
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
|
|
414
|
+
}
|
|
415
|
+
if (!assessment.correctTargetId?.trim()) {
|
|
416
|
+
issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
|
|
417
|
+
}
|
|
360
418
|
},
|
|
361
|
-
findMultipleHotspots: () => {
|
|
419
|
+
findMultipleHotspots: (assessment, path, issues) => {
|
|
420
|
+
if (assessment.kind !== "findMultipleHotspots") return;
|
|
421
|
+
if (!assessment.src?.trim()) {
|
|
422
|
+
issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
|
|
423
|
+
}
|
|
424
|
+
if (!assessment.alt?.trim()) {
|
|
425
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
|
|
426
|
+
}
|
|
427
|
+
const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
|
|
428
|
+
if (!ids.length) {
|
|
429
|
+
issues.push({
|
|
430
|
+
path: `${path}.correctTargetIds`,
|
|
431
|
+
message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
|
|
432
|
+
});
|
|
433
|
+
}
|
|
362
434
|
}
|
|
363
435
|
};
|
|
364
436
|
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
@@ -374,14 +446,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
|
374
446
|
if (!assessment.question?.trim()) {
|
|
375
447
|
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
376
448
|
}
|
|
449
|
+
const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
|
|
450
|
+
if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
|
|
451
|
+
issues.push({
|
|
452
|
+
path: `${path}.kind`,
|
|
453
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
377
457
|
const kind = assessment.kind ?? "mcq";
|
|
378
|
-
ASSESSMENT_VALIDATORS[kind]
|
|
458
|
+
const validator = ASSESSMENT_VALIDATORS[kind];
|
|
459
|
+
if (!validator) {
|
|
460
|
+
issues.push({
|
|
461
|
+
path: `${path}.kind`,
|
|
462
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
validator(assessment, path, issues);
|
|
379
467
|
const passingScore = assessment.passingScore;
|
|
380
468
|
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
381
469
|
issues.push({
|
|
382
470
|
path: `${path}.passingScore`,
|
|
383
471
|
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
384
472
|
});
|
|
473
|
+
} else if (passingScore !== void 0) {
|
|
474
|
+
const maxAchievable = maxAchievableAssessmentScore(assessment);
|
|
475
|
+
if (maxAchievable > 0 && passingScore > maxAchievable) {
|
|
476
|
+
issues.push({
|
|
477
|
+
path: `${path}.passingScore`,
|
|
478
|
+
message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
|
|
479
|
+
});
|
|
480
|
+
}
|
|
385
481
|
}
|
|
386
482
|
}
|
|
387
483
|
|
|
@@ -415,13 +511,23 @@ function validateCourseDescriptor(input) {
|
|
|
415
511
|
});
|
|
416
512
|
}
|
|
417
513
|
if (input.theme?.theme) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
514
|
+
const themeResult = (0, import_themes2.validateTheme)(input.theme.theme);
|
|
515
|
+
if (!themeResult.ok) {
|
|
516
|
+
for (const issue of themeResult.issues) {
|
|
517
|
+
issues.push({
|
|
518
|
+
path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
|
|
519
|
+
message: issue.message
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
try {
|
|
524
|
+
themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
|
|
525
|
+
} catch (err) {
|
|
526
|
+
issues.push({
|
|
527
|
+
path: "theme.theme",
|
|
528
|
+
message: err instanceof Error ? err.message : "invalid custom theme"
|
|
529
|
+
});
|
|
530
|
+
}
|
|
425
531
|
}
|
|
426
532
|
}
|
|
427
533
|
const completionThreshold = input.tracking?.completion?.threshold;
|
|
@@ -492,19 +598,102 @@ function validateCourseDescriptor(input) {
|
|
|
492
598
|
return issues;
|
|
493
599
|
}
|
|
494
600
|
|
|
601
|
+
// src/assessments.ts
|
|
602
|
+
function slugChoiceId(text, index) {
|
|
603
|
+
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
604
|
+
const stem = base.length ? base : "choice";
|
|
605
|
+
return `${stem}-${index + 1}`;
|
|
606
|
+
}
|
|
607
|
+
function mcqToLxpack(assessment) {
|
|
608
|
+
const choices = assessment.choices.map((text, index) => {
|
|
609
|
+
const id = slugChoiceId(text, index);
|
|
610
|
+
return {
|
|
611
|
+
id,
|
|
612
|
+
text,
|
|
613
|
+
correct: text === assessment.answer
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
return {
|
|
617
|
+
id: assessment.checkId,
|
|
618
|
+
passingScore: assessment.passingScore ?? 1,
|
|
619
|
+
questions: [
|
|
620
|
+
{
|
|
621
|
+
id: "q1",
|
|
622
|
+
prompt: assessment.question,
|
|
623
|
+
choices
|
|
624
|
+
}
|
|
625
|
+
]
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function assessmentDescriptorToLxpack(assessment) {
|
|
629
|
+
const kind = assessment.kind ?? "mcq";
|
|
630
|
+
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
631
|
+
const choices = ["True", "False"];
|
|
632
|
+
const answerText = assessment.answer ? "True" : "False";
|
|
633
|
+
return mcqToLxpack({
|
|
634
|
+
kind: "mcq",
|
|
635
|
+
checkId: assessment.checkId,
|
|
636
|
+
question: assessment.question,
|
|
637
|
+
choices,
|
|
638
|
+
answer: answerText,
|
|
639
|
+
passingScore: assessment.passingScore
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (kind === "fillInBlanks") {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
646
|
+
return mcqToLxpack({
|
|
647
|
+
kind: "mcq",
|
|
648
|
+
checkId: assessment.checkId,
|
|
649
|
+
question: assessment.question,
|
|
650
|
+
choices: [assessment.correctTargetId, "other"],
|
|
651
|
+
answer: assessment.correctTargetId,
|
|
652
|
+
passingScore: assessment.passingScore
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
if (kind === "findMultipleHotspots") {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
659
|
+
return mcqToLxpack(assessment);
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
function extractAssessments(descriptor) {
|
|
664
|
+
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
665
|
+
}
|
|
666
|
+
|
|
495
667
|
// src/descriptor/validateForTarget.ts
|
|
668
|
+
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
669
|
+
"scorm12",
|
|
670
|
+
"scorm2004",
|
|
671
|
+
"standalone",
|
|
672
|
+
"xapi",
|
|
673
|
+
"cmi5"
|
|
674
|
+
]);
|
|
496
675
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
{
|
|
676
|
+
const issues = [];
|
|
677
|
+
if (target === "xapi" || target === "cmi5") {
|
|
678
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
679
|
+
if (!activityIri) {
|
|
680
|
+
issues.push({
|
|
502
681
|
path: "course.tracking.xapi.activityIri",
|
|
503
682
|
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (LMS_SHELL_TARGETS.has(target)) {
|
|
687
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
688
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
689
|
+
issues.push({
|
|
690
|
+
path: `assessments[${index}]`,
|
|
691
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
692
|
+
});
|
|
504
693
|
}
|
|
505
|
-
|
|
694
|
+
});
|
|
506
695
|
}
|
|
507
|
-
return
|
|
696
|
+
return issues;
|
|
508
697
|
}
|
|
509
698
|
|
|
510
699
|
// src/validateDescriptor.ts
|
|
@@ -530,8 +719,114 @@ function validateDescriptorForTarget(input, target) {
|
|
|
530
719
|
return result;
|
|
531
720
|
}
|
|
532
721
|
|
|
533
|
-
// src/
|
|
722
|
+
// src/validateReactParity.ts
|
|
723
|
+
var import_node_fs2 = require("fs");
|
|
534
724
|
var import_node_path2 = require("path");
|
|
725
|
+
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
726
|
+
function collectSourceUnderSrc(projectRoot) {
|
|
727
|
+
const srcDir = (0, import_node_path2.join)(projectRoot, "src");
|
|
728
|
+
if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
|
|
729
|
+
const results = [];
|
|
730
|
+
const walk = (dir) => {
|
|
731
|
+
for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
|
|
732
|
+
const abs = (0, import_node_path2.join)(dir, entry);
|
|
733
|
+
if ((0, import_node_fs2.statSync)(abs).isDirectory()) {
|
|
734
|
+
walk(abs);
|
|
735
|
+
} else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
736
|
+
results.push((0, import_node_path2.relative)(projectRoot, abs));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
walk(srcDir);
|
|
741
|
+
return results;
|
|
742
|
+
}
|
|
743
|
+
function readAppSources(projectRoot, appSources) {
|
|
744
|
+
return appSources.map((rel) => (0, import_node_path2.join)(projectRoot, rel)).filter((abs) => (0, import_node_fs2.existsSync)(abs)).map((abs) => (0, import_node_fs2.readFileSync)(abs, "utf8")).join("\n");
|
|
745
|
+
}
|
|
746
|
+
function stripComments(source) {
|
|
747
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
748
|
+
}
|
|
749
|
+
function idPropPatterns(prop, id) {
|
|
750
|
+
return [
|
|
751
|
+
`${prop}="${id}"`,
|
|
752
|
+
`${prop}='${id}'`,
|
|
753
|
+
`${prop}={'${id}'}`,
|
|
754
|
+
`${prop}={"${id}"}`,
|
|
755
|
+
`${prop}={\`${id}\`}`
|
|
756
|
+
];
|
|
757
|
+
}
|
|
758
|
+
function extractStringConstants(source) {
|
|
759
|
+
const stripped = stripComments(source);
|
|
760
|
+
const map = /* @__PURE__ */ new Map();
|
|
761
|
+
const re = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
|
|
762
|
+
for (const match of stripped.matchAll(re)) {
|
|
763
|
+
map.set(match[1], match[3]);
|
|
764
|
+
}
|
|
765
|
+
return map;
|
|
766
|
+
}
|
|
767
|
+
function idUsedViaConstant(stripped, prop, id, constants) {
|
|
768
|
+
for (const [name, value] of constants) {
|
|
769
|
+
if (value !== id) continue;
|
|
770
|
+
const jsxPatterns = [
|
|
771
|
+
`${prop}={${name}}`,
|
|
772
|
+
`${prop}={ ${name} }`,
|
|
773
|
+
`${prop}={${name} }`,
|
|
774
|
+
`${prop}={ ${name}}`
|
|
775
|
+
];
|
|
776
|
+
if (jsxPatterns.some((p) => stripped.includes(p))) return true;
|
|
777
|
+
const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
|
|
778
|
+
if (objPatterns.some((p) => stripped.includes(p))) return true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
function courseIdPresent(source, courseId) {
|
|
783
|
+
const stripped = stripComments(source);
|
|
784
|
+
if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
|
|
785
|
+
return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
|
|
786
|
+
}
|
|
787
|
+
function checkIdPresent(source, checkId) {
|
|
788
|
+
const stripped = stripComments(source);
|
|
789
|
+
if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
|
|
790
|
+
return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
|
|
791
|
+
}
|
|
792
|
+
function validateReactManifestParity(opts) {
|
|
793
|
+
const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
|
|
794
|
+
const source = readAppSources(opts.projectRoot, appSources);
|
|
795
|
+
const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
|
|
796
|
+
if (!source.trim()) {
|
|
797
|
+
return [
|
|
798
|
+
{
|
|
799
|
+
path: appSources.length > 0 ? appSources.join(", ") : "src/",
|
|
800
|
+
message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
|
|
801
|
+
severity: hasDescriptorIds ? "error" : "warning"
|
|
802
|
+
}
|
|
803
|
+
];
|
|
804
|
+
}
|
|
805
|
+
const issues = [];
|
|
806
|
+
const courseId = opts.descriptor.courseId;
|
|
807
|
+
if (!courseIdPresent(source, courseId)) {
|
|
808
|
+
issues.push({
|
|
809
|
+
path: "course.courseId",
|
|
810
|
+
message: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
|
|
811
|
+
severity: "error"
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
for (const assessment of opts.descriptor.assessments ?? []) {
|
|
815
|
+
const checkId = assessment.checkId;
|
|
816
|
+
if (!checkId) continue;
|
|
817
|
+
if (!checkIdPresent(source, checkId)) {
|
|
818
|
+
issues.push({
|
|
819
|
+
path: `assessments.checkId:${checkId}`,
|
|
820
|
+
message: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
|
|
821
|
+
severity: "error"
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return issues;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/validateProjectPaths.ts
|
|
829
|
+
var import_node_path3 = require("path");
|
|
535
830
|
function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
536
831
|
if (!isSafeRelativeSpaPath(value)) {
|
|
537
832
|
issues.push({
|
|
@@ -541,7 +836,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
|
541
836
|
return;
|
|
542
837
|
}
|
|
543
838
|
try {
|
|
544
|
-
assertRealPathUnderRoot(projectRoot, (0,
|
|
839
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
|
|
545
840
|
} catch {
|
|
546
841
|
issues.push({
|
|
547
842
|
path: fieldPath,
|
|
@@ -551,7 +846,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
|
551
846
|
}
|
|
552
847
|
function validateProjectPaths(projectRoot, paths) {
|
|
553
848
|
const issues = [];
|
|
554
|
-
const root = (0,
|
|
849
|
+
const root = (0, import_node_path3.resolve)(projectRoot);
|
|
555
850
|
if (paths.spaDistDir?.trim()) {
|
|
556
851
|
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
557
852
|
}
|
|
@@ -564,20 +859,20 @@ function validateProjectPaths(projectRoot, paths) {
|
|
|
564
859
|
return issues;
|
|
565
860
|
}
|
|
566
861
|
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
567
|
-
const root = (0,
|
|
862
|
+
const root = (0, import_node_path3.resolve)(projectRoot);
|
|
568
863
|
const trimmed = override.trim();
|
|
569
864
|
if (!trimmed) {
|
|
570
865
|
throw new Error("output override must be a non-empty path");
|
|
571
866
|
}
|
|
572
|
-
if ((0,
|
|
573
|
-
const resolved2 = (0,
|
|
867
|
+
if ((0, import_node_path3.isAbsolute)(trimmed)) {
|
|
868
|
+
const resolved2 = (0, import_node_path3.resolve)(trimmed);
|
|
574
869
|
assertRealPathUnderRoot(root, resolved2);
|
|
575
870
|
return resolved2;
|
|
576
871
|
}
|
|
577
872
|
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
578
873
|
throw new Error(`unsafe output path: ${override}`);
|
|
579
874
|
}
|
|
580
|
-
const resolved = (0,
|
|
875
|
+
const resolved = (0, import_node_path3.resolve)(root, trimmed);
|
|
581
876
|
assertRealPathUnderRoot(root, resolved);
|
|
582
877
|
return resolved;
|
|
583
878
|
}
|
|
@@ -593,72 +888,6 @@ function mapLessonkitIds(descriptor) {
|
|
|
593
888
|
return { courseId, lessonIds, checkIds };
|
|
594
889
|
}
|
|
595
890
|
|
|
596
|
-
// src/assessments.ts
|
|
597
|
-
function slugChoiceId(text, index) {
|
|
598
|
-
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
599
|
-
const stem = base.length ? base : "choice";
|
|
600
|
-
return `${stem}-${index + 1}`;
|
|
601
|
-
}
|
|
602
|
-
function mcqToLxpack(assessment) {
|
|
603
|
-
const choices = assessment.choices.map((text, index) => {
|
|
604
|
-
const id = slugChoiceId(text, index);
|
|
605
|
-
return {
|
|
606
|
-
id,
|
|
607
|
-
text,
|
|
608
|
-
correct: text === assessment.answer
|
|
609
|
-
};
|
|
610
|
-
});
|
|
611
|
-
return {
|
|
612
|
-
id: assessment.checkId,
|
|
613
|
-
passingScore: assessment.passingScore ?? 1,
|
|
614
|
-
questions: [
|
|
615
|
-
{
|
|
616
|
-
id: "q1",
|
|
617
|
-
prompt: assessment.question,
|
|
618
|
-
choices
|
|
619
|
-
}
|
|
620
|
-
]
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
function assessmentDescriptorToLxpack(assessment) {
|
|
624
|
-
const kind = assessment.kind ?? "mcq";
|
|
625
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
626
|
-
const choices = ["True", "False"];
|
|
627
|
-
const answerText = assessment.answer ? "True" : "False";
|
|
628
|
-
return mcqToLxpack({
|
|
629
|
-
kind: "mcq",
|
|
630
|
-
checkId: assessment.checkId,
|
|
631
|
-
question: assessment.question,
|
|
632
|
-
choices,
|
|
633
|
-
answer: answerText,
|
|
634
|
-
passingScore: assessment.passingScore
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
if (kind === "fillInBlanks") {
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
641
|
-
return mcqToLxpack({
|
|
642
|
-
kind: "mcq",
|
|
643
|
-
checkId: assessment.checkId,
|
|
644
|
-
question: assessment.question,
|
|
645
|
-
choices: [assessment.correctTargetId, "other"],
|
|
646
|
-
answer: assessment.correctTargetId,
|
|
647
|
-
passingScore: assessment.passingScore
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
if (kind === "findMultipleHotspots") {
|
|
651
|
-
return null;
|
|
652
|
-
}
|
|
653
|
-
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
654
|
-
return mcqToLxpack(assessment);
|
|
655
|
-
}
|
|
656
|
-
return null;
|
|
657
|
-
}
|
|
658
|
-
function extractAssessments(descriptor) {
|
|
659
|
-
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
891
|
// src/interchange.ts
|
|
663
892
|
function mapDescriptorTracking(tracking) {
|
|
664
893
|
if (!tracking) return void 0;
|
|
@@ -719,21 +948,21 @@ function descriptorToInterchange(descriptor) {
|
|
|
719
948
|
}
|
|
720
949
|
|
|
721
950
|
// src/writeProject.ts
|
|
722
|
-
var
|
|
951
|
+
var import_node_path5 = require("path");
|
|
723
952
|
var import_validators = require("@lxpack/validators");
|
|
724
953
|
|
|
725
954
|
// src/spaDirs.ts
|
|
726
955
|
var import_promises = require("fs/promises");
|
|
727
|
-
var
|
|
956
|
+
var import_node_path4 = require("path");
|
|
728
957
|
async function resolveSpaDirs(options) {
|
|
729
958
|
const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
|
|
730
959
|
const spaLessons = resolveSpaLessons(descriptor);
|
|
731
960
|
if (descriptor.layout === "single-spa") {
|
|
732
961
|
const spaDistRelative = spaDistDir ?? descriptor.spaDistDir ?? /* v8 ignore next */
|
|
733
962
|
"dist";
|
|
734
|
-
const srcDist = projectRoot ? (0,
|
|
963
|
+
const srcDist = projectRoot ? (0, import_node_path4.resolve)(projectRoot, spaDistRelative) : (0, import_node_path4.resolve)(spaDistRelative);
|
|
735
964
|
if (projectRoot) {
|
|
736
|
-
assertRealPathUnderRoot((0,
|
|
965
|
+
assertRealPathUnderRoot((0, import_node_path4.resolve)(projectRoot), srcDist);
|
|
737
966
|
}
|
|
738
967
|
try {
|
|
739
968
|
await (0, import_promises.access)(srcDist);
|
|
@@ -741,9 +970,9 @@ async function resolveSpaDirs(options) {
|
|
|
741
970
|
throw new Error(`spaDistDir not found: ${srcDist}`);
|
|
742
971
|
}
|
|
743
972
|
try {
|
|
744
|
-
await (0, import_promises.access)((0,
|
|
973
|
+
await (0, import_promises.access)((0, import_node_path4.join)(srcDist, "index.html"));
|
|
745
974
|
} catch {
|
|
746
|
-
throw new Error(`spaDistDir must contain index.html: ${(0,
|
|
975
|
+
throw new Error(`spaDistDir must contain index.html: ${(0, import_node_path4.join)(srcDist, "index.html")}`);
|
|
747
976
|
}
|
|
748
977
|
const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
|
|
749
978
|
"main";
|
|
@@ -756,9 +985,9 @@ async function resolveSpaDirs(options) {
|
|
|
756
985
|
if (!src) {
|
|
757
986
|
throw new Error(`lessonSpaDirs missing build output for lesson "${lesson.id}"`);
|
|
758
987
|
}
|
|
759
|
-
const resolved = projectRoot ? (0,
|
|
988
|
+
const resolved = projectRoot ? (0, import_node_path4.resolve)(projectRoot, src) : (0, import_node_path4.resolve)(src);
|
|
760
989
|
if (projectRoot) {
|
|
761
|
-
assertRealPathUnderRoot((0,
|
|
990
|
+
assertRealPathUnderRoot((0, import_node_path4.resolve)(projectRoot), resolved);
|
|
762
991
|
}
|
|
763
992
|
try {
|
|
764
993
|
await (0, import_promises.access)(resolved);
|
|
@@ -766,10 +995,10 @@ async function resolveSpaDirs(options) {
|
|
|
766
995
|
throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
|
|
767
996
|
}
|
|
768
997
|
try {
|
|
769
|
-
await (0, import_promises.access)((0,
|
|
998
|
+
await (0, import_promises.access)((0, import_node_path4.join)(resolved, "index.html"));
|
|
770
999
|
} catch {
|
|
771
1000
|
throw new Error(
|
|
772
|
-
`lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0,
|
|
1001
|
+
`lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0, import_node_path4.join)(resolved, "index.html")}`
|
|
773
1002
|
);
|
|
774
1003
|
}
|
|
775
1004
|
dirs[lesson.id] = resolved;
|
|
@@ -786,9 +1015,9 @@ async function writeLxpackProject(options) {
|
|
|
786
1015
|
);
|
|
787
1016
|
}
|
|
788
1017
|
const descriptor = validation.descriptor;
|
|
789
|
-
const outDir = (0,
|
|
1018
|
+
const outDir = (0, import_node_path5.resolve)(options.outDir);
|
|
790
1019
|
if (options.projectRoot) {
|
|
791
|
-
assertRealPathUnderRoot((0,
|
|
1020
|
+
assertRealPathUnderRoot((0, import_node_path5.resolve)(options.projectRoot), outDir);
|
|
792
1021
|
}
|
|
793
1022
|
const spaDirs = await resolveSpaDirs({ ...options, descriptor });
|
|
794
1023
|
const interchange = descriptorToInterchange(descriptor);
|
|
@@ -806,60 +1035,87 @@ async function writeLxpackProject(options) {
|
|
|
806
1035
|
const courseDir = materialized.courseDir;
|
|
807
1036
|
return {
|
|
808
1037
|
outDir: courseDir,
|
|
809
|
-
courseYamlPath: (0,
|
|
810
|
-
lessonkitJsonPath: (0,
|
|
1038
|
+
courseYamlPath: (0, import_node_path5.join)(courseDir, "course.yaml"),
|
|
1039
|
+
lessonkitJsonPath: (0, import_node_path5.join)(courseDir, "lessonkit.json")
|
|
811
1040
|
};
|
|
812
1041
|
}
|
|
813
1042
|
|
|
814
1043
|
// src/packageCourse.ts
|
|
815
|
-
var
|
|
1044
|
+
var import_node_path9 = require("path");
|
|
816
1045
|
var fsp3 = __toESM(require("fs/promises"), 1);
|
|
817
1046
|
var import_api2 = require("@lxpack/api");
|
|
818
1047
|
|
|
819
1048
|
// src/packaging/validateInputs.ts
|
|
820
|
-
var
|
|
1049
|
+
var import_node_path6 = require("path");
|
|
821
1050
|
function validatePackageInputs(options) {
|
|
822
1051
|
const { target, output, outputBaseDir } = options;
|
|
823
|
-
const outDir = (0,
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
courseDir: outDir,
|
|
832
|
-
target,
|
|
833
|
-
issues: [
|
|
834
|
-
{
|
|
835
|
-
path: "outDir",
|
|
836
|
-
message: (
|
|
837
|
-
/* v8 ignore next */
|
|
838
|
-
err instanceof Error ? err.message : String(err)
|
|
839
|
-
)
|
|
840
|
-
}
|
|
841
|
-
]
|
|
842
|
-
};
|
|
843
|
-
}
|
|
1052
|
+
const outDir = (0, import_node_path6.resolve)(options.outDir);
|
|
1053
|
+
if (!options.projectRoot) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: false,
|
|
1056
|
+
courseDir: outDir,
|
|
1057
|
+
target,
|
|
1058
|
+
issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
|
|
1059
|
+
};
|
|
844
1060
|
}
|
|
845
|
-
|
|
1061
|
+
const projectRoot = (0, import_node_path6.resolve)(options.projectRoot);
|
|
1062
|
+
try {
|
|
1063
|
+
assertRealPathUnderRoot(projectRoot, outDir);
|
|
1064
|
+
} catch (err) {
|
|
846
1065
|
return {
|
|
847
1066
|
ok: false,
|
|
848
1067
|
courseDir: outDir,
|
|
849
1068
|
target,
|
|
850
|
-
issues: [
|
|
1069
|
+
issues: [
|
|
1070
|
+
{
|
|
1071
|
+
path: "outDir",
|
|
1072
|
+
message: (
|
|
1073
|
+
/* v8 ignore next */
|
|
1074
|
+
err instanceof Error ? err.message : String(err)
|
|
1075
|
+
)
|
|
1076
|
+
}
|
|
1077
|
+
]
|
|
851
1078
|
};
|
|
852
1079
|
}
|
|
853
|
-
if (
|
|
1080
|
+
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
854
1081
|
return {
|
|
855
1082
|
ok: false,
|
|
856
1083
|
courseDir: outDir,
|
|
857
1084
|
target,
|
|
858
|
-
issues: [{ path: "
|
|
1085
|
+
issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
|
|
859
1086
|
};
|
|
860
1087
|
}
|
|
861
|
-
if (
|
|
862
|
-
|
|
1088
|
+
if (output && !isSafeRelativeSpaPath(output)) {
|
|
1089
|
+
if ((0, import_node_path6.isAbsolute)(output)) {
|
|
1090
|
+
try {
|
|
1091
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path6.resolve)(output));
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
return {
|
|
1094
|
+
ok: false,
|
|
1095
|
+
courseDir: outDir,
|
|
1096
|
+
target,
|
|
1097
|
+
issues: [
|
|
1098
|
+
{
|
|
1099
|
+
path: "output",
|
|
1100
|
+
message: (
|
|
1101
|
+
/* v8 ignore next */
|
|
1102
|
+
err instanceof Error ? err.message : `unsafe output: ${output}`
|
|
1103
|
+
)
|
|
1104
|
+
}
|
|
1105
|
+
]
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
} else {
|
|
1109
|
+
return {
|
|
1110
|
+
ok: false,
|
|
1111
|
+
courseDir: outDir,
|
|
1112
|
+
target,
|
|
1113
|
+
issues: [{ path: "output", message: `unsafe output: ${output}` }]
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (outputBaseDir) {
|
|
1118
|
+
const resolvedOutputBase = (0, import_node_path6.resolve)(projectRoot, outputBaseDir);
|
|
863
1119
|
try {
|
|
864
1120
|
assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
|
|
865
1121
|
} catch (err) {
|
|
@@ -879,8 +1135,8 @@ function validatePackageInputs(options) {
|
|
|
879
1135
|
};
|
|
880
1136
|
}
|
|
881
1137
|
}
|
|
882
|
-
if (
|
|
883
|
-
const resolvedOutput = (0,
|
|
1138
|
+
if (output) {
|
|
1139
|
+
const resolvedOutput = (0, import_node_path6.isAbsolute)(output) ? (0, import_node_path6.resolve)(output) : (0, import_node_path6.resolve)(projectRoot, output);
|
|
884
1140
|
try {
|
|
885
1141
|
assertRealPathUnderRoot(projectRoot, resolvedOutput);
|
|
886
1142
|
} catch (err) {
|
|
@@ -917,23 +1173,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
917
1173
|
if (!artifactPath) return void 0;
|
|
918
1174
|
const resolved = resolveComparablePath(artifactPath);
|
|
919
1175
|
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
920
|
-
|
|
1176
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
921
1177
|
}
|
|
922
1178
|
const rel = relativePathUnderRoot(stagingRoot, resolved);
|
|
923
|
-
if (rel.startsWith("..") || (0,
|
|
924
|
-
|
|
1179
|
+
if (rel.startsWith("..") || (0, import_node_path6.isAbsolute)(rel)) {
|
|
1180
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
925
1181
|
}
|
|
926
1182
|
if (!rel) return outDir;
|
|
927
1183
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
928
|
-
return
|
|
1184
|
+
return import_node_path6.win32.join(outDir, rel.replace(/\//g, import_node_path6.win32.sep));
|
|
929
1185
|
}
|
|
930
|
-
return (0,
|
|
1186
|
+
return (0, import_node_path6.join)(outDir, rel);
|
|
931
1187
|
}
|
|
932
1188
|
|
|
933
1189
|
// src/packaging/promote.ts
|
|
934
1190
|
var fsp = __toESM(require("fs/promises"), 1);
|
|
935
1191
|
var import_node_crypto = require("crypto");
|
|
936
|
-
var
|
|
1192
|
+
var import_node_path7 = require("path");
|
|
937
1193
|
async function pathExists(path) {
|
|
938
1194
|
try {
|
|
939
1195
|
await fsp.access(path);
|
|
@@ -952,6 +1208,69 @@ async function renameOrCopy(from, to) {
|
|
|
952
1208
|
await fsp.rm(from, { recursive: true, force: true });
|
|
953
1209
|
}
|
|
954
1210
|
}
|
|
1211
|
+
function promoteLockPath(outDir) {
|
|
1212
|
+
const parent = (0, import_node_path7.dirname)(outDir);
|
|
1213
|
+
const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path7.resolve)(outDir)).digest("hex").slice(0, 16);
|
|
1214
|
+
return (0, import_node_path7.join)(parent, `.lk-promote-lock-${hash}`);
|
|
1215
|
+
}
|
|
1216
|
+
var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
|
|
1217
|
+
async function isStalePromoteLock(lockPath) {
|
|
1218
|
+
try {
|
|
1219
|
+
const content = await fsp.readFile(lockPath, "utf8");
|
|
1220
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
1221
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
1222
|
+
try {
|
|
1223
|
+
process.kill(pid, 0);
|
|
1224
|
+
return false;
|
|
1225
|
+
} catch {
|
|
1226
|
+
return true;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1230
|
+
return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
|
|
1231
|
+
} catch {
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
async function withPromoteLock(outDir, fn) {
|
|
1236
|
+
const lockPath = promoteLockPath(outDir);
|
|
1237
|
+
await fsp.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
|
|
1238
|
+
let lockHandle;
|
|
1239
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1240
|
+
try {
|
|
1241
|
+
lockHandle = await fsp.open(lockPath, "wx");
|
|
1242
|
+
await lockHandle.writeFile(`${process.pid}
|
|
1243
|
+
`, "utf8");
|
|
1244
|
+
break;
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
1247
|
+
if (code !== "EEXIST") throw err;
|
|
1248
|
+
if (await isStalePromoteLock(lockPath)) {
|
|
1249
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1250
|
+
/* v8 ignore next */
|
|
1251
|
+
() => void 0
|
|
1252
|
+
);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!lockHandle) {
|
|
1259
|
+
throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
return await fn();
|
|
1263
|
+
} finally {
|
|
1264
|
+
await lockHandle.close().catch(
|
|
1265
|
+
/* v8 ignore next */
|
|
1266
|
+
() => void 0
|
|
1267
|
+
);
|
|
1268
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1269
|
+
/* v8 ignore next */
|
|
1270
|
+
() => void 0
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
955
1274
|
async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
956
1275
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
957
1276
|
const legacyBak = `${outDir}.bak`;
|
|
@@ -965,45 +1284,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
|
965
1284
|
}
|
|
966
1285
|
}
|
|
967
1286
|
async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
await renameOrCopy(outDir, backup);
|
|
976
|
-
}
|
|
977
|
-
try {
|
|
978
|
-
await renameOrCopy(tmpPromote, outDir);
|
|
979
|
-
} catch (promoteError) {
|
|
1287
|
+
return withPromoteLock(outDir, async () => {
|
|
1288
|
+
await assertNoLegacyPromoteArtifacts(outDir);
|
|
1289
|
+
const parent = (0, import_node_path7.dirname)(outDir);
|
|
1290
|
+
const tmpPromote = await fsp.mkdtemp((0, import_node_path7.join)(parent, ".lk-promote-"));
|
|
1291
|
+
await renameOrCopy(stagingDir, tmpPromote);
|
|
1292
|
+
const hadOutDir = await pathExists(outDir);
|
|
1293
|
+
const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path7.join)(parent, ".lk-backup-")) : void 0;
|
|
980
1294
|
if (hadOutDir && backup) {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1295
|
+
await renameOrCopy(outDir, backup);
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
await renameOrCopy(tmpPromote, outDir);
|
|
1299
|
+
} catch (promoteError) {
|
|
1300
|
+
if (hadOutDir && backup) {
|
|
985
1301
|
try {
|
|
986
|
-
await renameOrCopy(
|
|
987
|
-
} catch {
|
|
1302
|
+
await renameOrCopy(backup, outDir);
|
|
1303
|
+
} catch (restoreError) {
|
|
1304
|
+
const failedPromote2 = (0, import_node_path7.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1305
|
+
try {
|
|
1306
|
+
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1307
|
+
} catch {
|
|
1308
|
+
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1309
|
+
/* v8 ignore next */
|
|
1310
|
+
() => void 0
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1314
|
+
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1315
|
+
throw new Error(
|
|
1316
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
} else {
|
|
1320
|
+
try {
|
|
1321
|
+
await renameOrCopy(tmpPromote, stagingDir);
|
|
1322
|
+
} catch (restoreError) {
|
|
1323
|
+
console.warn(
|
|
1324
|
+
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1325
|
+
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1326
|
+
);
|
|
988
1327
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
989
1328
|
/* v8 ignore next */
|
|
990
1329
|
() => void 0
|
|
991
1330
|
);
|
|
992
1331
|
}
|
|
993
|
-
|
|
994
|
-
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
995
|
-
throw new Error(
|
|
996
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
997
|
-
);
|
|
1332
|
+
throw promoteError;
|
|
998
1333
|
}
|
|
999
|
-
|
|
1334
|
+
const failedPromote = (0, import_node_path7.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1000
1335
|
try {
|
|
1001
|
-
await renameOrCopy(tmpPromote,
|
|
1002
|
-
} catch
|
|
1003
|
-
console.warn(
|
|
1004
|
-
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1005
|
-
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1006
|
-
);
|
|
1336
|
+
await renameOrCopy(tmpPromote, failedPromote);
|
|
1337
|
+
} catch {
|
|
1007
1338
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1008
1339
|
/* v8 ignore next */
|
|
1009
1340
|
() => void 0
|
|
@@ -1011,33 +1342,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1011
1342
|
}
|
|
1012
1343
|
throw promoteError;
|
|
1013
1344
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
await renameOrCopy(tmpPromote, failedPromote);
|
|
1017
|
-
} catch {
|
|
1018
|
-
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1345
|
+
if (backup) {
|
|
1346
|
+
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1019
1347
|
/* v8 ignore next */
|
|
1020
1348
|
() => void 0
|
|
1021
1349
|
);
|
|
1022
1350
|
}
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
if (backup) {
|
|
1026
|
-
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1027
|
-
/* v8 ignore next */
|
|
1028
|
-
() => void 0
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1351
|
+
});
|
|
1031
1352
|
}
|
|
1032
1353
|
|
|
1033
1354
|
// src/packaging/staging.ts
|
|
1034
1355
|
var fsp2 = __toESM(require("fs/promises"), 1);
|
|
1035
|
-
var
|
|
1356
|
+
var import_node_path8 = require("path");
|
|
1036
1357
|
var import_node_os = require("os");
|
|
1037
1358
|
var import_api = require("@lxpack/api");
|
|
1038
1359
|
async function buildStagingPackage(options) {
|
|
1039
1360
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1040
|
-
const stagingDir = await fsp2.mkdtemp((0,
|
|
1361
|
+
const stagingDir = await fsp2.mkdtemp((0, import_node_path8.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
|
|
1041
1362
|
try {
|
|
1042
1363
|
let spaDirs;
|
|
1043
1364
|
try {
|
|
@@ -1056,8 +1377,8 @@ async function buildStagingPackage(options) {
|
|
|
1056
1377
|
}
|
|
1057
1378
|
const interchange = descriptorToInterchange(descriptor);
|
|
1058
1379
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
1059
|
-
await fsp2.mkdir((0,
|
|
1060
|
-
const defaultOutput = output ?? (dir ? (0,
|
|
1380
|
+
await fsp2.mkdir((0, import_node_path8.join)(stagingDir, outputBase), { recursive: true });
|
|
1381
|
+
const defaultOutput = output ?? (dir ? (0, import_node_path8.join)(outputBase, target) : (0, import_node_path8.join)(outputBase, `course-${target}.zip`));
|
|
1061
1382
|
const build = await (0, import_api.packageLessonkit)({
|
|
1062
1383
|
interchange,
|
|
1063
1384
|
spaDirs,
|
|
@@ -1097,19 +1418,28 @@ async function buildStagingPackage(options) {
|
|
|
1097
1418
|
}
|
|
1098
1419
|
}
|
|
1099
1420
|
async function ensureOutDirParent(outDir) {
|
|
1100
|
-
await fsp2.mkdir((0,
|
|
1421
|
+
await fsp2.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// src/packaging/issueSeverity.ts
|
|
1425
|
+
function isPackagingErrorIssue(issue) {
|
|
1426
|
+
const severity = issue.severity?.toLowerCase();
|
|
1427
|
+
return severity === "error" || severity === "fatal";
|
|
1428
|
+
}
|
|
1429
|
+
function findPackagingErrorIssues(issues) {
|
|
1430
|
+
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1101
1431
|
}
|
|
1102
1432
|
|
|
1103
1433
|
// src/packageCourse.ts
|
|
1104
1434
|
async function validateLessonkitProject(options) {
|
|
1105
1435
|
return (0, import_api2.validateCourse)({
|
|
1106
|
-
courseDir: (0,
|
|
1436
|
+
courseDir: (0, import_node_path9.resolve)(options.courseDir),
|
|
1107
1437
|
target: options.target
|
|
1108
1438
|
});
|
|
1109
1439
|
}
|
|
1110
1440
|
async function buildLessonkitProject(options) {
|
|
1111
1441
|
const buildOptions = {
|
|
1112
|
-
courseDir: (0,
|
|
1442
|
+
courseDir: (0, import_node_path9.resolve)(options.courseDir),
|
|
1113
1443
|
target: options.target,
|
|
1114
1444
|
output: options.output,
|
|
1115
1445
|
dir: options.dir,
|
|
@@ -1140,7 +1470,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1140
1470
|
if (!descriptorValidation.ok) {
|
|
1141
1471
|
return {
|
|
1142
1472
|
ok: false,
|
|
1143
|
-
courseDir: (0,
|
|
1473
|
+
courseDir: (0, import_node_path9.resolve)(writeOpts.outDir),
|
|
1144
1474
|
target,
|
|
1145
1475
|
issues: descriptorValidation.issues.map((i) => ({
|
|
1146
1476
|
path: i.path,
|
|
@@ -1149,6 +1479,37 @@ async function packageLessonkitCourse(options) {
|
|
|
1149
1479
|
};
|
|
1150
1480
|
}
|
|
1151
1481
|
const descriptor = descriptorValidation.descriptor;
|
|
1482
|
+
if (writeOpts.projectRoot) {
|
|
1483
|
+
const parityIssues = validateReactManifestParity({
|
|
1484
|
+
projectRoot: writeOpts.projectRoot,
|
|
1485
|
+
descriptor
|
|
1486
|
+
});
|
|
1487
|
+
const parityErrors = parityIssues.filter((i) => i.severity === "error");
|
|
1488
|
+
if (parityErrors.length > 0) {
|
|
1489
|
+
return {
|
|
1490
|
+
ok: false,
|
|
1491
|
+
courseDir: outDir,
|
|
1492
|
+
target,
|
|
1493
|
+
issues: parityErrors.map((i) => ({
|
|
1494
|
+
path: i.path,
|
|
1495
|
+
message: i.message,
|
|
1496
|
+
severity: i.severity
|
|
1497
|
+
}))
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1502
|
+
if (nonInjectableAssessments.length > 0) {
|
|
1503
|
+
return {
|
|
1504
|
+
ok: false,
|
|
1505
|
+
courseDir: outDir,
|
|
1506
|
+
target,
|
|
1507
|
+
issues: nonInjectableAssessments.map(({ assessment, index }) => ({
|
|
1508
|
+
path: `assessments[${index}]`,
|
|
1509
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
1510
|
+
}))
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1152
1513
|
const staged = await buildStagingPackage({
|
|
1153
1514
|
...writeOpts,
|
|
1154
1515
|
descriptor,
|
|
@@ -1173,6 +1534,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1173
1534
|
};
|
|
1174
1535
|
}
|
|
1175
1536
|
const { stagingDir, build } = staged;
|
|
1537
|
+
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1538
|
+
if (buildErrorIssues.length > 0) {
|
|
1539
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1540
|
+
/* v8 ignore next */
|
|
1541
|
+
() => void 0
|
|
1542
|
+
);
|
|
1543
|
+
return {
|
|
1544
|
+
ok: false,
|
|
1545
|
+
courseDir: outDir,
|
|
1546
|
+
target,
|
|
1547
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1548
|
+
build,
|
|
1549
|
+
issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
|
|
1550
|
+
path: i.path ?? "build",
|
|
1551
|
+
message: i.message,
|
|
1552
|
+
severity: i.severity
|
|
1553
|
+
}))
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1176
1556
|
const stagingRoot = await fsp3.realpath(stagingDir);
|
|
1177
1557
|
const artifactIssues = [
|
|
1178
1558
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
@@ -1203,6 +1583,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1203
1583
|
await ensureOutDirParent(outDir);
|
|
1204
1584
|
await promoteStagingToOutDir(stagingDir, outDir);
|
|
1205
1585
|
} catch (err) {
|
|
1586
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1587
|
+
/* v8 ignore next */
|
|
1588
|
+
() => void 0
|
|
1589
|
+
);
|
|
1206
1590
|
return {
|
|
1207
1591
|
ok: false,
|
|
1208
1592
|
courseDir: outDir,
|
|
@@ -1418,5 +1802,6 @@ var import_validators2 = require("@lxpack/validators");
|
|
|
1418
1802
|
validateLessonkitProject,
|
|
1419
1803
|
validatePackageInputs,
|
|
1420
1804
|
validateProjectPaths,
|
|
1805
|
+
validateReactManifestParity,
|
|
1421
1806
|
writeLxpackProject
|
|
1422
1807
|
});
|