@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.cjs
CHANGED
|
@@ -61,97 +61,79 @@ __export(index_exports, {
|
|
|
61
61
|
});
|
|
62
62
|
module.exports = __toCommonJS(index_exports);
|
|
63
63
|
|
|
64
|
-
// src/
|
|
64
|
+
// src/descriptor/normalize.ts
|
|
65
65
|
var import_core = require("@lessonkit/core");
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
var import_node_path = require("path");
|
|
70
|
-
function resolveComparablePath(p) {
|
|
71
|
-
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
72
|
-
return import_node_path.win32.resolve(p);
|
|
73
|
-
}
|
|
74
|
-
return (0, import_node_path.resolve)(p);
|
|
75
|
-
}
|
|
76
|
-
function isSafeRelativeSpaPath(spaPath) {
|
|
77
|
-
if (!spaPath.length || spaPath.includes("\0")) return false;
|
|
78
|
-
if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
|
|
79
|
-
if (/^[a-zA-Z]:/.test(spaPath)) return false;
|
|
80
|
-
if (spaPath === "." || spaPath === "./") return false;
|
|
81
|
-
const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
82
|
-
if (segments.some((s) => s === "..")) return false;
|
|
83
|
-
return segments.length > 0;
|
|
84
|
-
}
|
|
85
|
-
function assertResolvedPathUnderRoot(root, target) {
|
|
86
|
-
const rootResolved = resolveComparablePath(root);
|
|
87
|
-
const targetResolved = resolveComparablePath(target);
|
|
88
|
-
const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
|
|
89
|
-
const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
|
|
90
|
-
if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
|
|
91
|
-
!targetResolved.startsWith(win32Prefix)) {
|
|
92
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
function assertRealPathUnderRoot(root, target) {
|
|
96
|
-
const rootResolved = resolveComparablePath(root);
|
|
97
|
-
const targetResolved = resolveComparablePath(target);
|
|
98
|
-
let rootReal;
|
|
99
|
-
try {
|
|
100
|
-
rootReal = (0, import_node_fs.realpathSync)(rootResolved);
|
|
101
|
-
} catch {
|
|
102
|
-
rootReal = rootResolved;
|
|
103
|
-
}
|
|
104
|
-
let targetCheck;
|
|
105
|
-
try {
|
|
106
|
-
targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
107
|
-
} catch {
|
|
108
|
-
const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
109
|
-
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
110
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
111
|
-
}
|
|
112
|
-
targetCheck = (0, import_node_path.resolve)(rootReal, rel);
|
|
113
|
-
}
|
|
114
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
115
|
-
}
|
|
116
|
-
function normalizePathForComparison(p) {
|
|
117
|
-
const resolved = resolveComparablePath(p);
|
|
118
|
-
return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
|
|
119
|
-
}
|
|
120
|
-
function relativePathUnderRoot(root, target) {
|
|
121
|
-
const rootResolved = normalizePathForComparison(root);
|
|
122
|
-
const targetResolved = normalizePathForComparison(target);
|
|
123
|
-
if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
|
|
124
|
-
return import_node_path.win32.relative(rootResolved, targetResolved);
|
|
125
|
-
}
|
|
126
|
-
return (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
127
|
-
}
|
|
128
|
-
function isResolvedPathUnderRoot(root, target) {
|
|
129
|
-
const rootResolved = normalizePathForComparison(root);
|
|
130
|
-
const targetResolved = normalizePathForComparison(target);
|
|
131
|
-
if (targetResolved === rootResolved) return true;
|
|
132
|
-
const rel = relativePathUnderRoot(root, target);
|
|
133
|
-
if (!rel) return true;
|
|
134
|
-
return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// src/theme.ts
|
|
138
|
-
var import_themes = require("@lessonkit/themes");
|
|
139
|
-
function themeToLxpackRuntime(input) {
|
|
140
|
-
const theme = input.theme ?? (0, import_themes.getPresetTheme)(input.preset ?? "default");
|
|
141
|
-
const raw = (0, import_themes.themeToCssVariables)(theme);
|
|
142
|
-
const cssVariables = {};
|
|
143
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
144
|
-
cssVariables[key] = String(value);
|
|
145
|
-
}
|
|
66
|
+
function normalizeDescriptor(input) {
|
|
67
|
+
const course = (0, import_core.validateId)(input.courseId, "courseId");
|
|
68
|
+
if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
|
|
146
69
|
return {
|
|
147
|
-
|
|
148
|
-
|
|
70
|
+
...input,
|
|
71
|
+
courseId: course.id,
|
|
72
|
+
title: input.title.trim(),
|
|
73
|
+
version: input.version?.trim() || void 0,
|
|
74
|
+
spaLessonId: input.spaLessonId?.trim() || void 0,
|
|
75
|
+
lessons: input.lessons.map((lesson) => {
|
|
76
|
+
const idResult = (0, import_core.validateId)(lesson.id, "lessonId");
|
|
77
|
+
if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
|
|
78
|
+
return {
|
|
79
|
+
...lesson,
|
|
80
|
+
id: idResult.id,
|
|
81
|
+
title: lesson.title.trim(),
|
|
82
|
+
spaPath: lesson.spaPath?.trim() || void 0
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
assessments: input.assessments?.map((assessment) => {
|
|
86
|
+
const check = (0, import_core.validateId)(assessment.checkId, "checkId");
|
|
87
|
+
if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
|
|
88
|
+
const question = assessment.question.trim();
|
|
89
|
+
if (assessment.kind === "trueFalse") {
|
|
90
|
+
return { ...assessment, checkId: check.id, question };
|
|
91
|
+
}
|
|
92
|
+
if (assessment.kind === "fillInBlanks") {
|
|
93
|
+
return {
|
|
94
|
+
...assessment,
|
|
95
|
+
checkId: check.id,
|
|
96
|
+
question,
|
|
97
|
+
template: assessment.template.trim(),
|
|
98
|
+
blanks: assessment.blanks?.map((b) => ({
|
|
99
|
+
id: b.id.trim(),
|
|
100
|
+
answer: b.answer.trim()
|
|
101
|
+
}))
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (assessment.kind === "findHotspot") {
|
|
105
|
+
return {
|
|
106
|
+
...assessment,
|
|
107
|
+
checkId: check.id,
|
|
108
|
+
question,
|
|
109
|
+
src: assessment.src.trim(),
|
|
110
|
+
alt: assessment.alt.trim(),
|
|
111
|
+
correctTargetId: assessment.correctTargetId.trim()
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (assessment.kind === "findMultipleHotspots") {
|
|
115
|
+
return {
|
|
116
|
+
...assessment,
|
|
117
|
+
checkId: check.id,
|
|
118
|
+
question,
|
|
119
|
+
src: assessment.src.trim(),
|
|
120
|
+
alt: assessment.alt.trim(),
|
|
121
|
+
correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const mcq = assessment;
|
|
125
|
+
return {
|
|
126
|
+
...mcq,
|
|
127
|
+
checkId: check.id,
|
|
128
|
+
question,
|
|
129
|
+
choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
130
|
+
answer: mcq.answer.trim()
|
|
131
|
+
};
|
|
132
|
+
})
|
|
149
133
|
};
|
|
150
134
|
}
|
|
151
135
|
|
|
152
|
-
// src/
|
|
153
|
-
var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
154
|
-
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
136
|
+
// src/descriptor/parseInput.ts
|
|
155
137
|
function isRecord(value) {
|
|
156
138
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
157
139
|
}
|
|
@@ -193,6 +175,32 @@ function parseAssessmentDescriptor(raw) {
|
|
|
193
175
|
})) : void 0
|
|
194
176
|
};
|
|
195
177
|
}
|
|
178
|
+
if (kind === "findHotspot") {
|
|
179
|
+
return {
|
|
180
|
+
kind: "findHotspot",
|
|
181
|
+
...base,
|
|
182
|
+
src: typeof raw.src === "string" ? raw.src : "",
|
|
183
|
+
alt: typeof raw.alt === "string" ? raw.alt : "",
|
|
184
|
+
correctTargetId: typeof raw.correctTargetId === "string" ? raw.correctTargetId : ""
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (kind === "findMultipleHotspots") {
|
|
188
|
+
return {
|
|
189
|
+
kind: "findMultipleHotspots",
|
|
190
|
+
...base,
|
|
191
|
+
src: typeof raw.src === "string" ? raw.src : "",
|
|
192
|
+
alt: typeof raw.alt === "string" ? raw.alt : "",
|
|
193
|
+
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
|
|
197
|
+
return {
|
|
198
|
+
kind,
|
|
199
|
+
...base,
|
|
200
|
+
choices: [],
|
|
201
|
+
answer: ""
|
|
202
|
+
};
|
|
203
|
+
}
|
|
196
204
|
return {
|
|
197
205
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
198
206
|
...base,
|
|
@@ -239,82 +247,245 @@ function parseCourseDescriptorInput(input) {
|
|
|
239
247
|
spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
|
|
240
248
|
};
|
|
241
249
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
250
|
+
|
|
251
|
+
// src/descriptor/validateCourse.ts
|
|
252
|
+
var import_core3 = require("@lessonkit/core");
|
|
253
|
+
var import_themes2 = require("@lessonkit/themes");
|
|
254
|
+
|
|
255
|
+
// src/spaPath.ts
|
|
256
|
+
var import_node_fs = require("fs");
|
|
257
|
+
var import_node_path = require("path");
|
|
258
|
+
function resolveComparablePath(p) {
|
|
259
|
+
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
260
|
+
return import_node_path.win32.resolve(p);
|
|
261
|
+
}
|
|
262
|
+
return (0, import_node_path.resolve)(p);
|
|
263
|
+
}
|
|
264
|
+
function isSafeRelativeSpaPath(spaPath) {
|
|
265
|
+
if (!spaPath.length || spaPath.includes("\0")) return false;
|
|
266
|
+
if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
|
|
267
|
+
if (/^[a-zA-Z]:/.test(spaPath)) return false;
|
|
268
|
+
if (spaPath === "." || spaPath === "./") return false;
|
|
269
|
+
const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
270
|
+
if (segments.some((s) => s === "..")) return false;
|
|
271
|
+
return segments.length > 0;
|
|
272
|
+
}
|
|
273
|
+
function assertResolvedPathUnderRoot(root, target) {
|
|
274
|
+
const rootResolved = resolveComparablePath(root);
|
|
275
|
+
const targetResolved = resolveComparablePath(target);
|
|
276
|
+
const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
|
|
277
|
+
const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
|
|
278
|
+
if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
|
|
279
|
+
!targetResolved.startsWith(win32Prefix)) {
|
|
280
|
+
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
|
|
284
|
+
const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
285
|
+
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
286
|
+
throw new Error(`unsafe path escapes project root: ${targetResolved}`);
|
|
287
|
+
}
|
|
288
|
+
const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
289
|
+
let current = rootReal;
|
|
290
|
+
for (const segment of segments) {
|
|
291
|
+
const next = (0, import_node_path.join)(current, segment);
|
|
292
|
+
if ((0, import_node_fs.existsSync)(next)) {
|
|
293
|
+
try {
|
|
294
|
+
current = (0, import_node_fs.realpathSync)(next);
|
|
295
|
+
} catch {
|
|
296
|
+
current = next;
|
|
279
297
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
298
|
+
} else {
|
|
299
|
+
current = next;
|
|
300
|
+
}
|
|
301
|
+
assertResolvedPathUnderRoot(rootReal, current);
|
|
302
|
+
}
|
|
303
|
+
return current;
|
|
304
|
+
}
|
|
305
|
+
function assertRealPathUnderRoot(root, target) {
|
|
306
|
+
const rootResolved = resolveComparablePath(root);
|
|
307
|
+
const targetResolved = resolveComparablePath(target);
|
|
308
|
+
let rootReal;
|
|
309
|
+
try {
|
|
310
|
+
rootReal = (0, import_node_fs.realpathSync)(rootResolved);
|
|
311
|
+
} catch {
|
|
312
|
+
rootReal = rootResolved;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
316
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
317
|
+
} catch {
|
|
318
|
+
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function normalizePathForComparison(p) {
|
|
322
|
+
const resolved = resolveComparablePath(p);
|
|
323
|
+
return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
|
|
324
|
+
}
|
|
325
|
+
function relativePathUnderRoot(root, target) {
|
|
326
|
+
const rootResolved = normalizePathForComparison(root);
|
|
327
|
+
const targetResolved = normalizePathForComparison(target);
|
|
328
|
+
if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
|
|
329
|
+
return import_node_path.win32.relative(rootResolved, targetResolved);
|
|
330
|
+
}
|
|
331
|
+
return (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
332
|
+
}
|
|
333
|
+
function isResolvedPathUnderRoot(root, target) {
|
|
334
|
+
const rootResolved = normalizePathForComparison(root);
|
|
335
|
+
const targetResolved = normalizePathForComparison(target);
|
|
336
|
+
if (targetResolved === rootResolved) return true;
|
|
337
|
+
const rel = relativePathUnderRoot(root, target);
|
|
338
|
+
if (!rel) return true;
|
|
339
|
+
return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/theme.ts
|
|
343
|
+
var import_themes = require("@lessonkit/themes");
|
|
344
|
+
function themeToLxpackRuntime(input) {
|
|
345
|
+
const theme = input.theme ?? (0, import_themes.getPresetTheme)(input.preset ?? "default");
|
|
346
|
+
const raw = (0, import_themes.themeToCssVariables)(theme);
|
|
347
|
+
const cssVariables = {};
|
|
348
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
349
|
+
cssVariables[key] = String(value);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
theme: theme.name,
|
|
353
|
+
cssVariables
|
|
288
354
|
};
|
|
289
355
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
356
|
+
|
|
357
|
+
// src/descriptor/validateAssessments.ts
|
|
358
|
+
var import_core2 = require("@lessonkit/core");
|
|
359
|
+
var validateMcqLike = (assessment, path, issues) => {
|
|
360
|
+
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
361
|
+
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
362
|
+
return;
|
|
294
363
|
}
|
|
295
|
-
|
|
364
|
+
if (!("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
365
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
369
|
+
if (!trimmedChoices.length) {
|
|
370
|
+
issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
|
|
371
|
+
}
|
|
372
|
+
if (!assessment.answer.trim()) {
|
|
373
|
+
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
374
|
+
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
375
|
+
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
function countStarDelimitedBlanks(template) {
|
|
379
|
+
const matches = template.match(/\*[^*]+\*/g);
|
|
380
|
+
return matches?.length ?? 0;
|
|
296
381
|
}
|
|
297
|
-
function
|
|
298
|
-
const
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return {
|
|
304
|
-
ok: false,
|
|
305
|
-
issues: [
|
|
306
|
-
{
|
|
307
|
-
path: "course.tracking.xapi.activityIri",
|
|
308
|
-
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
309
|
-
}
|
|
310
|
-
]
|
|
311
|
-
};
|
|
382
|
+
function maxAchievableAssessmentScore(assessment) {
|
|
383
|
+
const kind = assessment.kind ?? "mcq";
|
|
384
|
+
if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
|
|
385
|
+
const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
|
|
386
|
+
if (explicit > 0) return explicit;
|
|
387
|
+
return countStarDelimitedBlanks(assessment.template ?? "");
|
|
312
388
|
}
|
|
313
|
-
|
|
389
|
+
if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
|
|
390
|
+
return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
|
|
391
|
+
}
|
|
392
|
+
return 1;
|
|
314
393
|
}
|
|
315
|
-
|
|
394
|
+
var ASSESSMENT_VALIDATORS = {
|
|
395
|
+
mcq: validateMcqLike,
|
|
396
|
+
trueFalse: (assessment, path, issues) => {
|
|
397
|
+
if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
|
|
398
|
+
issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
fillInBlanks: (assessment, path, issues) => {
|
|
402
|
+
if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
|
|
403
|
+
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
findHotspot: (assessment, path, issues) => {
|
|
407
|
+
if (assessment.kind !== "findHotspot") return;
|
|
408
|
+
if (!assessment.src?.trim()) {
|
|
409
|
+
issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
|
|
410
|
+
}
|
|
411
|
+
if (!assessment.alt?.trim()) {
|
|
412
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
|
|
413
|
+
}
|
|
414
|
+
if (!assessment.correctTargetId?.trim()) {
|
|
415
|
+
issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
findMultipleHotspots: (assessment, path, issues) => {
|
|
419
|
+
if (assessment.kind !== "findMultipleHotspots") return;
|
|
420
|
+
if (!assessment.src?.trim()) {
|
|
421
|
+
issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
|
|
422
|
+
}
|
|
423
|
+
if (!assessment.alt?.trim()) {
|
|
424
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
|
|
425
|
+
}
|
|
426
|
+
const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
|
|
427
|
+
if (!ids.length) {
|
|
428
|
+
issues.push({
|
|
429
|
+
path: `${path}.correctTargetIds`,
|
|
430
|
+
message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
436
|
+
const path = `assessments[${index}]`;
|
|
437
|
+
const check = (0, import_core2.validateId)(assessment.checkId, `${path}.checkId`);
|
|
438
|
+
if (!check.ok) {
|
|
439
|
+
issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
440
|
+
} else if (checkIds.has(check.id)) {
|
|
441
|
+
issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
|
|
442
|
+
} else {
|
|
443
|
+
checkIds.add(check.id);
|
|
444
|
+
}
|
|
445
|
+
if (!assessment.question?.trim()) {
|
|
446
|
+
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
447
|
+
}
|
|
448
|
+
const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
|
|
449
|
+
if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
|
|
450
|
+
issues.push({
|
|
451
|
+
path: `${path}.kind`,
|
|
452
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const kind = assessment.kind ?? "mcq";
|
|
457
|
+
const validator = ASSESSMENT_VALIDATORS[kind];
|
|
458
|
+
if (!validator) {
|
|
459
|
+
issues.push({
|
|
460
|
+
path: `${path}.kind`,
|
|
461
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
validator(assessment, path, issues);
|
|
466
|
+
const passingScore = assessment.passingScore;
|
|
467
|
+
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
468
|
+
issues.push({
|
|
469
|
+
path: `${path}.passingScore`,
|
|
470
|
+
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
471
|
+
});
|
|
472
|
+
} else if (passingScore !== void 0) {
|
|
473
|
+
const maxAchievable = maxAchievableAssessmentScore(assessment);
|
|
474
|
+
if (maxAchievable > 0 && passingScore > maxAchievable) {
|
|
475
|
+
issues.push({
|
|
476
|
+
path: `${path}.passingScore`,
|
|
477
|
+
message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/descriptor/validateCourse.ts
|
|
484
|
+
var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
485
|
+
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
486
|
+
function validateCourseDescriptor(input) {
|
|
316
487
|
const issues = [];
|
|
317
|
-
const course = (0,
|
|
488
|
+
const course = (0, import_core3.validateId)(input.courseId, "courseId");
|
|
318
489
|
if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
319
490
|
if (!input.title?.trim()) {
|
|
320
491
|
issues.push({ path: "title", message: "title is required" });
|
|
@@ -339,13 +510,23 @@ function validateDescriptorParsed(input) {
|
|
|
339
510
|
});
|
|
340
511
|
}
|
|
341
512
|
if (input.theme?.theme) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
513
|
+
const themeResult = (0, import_themes2.validateTheme)(input.theme.theme);
|
|
514
|
+
if (!themeResult.ok) {
|
|
515
|
+
for (const issue of themeResult.issues) {
|
|
516
|
+
issues.push({
|
|
517
|
+
path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
|
|
518
|
+
message: issue.message
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
try {
|
|
523
|
+
themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
|
|
524
|
+
} catch (err) {
|
|
525
|
+
issues.push({
|
|
526
|
+
path: "theme.theme",
|
|
527
|
+
message: err instanceof Error ? err.message : "invalid custom theme"
|
|
528
|
+
});
|
|
529
|
+
}
|
|
349
530
|
}
|
|
350
531
|
}
|
|
351
532
|
const completionThreshold = input.tracking?.completion?.threshold;
|
|
@@ -367,7 +548,7 @@ function validateDescriptorParsed(input) {
|
|
|
367
548
|
const spaPaths = /* @__PURE__ */ new Set();
|
|
368
549
|
for (const [index, lesson] of (input.lessons ?? []).entries()) {
|
|
369
550
|
const path = `lessons[${index}]`;
|
|
370
|
-
const lessonResult = (0,
|
|
551
|
+
const lessonResult = (0, import_core3.validateId)(lesson.id, `${path}.id`);
|
|
371
552
|
if (!lessonResult.ok) {
|
|
372
553
|
issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
373
554
|
} else if (lessonIds.has(lessonResult.id)) {
|
|
@@ -395,68 +576,147 @@ function validateDescriptorParsed(input) {
|
|
|
395
576
|
} else {
|
|
396
577
|
spaPaths.add(spaPath);
|
|
397
578
|
}
|
|
398
|
-
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (layout === "single-spa" && input.spaLessonId?.trim()) {
|
|
582
|
+
const spaId = input.spaLessonId.trim();
|
|
583
|
+
const spaResult = (0, import_core3.validateId)(spaId, "spaLessonId");
|
|
584
|
+
if (!spaResult.ok) {
|
|
585
|
+
issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
586
|
+
} else if (!lessonIds.has(spaResult.id)) {
|
|
587
|
+
issues.push({
|
|
588
|
+
path: "spaLessonId",
|
|
589
|
+
message: "spaLessonId must match a lesson id in lessons"
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const checkIds = /* @__PURE__ */ new Set();
|
|
594
|
+
for (const [index, assessment] of (input.assessments ?? []).entries()) {
|
|
595
|
+
validateAssessmentEntry(assessment, index, issues, checkIds);
|
|
596
|
+
}
|
|
597
|
+
return issues;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/assessments.ts
|
|
601
|
+
function slugChoiceId(text, index) {
|
|
602
|
+
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
603
|
+
const stem = base.length ? base : "choice";
|
|
604
|
+
return `${stem}-${index + 1}`;
|
|
605
|
+
}
|
|
606
|
+
function mcqToLxpack(assessment) {
|
|
607
|
+
const choices = assessment.choices.map((text, index) => {
|
|
608
|
+
const id = slugChoiceId(text, index);
|
|
609
|
+
return {
|
|
610
|
+
id,
|
|
611
|
+
text,
|
|
612
|
+
correct: text === assessment.answer
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
return {
|
|
616
|
+
id: assessment.checkId,
|
|
617
|
+
passingScore: assessment.passingScore ?? 1,
|
|
618
|
+
questions: [
|
|
619
|
+
{
|
|
620
|
+
id: "q1",
|
|
621
|
+
prompt: assessment.question,
|
|
622
|
+
choices
|
|
623
|
+
}
|
|
624
|
+
]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function assessmentDescriptorToLxpack(assessment) {
|
|
628
|
+
const kind = assessment.kind ?? "mcq";
|
|
629
|
+
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
630
|
+
const choices = ["True", "False"];
|
|
631
|
+
const answerText = assessment.answer ? "True" : "False";
|
|
632
|
+
return mcqToLxpack({
|
|
633
|
+
kind: "mcq",
|
|
634
|
+
checkId: assessment.checkId,
|
|
635
|
+
question: assessment.question,
|
|
636
|
+
choices,
|
|
637
|
+
answer: answerText,
|
|
638
|
+
passingScore: assessment.passingScore
|
|
639
|
+
});
|
|
399
640
|
}
|
|
400
|
-
if (
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
641
|
+
if (kind === "fillInBlanks") {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
645
|
+
return mcqToLxpack({
|
|
646
|
+
kind: "mcq",
|
|
647
|
+
checkId: assessment.checkId,
|
|
648
|
+
question: assessment.question,
|
|
649
|
+
choices: [assessment.correctTargetId, "other"],
|
|
650
|
+
answer: assessment.correctTargetId,
|
|
651
|
+
passingScore: assessment.passingScore
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (kind === "findMultipleHotspots") {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
658
|
+
return mcqToLxpack(assessment);
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
function extractAssessments(descriptor) {
|
|
663
|
+
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/descriptor/validateForTarget.ts
|
|
667
|
+
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
668
|
+
"scorm12",
|
|
669
|
+
"scorm2004",
|
|
670
|
+
"standalone",
|
|
671
|
+
"xapi",
|
|
672
|
+
"cmi5"
|
|
673
|
+
]);
|
|
674
|
+
function validateDescriptorForExportTarget(descriptor, target) {
|
|
675
|
+
const issues = [];
|
|
676
|
+
if (target === "xapi" || target === "cmi5") {
|
|
677
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
678
|
+
if (!activityIri) {
|
|
406
679
|
issues.push({
|
|
407
|
-
path: "
|
|
408
|
-
message: "
|
|
680
|
+
path: "course.tracking.xapi.activityIri",
|
|
681
|
+
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
409
682
|
});
|
|
410
683
|
}
|
|
411
684
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const check = (0, import_core.validateId)(assessment.checkId, `${path}.checkId`);
|
|
416
|
-
if (!check.ok) {
|
|
417
|
-
issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
418
|
-
} else if (checkIds.has(check.id)) {
|
|
419
|
-
issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
|
|
420
|
-
} else {
|
|
421
|
-
checkIds.add(check.id);
|
|
422
|
-
}
|
|
423
|
-
if (!assessment.question?.trim()) {
|
|
424
|
-
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
425
|
-
}
|
|
426
|
-
const kind = assessment.kind ?? "mcq";
|
|
427
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
428
|
-
if (typeof assessment.answer !== "boolean") {
|
|
429
|
-
issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
|
|
430
|
-
}
|
|
431
|
-
} else if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
|
|
432
|
-
if (!assessment.template?.trim()) {
|
|
433
|
-
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
434
|
-
}
|
|
435
|
-
} else if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
436
|
-
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
437
|
-
if (!trimmedChoices.length) {
|
|
685
|
+
if (LMS_SHELL_TARGETS.has(target)) {
|
|
686
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
687
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
438
688
|
issues.push({
|
|
439
|
-
path:
|
|
440
|
-
message: "
|
|
689
|
+
path: `assessments[${index}]`,
|
|
690
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
441
691
|
});
|
|
442
692
|
}
|
|
443
|
-
|
|
444
|
-
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
445
|
-
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
446
|
-
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
const passingScore = assessment.passingScore;
|
|
450
|
-
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
451
|
-
issues.push({
|
|
452
|
-
path: `${path}.passingScore`,
|
|
453
|
-
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
454
|
-
});
|
|
455
|
-
}
|
|
693
|
+
});
|
|
456
694
|
}
|
|
695
|
+
return issues;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/validateDescriptor.ts
|
|
699
|
+
function validateDescriptorParsed(input) {
|
|
700
|
+
const issues = validateCourseDescriptor(input);
|
|
457
701
|
if (issues.length) return { ok: false, issues };
|
|
458
702
|
return { ok: true, descriptor: normalizeDescriptor(input) };
|
|
459
703
|
}
|
|
704
|
+
function validateDescriptor(input) {
|
|
705
|
+
const parsed = parseCourseDescriptorInput(input);
|
|
706
|
+
if (parsed === null) {
|
|
707
|
+
return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
|
|
708
|
+
}
|
|
709
|
+
return validateDescriptorParsed(parsed);
|
|
710
|
+
}
|
|
711
|
+
function validateDescriptorForTarget(input, target) {
|
|
712
|
+
const result = validateDescriptor(input);
|
|
713
|
+
if (!result.ok || !target) return result;
|
|
714
|
+
const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
|
|
715
|
+
if (targetIssues.length) {
|
|
716
|
+
return { ok: false, issues: targetIssues };
|
|
717
|
+
}
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
460
720
|
|
|
461
721
|
// src/validateProjectPaths.ts
|
|
462
722
|
var import_node_path2 = require("path");
|
|
@@ -511,69 +771,16 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
|
511
771
|
}
|
|
512
772
|
|
|
513
773
|
// src/mapIds.ts
|
|
514
|
-
var
|
|
774
|
+
var import_core4 = require("@lessonkit/core");
|
|
515
775
|
function mapLessonkitIds(descriptor) {
|
|
516
|
-
const courseId = (0,
|
|
517
|
-
const lessonIds = descriptor.lessons.map((l) => (0,
|
|
776
|
+
const courseId = (0, import_core4.assertValidId)(descriptor.courseId, "courseId");
|
|
777
|
+
const lessonIds = descriptor.lessons.map((l) => (0, import_core4.assertValidId)(l.id, "lessonId"));
|
|
518
778
|
const checkIds = (descriptor.assessments ?? []).map(
|
|
519
|
-
(a) => (0,
|
|
779
|
+
(a) => (0, import_core4.assertValidId)(a.checkId, "checkId")
|
|
520
780
|
);
|
|
521
781
|
return { courseId, lessonIds, checkIds };
|
|
522
782
|
}
|
|
523
783
|
|
|
524
|
-
// src/assessments.ts
|
|
525
|
-
function slugChoiceId(text, index) {
|
|
526
|
-
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
527
|
-
const stem = base.length ? base : "choice";
|
|
528
|
-
return `${stem}-${index + 1}`;
|
|
529
|
-
}
|
|
530
|
-
function mcqToLxpack(assessment) {
|
|
531
|
-
const choices = assessment.choices.map((text, index) => {
|
|
532
|
-
const id = slugChoiceId(text, index);
|
|
533
|
-
return {
|
|
534
|
-
id,
|
|
535
|
-
text,
|
|
536
|
-
correct: text === assessment.answer
|
|
537
|
-
};
|
|
538
|
-
});
|
|
539
|
-
return {
|
|
540
|
-
id: assessment.checkId,
|
|
541
|
-
passingScore: assessment.passingScore ?? 1,
|
|
542
|
-
questions: [
|
|
543
|
-
{
|
|
544
|
-
id: "q1",
|
|
545
|
-
prompt: assessment.question,
|
|
546
|
-
choices
|
|
547
|
-
}
|
|
548
|
-
]
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
function assessmentDescriptorToLxpack(assessment) {
|
|
552
|
-
const kind = assessment.kind ?? "mcq";
|
|
553
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
554
|
-
const choices = ["True", "False"];
|
|
555
|
-
const answerText = assessment.answer ? "True" : "False";
|
|
556
|
-
return mcqToLxpack({
|
|
557
|
-
kind: "mcq",
|
|
558
|
-
checkId: assessment.checkId,
|
|
559
|
-
question: assessment.question,
|
|
560
|
-
choices,
|
|
561
|
-
answer: answerText,
|
|
562
|
-
passingScore: assessment.passingScore
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
if (kind === "fillInBlanks") {
|
|
566
|
-
return null;
|
|
567
|
-
}
|
|
568
|
-
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
569
|
-
return mcqToLxpack(assessment);
|
|
570
|
-
}
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
|
-
function extractAssessments(descriptor) {
|
|
574
|
-
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
784
|
// src/interchange.ts
|
|
578
785
|
function mapDescriptorTracking(tracking) {
|
|
579
786
|
if (!tracking) return void 0;
|
|
@@ -736,44 +943,71 @@ var import_node_path5 = require("path");
|
|
|
736
943
|
function validatePackageInputs(options) {
|
|
737
944
|
const { target, output, outputBaseDir } = options;
|
|
738
945
|
const outDir = (0, import_node_path5.resolve)(options.outDir);
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
courseDir: outDir,
|
|
747
|
-
target,
|
|
748
|
-
issues: [
|
|
749
|
-
{
|
|
750
|
-
path: "outDir",
|
|
751
|
-
message: (
|
|
752
|
-
/* v8 ignore next */
|
|
753
|
-
err instanceof Error ? err.message : String(err)
|
|
754
|
-
)
|
|
755
|
-
}
|
|
756
|
-
]
|
|
757
|
-
};
|
|
758
|
-
}
|
|
946
|
+
if (!options.projectRoot) {
|
|
947
|
+
return {
|
|
948
|
+
ok: false,
|
|
949
|
+
courseDir: outDir,
|
|
950
|
+
target,
|
|
951
|
+
issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
|
|
952
|
+
};
|
|
759
953
|
}
|
|
760
|
-
|
|
954
|
+
const projectRoot = (0, import_node_path5.resolve)(options.projectRoot);
|
|
955
|
+
try {
|
|
956
|
+
assertRealPathUnderRoot(projectRoot, outDir);
|
|
957
|
+
} catch (err) {
|
|
761
958
|
return {
|
|
762
959
|
ok: false,
|
|
763
960
|
courseDir: outDir,
|
|
764
961
|
target,
|
|
765
|
-
issues: [
|
|
962
|
+
issues: [
|
|
963
|
+
{
|
|
964
|
+
path: "outDir",
|
|
965
|
+
message: (
|
|
966
|
+
/* v8 ignore next */
|
|
967
|
+
err instanceof Error ? err.message : String(err)
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
]
|
|
766
971
|
};
|
|
767
972
|
}
|
|
768
|
-
if (
|
|
973
|
+
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
769
974
|
return {
|
|
770
975
|
ok: false,
|
|
771
976
|
courseDir: outDir,
|
|
772
977
|
target,
|
|
773
|
-
issues: [{ path: "
|
|
978
|
+
issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
|
|
774
979
|
};
|
|
775
980
|
}
|
|
776
|
-
if (
|
|
981
|
+
if (output && !isSafeRelativeSpaPath(output)) {
|
|
982
|
+
if ((0, import_node_path5.isAbsolute)(output)) {
|
|
983
|
+
try {
|
|
984
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path5.resolve)(output));
|
|
985
|
+
} catch (err) {
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
courseDir: outDir,
|
|
989
|
+
target,
|
|
990
|
+
issues: [
|
|
991
|
+
{
|
|
992
|
+
path: "output",
|
|
993
|
+
message: (
|
|
994
|
+
/* v8 ignore next */
|
|
995
|
+
err instanceof Error ? err.message : `unsafe output: ${output}`
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
]
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
} else {
|
|
1002
|
+
return {
|
|
1003
|
+
ok: false,
|
|
1004
|
+
courseDir: outDir,
|
|
1005
|
+
target,
|
|
1006
|
+
issues: [{ path: "output", message: `unsafe output: ${output}` }]
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (outputBaseDir) {
|
|
777
1011
|
const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
|
|
778
1012
|
try {
|
|
779
1013
|
assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
|
|
@@ -794,8 +1028,8 @@ function validatePackageInputs(options) {
|
|
|
794
1028
|
};
|
|
795
1029
|
}
|
|
796
1030
|
}
|
|
797
|
-
if (
|
|
798
|
-
const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
|
|
1031
|
+
if (output) {
|
|
1032
|
+
const resolvedOutput = (0, import_node_path5.isAbsolute)(output) ? (0, import_node_path5.resolve)(output) : (0, import_node_path5.resolve)(projectRoot, output);
|
|
799
1033
|
try {
|
|
800
1034
|
assertRealPathUnderRoot(projectRoot, resolvedOutput);
|
|
801
1035
|
} catch (err) {
|
|
@@ -832,11 +1066,11 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
832
1066
|
if (!artifactPath) return void 0;
|
|
833
1067
|
const resolved = resolveComparablePath(artifactPath);
|
|
834
1068
|
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
835
|
-
|
|
1069
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
836
1070
|
}
|
|
837
1071
|
const rel = relativePathUnderRoot(stagingRoot, resolved);
|
|
838
1072
|
if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
|
|
839
|
-
|
|
1073
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
840
1074
|
}
|
|
841
1075
|
if (!rel) return outDir;
|
|
842
1076
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
@@ -867,6 +1101,68 @@ async function renameOrCopy(from, to) {
|
|
|
867
1101
|
await fsp.rm(from, { recursive: true, force: true });
|
|
868
1102
|
}
|
|
869
1103
|
}
|
|
1104
|
+
function promoteLockPath(outDir) {
|
|
1105
|
+
const parent = (0, import_node_path6.dirname)(outDir);
|
|
1106
|
+
const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path6.resolve)(outDir)).digest("hex").slice(0, 16);
|
|
1107
|
+
return (0, import_node_path6.join)(parent, `.lk-promote-lock-${hash}`);
|
|
1108
|
+
}
|
|
1109
|
+
var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
|
|
1110
|
+
async function isStalePromoteLock(lockPath) {
|
|
1111
|
+
try {
|
|
1112
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1113
|
+
if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
|
|
1114
|
+
const content = await fsp.readFile(lockPath, "utf8");
|
|
1115
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
1116
|
+
if (!Number.isFinite(pid) || pid <= 0) return true;
|
|
1117
|
+
try {
|
|
1118
|
+
process.kill(pid, 0);
|
|
1119
|
+
return false;
|
|
1120
|
+
} catch {
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
} catch {
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
async function withPromoteLock(outDir, fn) {
|
|
1128
|
+
const lockPath = promoteLockPath(outDir);
|
|
1129
|
+
await fsp.mkdir((0, import_node_path6.dirname)(outDir), { recursive: true });
|
|
1130
|
+
let lockHandle;
|
|
1131
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1132
|
+
try {
|
|
1133
|
+
lockHandle = await fsp.open(lockPath, "wx");
|
|
1134
|
+
await lockHandle.writeFile(`${process.pid}
|
|
1135
|
+
`, "utf8");
|
|
1136
|
+
break;
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
1139
|
+
if (code !== "EEXIST") throw err;
|
|
1140
|
+
if (await isStalePromoteLock(lockPath)) {
|
|
1141
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1142
|
+
/* v8 ignore next */
|
|
1143
|
+
() => void 0
|
|
1144
|
+
);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
if (!lockHandle) {
|
|
1151
|
+
throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
return await fn();
|
|
1155
|
+
} finally {
|
|
1156
|
+
await lockHandle.close().catch(
|
|
1157
|
+
/* v8 ignore next */
|
|
1158
|
+
() => void 0
|
|
1159
|
+
);
|
|
1160
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1161
|
+
/* v8 ignore next */
|
|
1162
|
+
() => void 0
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
870
1166
|
async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
871
1167
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
872
1168
|
const legacyBak = `${outDir}.bak`;
|
|
@@ -880,45 +1176,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
|
880
1176
|
}
|
|
881
1177
|
}
|
|
882
1178
|
async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
await renameOrCopy(outDir, backup);
|
|
891
|
-
}
|
|
892
|
-
try {
|
|
893
|
-
await renameOrCopy(tmpPromote, outDir);
|
|
894
|
-
} catch (promoteError) {
|
|
1179
|
+
return withPromoteLock(outDir, async () => {
|
|
1180
|
+
await assertNoLegacyPromoteArtifacts(outDir);
|
|
1181
|
+
const parent = (0, import_node_path6.dirname)(outDir);
|
|
1182
|
+
const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
|
|
1183
|
+
await renameOrCopy(stagingDir, tmpPromote);
|
|
1184
|
+
const hadOutDir = await pathExists(outDir);
|
|
1185
|
+
const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
|
|
895
1186
|
if (hadOutDir && backup) {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1187
|
+
await renameOrCopy(outDir, backup);
|
|
1188
|
+
}
|
|
1189
|
+
try {
|
|
1190
|
+
await renameOrCopy(tmpPromote, outDir);
|
|
1191
|
+
} catch (promoteError) {
|
|
1192
|
+
if (hadOutDir && backup) {
|
|
1193
|
+
try {
|
|
1194
|
+
await renameOrCopy(backup, outDir);
|
|
1195
|
+
} catch (restoreError) {
|
|
1196
|
+
const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1197
|
+
try {
|
|
1198
|
+
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1199
|
+
} catch {
|
|
1200
|
+
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1201
|
+
/* v8 ignore next */
|
|
1202
|
+
() => void 0
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1206
|
+
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1207
|
+
throw new Error(
|
|
1208
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
900
1212
|
try {
|
|
901
|
-
await renameOrCopy(tmpPromote,
|
|
902
|
-
} catch {
|
|
1213
|
+
await renameOrCopy(tmpPromote, stagingDir);
|
|
1214
|
+
} catch (restoreError) {
|
|
1215
|
+
console.warn(
|
|
1216
|
+
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1217
|
+
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1218
|
+
);
|
|
903
1219
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
904
1220
|
/* v8 ignore next */
|
|
905
1221
|
() => void 0
|
|
906
1222
|
);
|
|
907
1223
|
}
|
|
908
|
-
|
|
909
|
-
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
910
|
-
throw new Error(
|
|
911
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
912
|
-
);
|
|
1224
|
+
throw promoteError;
|
|
913
1225
|
}
|
|
914
|
-
|
|
1226
|
+
const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
915
1227
|
try {
|
|
916
|
-
await renameOrCopy(tmpPromote,
|
|
917
|
-
} catch
|
|
918
|
-
console.warn(
|
|
919
|
-
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
920
|
-
restoreError instanceof Error ? restoreError.message : restoreError
|
|
921
|
-
);
|
|
1228
|
+
await renameOrCopy(tmpPromote, failedPromote);
|
|
1229
|
+
} catch {
|
|
922
1230
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
923
1231
|
/* v8 ignore next */
|
|
924
1232
|
() => void 0
|
|
@@ -926,23 +1234,13 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
926
1234
|
}
|
|
927
1235
|
throw promoteError;
|
|
928
1236
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
await renameOrCopy(tmpPromote, failedPromote);
|
|
932
|
-
} catch {
|
|
933
|
-
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1237
|
+
if (backup) {
|
|
1238
|
+
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
934
1239
|
/* v8 ignore next */
|
|
935
1240
|
() => void 0
|
|
936
1241
|
);
|
|
937
1242
|
}
|
|
938
|
-
|
|
939
|
-
}
|
|
940
|
-
if (backup) {
|
|
941
|
-
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
942
|
-
/* v8 ignore next */
|
|
943
|
-
() => void 0
|
|
944
|
-
);
|
|
945
|
-
}
|
|
1243
|
+
});
|
|
946
1244
|
}
|
|
947
1245
|
|
|
948
1246
|
// src/packaging/staging.ts
|
|
@@ -1015,6 +1313,15 @@ async function ensureOutDirParent(outDir) {
|
|
|
1015
1313
|
await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
|
|
1016
1314
|
}
|
|
1017
1315
|
|
|
1316
|
+
// src/packaging/issueSeverity.ts
|
|
1317
|
+
function isPackagingErrorIssue(issue) {
|
|
1318
|
+
const severity = issue.severity?.toLowerCase();
|
|
1319
|
+
return severity === "error" || severity === "fatal";
|
|
1320
|
+
}
|
|
1321
|
+
function findPackagingErrorIssues(issues) {
|
|
1322
|
+
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1018
1325
|
// src/packageCourse.ts
|
|
1019
1326
|
async function validateLessonkitProject(options) {
|
|
1020
1327
|
return (0, import_api2.validateCourse)({
|
|
@@ -1064,6 +1371,18 @@ async function packageLessonkitCourse(options) {
|
|
|
1064
1371
|
};
|
|
1065
1372
|
}
|
|
1066
1373
|
const descriptor = descriptorValidation.descriptor;
|
|
1374
|
+
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1375
|
+
if (nonInjectableAssessments.length > 0) {
|
|
1376
|
+
return {
|
|
1377
|
+
ok: false,
|
|
1378
|
+
courseDir: outDir,
|
|
1379
|
+
target,
|
|
1380
|
+
issues: nonInjectableAssessments.map(({ assessment, index }) => ({
|
|
1381
|
+
path: `assessments[${index}]`,
|
|
1382
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
1383
|
+
}))
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1067
1386
|
const staged = await buildStagingPackage({
|
|
1068
1387
|
...writeOpts,
|
|
1069
1388
|
descriptor,
|
|
@@ -1088,6 +1407,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1088
1407
|
};
|
|
1089
1408
|
}
|
|
1090
1409
|
const { stagingDir, build } = staged;
|
|
1410
|
+
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1411
|
+
if (buildErrorIssues.length > 0) {
|
|
1412
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1413
|
+
/* v8 ignore next */
|
|
1414
|
+
() => void 0
|
|
1415
|
+
);
|
|
1416
|
+
return {
|
|
1417
|
+
ok: false,
|
|
1418
|
+
courseDir: outDir,
|
|
1419
|
+
target,
|
|
1420
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1421
|
+
build,
|
|
1422
|
+
issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
|
|
1423
|
+
path: i.path ?? "build",
|
|
1424
|
+
message: i.message,
|
|
1425
|
+
severity: i.severity
|
|
1426
|
+
}))
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1091
1429
|
const stagingRoot = await fsp3.realpath(stagingDir);
|
|
1092
1430
|
const artifactIssues = [
|
|
1093
1431
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
@@ -1118,6 +1456,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1118
1456
|
await ensureOutDirParent(outDir);
|
|
1119
1457
|
await promoteStagingToOutDir(stagingDir, outDir);
|
|
1120
1458
|
} catch (err) {
|
|
1459
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1460
|
+
/* v8 ignore next */
|
|
1461
|
+
() => void 0
|
|
1462
|
+
);
|
|
1121
1463
|
return {
|
|
1122
1464
|
ok: false,
|
|
1123
1465
|
courseDir: outDir,
|