@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.js
CHANGED
|
@@ -134,6 +134,14 @@ function parseAssessmentDescriptor(raw) {
|
|
|
134
134
|
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
|
+
if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
|
|
138
|
+
return {
|
|
139
|
+
kind,
|
|
140
|
+
...base,
|
|
141
|
+
choices: [],
|
|
142
|
+
answer: ""
|
|
143
|
+
};
|
|
144
|
+
}
|
|
137
145
|
return {
|
|
138
146
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
139
147
|
...base,
|
|
@@ -183,10 +191,11 @@ function parseCourseDescriptorInput(input) {
|
|
|
183
191
|
|
|
184
192
|
// src/descriptor/validateCourse.ts
|
|
185
193
|
import { validateId as validateId3 } from "@lessonkit/core";
|
|
194
|
+
import { validateTheme } from "@lessonkit/themes";
|
|
186
195
|
|
|
187
196
|
// src/spaPath.ts
|
|
188
|
-
import { realpathSync } from "fs";
|
|
189
|
-
import { isAbsolute, relative, resolve, sep, win32 } from "path";
|
|
197
|
+
import { existsSync, realpathSync } from "fs";
|
|
198
|
+
import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
|
|
190
199
|
function resolveComparablePath(p) {
|
|
191
200
|
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
192
201
|
return win32.resolve(p);
|
|
@@ -212,6 +221,28 @@ function assertResolvedPathUnderRoot(root, target) {
|
|
|
212
221
|
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
213
222
|
}
|
|
214
223
|
}
|
|
224
|
+
function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
|
|
225
|
+
const rel = relative(rootResolved, targetResolved);
|
|
226
|
+
if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
227
|
+
throw new Error(`unsafe path escapes project root: ${targetResolved}`);
|
|
228
|
+
}
|
|
229
|
+
const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
230
|
+
let current = rootReal;
|
|
231
|
+
for (const segment of segments) {
|
|
232
|
+
const next = join(current, segment);
|
|
233
|
+
if (existsSync(next)) {
|
|
234
|
+
try {
|
|
235
|
+
current = realpathSync(next);
|
|
236
|
+
} catch {
|
|
237
|
+
current = next;
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
current = next;
|
|
241
|
+
}
|
|
242
|
+
assertResolvedPathUnderRoot(rootReal, current);
|
|
243
|
+
}
|
|
244
|
+
return current;
|
|
245
|
+
}
|
|
215
246
|
function assertRealPathUnderRoot(root, target) {
|
|
216
247
|
const rootResolved = resolveComparablePath(root);
|
|
217
248
|
const targetResolved = resolveComparablePath(target);
|
|
@@ -221,17 +252,12 @@ function assertRealPathUnderRoot(root, target) {
|
|
|
221
252
|
} catch {
|
|
222
253
|
rootReal = rootResolved;
|
|
223
254
|
}
|
|
224
|
-
let targetCheck;
|
|
225
255
|
try {
|
|
226
|
-
targetCheck = realpathSync(targetResolved);
|
|
256
|
+
const targetCheck = realpathSync(targetResolved);
|
|
257
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
227
258
|
} catch {
|
|
228
|
-
|
|
229
|
-
if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
230
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
231
|
-
}
|
|
232
|
-
targetCheck = resolve(rootReal, rel);
|
|
259
|
+
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
233
260
|
}
|
|
234
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
235
261
|
}
|
|
236
262
|
function normalizePathForComparison(p) {
|
|
237
263
|
const resolved = resolveComparablePath(p);
|
|
@@ -272,7 +298,12 @@ function themeToLxpackRuntime(input) {
|
|
|
272
298
|
// src/descriptor/validateAssessments.ts
|
|
273
299
|
import { validateId as validateId2 } from "@lessonkit/core";
|
|
274
300
|
var validateMcqLike = (assessment, path, issues) => {
|
|
275
|
-
if (!("choices" in assessment) || !(
|
|
301
|
+
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
302
|
+
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
306
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
|
|
276
307
|
return;
|
|
277
308
|
}
|
|
278
309
|
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
@@ -285,6 +316,22 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
285
316
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
286
317
|
}
|
|
287
318
|
};
|
|
319
|
+
function countStarDelimitedBlanks(template) {
|
|
320
|
+
const matches = template.match(/\*[^*]+\*/g);
|
|
321
|
+
return matches?.length ?? 0;
|
|
322
|
+
}
|
|
323
|
+
function maxAchievableAssessmentScore(assessment) {
|
|
324
|
+
const kind = assessment.kind ?? "mcq";
|
|
325
|
+
if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
|
|
326
|
+
const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
|
|
327
|
+
if (explicit > 0) return explicit;
|
|
328
|
+
return countStarDelimitedBlanks(assessment.template ?? "");
|
|
329
|
+
}
|
|
330
|
+
if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
|
|
331
|
+
return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
|
|
332
|
+
}
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
288
335
|
var ASSESSMENT_VALIDATORS = {
|
|
289
336
|
mcq: validateMcqLike,
|
|
290
337
|
trueFalse: (assessment, path, issues) => {
|
|
@@ -297,9 +344,33 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
297
344
|
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
298
345
|
}
|
|
299
346
|
},
|
|
300
|
-
findHotspot: () => {
|
|
347
|
+
findHotspot: (assessment, path, issues) => {
|
|
348
|
+
if (assessment.kind !== "findHotspot") return;
|
|
349
|
+
if (!assessment.src?.trim()) {
|
|
350
|
+
issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
|
|
351
|
+
}
|
|
352
|
+
if (!assessment.alt?.trim()) {
|
|
353
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
|
|
354
|
+
}
|
|
355
|
+
if (!assessment.correctTargetId?.trim()) {
|
|
356
|
+
issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
|
|
357
|
+
}
|
|
301
358
|
},
|
|
302
|
-
findMultipleHotspots: () => {
|
|
359
|
+
findMultipleHotspots: (assessment, path, issues) => {
|
|
360
|
+
if (assessment.kind !== "findMultipleHotspots") return;
|
|
361
|
+
if (!assessment.src?.trim()) {
|
|
362
|
+
issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
|
|
363
|
+
}
|
|
364
|
+
if (!assessment.alt?.trim()) {
|
|
365
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
|
|
366
|
+
}
|
|
367
|
+
const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
|
|
368
|
+
if (!ids.length) {
|
|
369
|
+
issues.push({
|
|
370
|
+
path: `${path}.correctTargetIds`,
|
|
371
|
+
message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
|
|
372
|
+
});
|
|
373
|
+
}
|
|
303
374
|
}
|
|
304
375
|
};
|
|
305
376
|
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
@@ -315,14 +386,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
|
315
386
|
if (!assessment.question?.trim()) {
|
|
316
387
|
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
317
388
|
}
|
|
389
|
+
const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
|
|
390
|
+
if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
|
|
391
|
+
issues.push({
|
|
392
|
+
path: `${path}.kind`,
|
|
393
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
318
397
|
const kind = assessment.kind ?? "mcq";
|
|
319
|
-
ASSESSMENT_VALIDATORS[kind]
|
|
398
|
+
const validator = ASSESSMENT_VALIDATORS[kind];
|
|
399
|
+
if (!validator) {
|
|
400
|
+
issues.push({
|
|
401
|
+
path: `${path}.kind`,
|
|
402
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
validator(assessment, path, issues);
|
|
320
407
|
const passingScore = assessment.passingScore;
|
|
321
408
|
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
322
409
|
issues.push({
|
|
323
410
|
path: `${path}.passingScore`,
|
|
324
411
|
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
325
412
|
});
|
|
413
|
+
} else if (passingScore !== void 0) {
|
|
414
|
+
const maxAchievable = maxAchievableAssessmentScore(assessment);
|
|
415
|
+
if (maxAchievable > 0 && passingScore > maxAchievable) {
|
|
416
|
+
issues.push({
|
|
417
|
+
path: `${path}.passingScore`,
|
|
418
|
+
message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
|
|
419
|
+
});
|
|
420
|
+
}
|
|
326
421
|
}
|
|
327
422
|
}
|
|
328
423
|
|
|
@@ -356,13 +451,23 @@ function validateCourseDescriptor(input) {
|
|
|
356
451
|
});
|
|
357
452
|
}
|
|
358
453
|
if (input.theme?.theme) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
454
|
+
const themeResult = validateTheme(input.theme.theme);
|
|
455
|
+
if (!themeResult.ok) {
|
|
456
|
+
for (const issue of themeResult.issues) {
|
|
457
|
+
issues.push({
|
|
458
|
+
path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
|
|
459
|
+
message: issue.message
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
try {
|
|
464
|
+
themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
|
|
465
|
+
} catch (err) {
|
|
466
|
+
issues.push({
|
|
467
|
+
path: "theme.theme",
|
|
468
|
+
message: err instanceof Error ? err.message : "invalid custom theme"
|
|
469
|
+
});
|
|
470
|
+
}
|
|
366
471
|
}
|
|
367
472
|
}
|
|
368
473
|
const completionThreshold = input.tracking?.completion?.threshold;
|
|
@@ -433,19 +538,102 @@ function validateCourseDescriptor(input) {
|
|
|
433
538
|
return issues;
|
|
434
539
|
}
|
|
435
540
|
|
|
541
|
+
// src/assessments.ts
|
|
542
|
+
function slugChoiceId(text, index) {
|
|
543
|
+
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
544
|
+
const stem = base.length ? base : "choice";
|
|
545
|
+
return `${stem}-${index + 1}`;
|
|
546
|
+
}
|
|
547
|
+
function mcqToLxpack(assessment) {
|
|
548
|
+
const choices = assessment.choices.map((text, index) => {
|
|
549
|
+
const id = slugChoiceId(text, index);
|
|
550
|
+
return {
|
|
551
|
+
id,
|
|
552
|
+
text,
|
|
553
|
+
correct: text === assessment.answer
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
id: assessment.checkId,
|
|
558
|
+
passingScore: assessment.passingScore ?? 1,
|
|
559
|
+
questions: [
|
|
560
|
+
{
|
|
561
|
+
id: "q1",
|
|
562
|
+
prompt: assessment.question,
|
|
563
|
+
choices
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function assessmentDescriptorToLxpack(assessment) {
|
|
569
|
+
const kind = assessment.kind ?? "mcq";
|
|
570
|
+
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
571
|
+
const choices = ["True", "False"];
|
|
572
|
+
const answerText = assessment.answer ? "True" : "False";
|
|
573
|
+
return mcqToLxpack({
|
|
574
|
+
kind: "mcq",
|
|
575
|
+
checkId: assessment.checkId,
|
|
576
|
+
question: assessment.question,
|
|
577
|
+
choices,
|
|
578
|
+
answer: answerText,
|
|
579
|
+
passingScore: assessment.passingScore
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (kind === "fillInBlanks") {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
586
|
+
return mcqToLxpack({
|
|
587
|
+
kind: "mcq",
|
|
588
|
+
checkId: assessment.checkId,
|
|
589
|
+
question: assessment.question,
|
|
590
|
+
choices: [assessment.correctTargetId, "other"],
|
|
591
|
+
answer: assessment.correctTargetId,
|
|
592
|
+
passingScore: assessment.passingScore
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
if (kind === "findMultipleHotspots") {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
599
|
+
return mcqToLxpack(assessment);
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
function extractAssessments(descriptor) {
|
|
604
|
+
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
605
|
+
}
|
|
606
|
+
|
|
436
607
|
// src/descriptor/validateForTarget.ts
|
|
608
|
+
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
609
|
+
"scorm12",
|
|
610
|
+
"scorm2004",
|
|
611
|
+
"standalone",
|
|
612
|
+
"xapi",
|
|
613
|
+
"cmi5"
|
|
614
|
+
]);
|
|
437
615
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
{
|
|
616
|
+
const issues = [];
|
|
617
|
+
if (target === "xapi" || target === "cmi5") {
|
|
618
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
619
|
+
if (!activityIri) {
|
|
620
|
+
issues.push({
|
|
443
621
|
path: "course.tracking.xapi.activityIri",
|
|
444
622
|
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (LMS_SHELL_TARGETS.has(target)) {
|
|
627
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
628
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
629
|
+
issues.push({
|
|
630
|
+
path: `assessments[${index}]`,
|
|
631
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
632
|
+
});
|
|
445
633
|
}
|
|
446
|
-
|
|
634
|
+
});
|
|
447
635
|
}
|
|
448
|
-
return
|
|
636
|
+
return issues;
|
|
449
637
|
}
|
|
450
638
|
|
|
451
639
|
// src/validateDescriptor.ts
|
|
@@ -471,6 +659,112 @@ function validateDescriptorForTarget(input, target) {
|
|
|
471
659
|
return result;
|
|
472
660
|
}
|
|
473
661
|
|
|
662
|
+
// src/validateReactParity.ts
|
|
663
|
+
import { readFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
|
|
664
|
+
import { join as join2, relative as relative2 } from "path";
|
|
665
|
+
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
666
|
+
function collectSourceUnderSrc(projectRoot) {
|
|
667
|
+
const srcDir = join2(projectRoot, "src");
|
|
668
|
+
if (!existsSync2(srcDir)) return [];
|
|
669
|
+
const results = [];
|
|
670
|
+
const walk = (dir) => {
|
|
671
|
+
for (const entry of readdirSync(dir)) {
|
|
672
|
+
const abs = join2(dir, entry);
|
|
673
|
+
if (statSync(abs).isDirectory()) {
|
|
674
|
+
walk(abs);
|
|
675
|
+
} else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
676
|
+
results.push(relative2(projectRoot, abs));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
walk(srcDir);
|
|
681
|
+
return results;
|
|
682
|
+
}
|
|
683
|
+
function readAppSources(projectRoot, appSources) {
|
|
684
|
+
return appSources.map((rel) => join2(projectRoot, rel)).filter((abs) => existsSync2(abs)).map((abs) => readFileSync(abs, "utf8")).join("\n");
|
|
685
|
+
}
|
|
686
|
+
function stripComments(source) {
|
|
687
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
688
|
+
}
|
|
689
|
+
function idPropPatterns(prop, id) {
|
|
690
|
+
return [
|
|
691
|
+
`${prop}="${id}"`,
|
|
692
|
+
`${prop}='${id}'`,
|
|
693
|
+
`${prop}={'${id}'}`,
|
|
694
|
+
`${prop}={"${id}"}`,
|
|
695
|
+
`${prop}={\`${id}\`}`
|
|
696
|
+
];
|
|
697
|
+
}
|
|
698
|
+
function extractStringConstants(source) {
|
|
699
|
+
const stripped = stripComments(source);
|
|
700
|
+
const map = /* @__PURE__ */ new Map();
|
|
701
|
+
const re = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
|
|
702
|
+
for (const match of stripped.matchAll(re)) {
|
|
703
|
+
map.set(match[1], match[3]);
|
|
704
|
+
}
|
|
705
|
+
return map;
|
|
706
|
+
}
|
|
707
|
+
function idUsedViaConstant(stripped, prop, id, constants) {
|
|
708
|
+
for (const [name, value] of constants) {
|
|
709
|
+
if (value !== id) continue;
|
|
710
|
+
const jsxPatterns = [
|
|
711
|
+
`${prop}={${name}}`,
|
|
712
|
+
`${prop}={ ${name} }`,
|
|
713
|
+
`${prop}={${name} }`,
|
|
714
|
+
`${prop}={ ${name}}`
|
|
715
|
+
];
|
|
716
|
+
if (jsxPatterns.some((p) => stripped.includes(p))) return true;
|
|
717
|
+
const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
|
|
718
|
+
if (objPatterns.some((p) => stripped.includes(p))) return true;
|
|
719
|
+
}
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
function courseIdPresent(source, courseId) {
|
|
723
|
+
const stripped = stripComments(source);
|
|
724
|
+
if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
|
|
725
|
+
return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
|
|
726
|
+
}
|
|
727
|
+
function checkIdPresent(source, checkId) {
|
|
728
|
+
const stripped = stripComments(source);
|
|
729
|
+
if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
|
|
730
|
+
return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
|
|
731
|
+
}
|
|
732
|
+
function validateReactManifestParity(opts) {
|
|
733
|
+
const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
|
|
734
|
+
const source = readAppSources(opts.projectRoot, appSources);
|
|
735
|
+
const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
|
|
736
|
+
if (!source.trim()) {
|
|
737
|
+
return [
|
|
738
|
+
{
|
|
739
|
+
path: appSources.length > 0 ? appSources.join(", ") : "src/",
|
|
740
|
+
message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
|
|
741
|
+
severity: hasDescriptorIds ? "error" : "warning"
|
|
742
|
+
}
|
|
743
|
+
];
|
|
744
|
+
}
|
|
745
|
+
const issues = [];
|
|
746
|
+
const courseId = opts.descriptor.courseId;
|
|
747
|
+
if (!courseIdPresent(source, courseId)) {
|
|
748
|
+
issues.push({
|
|
749
|
+
path: "course.courseId",
|
|
750
|
+
message: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
|
|
751
|
+
severity: "error"
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
for (const assessment of opts.descriptor.assessments ?? []) {
|
|
755
|
+
const checkId = assessment.checkId;
|
|
756
|
+
if (!checkId) continue;
|
|
757
|
+
if (!checkIdPresent(source, checkId)) {
|
|
758
|
+
issues.push({
|
|
759
|
+
path: `assessments.checkId:${checkId}`,
|
|
760
|
+
message: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
|
|
761
|
+
severity: "error"
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return issues;
|
|
766
|
+
}
|
|
767
|
+
|
|
474
768
|
// src/validateProjectPaths.ts
|
|
475
769
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
476
770
|
function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
@@ -534,72 +828,6 @@ function mapLessonkitIds(descriptor) {
|
|
|
534
828
|
return { courseId, lessonIds, checkIds };
|
|
535
829
|
}
|
|
536
830
|
|
|
537
|
-
// src/assessments.ts
|
|
538
|
-
function slugChoiceId(text, index) {
|
|
539
|
-
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
540
|
-
const stem = base.length ? base : "choice";
|
|
541
|
-
return `${stem}-${index + 1}`;
|
|
542
|
-
}
|
|
543
|
-
function mcqToLxpack(assessment) {
|
|
544
|
-
const choices = assessment.choices.map((text, index) => {
|
|
545
|
-
const id = slugChoiceId(text, index);
|
|
546
|
-
return {
|
|
547
|
-
id,
|
|
548
|
-
text,
|
|
549
|
-
correct: text === assessment.answer
|
|
550
|
-
};
|
|
551
|
-
});
|
|
552
|
-
return {
|
|
553
|
-
id: assessment.checkId,
|
|
554
|
-
passingScore: assessment.passingScore ?? 1,
|
|
555
|
-
questions: [
|
|
556
|
-
{
|
|
557
|
-
id: "q1",
|
|
558
|
-
prompt: assessment.question,
|
|
559
|
-
choices
|
|
560
|
-
}
|
|
561
|
-
]
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
function assessmentDescriptorToLxpack(assessment) {
|
|
565
|
-
const kind = assessment.kind ?? "mcq";
|
|
566
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
567
|
-
const choices = ["True", "False"];
|
|
568
|
-
const answerText = assessment.answer ? "True" : "False";
|
|
569
|
-
return mcqToLxpack({
|
|
570
|
-
kind: "mcq",
|
|
571
|
-
checkId: assessment.checkId,
|
|
572
|
-
question: assessment.question,
|
|
573
|
-
choices,
|
|
574
|
-
answer: answerText,
|
|
575
|
-
passingScore: assessment.passingScore
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
if (kind === "fillInBlanks") {
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
582
|
-
return mcqToLxpack({
|
|
583
|
-
kind: "mcq",
|
|
584
|
-
checkId: assessment.checkId,
|
|
585
|
-
question: assessment.question,
|
|
586
|
-
choices: [assessment.correctTargetId, "other"],
|
|
587
|
-
answer: assessment.correctTargetId,
|
|
588
|
-
passingScore: assessment.passingScore
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
if (kind === "findMultipleHotspots") {
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
595
|
-
return mcqToLxpack(assessment);
|
|
596
|
-
}
|
|
597
|
-
return null;
|
|
598
|
-
}
|
|
599
|
-
function extractAssessments(descriptor) {
|
|
600
|
-
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
831
|
// src/interchange.ts
|
|
604
832
|
function mapDescriptorTracking(tracking) {
|
|
605
833
|
if (!tracking) return void 0;
|
|
@@ -660,12 +888,12 @@ function descriptorToInterchange(descriptor) {
|
|
|
660
888
|
}
|
|
661
889
|
|
|
662
890
|
// src/writeProject.ts
|
|
663
|
-
import { join as
|
|
891
|
+
import { join as join4, resolve as resolve4 } from "path";
|
|
664
892
|
import { materializeLessonkitProject } from "@lxpack/validators";
|
|
665
893
|
|
|
666
894
|
// src/spaDirs.ts
|
|
667
895
|
import { access } from "fs/promises";
|
|
668
|
-
import { join, resolve as resolve3 } from "path";
|
|
896
|
+
import { join as join3, resolve as resolve3 } from "path";
|
|
669
897
|
async function resolveSpaDirs(options) {
|
|
670
898
|
const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
|
|
671
899
|
const spaLessons = resolveSpaLessons(descriptor);
|
|
@@ -682,9 +910,9 @@ async function resolveSpaDirs(options) {
|
|
|
682
910
|
throw new Error(`spaDistDir not found: ${srcDist}`);
|
|
683
911
|
}
|
|
684
912
|
try {
|
|
685
|
-
await access(
|
|
913
|
+
await access(join3(srcDist, "index.html"));
|
|
686
914
|
} catch {
|
|
687
|
-
throw new Error(`spaDistDir must contain index.html: ${
|
|
915
|
+
throw new Error(`spaDistDir must contain index.html: ${join3(srcDist, "index.html")}`);
|
|
688
916
|
}
|
|
689
917
|
const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
|
|
690
918
|
"main";
|
|
@@ -707,10 +935,10 @@ async function resolveSpaDirs(options) {
|
|
|
707
935
|
throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
|
|
708
936
|
}
|
|
709
937
|
try {
|
|
710
|
-
await access(
|
|
938
|
+
await access(join3(resolved, "index.html"));
|
|
711
939
|
} catch {
|
|
712
940
|
throw new Error(
|
|
713
|
-
`lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${
|
|
941
|
+
`lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join3(resolved, "index.html")}`
|
|
714
942
|
);
|
|
715
943
|
}
|
|
716
944
|
dirs[lesson.id] = resolved;
|
|
@@ -747,13 +975,13 @@ async function writeLxpackProject(options) {
|
|
|
747
975
|
const courseDir = materialized.courseDir;
|
|
748
976
|
return {
|
|
749
977
|
outDir: courseDir,
|
|
750
|
-
courseYamlPath:
|
|
751
|
-
lessonkitJsonPath:
|
|
978
|
+
courseYamlPath: join4(courseDir, "course.yaml"),
|
|
979
|
+
lessonkitJsonPath: join4(courseDir, "lessonkit.json")
|
|
752
980
|
};
|
|
753
981
|
}
|
|
754
982
|
|
|
755
983
|
// src/packageCourse.ts
|
|
756
|
-
import { resolve as
|
|
984
|
+
import { resolve as resolve7 } from "path";
|
|
757
985
|
import * as fsp3 from "fs/promises";
|
|
758
986
|
import {
|
|
759
987
|
buildCourse,
|
|
@@ -761,48 +989,75 @@ import {
|
|
|
761
989
|
} from "@lxpack/api";
|
|
762
990
|
|
|
763
991
|
// src/packaging/validateInputs.ts
|
|
764
|
-
import { isAbsolute as isAbsolute3, join as
|
|
992
|
+
import { isAbsolute as isAbsolute3, join as join5, resolve as resolve5, win32 as win322 } from "path";
|
|
765
993
|
function validatePackageInputs(options) {
|
|
766
994
|
const { target, output, outputBaseDir } = options;
|
|
767
995
|
const outDir = resolve5(options.outDir);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
courseDir: outDir,
|
|
776
|
-
target,
|
|
777
|
-
issues: [
|
|
778
|
-
{
|
|
779
|
-
path: "outDir",
|
|
780
|
-
message: (
|
|
781
|
-
/* v8 ignore next */
|
|
782
|
-
err instanceof Error ? err.message : String(err)
|
|
783
|
-
)
|
|
784
|
-
}
|
|
785
|
-
]
|
|
786
|
-
};
|
|
787
|
-
}
|
|
996
|
+
if (!options.projectRoot) {
|
|
997
|
+
return {
|
|
998
|
+
ok: false,
|
|
999
|
+
courseDir: outDir,
|
|
1000
|
+
target,
|
|
1001
|
+
issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
|
|
1002
|
+
};
|
|
788
1003
|
}
|
|
789
|
-
|
|
1004
|
+
const projectRoot = resolve5(options.projectRoot);
|
|
1005
|
+
try {
|
|
1006
|
+
assertRealPathUnderRoot(projectRoot, outDir);
|
|
1007
|
+
} catch (err) {
|
|
790
1008
|
return {
|
|
791
1009
|
ok: false,
|
|
792
1010
|
courseDir: outDir,
|
|
793
1011
|
target,
|
|
794
|
-
issues: [
|
|
1012
|
+
issues: [
|
|
1013
|
+
{
|
|
1014
|
+
path: "outDir",
|
|
1015
|
+
message: (
|
|
1016
|
+
/* v8 ignore next */
|
|
1017
|
+
err instanceof Error ? err.message : String(err)
|
|
1018
|
+
)
|
|
1019
|
+
}
|
|
1020
|
+
]
|
|
795
1021
|
};
|
|
796
1022
|
}
|
|
797
|
-
if (
|
|
1023
|
+
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
798
1024
|
return {
|
|
799
1025
|
ok: false,
|
|
800
1026
|
courseDir: outDir,
|
|
801
1027
|
target,
|
|
802
|
-
issues: [{ path: "
|
|
1028
|
+
issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
|
|
803
1029
|
};
|
|
804
1030
|
}
|
|
805
|
-
if (
|
|
1031
|
+
if (output && !isSafeRelativeSpaPath(output)) {
|
|
1032
|
+
if (isAbsolute3(output)) {
|
|
1033
|
+
try {
|
|
1034
|
+
assertRealPathUnderRoot(projectRoot, resolve5(output));
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
return {
|
|
1037
|
+
ok: false,
|
|
1038
|
+
courseDir: outDir,
|
|
1039
|
+
target,
|
|
1040
|
+
issues: [
|
|
1041
|
+
{
|
|
1042
|
+
path: "output",
|
|
1043
|
+
message: (
|
|
1044
|
+
/* v8 ignore next */
|
|
1045
|
+
err instanceof Error ? err.message : `unsafe output: ${output}`
|
|
1046
|
+
)
|
|
1047
|
+
}
|
|
1048
|
+
]
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
} else {
|
|
1052
|
+
return {
|
|
1053
|
+
ok: false,
|
|
1054
|
+
courseDir: outDir,
|
|
1055
|
+
target,
|
|
1056
|
+
issues: [{ path: "output", message: `unsafe output: ${output}` }]
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (outputBaseDir) {
|
|
806
1061
|
const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
|
|
807
1062
|
try {
|
|
808
1063
|
assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
|
|
@@ -823,8 +1078,8 @@ function validatePackageInputs(options) {
|
|
|
823
1078
|
};
|
|
824
1079
|
}
|
|
825
1080
|
}
|
|
826
|
-
if (
|
|
827
|
-
const resolvedOutput = resolve5(projectRoot, output);
|
|
1081
|
+
if (output) {
|
|
1082
|
+
const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
|
|
828
1083
|
try {
|
|
829
1084
|
assertRealPathUnderRoot(projectRoot, resolvedOutput);
|
|
830
1085
|
} catch (err) {
|
|
@@ -861,23 +1116,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
861
1116
|
if (!artifactPath) return void 0;
|
|
862
1117
|
const resolved = resolveComparablePath(artifactPath);
|
|
863
1118
|
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
864
|
-
|
|
1119
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
865
1120
|
}
|
|
866
1121
|
const rel = relativePathUnderRoot(stagingRoot, resolved);
|
|
867
1122
|
if (rel.startsWith("..") || isAbsolute3(rel)) {
|
|
868
|
-
|
|
1123
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
869
1124
|
}
|
|
870
1125
|
if (!rel) return outDir;
|
|
871
1126
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
872
1127
|
return win322.join(outDir, rel.replace(/\//g, win322.sep));
|
|
873
1128
|
}
|
|
874
|
-
return
|
|
1129
|
+
return join5(outDir, rel);
|
|
875
1130
|
}
|
|
876
1131
|
|
|
877
1132
|
// src/packaging/promote.ts
|
|
878
1133
|
import * as fsp from "fs/promises";
|
|
879
|
-
import { randomUUID } from "crypto";
|
|
880
|
-
import { dirname, join as
|
|
1134
|
+
import { createHash, randomUUID } from "crypto";
|
|
1135
|
+
import { dirname, join as join6, resolve as resolve6 } from "path";
|
|
881
1136
|
async function pathExists(path) {
|
|
882
1137
|
try {
|
|
883
1138
|
await fsp.access(path);
|
|
@@ -896,6 +1151,69 @@ async function renameOrCopy(from, to) {
|
|
|
896
1151
|
await fsp.rm(from, { recursive: true, force: true });
|
|
897
1152
|
}
|
|
898
1153
|
}
|
|
1154
|
+
function promoteLockPath(outDir) {
|
|
1155
|
+
const parent = dirname(outDir);
|
|
1156
|
+
const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
|
|
1157
|
+
return join6(parent, `.lk-promote-lock-${hash}`);
|
|
1158
|
+
}
|
|
1159
|
+
var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
|
|
1160
|
+
async function isStalePromoteLock(lockPath) {
|
|
1161
|
+
try {
|
|
1162
|
+
const content = await fsp.readFile(lockPath, "utf8");
|
|
1163
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
1164
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
1165
|
+
try {
|
|
1166
|
+
process.kill(pid, 0);
|
|
1167
|
+
return false;
|
|
1168
|
+
} catch {
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1173
|
+
return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
|
|
1174
|
+
} catch {
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
async function withPromoteLock(outDir, fn) {
|
|
1179
|
+
const lockPath = promoteLockPath(outDir);
|
|
1180
|
+
await fsp.mkdir(dirname(outDir), { recursive: true });
|
|
1181
|
+
let lockHandle;
|
|
1182
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1183
|
+
try {
|
|
1184
|
+
lockHandle = await fsp.open(lockPath, "wx");
|
|
1185
|
+
await lockHandle.writeFile(`${process.pid}
|
|
1186
|
+
`, "utf8");
|
|
1187
|
+
break;
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
1190
|
+
if (code !== "EEXIST") throw err;
|
|
1191
|
+
if (await isStalePromoteLock(lockPath)) {
|
|
1192
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1193
|
+
/* v8 ignore next */
|
|
1194
|
+
() => void 0
|
|
1195
|
+
);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (!lockHandle) {
|
|
1202
|
+
throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
return await fn();
|
|
1206
|
+
} finally {
|
|
1207
|
+
await lockHandle.close().catch(
|
|
1208
|
+
/* v8 ignore next */
|
|
1209
|
+
() => void 0
|
|
1210
|
+
);
|
|
1211
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1212
|
+
/* v8 ignore next */
|
|
1213
|
+
() => void 0
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
899
1217
|
async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
900
1218
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
901
1219
|
const legacyBak = `${outDir}.bak`;
|
|
@@ -909,45 +1227,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
|
909
1227
|
}
|
|
910
1228
|
}
|
|
911
1229
|
async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
await renameOrCopy(outDir, backup);
|
|
920
|
-
}
|
|
921
|
-
try {
|
|
922
|
-
await renameOrCopy(tmpPromote, outDir);
|
|
923
|
-
} catch (promoteError) {
|
|
1230
|
+
return withPromoteLock(outDir, async () => {
|
|
1231
|
+
await assertNoLegacyPromoteArtifacts(outDir);
|
|
1232
|
+
const parent = dirname(outDir);
|
|
1233
|
+
const tmpPromote = await fsp.mkdtemp(join6(parent, ".lk-promote-"));
|
|
1234
|
+
await renameOrCopy(stagingDir, tmpPromote);
|
|
1235
|
+
const hadOutDir = await pathExists(outDir);
|
|
1236
|
+
const backup = hadOutDir ? await fsp.mkdtemp(join6(parent, ".lk-backup-")) : void 0;
|
|
924
1237
|
if (hadOutDir && backup) {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1238
|
+
await renameOrCopy(outDir, backup);
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
await renameOrCopy(tmpPromote, outDir);
|
|
1242
|
+
} catch (promoteError) {
|
|
1243
|
+
if (hadOutDir && backup) {
|
|
1244
|
+
try {
|
|
1245
|
+
await renameOrCopy(backup, outDir);
|
|
1246
|
+
} catch (restoreError) {
|
|
1247
|
+
const failedPromote2 = join6(parent, `.lk-failed-promote-${randomUUID()}`);
|
|
1248
|
+
try {
|
|
1249
|
+
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1250
|
+
} catch {
|
|
1251
|
+
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1252
|
+
/* v8 ignore next */
|
|
1253
|
+
() => void 0
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1257
|
+
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1258
|
+
throw new Error(
|
|
1259
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
} else {
|
|
929
1263
|
try {
|
|
930
|
-
await renameOrCopy(tmpPromote,
|
|
931
|
-
} catch {
|
|
1264
|
+
await renameOrCopy(tmpPromote, stagingDir);
|
|
1265
|
+
} catch (restoreError) {
|
|
1266
|
+
console.warn(
|
|
1267
|
+
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1268
|
+
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1269
|
+
);
|
|
932
1270
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
933
1271
|
/* v8 ignore next */
|
|
934
1272
|
() => void 0
|
|
935
1273
|
);
|
|
936
1274
|
}
|
|
937
|
-
|
|
938
|
-
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
939
|
-
throw new Error(
|
|
940
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
941
|
-
);
|
|
1275
|
+
throw promoteError;
|
|
942
1276
|
}
|
|
943
|
-
|
|
1277
|
+
const failedPromote = join6(parent, `.lk-failed-promote-${randomUUID()}`);
|
|
944
1278
|
try {
|
|
945
|
-
await renameOrCopy(tmpPromote,
|
|
946
|
-
} catch
|
|
947
|
-
console.warn(
|
|
948
|
-
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
949
|
-
restoreError instanceof Error ? restoreError.message : restoreError
|
|
950
|
-
);
|
|
1279
|
+
await renameOrCopy(tmpPromote, failedPromote);
|
|
1280
|
+
} catch {
|
|
951
1281
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
952
1282
|
/* v8 ignore next */
|
|
953
1283
|
() => void 0
|
|
@@ -955,33 +1285,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
955
1285
|
}
|
|
956
1286
|
throw promoteError;
|
|
957
1287
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
await renameOrCopy(tmpPromote, failedPromote);
|
|
961
|
-
} catch {
|
|
962
|
-
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1288
|
+
if (backup) {
|
|
1289
|
+
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
963
1290
|
/* v8 ignore next */
|
|
964
1291
|
() => void 0
|
|
965
1292
|
);
|
|
966
1293
|
}
|
|
967
|
-
|
|
968
|
-
}
|
|
969
|
-
if (backup) {
|
|
970
|
-
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
971
|
-
/* v8 ignore next */
|
|
972
|
-
() => void 0
|
|
973
|
-
);
|
|
974
|
-
}
|
|
1294
|
+
});
|
|
975
1295
|
}
|
|
976
1296
|
|
|
977
1297
|
// src/packaging/staging.ts
|
|
978
1298
|
import * as fsp2 from "fs/promises";
|
|
979
|
-
import { dirname as dirname2, join as
|
|
1299
|
+
import { dirname as dirname2, join as join7 } from "path";
|
|
980
1300
|
import { tmpdir } from "os";
|
|
981
1301
|
import { packageLessonkit } from "@lxpack/api";
|
|
982
1302
|
async function buildStagingPackage(options) {
|
|
983
1303
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
984
|
-
const stagingDir = await fsp2.mkdtemp(
|
|
1304
|
+
const stagingDir = await fsp2.mkdtemp(join7(tmpdir(), "lessonkit-lxpack-"));
|
|
985
1305
|
try {
|
|
986
1306
|
let spaDirs;
|
|
987
1307
|
try {
|
|
@@ -1000,8 +1320,8 @@ async function buildStagingPackage(options) {
|
|
|
1000
1320
|
}
|
|
1001
1321
|
const interchange = descriptorToInterchange(descriptor);
|
|
1002
1322
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
1003
|
-
await fsp2.mkdir(
|
|
1004
|
-
const defaultOutput = output ?? (dir ?
|
|
1323
|
+
await fsp2.mkdir(join7(stagingDir, outputBase), { recursive: true });
|
|
1324
|
+
const defaultOutput = output ?? (dir ? join7(outputBase, target) : join7(outputBase, `course-${target}.zip`));
|
|
1005
1325
|
const build = await packageLessonkit({
|
|
1006
1326
|
interchange,
|
|
1007
1327
|
spaDirs,
|
|
@@ -1044,16 +1364,25 @@ async function ensureOutDirParent(outDir) {
|
|
|
1044
1364
|
await fsp2.mkdir(dirname2(outDir), { recursive: true });
|
|
1045
1365
|
}
|
|
1046
1366
|
|
|
1367
|
+
// src/packaging/issueSeverity.ts
|
|
1368
|
+
function isPackagingErrorIssue(issue) {
|
|
1369
|
+
const severity = issue.severity?.toLowerCase();
|
|
1370
|
+
return severity === "error" || severity === "fatal";
|
|
1371
|
+
}
|
|
1372
|
+
function findPackagingErrorIssues(issues) {
|
|
1373
|
+
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1047
1376
|
// src/packageCourse.ts
|
|
1048
1377
|
async function validateLessonkitProject(options) {
|
|
1049
1378
|
return validateCourse({
|
|
1050
|
-
courseDir:
|
|
1379
|
+
courseDir: resolve7(options.courseDir),
|
|
1051
1380
|
target: options.target
|
|
1052
1381
|
});
|
|
1053
1382
|
}
|
|
1054
1383
|
async function buildLessonkitProject(options) {
|
|
1055
1384
|
const buildOptions = {
|
|
1056
|
-
courseDir:
|
|
1385
|
+
courseDir: resolve7(options.courseDir),
|
|
1057
1386
|
target: options.target,
|
|
1058
1387
|
output: options.output,
|
|
1059
1388
|
dir: options.dir,
|
|
@@ -1084,7 +1413,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1084
1413
|
if (!descriptorValidation.ok) {
|
|
1085
1414
|
return {
|
|
1086
1415
|
ok: false,
|
|
1087
|
-
courseDir:
|
|
1416
|
+
courseDir: resolve7(writeOpts.outDir),
|
|
1088
1417
|
target,
|
|
1089
1418
|
issues: descriptorValidation.issues.map((i) => ({
|
|
1090
1419
|
path: i.path,
|
|
@@ -1093,6 +1422,37 @@ async function packageLessonkitCourse(options) {
|
|
|
1093
1422
|
};
|
|
1094
1423
|
}
|
|
1095
1424
|
const descriptor = descriptorValidation.descriptor;
|
|
1425
|
+
if (writeOpts.projectRoot) {
|
|
1426
|
+
const parityIssues = validateReactManifestParity({
|
|
1427
|
+
projectRoot: writeOpts.projectRoot,
|
|
1428
|
+
descriptor
|
|
1429
|
+
});
|
|
1430
|
+
const parityErrors = parityIssues.filter((i) => i.severity === "error");
|
|
1431
|
+
if (parityErrors.length > 0) {
|
|
1432
|
+
return {
|
|
1433
|
+
ok: false,
|
|
1434
|
+
courseDir: outDir,
|
|
1435
|
+
target,
|
|
1436
|
+
issues: parityErrors.map((i) => ({
|
|
1437
|
+
path: i.path,
|
|
1438
|
+
message: i.message,
|
|
1439
|
+
severity: i.severity
|
|
1440
|
+
}))
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1445
|
+
if (nonInjectableAssessments.length > 0) {
|
|
1446
|
+
return {
|
|
1447
|
+
ok: false,
|
|
1448
|
+
courseDir: outDir,
|
|
1449
|
+
target,
|
|
1450
|
+
issues: nonInjectableAssessments.map(({ assessment, index }) => ({
|
|
1451
|
+
path: `assessments[${index}]`,
|
|
1452
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
1453
|
+
}))
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1096
1456
|
const staged = await buildStagingPackage({
|
|
1097
1457
|
...writeOpts,
|
|
1098
1458
|
descriptor,
|
|
@@ -1117,6 +1477,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1117
1477
|
};
|
|
1118
1478
|
}
|
|
1119
1479
|
const { stagingDir, build } = staged;
|
|
1480
|
+
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1481
|
+
if (buildErrorIssues.length > 0) {
|
|
1482
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1483
|
+
/* v8 ignore next */
|
|
1484
|
+
() => void 0
|
|
1485
|
+
);
|
|
1486
|
+
return {
|
|
1487
|
+
ok: false,
|
|
1488
|
+
courseDir: outDir,
|
|
1489
|
+
target,
|
|
1490
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1491
|
+
build,
|
|
1492
|
+
issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
|
|
1493
|
+
path: i.path ?? "build",
|
|
1494
|
+
message: i.message,
|
|
1495
|
+
severity: i.severity
|
|
1496
|
+
}))
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1120
1499
|
const stagingRoot = await fsp3.realpath(stagingDir);
|
|
1121
1500
|
const artifactIssues = [
|
|
1122
1501
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
@@ -1147,6 +1526,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1147
1526
|
await ensureOutDirParent(outDir);
|
|
1148
1527
|
await promoteStagingToOutDir(stagingDir, outDir);
|
|
1149
1528
|
} catch (err) {
|
|
1529
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1530
|
+
/* v8 ignore next */
|
|
1531
|
+
() => void 0
|
|
1532
|
+
);
|
|
1150
1533
|
return {
|
|
1151
1534
|
ok: false,
|
|
1152
1535
|
courseDir: outDir,
|
|
@@ -1329,5 +1712,6 @@ export {
|
|
|
1329
1712
|
validateLessonkitProject,
|
|
1330
1713
|
validatePackageInputs,
|
|
1331
1714
|
validateProjectPaths,
|
|
1715
|
+
validateReactManifestParity,
|
|
1332
1716
|
writeLxpackProject
|
|
1333
1717
|
};
|