@lessonkit/lxpack 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +683 -341
- package/dist/index.d.cts +20 -2
- package/dist/index.d.ts +20 -2
- package/dist/index.js +699 -357
- package/lessonkit-manifest.v1.json +99 -7
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -2,97 +2,79 @@ import {
|
|
|
2
2
|
telemetryEventToLessonkit
|
|
3
3
|
} from "./chunk-DYQI222N.js";
|
|
4
4
|
|
|
5
|
-
// src/
|
|
5
|
+
// src/descriptor/normalize.ts
|
|
6
6
|
import { validateId } from "@lessonkit/core";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { isAbsolute, relative, resolve, sep, win32 } from "path";
|
|
11
|
-
function resolveComparablePath(p) {
|
|
12
|
-
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
13
|
-
return win32.resolve(p);
|
|
14
|
-
}
|
|
15
|
-
return resolve(p);
|
|
16
|
-
}
|
|
17
|
-
function isSafeRelativeSpaPath(spaPath) {
|
|
18
|
-
if (!spaPath.length || spaPath.includes("\0")) return false;
|
|
19
|
-
if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
|
|
20
|
-
if (/^[a-zA-Z]:/.test(spaPath)) return false;
|
|
21
|
-
if (spaPath === "." || spaPath === "./") return false;
|
|
22
|
-
const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
23
|
-
if (segments.some((s) => s === "..")) return false;
|
|
24
|
-
return segments.length > 0;
|
|
25
|
-
}
|
|
26
|
-
function assertResolvedPathUnderRoot(root, target) {
|
|
27
|
-
const rootResolved = resolveComparablePath(root);
|
|
28
|
-
const targetResolved = resolveComparablePath(target);
|
|
29
|
-
const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
|
|
30
|
-
const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
|
|
31
|
-
if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
|
|
32
|
-
!targetResolved.startsWith(win32Prefix)) {
|
|
33
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
function assertRealPathUnderRoot(root, target) {
|
|
37
|
-
const rootResolved = resolveComparablePath(root);
|
|
38
|
-
const targetResolved = resolveComparablePath(target);
|
|
39
|
-
let rootReal;
|
|
40
|
-
try {
|
|
41
|
-
rootReal = realpathSync(rootResolved);
|
|
42
|
-
} catch {
|
|
43
|
-
rootReal = rootResolved;
|
|
44
|
-
}
|
|
45
|
-
let targetCheck;
|
|
46
|
-
try {
|
|
47
|
-
targetCheck = realpathSync(targetResolved);
|
|
48
|
-
} catch {
|
|
49
|
-
const rel = relative(rootResolved, targetResolved);
|
|
50
|
-
if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
51
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
52
|
-
}
|
|
53
|
-
targetCheck = resolve(rootReal, rel);
|
|
54
|
-
}
|
|
55
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
56
|
-
}
|
|
57
|
-
function normalizePathForComparison(p) {
|
|
58
|
-
const resolved = resolveComparablePath(p);
|
|
59
|
-
return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
|
|
60
|
-
}
|
|
61
|
-
function relativePathUnderRoot(root, target) {
|
|
62
|
-
const rootResolved = normalizePathForComparison(root);
|
|
63
|
-
const targetResolved = normalizePathForComparison(target);
|
|
64
|
-
if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
|
|
65
|
-
return win32.relative(rootResolved, targetResolved);
|
|
66
|
-
}
|
|
67
|
-
return relative(rootResolved, targetResolved);
|
|
68
|
-
}
|
|
69
|
-
function isResolvedPathUnderRoot(root, target) {
|
|
70
|
-
const rootResolved = normalizePathForComparison(root);
|
|
71
|
-
const targetResolved = normalizePathForComparison(target);
|
|
72
|
-
if (targetResolved === rootResolved) return true;
|
|
73
|
-
const rel = relativePathUnderRoot(root, target);
|
|
74
|
-
if (!rel) return true;
|
|
75
|
-
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// src/theme.ts
|
|
79
|
-
import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
|
|
80
|
-
function themeToLxpackRuntime(input) {
|
|
81
|
-
const theme = input.theme ?? getPresetTheme(input.preset ?? "default");
|
|
82
|
-
const raw = themeToCssVariables(theme);
|
|
83
|
-
const cssVariables = {};
|
|
84
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
85
|
-
cssVariables[key] = String(value);
|
|
86
|
-
}
|
|
7
|
+
function normalizeDescriptor(input) {
|
|
8
|
+
const course = validateId(input.courseId, "courseId");
|
|
9
|
+
if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
|
|
87
10
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
11
|
+
...input,
|
|
12
|
+
courseId: course.id,
|
|
13
|
+
title: input.title.trim(),
|
|
14
|
+
version: input.version?.trim() || void 0,
|
|
15
|
+
spaLessonId: input.spaLessonId?.trim() || void 0,
|
|
16
|
+
lessons: input.lessons.map((lesson) => {
|
|
17
|
+
const idResult = validateId(lesson.id, "lessonId");
|
|
18
|
+
if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
|
|
19
|
+
return {
|
|
20
|
+
...lesson,
|
|
21
|
+
id: idResult.id,
|
|
22
|
+
title: lesson.title.trim(),
|
|
23
|
+
spaPath: lesson.spaPath?.trim() || void 0
|
|
24
|
+
};
|
|
25
|
+
}),
|
|
26
|
+
assessments: input.assessments?.map((assessment) => {
|
|
27
|
+
const check = validateId(assessment.checkId, "checkId");
|
|
28
|
+
if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
|
|
29
|
+
const question = assessment.question.trim();
|
|
30
|
+
if (assessment.kind === "trueFalse") {
|
|
31
|
+
return { ...assessment, checkId: check.id, question };
|
|
32
|
+
}
|
|
33
|
+
if (assessment.kind === "fillInBlanks") {
|
|
34
|
+
return {
|
|
35
|
+
...assessment,
|
|
36
|
+
checkId: check.id,
|
|
37
|
+
question,
|
|
38
|
+
template: assessment.template.trim(),
|
|
39
|
+
blanks: assessment.blanks?.map((b) => ({
|
|
40
|
+
id: b.id.trim(),
|
|
41
|
+
answer: b.answer.trim()
|
|
42
|
+
}))
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (assessment.kind === "findHotspot") {
|
|
46
|
+
return {
|
|
47
|
+
...assessment,
|
|
48
|
+
checkId: check.id,
|
|
49
|
+
question,
|
|
50
|
+
src: assessment.src.trim(),
|
|
51
|
+
alt: assessment.alt.trim(),
|
|
52
|
+
correctTargetId: assessment.correctTargetId.trim()
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (assessment.kind === "findMultipleHotspots") {
|
|
56
|
+
return {
|
|
57
|
+
...assessment,
|
|
58
|
+
checkId: check.id,
|
|
59
|
+
question,
|
|
60
|
+
src: assessment.src.trim(),
|
|
61
|
+
alt: assessment.alt.trim(),
|
|
62
|
+
correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const mcq = assessment;
|
|
66
|
+
return {
|
|
67
|
+
...mcq,
|
|
68
|
+
checkId: check.id,
|
|
69
|
+
question,
|
|
70
|
+
choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
71
|
+
answer: mcq.answer.trim()
|
|
72
|
+
};
|
|
73
|
+
})
|
|
90
74
|
};
|
|
91
75
|
}
|
|
92
76
|
|
|
93
|
-
// src/
|
|
94
|
-
var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
95
|
-
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
77
|
+
// src/descriptor/parseInput.ts
|
|
96
78
|
function isRecord(value) {
|
|
97
79
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
98
80
|
}
|
|
@@ -134,6 +116,32 @@ function parseAssessmentDescriptor(raw) {
|
|
|
134
116
|
})) : void 0
|
|
135
117
|
};
|
|
136
118
|
}
|
|
119
|
+
if (kind === "findHotspot") {
|
|
120
|
+
return {
|
|
121
|
+
kind: "findHotspot",
|
|
122
|
+
...base,
|
|
123
|
+
src: typeof raw.src === "string" ? raw.src : "",
|
|
124
|
+
alt: typeof raw.alt === "string" ? raw.alt : "",
|
|
125
|
+
correctTargetId: typeof raw.correctTargetId === "string" ? raw.correctTargetId : ""
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (kind === "findMultipleHotspots") {
|
|
129
|
+
return {
|
|
130
|
+
kind: "findMultipleHotspots",
|
|
131
|
+
...base,
|
|
132
|
+
src: typeof raw.src === "string" ? raw.src : "",
|
|
133
|
+
alt: typeof raw.alt === "string" ? raw.alt : "",
|
|
134
|
+
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
135
|
+
};
|
|
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,
|
|
@@ -180,82 +188,245 @@ function parseCourseDescriptorInput(input) {
|
|
|
180
188
|
spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
|
|
181
189
|
};
|
|
182
190
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
191
|
+
|
|
192
|
+
// src/descriptor/validateCourse.ts
|
|
193
|
+
import { validateId as validateId3 } from "@lessonkit/core";
|
|
194
|
+
import { validateTheme } from "@lessonkit/themes";
|
|
195
|
+
|
|
196
|
+
// src/spaPath.ts
|
|
197
|
+
import { existsSync, realpathSync } from "fs";
|
|
198
|
+
import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
|
|
199
|
+
function resolveComparablePath(p) {
|
|
200
|
+
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
201
|
+
return win32.resolve(p);
|
|
202
|
+
}
|
|
203
|
+
return resolve(p);
|
|
204
|
+
}
|
|
205
|
+
function isSafeRelativeSpaPath(spaPath) {
|
|
206
|
+
if (!spaPath.length || spaPath.includes("\0")) return false;
|
|
207
|
+
if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
|
|
208
|
+
if (/^[a-zA-Z]:/.test(spaPath)) return false;
|
|
209
|
+
if (spaPath === "." || spaPath === "./") return false;
|
|
210
|
+
const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
211
|
+
if (segments.some((s) => s === "..")) return false;
|
|
212
|
+
return segments.length > 0;
|
|
213
|
+
}
|
|
214
|
+
function assertResolvedPathUnderRoot(root, target) {
|
|
215
|
+
const rootResolved = resolveComparablePath(root);
|
|
216
|
+
const targetResolved = resolveComparablePath(target);
|
|
217
|
+
const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
|
|
218
|
+
const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
|
|
219
|
+
if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
|
|
220
|
+
!targetResolved.startsWith(win32Prefix)) {
|
|
221
|
+
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
222
|
+
}
|
|
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;
|
|
220
238
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
} else {
|
|
240
|
+
current = next;
|
|
241
|
+
}
|
|
242
|
+
assertResolvedPathUnderRoot(rootReal, current);
|
|
243
|
+
}
|
|
244
|
+
return current;
|
|
245
|
+
}
|
|
246
|
+
function assertRealPathUnderRoot(root, target) {
|
|
247
|
+
const rootResolved = resolveComparablePath(root);
|
|
248
|
+
const targetResolved = resolveComparablePath(target);
|
|
249
|
+
let rootReal;
|
|
250
|
+
try {
|
|
251
|
+
rootReal = realpathSync(rootResolved);
|
|
252
|
+
} catch {
|
|
253
|
+
rootReal = rootResolved;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const targetCheck = realpathSync(targetResolved);
|
|
257
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
258
|
+
} catch {
|
|
259
|
+
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function normalizePathForComparison(p) {
|
|
263
|
+
const resolved = resolveComparablePath(p);
|
|
264
|
+
return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
|
|
265
|
+
}
|
|
266
|
+
function relativePathUnderRoot(root, target) {
|
|
267
|
+
const rootResolved = normalizePathForComparison(root);
|
|
268
|
+
const targetResolved = normalizePathForComparison(target);
|
|
269
|
+
if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
|
|
270
|
+
return win32.relative(rootResolved, targetResolved);
|
|
271
|
+
}
|
|
272
|
+
return relative(rootResolved, targetResolved);
|
|
273
|
+
}
|
|
274
|
+
function isResolvedPathUnderRoot(root, target) {
|
|
275
|
+
const rootResolved = normalizePathForComparison(root);
|
|
276
|
+
const targetResolved = normalizePathForComparison(target);
|
|
277
|
+
if (targetResolved === rootResolved) return true;
|
|
278
|
+
const rel = relativePathUnderRoot(root, target);
|
|
279
|
+
if (!rel) return true;
|
|
280
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/theme.ts
|
|
284
|
+
import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
|
|
285
|
+
function themeToLxpackRuntime(input) {
|
|
286
|
+
const theme = input.theme ?? getPresetTheme(input.preset ?? "default");
|
|
287
|
+
const raw = themeToCssVariables(theme);
|
|
288
|
+
const cssVariables = {};
|
|
289
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
290
|
+
cssVariables[key] = String(value);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
theme: theme.name,
|
|
294
|
+
cssVariables
|
|
229
295
|
};
|
|
230
296
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
297
|
+
|
|
298
|
+
// src/descriptor/validateAssessments.ts
|
|
299
|
+
import { validateId as validateId2 } from "@lessonkit/core";
|
|
300
|
+
var validateMcqLike = (assessment, path, issues) => {
|
|
301
|
+
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
302
|
+
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
303
|
+
return;
|
|
235
304
|
}
|
|
236
|
-
|
|
305
|
+
if (!("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
306
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
310
|
+
if (!trimmedChoices.length) {
|
|
311
|
+
issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
|
|
312
|
+
}
|
|
313
|
+
if (!assessment.answer.trim()) {
|
|
314
|
+
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
315
|
+
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
316
|
+
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
function countStarDelimitedBlanks(template) {
|
|
320
|
+
const matches = template.match(/\*[^*]+\*/g);
|
|
321
|
+
return matches?.length ?? 0;
|
|
237
322
|
}
|
|
238
|
-
function
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
ok: false,
|
|
246
|
-
issues: [
|
|
247
|
-
{
|
|
248
|
-
path: "course.tracking.xapi.activityIri",
|
|
249
|
-
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
250
|
-
}
|
|
251
|
-
]
|
|
252
|
-
};
|
|
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 ?? "");
|
|
253
329
|
}
|
|
254
|
-
|
|
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;
|
|
255
334
|
}
|
|
256
|
-
|
|
335
|
+
var ASSESSMENT_VALIDATORS = {
|
|
336
|
+
mcq: validateMcqLike,
|
|
337
|
+
trueFalse: (assessment, path, issues) => {
|
|
338
|
+
if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
|
|
339
|
+
issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
fillInBlanks: (assessment, path, issues) => {
|
|
343
|
+
if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
|
|
344
|
+
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
345
|
+
}
|
|
346
|
+
},
|
|
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
|
+
}
|
|
358
|
+
},
|
|
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
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
377
|
+
const path = `assessments[${index}]`;
|
|
378
|
+
const check = validateId2(assessment.checkId, `${path}.checkId`);
|
|
379
|
+
if (!check.ok) {
|
|
380
|
+
issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
381
|
+
} else if (checkIds.has(check.id)) {
|
|
382
|
+
issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
|
|
383
|
+
} else {
|
|
384
|
+
checkIds.add(check.id);
|
|
385
|
+
}
|
|
386
|
+
if (!assessment.question?.trim()) {
|
|
387
|
+
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
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
|
+
}
|
|
397
|
+
const kind = assessment.kind ?? "mcq";
|
|
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);
|
|
407
|
+
const passingScore = assessment.passingScore;
|
|
408
|
+
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
409
|
+
issues.push({
|
|
410
|
+
path: `${path}.passingScore`,
|
|
411
|
+
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
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
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/descriptor/validateCourse.ts
|
|
425
|
+
var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
426
|
+
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
427
|
+
function validateCourseDescriptor(input) {
|
|
257
428
|
const issues = [];
|
|
258
|
-
const course =
|
|
429
|
+
const course = validateId3(input.courseId, "courseId");
|
|
259
430
|
if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
260
431
|
if (!input.title?.trim()) {
|
|
261
432
|
issues.push({ path: "title", message: "title is required" });
|
|
@@ -280,13 +451,23 @@ function validateDescriptorParsed(input) {
|
|
|
280
451
|
});
|
|
281
452
|
}
|
|
282
453
|
if (input.theme?.theme) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
}
|
|
290
471
|
}
|
|
291
472
|
}
|
|
292
473
|
const completionThreshold = input.tracking?.completion?.threshold;
|
|
@@ -308,7 +489,7 @@ function validateDescriptorParsed(input) {
|
|
|
308
489
|
const spaPaths = /* @__PURE__ */ new Set();
|
|
309
490
|
for (const [index, lesson] of (input.lessons ?? []).entries()) {
|
|
310
491
|
const path = `lessons[${index}]`;
|
|
311
|
-
const lessonResult =
|
|
492
|
+
const lessonResult = validateId3(lesson.id, `${path}.id`);
|
|
312
493
|
if (!lessonResult.ok) {
|
|
313
494
|
issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
314
495
|
} else if (lessonIds.has(lessonResult.id)) {
|
|
@@ -336,68 +517,147 @@ function validateDescriptorParsed(input) {
|
|
|
336
517
|
} else {
|
|
337
518
|
spaPaths.add(spaPath);
|
|
338
519
|
}
|
|
339
|
-
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (layout === "single-spa" && input.spaLessonId?.trim()) {
|
|
523
|
+
const spaId = input.spaLessonId.trim();
|
|
524
|
+
const spaResult = validateId3(spaId, "spaLessonId");
|
|
525
|
+
if (!spaResult.ok) {
|
|
526
|
+
issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
527
|
+
} else if (!lessonIds.has(spaResult.id)) {
|
|
528
|
+
issues.push({
|
|
529
|
+
path: "spaLessonId",
|
|
530
|
+
message: "spaLessonId must match a lesson id in lessons"
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const checkIds = /* @__PURE__ */ new Set();
|
|
535
|
+
for (const [index, assessment] of (input.assessments ?? []).entries()) {
|
|
536
|
+
validateAssessmentEntry(assessment, index, issues, checkIds);
|
|
537
|
+
}
|
|
538
|
+
return issues;
|
|
539
|
+
}
|
|
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
|
+
});
|
|
340
581
|
}
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
|
|
607
|
+
// src/descriptor/validateForTarget.ts
|
|
608
|
+
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
609
|
+
"scorm12",
|
|
610
|
+
"scorm2004",
|
|
611
|
+
"standalone",
|
|
612
|
+
"xapi",
|
|
613
|
+
"cmi5"
|
|
614
|
+
]);
|
|
615
|
+
function validateDescriptorForExportTarget(descriptor, target) {
|
|
616
|
+
const issues = [];
|
|
617
|
+
if (target === "xapi" || target === "cmi5") {
|
|
618
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
619
|
+
if (!activityIri) {
|
|
347
620
|
issues.push({
|
|
348
|
-
path: "
|
|
349
|
-
message: "
|
|
621
|
+
path: "course.tracking.xapi.activityIri",
|
|
622
|
+
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
350
623
|
});
|
|
351
624
|
}
|
|
352
625
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const check = validateId(assessment.checkId, `${path}.checkId`);
|
|
357
|
-
if (!check.ok) {
|
|
358
|
-
issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
359
|
-
} else if (checkIds.has(check.id)) {
|
|
360
|
-
issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
|
|
361
|
-
} else {
|
|
362
|
-
checkIds.add(check.id);
|
|
363
|
-
}
|
|
364
|
-
if (!assessment.question?.trim()) {
|
|
365
|
-
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
366
|
-
}
|
|
367
|
-
const kind = assessment.kind ?? "mcq";
|
|
368
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
369
|
-
if (typeof assessment.answer !== "boolean") {
|
|
370
|
-
issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
|
|
371
|
-
}
|
|
372
|
-
} else if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
|
|
373
|
-
if (!assessment.template?.trim()) {
|
|
374
|
-
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
375
|
-
}
|
|
376
|
-
} else if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
377
|
-
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
378
|
-
if (!trimmedChoices.length) {
|
|
626
|
+
if (LMS_SHELL_TARGETS.has(target)) {
|
|
627
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
628
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
379
629
|
issues.push({
|
|
380
|
-
path:
|
|
381
|
-
message: "
|
|
630
|
+
path: `assessments[${index}]`,
|
|
631
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
382
632
|
});
|
|
383
633
|
}
|
|
384
|
-
|
|
385
|
-
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
386
|
-
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
387
|
-
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
const passingScore = assessment.passingScore;
|
|
391
|
-
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
392
|
-
issues.push({
|
|
393
|
-
path: `${path}.passingScore`,
|
|
394
|
-
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
395
|
-
});
|
|
396
|
-
}
|
|
634
|
+
});
|
|
397
635
|
}
|
|
636
|
+
return issues;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/validateDescriptor.ts
|
|
640
|
+
function validateDescriptorParsed(input) {
|
|
641
|
+
const issues = validateCourseDescriptor(input);
|
|
398
642
|
if (issues.length) return { ok: false, issues };
|
|
399
643
|
return { ok: true, descriptor: normalizeDescriptor(input) };
|
|
400
644
|
}
|
|
645
|
+
function validateDescriptor(input) {
|
|
646
|
+
const parsed = parseCourseDescriptorInput(input);
|
|
647
|
+
if (parsed === null) {
|
|
648
|
+
return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
|
|
649
|
+
}
|
|
650
|
+
return validateDescriptorParsed(parsed);
|
|
651
|
+
}
|
|
652
|
+
function validateDescriptorForTarget(input, target) {
|
|
653
|
+
const result = validateDescriptor(input);
|
|
654
|
+
if (!result.ok || !target) return result;
|
|
655
|
+
const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
|
|
656
|
+
if (targetIssues.length) {
|
|
657
|
+
return { ok: false, issues: targetIssues };
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
401
661
|
|
|
402
662
|
// src/validateProjectPaths.ts
|
|
403
663
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
@@ -462,59 +722,6 @@ function mapLessonkitIds(descriptor) {
|
|
|
462
722
|
return { courseId, lessonIds, checkIds };
|
|
463
723
|
}
|
|
464
724
|
|
|
465
|
-
// src/assessments.ts
|
|
466
|
-
function slugChoiceId(text, index) {
|
|
467
|
-
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
468
|
-
const stem = base.length ? base : "choice";
|
|
469
|
-
return `${stem}-${index + 1}`;
|
|
470
|
-
}
|
|
471
|
-
function mcqToLxpack(assessment) {
|
|
472
|
-
const choices = assessment.choices.map((text, index) => {
|
|
473
|
-
const id = slugChoiceId(text, index);
|
|
474
|
-
return {
|
|
475
|
-
id,
|
|
476
|
-
text,
|
|
477
|
-
correct: text === assessment.answer
|
|
478
|
-
};
|
|
479
|
-
});
|
|
480
|
-
return {
|
|
481
|
-
id: assessment.checkId,
|
|
482
|
-
passingScore: assessment.passingScore ?? 1,
|
|
483
|
-
questions: [
|
|
484
|
-
{
|
|
485
|
-
id: "q1",
|
|
486
|
-
prompt: assessment.question,
|
|
487
|
-
choices
|
|
488
|
-
}
|
|
489
|
-
]
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
function assessmentDescriptorToLxpack(assessment) {
|
|
493
|
-
const kind = assessment.kind ?? "mcq";
|
|
494
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
495
|
-
const choices = ["True", "False"];
|
|
496
|
-
const answerText = assessment.answer ? "True" : "False";
|
|
497
|
-
return mcqToLxpack({
|
|
498
|
-
kind: "mcq",
|
|
499
|
-
checkId: assessment.checkId,
|
|
500
|
-
question: assessment.question,
|
|
501
|
-
choices,
|
|
502
|
-
answer: answerText,
|
|
503
|
-
passingScore: assessment.passingScore
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
if (kind === "fillInBlanks") {
|
|
507
|
-
return null;
|
|
508
|
-
}
|
|
509
|
-
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
510
|
-
return mcqToLxpack(assessment);
|
|
511
|
-
}
|
|
512
|
-
return null;
|
|
513
|
-
}
|
|
514
|
-
function extractAssessments(descriptor) {
|
|
515
|
-
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
725
|
// src/interchange.ts
|
|
519
726
|
function mapDescriptorTracking(tracking) {
|
|
520
727
|
if (!tracking) return void 0;
|
|
@@ -575,12 +782,12 @@ function descriptorToInterchange(descriptor) {
|
|
|
575
782
|
}
|
|
576
783
|
|
|
577
784
|
// src/writeProject.ts
|
|
578
|
-
import { join as
|
|
785
|
+
import { join as join3, resolve as resolve4 } from "path";
|
|
579
786
|
import { materializeLessonkitProject } from "@lxpack/validators";
|
|
580
787
|
|
|
581
788
|
// src/spaDirs.ts
|
|
582
789
|
import { access } from "fs/promises";
|
|
583
|
-
import { join, resolve as resolve3 } from "path";
|
|
790
|
+
import { join as join2, resolve as resolve3 } from "path";
|
|
584
791
|
async function resolveSpaDirs(options) {
|
|
585
792
|
const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
|
|
586
793
|
const spaLessons = resolveSpaLessons(descriptor);
|
|
@@ -597,9 +804,9 @@ async function resolveSpaDirs(options) {
|
|
|
597
804
|
throw new Error(`spaDistDir not found: ${srcDist}`);
|
|
598
805
|
}
|
|
599
806
|
try {
|
|
600
|
-
await access(
|
|
807
|
+
await access(join2(srcDist, "index.html"));
|
|
601
808
|
} catch {
|
|
602
|
-
throw new Error(`spaDistDir must contain index.html: ${
|
|
809
|
+
throw new Error(`spaDistDir must contain index.html: ${join2(srcDist, "index.html")}`);
|
|
603
810
|
}
|
|
604
811
|
const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
|
|
605
812
|
"main";
|
|
@@ -622,10 +829,10 @@ async function resolveSpaDirs(options) {
|
|
|
622
829
|
throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
|
|
623
830
|
}
|
|
624
831
|
try {
|
|
625
|
-
await access(
|
|
832
|
+
await access(join2(resolved, "index.html"));
|
|
626
833
|
} catch {
|
|
627
834
|
throw new Error(
|
|
628
|
-
`lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${
|
|
835
|
+
`lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join2(resolved, "index.html")}`
|
|
629
836
|
);
|
|
630
837
|
}
|
|
631
838
|
dirs[lesson.id] = resolved;
|
|
@@ -662,13 +869,13 @@ async function writeLxpackProject(options) {
|
|
|
662
869
|
const courseDir = materialized.courseDir;
|
|
663
870
|
return {
|
|
664
871
|
outDir: courseDir,
|
|
665
|
-
courseYamlPath:
|
|
666
|
-
lessonkitJsonPath:
|
|
872
|
+
courseYamlPath: join3(courseDir, "course.yaml"),
|
|
873
|
+
lessonkitJsonPath: join3(courseDir, "lessonkit.json")
|
|
667
874
|
};
|
|
668
875
|
}
|
|
669
876
|
|
|
670
877
|
// src/packageCourse.ts
|
|
671
|
-
import { resolve as
|
|
878
|
+
import { resolve as resolve7 } from "path";
|
|
672
879
|
import * as fsp3 from "fs/promises";
|
|
673
880
|
import {
|
|
674
881
|
buildCourse,
|
|
@@ -676,48 +883,75 @@ import {
|
|
|
676
883
|
} from "@lxpack/api";
|
|
677
884
|
|
|
678
885
|
// src/packaging/validateInputs.ts
|
|
679
|
-
import { isAbsolute as isAbsolute3, join as
|
|
886
|
+
import { isAbsolute as isAbsolute3, join as join4, resolve as resolve5, win32 as win322 } from "path";
|
|
680
887
|
function validatePackageInputs(options) {
|
|
681
888
|
const { target, output, outputBaseDir } = options;
|
|
682
889
|
const outDir = resolve5(options.outDir);
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
courseDir: outDir,
|
|
691
|
-
target,
|
|
692
|
-
issues: [
|
|
693
|
-
{
|
|
694
|
-
path: "outDir",
|
|
695
|
-
message: (
|
|
696
|
-
/* v8 ignore next */
|
|
697
|
-
err instanceof Error ? err.message : String(err)
|
|
698
|
-
)
|
|
699
|
-
}
|
|
700
|
-
]
|
|
701
|
-
};
|
|
702
|
-
}
|
|
890
|
+
if (!options.projectRoot) {
|
|
891
|
+
return {
|
|
892
|
+
ok: false,
|
|
893
|
+
courseDir: outDir,
|
|
894
|
+
target,
|
|
895
|
+
issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
|
|
896
|
+
};
|
|
703
897
|
}
|
|
704
|
-
|
|
898
|
+
const projectRoot = resolve5(options.projectRoot);
|
|
899
|
+
try {
|
|
900
|
+
assertRealPathUnderRoot(projectRoot, outDir);
|
|
901
|
+
} catch (err) {
|
|
705
902
|
return {
|
|
706
903
|
ok: false,
|
|
707
904
|
courseDir: outDir,
|
|
708
905
|
target,
|
|
709
|
-
issues: [
|
|
906
|
+
issues: [
|
|
907
|
+
{
|
|
908
|
+
path: "outDir",
|
|
909
|
+
message: (
|
|
910
|
+
/* v8 ignore next */
|
|
911
|
+
err instanceof Error ? err.message : String(err)
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
]
|
|
710
915
|
};
|
|
711
916
|
}
|
|
712
|
-
if (
|
|
917
|
+
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
713
918
|
return {
|
|
714
919
|
ok: false,
|
|
715
920
|
courseDir: outDir,
|
|
716
921
|
target,
|
|
717
|
-
issues: [{ path: "
|
|
922
|
+
issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
|
|
718
923
|
};
|
|
719
924
|
}
|
|
720
|
-
if (
|
|
925
|
+
if (output && !isSafeRelativeSpaPath(output)) {
|
|
926
|
+
if (isAbsolute3(output)) {
|
|
927
|
+
try {
|
|
928
|
+
assertRealPathUnderRoot(projectRoot, resolve5(output));
|
|
929
|
+
} catch (err) {
|
|
930
|
+
return {
|
|
931
|
+
ok: false,
|
|
932
|
+
courseDir: outDir,
|
|
933
|
+
target,
|
|
934
|
+
issues: [
|
|
935
|
+
{
|
|
936
|
+
path: "output",
|
|
937
|
+
message: (
|
|
938
|
+
/* v8 ignore next */
|
|
939
|
+
err instanceof Error ? err.message : `unsafe output: ${output}`
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
]
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
} else {
|
|
946
|
+
return {
|
|
947
|
+
ok: false,
|
|
948
|
+
courseDir: outDir,
|
|
949
|
+
target,
|
|
950
|
+
issues: [{ path: "output", message: `unsafe output: ${output}` }]
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (outputBaseDir) {
|
|
721
955
|
const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
|
|
722
956
|
try {
|
|
723
957
|
assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
|
|
@@ -738,8 +972,8 @@ function validatePackageInputs(options) {
|
|
|
738
972
|
};
|
|
739
973
|
}
|
|
740
974
|
}
|
|
741
|
-
if (
|
|
742
|
-
const resolvedOutput = resolve5(projectRoot, output);
|
|
975
|
+
if (output) {
|
|
976
|
+
const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
|
|
743
977
|
try {
|
|
744
978
|
assertRealPathUnderRoot(projectRoot, resolvedOutput);
|
|
745
979
|
} catch (err) {
|
|
@@ -776,23 +1010,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
776
1010
|
if (!artifactPath) return void 0;
|
|
777
1011
|
const resolved = resolveComparablePath(artifactPath);
|
|
778
1012
|
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
779
|
-
|
|
1013
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
780
1014
|
}
|
|
781
1015
|
const rel = relativePathUnderRoot(stagingRoot, resolved);
|
|
782
1016
|
if (rel.startsWith("..") || isAbsolute3(rel)) {
|
|
783
|
-
|
|
1017
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
784
1018
|
}
|
|
785
1019
|
if (!rel) return outDir;
|
|
786
1020
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
787
1021
|
return win322.join(outDir, rel.replace(/\//g, win322.sep));
|
|
788
1022
|
}
|
|
789
|
-
return
|
|
1023
|
+
return join4(outDir, rel);
|
|
790
1024
|
}
|
|
791
1025
|
|
|
792
1026
|
// src/packaging/promote.ts
|
|
793
1027
|
import * as fsp from "fs/promises";
|
|
794
|
-
import { randomUUID } from "crypto";
|
|
795
|
-
import { dirname, join as
|
|
1028
|
+
import { createHash, randomUUID } from "crypto";
|
|
1029
|
+
import { dirname, join as join5, resolve as resolve6 } from "path";
|
|
796
1030
|
async function pathExists(path) {
|
|
797
1031
|
try {
|
|
798
1032
|
await fsp.access(path);
|
|
@@ -811,6 +1045,68 @@ async function renameOrCopy(from, to) {
|
|
|
811
1045
|
await fsp.rm(from, { recursive: true, force: true });
|
|
812
1046
|
}
|
|
813
1047
|
}
|
|
1048
|
+
function promoteLockPath(outDir) {
|
|
1049
|
+
const parent = dirname(outDir);
|
|
1050
|
+
const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
|
|
1051
|
+
return join5(parent, `.lk-promote-lock-${hash}`);
|
|
1052
|
+
}
|
|
1053
|
+
var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
|
|
1054
|
+
async function isStalePromoteLock(lockPath) {
|
|
1055
|
+
try {
|
|
1056
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1057
|
+
if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
|
|
1058
|
+
const content = await fsp.readFile(lockPath, "utf8");
|
|
1059
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
1060
|
+
if (!Number.isFinite(pid) || pid <= 0) return true;
|
|
1061
|
+
try {
|
|
1062
|
+
process.kill(pid, 0);
|
|
1063
|
+
return false;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
async function withPromoteLock(outDir, fn) {
|
|
1072
|
+
const lockPath = promoteLockPath(outDir);
|
|
1073
|
+
await fsp.mkdir(dirname(outDir), { recursive: true });
|
|
1074
|
+
let lockHandle;
|
|
1075
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1076
|
+
try {
|
|
1077
|
+
lockHandle = await fsp.open(lockPath, "wx");
|
|
1078
|
+
await lockHandle.writeFile(`${process.pid}
|
|
1079
|
+
`, "utf8");
|
|
1080
|
+
break;
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
1083
|
+
if (code !== "EEXIST") throw err;
|
|
1084
|
+
if (await isStalePromoteLock(lockPath)) {
|
|
1085
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1086
|
+
/* v8 ignore next */
|
|
1087
|
+
() => void 0
|
|
1088
|
+
);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (!lockHandle) {
|
|
1095
|
+
throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
|
|
1096
|
+
}
|
|
1097
|
+
try {
|
|
1098
|
+
return await fn();
|
|
1099
|
+
} finally {
|
|
1100
|
+
await lockHandle.close().catch(
|
|
1101
|
+
/* v8 ignore next */
|
|
1102
|
+
() => void 0
|
|
1103
|
+
);
|
|
1104
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1105
|
+
/* v8 ignore next */
|
|
1106
|
+
() => void 0
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
814
1110
|
async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
815
1111
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
816
1112
|
const legacyBak = `${outDir}.bak`;
|
|
@@ -824,45 +1120,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
|
824
1120
|
}
|
|
825
1121
|
}
|
|
826
1122
|
async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
await renameOrCopy(outDir, backup);
|
|
835
|
-
}
|
|
836
|
-
try {
|
|
837
|
-
await renameOrCopy(tmpPromote, outDir);
|
|
838
|
-
} catch (promoteError) {
|
|
1123
|
+
return withPromoteLock(outDir, async () => {
|
|
1124
|
+
await assertNoLegacyPromoteArtifacts(outDir);
|
|
1125
|
+
const parent = dirname(outDir);
|
|
1126
|
+
const tmpPromote = await fsp.mkdtemp(join5(parent, ".lk-promote-"));
|
|
1127
|
+
await renameOrCopy(stagingDir, tmpPromote);
|
|
1128
|
+
const hadOutDir = await pathExists(outDir);
|
|
1129
|
+
const backup = hadOutDir ? await fsp.mkdtemp(join5(parent, ".lk-backup-")) : void 0;
|
|
839
1130
|
if (hadOutDir && backup) {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1131
|
+
await renameOrCopy(outDir, backup);
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
await renameOrCopy(tmpPromote, outDir);
|
|
1135
|
+
} catch (promoteError) {
|
|
1136
|
+
if (hadOutDir && backup) {
|
|
1137
|
+
try {
|
|
1138
|
+
await renameOrCopy(backup, outDir);
|
|
1139
|
+
} catch (restoreError) {
|
|
1140
|
+
const failedPromote2 = join5(parent, `.lk-failed-promote-${randomUUID()}`);
|
|
1141
|
+
try {
|
|
1142
|
+
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1143
|
+
} catch {
|
|
1144
|
+
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1145
|
+
/* v8 ignore next */
|
|
1146
|
+
() => void 0
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1150
|
+
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1151
|
+
throw new Error(
|
|
1152
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
} else {
|
|
844
1156
|
try {
|
|
845
|
-
await renameOrCopy(tmpPromote,
|
|
846
|
-
} catch {
|
|
1157
|
+
await renameOrCopy(tmpPromote, stagingDir);
|
|
1158
|
+
} catch (restoreError) {
|
|
1159
|
+
console.warn(
|
|
1160
|
+
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1161
|
+
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1162
|
+
);
|
|
847
1163
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
848
1164
|
/* v8 ignore next */
|
|
849
1165
|
() => void 0
|
|
850
1166
|
);
|
|
851
1167
|
}
|
|
852
|
-
|
|
853
|
-
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
854
|
-
throw new Error(
|
|
855
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
856
|
-
);
|
|
1168
|
+
throw promoteError;
|
|
857
1169
|
}
|
|
858
|
-
|
|
1170
|
+
const failedPromote = join5(parent, `.lk-failed-promote-${randomUUID()}`);
|
|
859
1171
|
try {
|
|
860
|
-
await renameOrCopy(tmpPromote,
|
|
861
|
-
} catch
|
|
862
|
-
console.warn(
|
|
863
|
-
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
864
|
-
restoreError instanceof Error ? restoreError.message : restoreError
|
|
865
|
-
);
|
|
1172
|
+
await renameOrCopy(tmpPromote, failedPromote);
|
|
1173
|
+
} catch {
|
|
866
1174
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
867
1175
|
/* v8 ignore next */
|
|
868
1176
|
() => void 0
|
|
@@ -870,33 +1178,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
870
1178
|
}
|
|
871
1179
|
throw promoteError;
|
|
872
1180
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
await renameOrCopy(tmpPromote, failedPromote);
|
|
876
|
-
} catch {
|
|
877
|
-
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1181
|
+
if (backup) {
|
|
1182
|
+
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
878
1183
|
/* v8 ignore next */
|
|
879
1184
|
() => void 0
|
|
880
1185
|
);
|
|
881
1186
|
}
|
|
882
|
-
|
|
883
|
-
}
|
|
884
|
-
if (backup) {
|
|
885
|
-
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
886
|
-
/* v8 ignore next */
|
|
887
|
-
() => void 0
|
|
888
|
-
);
|
|
889
|
-
}
|
|
1187
|
+
});
|
|
890
1188
|
}
|
|
891
1189
|
|
|
892
1190
|
// src/packaging/staging.ts
|
|
893
1191
|
import * as fsp2 from "fs/promises";
|
|
894
|
-
import { dirname as dirname2, join as
|
|
1192
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
895
1193
|
import { tmpdir } from "os";
|
|
896
1194
|
import { packageLessonkit } from "@lxpack/api";
|
|
897
1195
|
async function buildStagingPackage(options) {
|
|
898
1196
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
899
|
-
const stagingDir = await fsp2.mkdtemp(
|
|
1197
|
+
const stagingDir = await fsp2.mkdtemp(join6(tmpdir(), "lessonkit-lxpack-"));
|
|
900
1198
|
try {
|
|
901
1199
|
let spaDirs;
|
|
902
1200
|
try {
|
|
@@ -915,8 +1213,8 @@ async function buildStagingPackage(options) {
|
|
|
915
1213
|
}
|
|
916
1214
|
const interchange = descriptorToInterchange(descriptor);
|
|
917
1215
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
918
|
-
await fsp2.mkdir(
|
|
919
|
-
const defaultOutput = output ?? (dir ?
|
|
1216
|
+
await fsp2.mkdir(join6(stagingDir, outputBase), { recursive: true });
|
|
1217
|
+
const defaultOutput = output ?? (dir ? join6(outputBase, target) : join6(outputBase, `course-${target}.zip`));
|
|
920
1218
|
const build = await packageLessonkit({
|
|
921
1219
|
interchange,
|
|
922
1220
|
spaDirs,
|
|
@@ -959,16 +1257,25 @@ async function ensureOutDirParent(outDir) {
|
|
|
959
1257
|
await fsp2.mkdir(dirname2(outDir), { recursive: true });
|
|
960
1258
|
}
|
|
961
1259
|
|
|
1260
|
+
// src/packaging/issueSeverity.ts
|
|
1261
|
+
function isPackagingErrorIssue(issue) {
|
|
1262
|
+
const severity = issue.severity?.toLowerCase();
|
|
1263
|
+
return severity === "error" || severity === "fatal";
|
|
1264
|
+
}
|
|
1265
|
+
function findPackagingErrorIssues(issues) {
|
|
1266
|
+
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
962
1269
|
// src/packageCourse.ts
|
|
963
1270
|
async function validateLessonkitProject(options) {
|
|
964
1271
|
return validateCourse({
|
|
965
|
-
courseDir:
|
|
1272
|
+
courseDir: resolve7(options.courseDir),
|
|
966
1273
|
target: options.target
|
|
967
1274
|
});
|
|
968
1275
|
}
|
|
969
1276
|
async function buildLessonkitProject(options) {
|
|
970
1277
|
const buildOptions = {
|
|
971
|
-
courseDir:
|
|
1278
|
+
courseDir: resolve7(options.courseDir),
|
|
972
1279
|
target: options.target,
|
|
973
1280
|
output: options.output,
|
|
974
1281
|
dir: options.dir,
|
|
@@ -999,7 +1306,7 @@ async function packageLessonkitCourse(options) {
|
|
|
999
1306
|
if (!descriptorValidation.ok) {
|
|
1000
1307
|
return {
|
|
1001
1308
|
ok: false,
|
|
1002
|
-
courseDir:
|
|
1309
|
+
courseDir: resolve7(writeOpts.outDir),
|
|
1003
1310
|
target,
|
|
1004
1311
|
issues: descriptorValidation.issues.map((i) => ({
|
|
1005
1312
|
path: i.path,
|
|
@@ -1008,6 +1315,18 @@ async function packageLessonkitCourse(options) {
|
|
|
1008
1315
|
};
|
|
1009
1316
|
}
|
|
1010
1317
|
const descriptor = descriptorValidation.descriptor;
|
|
1318
|
+
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1319
|
+
if (nonInjectableAssessments.length > 0) {
|
|
1320
|
+
return {
|
|
1321
|
+
ok: false,
|
|
1322
|
+
courseDir: outDir,
|
|
1323
|
+
target,
|
|
1324
|
+
issues: nonInjectableAssessments.map(({ assessment, index }) => ({
|
|
1325
|
+
path: `assessments[${index}]`,
|
|
1326
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
1327
|
+
}))
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1011
1330
|
const staged = await buildStagingPackage({
|
|
1012
1331
|
...writeOpts,
|
|
1013
1332
|
descriptor,
|
|
@@ -1032,6 +1351,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1032
1351
|
};
|
|
1033
1352
|
}
|
|
1034
1353
|
const { stagingDir, build } = staged;
|
|
1354
|
+
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1355
|
+
if (buildErrorIssues.length > 0) {
|
|
1356
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1357
|
+
/* v8 ignore next */
|
|
1358
|
+
() => void 0
|
|
1359
|
+
);
|
|
1360
|
+
return {
|
|
1361
|
+
ok: false,
|
|
1362
|
+
courseDir: outDir,
|
|
1363
|
+
target,
|
|
1364
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1365
|
+
build,
|
|
1366
|
+
issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
|
|
1367
|
+
path: i.path ?? "build",
|
|
1368
|
+
message: i.message,
|
|
1369
|
+
severity: i.severity
|
|
1370
|
+
}))
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1035
1373
|
const stagingRoot = await fsp3.realpath(stagingDir);
|
|
1036
1374
|
const artifactIssues = [
|
|
1037
1375
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
@@ -1062,6 +1400,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1062
1400
|
await ensureOutDirParent(outDir);
|
|
1063
1401
|
await promoteStagingToOutDir(stagingDir, outDir);
|
|
1064
1402
|
} catch (err) {
|
|
1403
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1404
|
+
/* v8 ignore next */
|
|
1405
|
+
() => void 0
|
|
1406
|
+
);
|
|
1065
1407
|
return {
|
|
1066
1408
|
ok: false,
|
|
1067
1409
|
courseDir: outDir,
|