@lessonkit/lxpack 1.1.0 → 1.2.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 +287 -202
- package/dist/index.d.cts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +283 -198
- 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,24 @@ 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
196
|
return {
|
|
197
197
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
198
198
|
...base,
|
|
@@ -239,82 +239,158 @@ function parseCourseDescriptorInput(input) {
|
|
|
239
239
|
spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
|
|
243
|
+
// src/descriptor/validateCourse.ts
|
|
244
|
+
var import_core3 = require("@lessonkit/core");
|
|
245
|
+
|
|
246
|
+
// src/spaPath.ts
|
|
247
|
+
var import_node_fs = require("fs");
|
|
248
|
+
var import_node_path = require("path");
|
|
249
|
+
function resolveComparablePath(p) {
|
|
250
|
+
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
251
|
+
return import_node_path.win32.resolve(p);
|
|
252
|
+
}
|
|
253
|
+
return (0, import_node_path.resolve)(p);
|
|
254
|
+
}
|
|
255
|
+
function isSafeRelativeSpaPath(spaPath) {
|
|
256
|
+
if (!spaPath.length || spaPath.includes("\0")) return false;
|
|
257
|
+
if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
|
|
258
|
+
if (/^[a-zA-Z]:/.test(spaPath)) return false;
|
|
259
|
+
if (spaPath === "." || spaPath === "./") return false;
|
|
260
|
+
const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
261
|
+
if (segments.some((s) => s === "..")) return false;
|
|
262
|
+
return segments.length > 0;
|
|
263
|
+
}
|
|
264
|
+
function assertResolvedPathUnderRoot(root, target) {
|
|
265
|
+
const rootResolved = resolveComparablePath(root);
|
|
266
|
+
const targetResolved = resolveComparablePath(target);
|
|
267
|
+
const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
|
|
268
|
+
const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
|
|
269
|
+
if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
|
|
270
|
+
!targetResolved.startsWith(win32Prefix)) {
|
|
271
|
+
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function assertRealPathUnderRoot(root, target) {
|
|
275
|
+
const rootResolved = resolveComparablePath(root);
|
|
276
|
+
const targetResolved = resolveComparablePath(target);
|
|
277
|
+
let rootReal;
|
|
278
|
+
try {
|
|
279
|
+
rootReal = (0, import_node_fs.realpathSync)(rootResolved);
|
|
280
|
+
} catch {
|
|
281
|
+
rootReal = rootResolved;
|
|
282
|
+
}
|
|
283
|
+
let targetCheck;
|
|
284
|
+
try {
|
|
285
|
+
targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
286
|
+
} catch {
|
|
287
|
+
const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
288
|
+
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
289
|
+
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
290
|
+
}
|
|
291
|
+
targetCheck = (0, import_node_path.resolve)(rootReal, rel);
|
|
292
|
+
}
|
|
293
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
294
|
+
}
|
|
295
|
+
function normalizePathForComparison(p) {
|
|
296
|
+
const resolved = resolveComparablePath(p);
|
|
297
|
+
return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
|
|
298
|
+
}
|
|
299
|
+
function relativePathUnderRoot(root, target) {
|
|
300
|
+
const rootResolved = normalizePathForComparison(root);
|
|
301
|
+
const targetResolved = normalizePathForComparison(target);
|
|
302
|
+
if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
|
|
303
|
+
return import_node_path.win32.relative(rootResolved, targetResolved);
|
|
304
|
+
}
|
|
305
|
+
return (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
306
|
+
}
|
|
307
|
+
function isResolvedPathUnderRoot(root, target) {
|
|
308
|
+
const rootResolved = normalizePathForComparison(root);
|
|
309
|
+
const targetResolved = normalizePathForComparison(target);
|
|
310
|
+
if (targetResolved === rootResolved) return true;
|
|
311
|
+
const rel = relativePathUnderRoot(root, target);
|
|
312
|
+
if (!rel) return true;
|
|
313
|
+
return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/theme.ts
|
|
317
|
+
var import_themes = require("@lessonkit/themes");
|
|
318
|
+
function themeToLxpackRuntime(input) {
|
|
319
|
+
const theme = input.theme ?? (0, import_themes.getPresetTheme)(input.preset ?? "default");
|
|
320
|
+
const raw = (0, import_themes.themeToCssVariables)(theme);
|
|
321
|
+
const cssVariables = {};
|
|
322
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
323
|
+
cssVariables[key] = String(value);
|
|
324
|
+
}
|
|
245
325
|
return {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
title: input.title.trim(),
|
|
249
|
-
version: input.version?.trim() || void 0,
|
|
250
|
-
spaLessonId: input.spaLessonId?.trim() || void 0,
|
|
251
|
-
lessons: input.lessons.map((lesson) => {
|
|
252
|
-
const idResult = (0, import_core.validateId)(lesson.id, "lessonId");
|
|
253
|
-
if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
|
|
254
|
-
return {
|
|
255
|
-
...lesson,
|
|
256
|
-
id: idResult.id,
|
|
257
|
-
title: lesson.title.trim(),
|
|
258
|
-
spaPath: lesson.spaPath?.trim() || void 0
|
|
259
|
-
};
|
|
260
|
-
}),
|
|
261
|
-
assessments: input.assessments?.map((assessment) => {
|
|
262
|
-
const check = (0, import_core.validateId)(assessment.checkId, "checkId");
|
|
263
|
-
if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
|
|
264
|
-
const question = assessment.question.trim();
|
|
265
|
-
if (assessment.kind === "trueFalse") {
|
|
266
|
-
return { ...assessment, checkId: check.id, question };
|
|
267
|
-
}
|
|
268
|
-
if (assessment.kind === "fillInBlanks") {
|
|
269
|
-
return {
|
|
270
|
-
...assessment,
|
|
271
|
-
checkId: check.id,
|
|
272
|
-
question,
|
|
273
|
-
template: assessment.template.trim(),
|
|
274
|
-
blanks: assessment.blanks?.map((b) => ({
|
|
275
|
-
id: b.id.trim(),
|
|
276
|
-
answer: b.answer.trim()
|
|
277
|
-
}))
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
return {
|
|
281
|
-
...assessment,
|
|
282
|
-
checkId: check.id,
|
|
283
|
-
question,
|
|
284
|
-
choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
285
|
-
answer: assessment.answer.trim()
|
|
286
|
-
};
|
|
287
|
-
})
|
|
326
|
+
theme: theme.name,
|
|
327
|
+
cssVariables
|
|
288
328
|
};
|
|
289
329
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
330
|
+
|
|
331
|
+
// src/descriptor/validateAssessments.ts
|
|
332
|
+
var import_core2 = require("@lessonkit/core");
|
|
333
|
+
var validateMcqLike = (assessment, path, issues) => {
|
|
334
|
+
if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
335
|
+
return;
|
|
294
336
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (!
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
337
|
+
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
338
|
+
if (!trimmedChoices.length) {
|
|
339
|
+
issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
|
|
340
|
+
}
|
|
341
|
+
if (!assessment.answer.trim()) {
|
|
342
|
+
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
343
|
+
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
344
|
+
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
var ASSESSMENT_VALIDATORS = {
|
|
348
|
+
mcq: validateMcqLike,
|
|
349
|
+
trueFalse: (assessment, path, issues) => {
|
|
350
|
+
if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
|
|
351
|
+
issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
fillInBlanks: (assessment, path, issues) => {
|
|
355
|
+
if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
|
|
356
|
+
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
findHotspot: () => {
|
|
360
|
+
},
|
|
361
|
+
findMultipleHotspots: () => {
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
365
|
+
const path = `assessments[${index}]`;
|
|
366
|
+
const check = (0, import_core2.validateId)(assessment.checkId, `${path}.checkId`);
|
|
367
|
+
if (!check.ok) {
|
|
368
|
+
issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
369
|
+
} else if (checkIds.has(check.id)) {
|
|
370
|
+
issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
|
|
371
|
+
} else {
|
|
372
|
+
checkIds.add(check.id);
|
|
373
|
+
}
|
|
374
|
+
if (!assessment.question?.trim()) {
|
|
375
|
+
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
376
|
+
}
|
|
377
|
+
const kind = assessment.kind ?? "mcq";
|
|
378
|
+
ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
|
|
379
|
+
const passingScore = assessment.passingScore;
|
|
380
|
+
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
381
|
+
issues.push({
|
|
382
|
+
path: `${path}.passingScore`,
|
|
383
|
+
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
384
|
+
});
|
|
312
385
|
}
|
|
313
|
-
return result;
|
|
314
386
|
}
|
|
315
|
-
|
|
387
|
+
|
|
388
|
+
// src/descriptor/validateCourse.ts
|
|
389
|
+
var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
390
|
+
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
391
|
+
function validateCourseDescriptor(input) {
|
|
316
392
|
const issues = [];
|
|
317
|
-
const course = (0,
|
|
393
|
+
const course = (0, import_core3.validateId)(input.courseId, "courseId");
|
|
318
394
|
if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
319
395
|
if (!input.title?.trim()) {
|
|
320
396
|
issues.push({ path: "title", message: "title is required" });
|
|
@@ -367,7 +443,7 @@ function validateDescriptorParsed(input) {
|
|
|
367
443
|
const spaPaths = /* @__PURE__ */ new Set();
|
|
368
444
|
for (const [index, lesson] of (input.lessons ?? []).entries()) {
|
|
369
445
|
const path = `lessons[${index}]`;
|
|
370
|
-
const lessonResult = (0,
|
|
446
|
+
const lessonResult = (0, import_core3.validateId)(lesson.id, `${path}.id`);
|
|
371
447
|
if (!lessonResult.ok) {
|
|
372
448
|
issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
373
449
|
} else if (lessonIds.has(lessonResult.id)) {
|
|
@@ -399,7 +475,7 @@ function validateDescriptorParsed(input) {
|
|
|
399
475
|
}
|
|
400
476
|
if (layout === "single-spa" && input.spaLessonId?.trim()) {
|
|
401
477
|
const spaId = input.spaLessonId.trim();
|
|
402
|
-
const spaResult = (0,
|
|
478
|
+
const spaResult = (0, import_core3.validateId)(spaId, "spaLessonId");
|
|
403
479
|
if (!spaResult.ok) {
|
|
404
480
|
issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
405
481
|
} else if (!lessonIds.has(spaResult.id)) {
|
|
@@ -411,52 +487,48 @@ function validateDescriptorParsed(input) {
|
|
|
411
487
|
}
|
|
412
488
|
const checkIds = /* @__PURE__ */ new Set();
|
|
413
489
|
for (const [index, assessment] of (input.assessments ?? []).entries()) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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) {
|
|
438
|
-
issues.push({
|
|
439
|
-
path: `${path}.choices`,
|
|
440
|
-
message: "at least one non-empty choice is required"
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
if (!assessment.answer.trim()) {
|
|
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" });
|
|
490
|
+
validateAssessmentEntry(assessment, index, issues, checkIds);
|
|
491
|
+
}
|
|
492
|
+
return issues;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/descriptor/validateForTarget.ts
|
|
496
|
+
function validateDescriptorForExportTarget(descriptor, target) {
|
|
497
|
+
if (target !== "xapi" && target !== "cmi5") return [];
|
|
498
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
499
|
+
if (!activityIri) {
|
|
500
|
+
return [
|
|
501
|
+
{
|
|
502
|
+
path: "course.tracking.xapi.activityIri",
|
|
503
|
+
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
447
504
|
}
|
|
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
|
-
}
|
|
505
|
+
];
|
|
456
506
|
}
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/validateDescriptor.ts
|
|
511
|
+
function validateDescriptorParsed(input) {
|
|
512
|
+
const issues = validateCourseDescriptor(input);
|
|
457
513
|
if (issues.length) return { ok: false, issues };
|
|
458
514
|
return { ok: true, descriptor: normalizeDescriptor(input) };
|
|
459
515
|
}
|
|
516
|
+
function validateDescriptor(input) {
|
|
517
|
+
const parsed = parseCourseDescriptorInput(input);
|
|
518
|
+
if (parsed === null) {
|
|
519
|
+
return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
|
|
520
|
+
}
|
|
521
|
+
return validateDescriptorParsed(parsed);
|
|
522
|
+
}
|
|
523
|
+
function validateDescriptorForTarget(input, target) {
|
|
524
|
+
const result = validateDescriptor(input);
|
|
525
|
+
if (!result.ok || !target) return result;
|
|
526
|
+
const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
|
|
527
|
+
if (targetIssues.length) {
|
|
528
|
+
return { ok: false, issues: targetIssues };
|
|
529
|
+
}
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
460
532
|
|
|
461
533
|
// src/validateProjectPaths.ts
|
|
462
534
|
var import_node_path2 = require("path");
|
|
@@ -511,12 +583,12 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
|
511
583
|
}
|
|
512
584
|
|
|
513
585
|
// src/mapIds.ts
|
|
514
|
-
var
|
|
586
|
+
var import_core4 = require("@lessonkit/core");
|
|
515
587
|
function mapLessonkitIds(descriptor) {
|
|
516
|
-
const courseId = (0,
|
|
517
|
-
const lessonIds = descriptor.lessons.map((l) => (0,
|
|
588
|
+
const courseId = (0, import_core4.assertValidId)(descriptor.courseId, "courseId");
|
|
589
|
+
const lessonIds = descriptor.lessons.map((l) => (0, import_core4.assertValidId)(l.id, "lessonId"));
|
|
518
590
|
const checkIds = (descriptor.assessments ?? []).map(
|
|
519
|
-
(a) => (0,
|
|
591
|
+
(a) => (0, import_core4.assertValidId)(a.checkId, "checkId")
|
|
520
592
|
);
|
|
521
593
|
return { courseId, lessonIds, checkIds };
|
|
522
594
|
}
|
|
@@ -565,6 +637,19 @@ function assessmentDescriptorToLxpack(assessment) {
|
|
|
565
637
|
if (kind === "fillInBlanks") {
|
|
566
638
|
return null;
|
|
567
639
|
}
|
|
640
|
+
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
641
|
+
return mcqToLxpack({
|
|
642
|
+
kind: "mcq",
|
|
643
|
+
checkId: assessment.checkId,
|
|
644
|
+
question: assessment.question,
|
|
645
|
+
choices: [assessment.correctTargetId, "other"],
|
|
646
|
+
answer: assessment.correctTargetId,
|
|
647
|
+
passingScore: assessment.passingScore
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (kind === "findMultipleHotspots") {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
568
653
|
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
569
654
|
return mcqToLxpack(assessment);
|
|
570
655
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -40,8 +40,26 @@ type FillInBlanksAssessmentDescriptor = {
|
|
|
40
40
|
}>;
|
|
41
41
|
passingScore?: number;
|
|
42
42
|
};
|
|
43
|
+
type FindHotspotAssessmentDescriptor = {
|
|
44
|
+
kind: "findHotspot";
|
|
45
|
+
checkId: CheckId;
|
|
46
|
+
question: string;
|
|
47
|
+
src: string;
|
|
48
|
+
alt: string;
|
|
49
|
+
correctTargetId: string;
|
|
50
|
+
passingScore?: number;
|
|
51
|
+
};
|
|
52
|
+
type FindMultipleHotspotsAssessmentDescriptor = {
|
|
53
|
+
kind: "findMultipleHotspots";
|
|
54
|
+
checkId: CheckId;
|
|
55
|
+
question: string;
|
|
56
|
+
src: string;
|
|
57
|
+
alt: string;
|
|
58
|
+
correctTargetIds: string[];
|
|
59
|
+
passingScore?: number;
|
|
60
|
+
};
|
|
43
61
|
/** Discriminated assessment entries in lessonkit.json (defaults to MCQ when kind omitted). */
|
|
44
|
-
type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor;
|
|
62
|
+
type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor | FindHotspotAssessmentDescriptor | FindMultipleHotspotsAssessmentDescriptor;
|
|
45
63
|
type LessonkitCourseDescriptor = {
|
|
46
64
|
courseId: CourseId;
|
|
47
65
|
title: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -40,8 +40,26 @@ type FillInBlanksAssessmentDescriptor = {
|
|
|
40
40
|
}>;
|
|
41
41
|
passingScore?: number;
|
|
42
42
|
};
|
|
43
|
+
type FindHotspotAssessmentDescriptor = {
|
|
44
|
+
kind: "findHotspot";
|
|
45
|
+
checkId: CheckId;
|
|
46
|
+
question: string;
|
|
47
|
+
src: string;
|
|
48
|
+
alt: string;
|
|
49
|
+
correctTargetId: string;
|
|
50
|
+
passingScore?: number;
|
|
51
|
+
};
|
|
52
|
+
type FindMultipleHotspotsAssessmentDescriptor = {
|
|
53
|
+
kind: "findMultipleHotspots";
|
|
54
|
+
checkId: CheckId;
|
|
55
|
+
question: string;
|
|
56
|
+
src: string;
|
|
57
|
+
alt: string;
|
|
58
|
+
correctTargetIds: string[];
|
|
59
|
+
passingScore?: number;
|
|
60
|
+
};
|
|
43
61
|
/** Discriminated assessment entries in lessonkit.json (defaults to MCQ when kind omitted). */
|
|
44
|
-
type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor;
|
|
62
|
+
type AssessmentDescriptor = McqAssessmentDescriptor | TrueFalseAssessmentDescriptor | FillInBlanksAssessmentDescriptor | FindHotspotAssessmentDescriptor | FindMultipleHotspotsAssessmentDescriptor;
|
|
45
63
|
type LessonkitCourseDescriptor = {
|
|
46
64
|
courseId: CourseId;
|
|
47
65
|
title: string;
|
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,24 @@ 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
137
|
return {
|
|
138
138
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
139
139
|
...base,
|
|
@@ -180,82 +180,158 @@ function parseCourseDescriptorInput(input) {
|
|
|
180
180
|
spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
|
|
184
|
+
// src/descriptor/validateCourse.ts
|
|
185
|
+
import { validateId as validateId3 } from "@lessonkit/core";
|
|
186
|
+
|
|
187
|
+
// src/spaPath.ts
|
|
188
|
+
import { realpathSync } from "fs";
|
|
189
|
+
import { isAbsolute, relative, resolve, sep, win32 } from "path";
|
|
190
|
+
function resolveComparablePath(p) {
|
|
191
|
+
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
192
|
+
return win32.resolve(p);
|
|
193
|
+
}
|
|
194
|
+
return resolve(p);
|
|
195
|
+
}
|
|
196
|
+
function isSafeRelativeSpaPath(spaPath) {
|
|
197
|
+
if (!spaPath.length || spaPath.includes("\0")) return false;
|
|
198
|
+
if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
|
|
199
|
+
if (/^[a-zA-Z]:/.test(spaPath)) return false;
|
|
200
|
+
if (spaPath === "." || spaPath === "./") return false;
|
|
201
|
+
const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
202
|
+
if (segments.some((s) => s === "..")) return false;
|
|
203
|
+
return segments.length > 0;
|
|
204
|
+
}
|
|
205
|
+
function assertResolvedPathUnderRoot(root, target) {
|
|
206
|
+
const rootResolved = resolveComparablePath(root);
|
|
207
|
+
const targetResolved = resolveComparablePath(target);
|
|
208
|
+
const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
|
|
209
|
+
const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
|
|
210
|
+
if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && /* v8 ignore next */
|
|
211
|
+
!targetResolved.startsWith(win32Prefix)) {
|
|
212
|
+
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function assertRealPathUnderRoot(root, target) {
|
|
216
|
+
const rootResolved = resolveComparablePath(root);
|
|
217
|
+
const targetResolved = resolveComparablePath(target);
|
|
218
|
+
let rootReal;
|
|
219
|
+
try {
|
|
220
|
+
rootReal = realpathSync(rootResolved);
|
|
221
|
+
} catch {
|
|
222
|
+
rootReal = rootResolved;
|
|
223
|
+
}
|
|
224
|
+
let targetCheck;
|
|
225
|
+
try {
|
|
226
|
+
targetCheck = realpathSync(targetResolved);
|
|
227
|
+
} catch {
|
|
228
|
+
const rel = relative(rootResolved, targetResolved);
|
|
229
|
+
if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
230
|
+
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
231
|
+
}
|
|
232
|
+
targetCheck = resolve(rootReal, rel);
|
|
233
|
+
}
|
|
234
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
235
|
+
}
|
|
236
|
+
function normalizePathForComparison(p) {
|
|
237
|
+
const resolved = resolveComparablePath(p);
|
|
238
|
+
return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
|
|
239
|
+
}
|
|
240
|
+
function relativePathUnderRoot(root, target) {
|
|
241
|
+
const rootResolved = normalizePathForComparison(root);
|
|
242
|
+
const targetResolved = normalizePathForComparison(target);
|
|
243
|
+
if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
|
|
244
|
+
return win32.relative(rootResolved, targetResolved);
|
|
245
|
+
}
|
|
246
|
+
return relative(rootResolved, targetResolved);
|
|
247
|
+
}
|
|
248
|
+
function isResolvedPathUnderRoot(root, target) {
|
|
249
|
+
const rootResolved = normalizePathForComparison(root);
|
|
250
|
+
const targetResolved = normalizePathForComparison(target);
|
|
251
|
+
if (targetResolved === rootResolved) return true;
|
|
252
|
+
const rel = relativePathUnderRoot(root, target);
|
|
253
|
+
if (!rel) return true;
|
|
254
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/theme.ts
|
|
258
|
+
import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
|
|
259
|
+
function themeToLxpackRuntime(input) {
|
|
260
|
+
const theme = input.theme ?? getPresetTheme(input.preset ?? "default");
|
|
261
|
+
const raw = themeToCssVariables(theme);
|
|
262
|
+
const cssVariables = {};
|
|
263
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
264
|
+
cssVariables[key] = String(value);
|
|
265
|
+
}
|
|
186
266
|
return {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
title: input.title.trim(),
|
|
190
|
-
version: input.version?.trim() || void 0,
|
|
191
|
-
spaLessonId: input.spaLessonId?.trim() || void 0,
|
|
192
|
-
lessons: input.lessons.map((lesson) => {
|
|
193
|
-
const idResult = validateId(lesson.id, "lessonId");
|
|
194
|
-
if (!idResult.ok) throw new Error("normalizeDescriptor called with invalid lesson id");
|
|
195
|
-
return {
|
|
196
|
-
...lesson,
|
|
197
|
-
id: idResult.id,
|
|
198
|
-
title: lesson.title.trim(),
|
|
199
|
-
spaPath: lesson.spaPath?.trim() || void 0
|
|
200
|
-
};
|
|
201
|
-
}),
|
|
202
|
-
assessments: input.assessments?.map((assessment) => {
|
|
203
|
-
const check = validateId(assessment.checkId, "checkId");
|
|
204
|
-
if (!check.ok) throw new Error("normalizeDescriptor called with invalid checkId");
|
|
205
|
-
const question = assessment.question.trim();
|
|
206
|
-
if (assessment.kind === "trueFalse") {
|
|
207
|
-
return { ...assessment, checkId: check.id, question };
|
|
208
|
-
}
|
|
209
|
-
if (assessment.kind === "fillInBlanks") {
|
|
210
|
-
return {
|
|
211
|
-
...assessment,
|
|
212
|
-
checkId: check.id,
|
|
213
|
-
question,
|
|
214
|
-
template: assessment.template.trim(),
|
|
215
|
-
blanks: assessment.blanks?.map((b) => ({
|
|
216
|
-
id: b.id.trim(),
|
|
217
|
-
answer: b.answer.trim()
|
|
218
|
-
}))
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
return {
|
|
222
|
-
...assessment,
|
|
223
|
-
checkId: check.id,
|
|
224
|
-
question,
|
|
225
|
-
choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
226
|
-
answer: assessment.answer.trim()
|
|
227
|
-
};
|
|
228
|
-
})
|
|
267
|
+
theme: theme.name,
|
|
268
|
+
cssVariables
|
|
229
269
|
};
|
|
230
270
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
271
|
+
|
|
272
|
+
// src/descriptor/validateAssessments.ts
|
|
273
|
+
import { validateId as validateId2 } from "@lessonkit/core";
|
|
274
|
+
var validateMcqLike = (assessment, path, issues) => {
|
|
275
|
+
if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
276
|
+
return;
|
|
235
277
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (!
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
278
|
+
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
279
|
+
if (!trimmedChoices.length) {
|
|
280
|
+
issues.push({ path: `${path}.choices`, message: "at least one non-empty choice is required" });
|
|
281
|
+
}
|
|
282
|
+
if (!assessment.answer.trim()) {
|
|
283
|
+
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
284
|
+
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
285
|
+
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
var ASSESSMENT_VALIDATORS = {
|
|
289
|
+
mcq: validateMcqLike,
|
|
290
|
+
trueFalse: (assessment, path, issues) => {
|
|
291
|
+
if (assessment.kind === "trueFalse" && typeof assessment.answer !== "boolean") {
|
|
292
|
+
issues.push({ path: `${path}.answer`, message: "answer must be a boolean for trueFalse" });
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
fillInBlanks: (assessment, path, issues) => {
|
|
296
|
+
if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
|
|
297
|
+
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
findHotspot: () => {
|
|
301
|
+
},
|
|
302
|
+
findMultipleHotspots: () => {
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
306
|
+
const path = `assessments[${index}]`;
|
|
307
|
+
const check = validateId2(assessment.checkId, `${path}.checkId`);
|
|
308
|
+
if (!check.ok) {
|
|
309
|
+
issues.push(...check.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
310
|
+
} else if (checkIds.has(check.id)) {
|
|
311
|
+
issues.push({ path: `${path}.checkId`, message: "duplicate checkId" });
|
|
312
|
+
} else {
|
|
313
|
+
checkIds.add(check.id);
|
|
314
|
+
}
|
|
315
|
+
if (!assessment.question?.trim()) {
|
|
316
|
+
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
317
|
+
}
|
|
318
|
+
const kind = assessment.kind ?? "mcq";
|
|
319
|
+
ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
|
|
320
|
+
const passingScore = assessment.passingScore;
|
|
321
|
+
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
322
|
+
issues.push({
|
|
323
|
+
path: `${path}.passingScore`,
|
|
324
|
+
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
325
|
+
});
|
|
253
326
|
}
|
|
254
|
-
return result;
|
|
255
327
|
}
|
|
256
|
-
|
|
328
|
+
|
|
329
|
+
// src/descriptor/validateCourse.ts
|
|
330
|
+
var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
331
|
+
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
332
|
+
function validateCourseDescriptor(input) {
|
|
257
333
|
const issues = [];
|
|
258
|
-
const course =
|
|
334
|
+
const course = validateId3(input.courseId, "courseId");
|
|
259
335
|
if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
260
336
|
if (!input.title?.trim()) {
|
|
261
337
|
issues.push({ path: "title", message: "title is required" });
|
|
@@ -308,7 +384,7 @@ function validateDescriptorParsed(input) {
|
|
|
308
384
|
const spaPaths = /* @__PURE__ */ new Set();
|
|
309
385
|
for (const [index, lesson] of (input.lessons ?? []).entries()) {
|
|
310
386
|
const path = `lessons[${index}]`;
|
|
311
|
-
const lessonResult =
|
|
387
|
+
const lessonResult = validateId3(lesson.id, `${path}.id`);
|
|
312
388
|
if (!lessonResult.ok) {
|
|
313
389
|
issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
314
390
|
} else if (lessonIds.has(lessonResult.id)) {
|
|
@@ -340,7 +416,7 @@ function validateDescriptorParsed(input) {
|
|
|
340
416
|
}
|
|
341
417
|
if (layout === "single-spa" && input.spaLessonId?.trim()) {
|
|
342
418
|
const spaId = input.spaLessonId.trim();
|
|
343
|
-
const spaResult =
|
|
419
|
+
const spaResult = validateId3(spaId, "spaLessonId");
|
|
344
420
|
if (!spaResult.ok) {
|
|
345
421
|
issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
346
422
|
} else if (!lessonIds.has(spaResult.id)) {
|
|
@@ -352,52 +428,48 @@ function validateDescriptorParsed(input) {
|
|
|
352
428
|
}
|
|
353
429
|
const checkIds = /* @__PURE__ */ new Set();
|
|
354
430
|
for (const [index, assessment] of (input.assessments ?? []).entries()) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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) {
|
|
379
|
-
issues.push({
|
|
380
|
-
path: `${path}.choices`,
|
|
381
|
-
message: "at least one non-empty choice is required"
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
if (!assessment.answer.trim()) {
|
|
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" });
|
|
431
|
+
validateAssessmentEntry(assessment, index, issues, checkIds);
|
|
432
|
+
}
|
|
433
|
+
return issues;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/descriptor/validateForTarget.ts
|
|
437
|
+
function validateDescriptorForExportTarget(descriptor, target) {
|
|
438
|
+
if (target !== "xapi" && target !== "cmi5") return [];
|
|
439
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
440
|
+
if (!activityIri) {
|
|
441
|
+
return [
|
|
442
|
+
{
|
|
443
|
+
path: "course.tracking.xapi.activityIri",
|
|
444
|
+
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
388
445
|
}
|
|
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
|
-
}
|
|
446
|
+
];
|
|
397
447
|
}
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/validateDescriptor.ts
|
|
452
|
+
function validateDescriptorParsed(input) {
|
|
453
|
+
const issues = validateCourseDescriptor(input);
|
|
398
454
|
if (issues.length) return { ok: false, issues };
|
|
399
455
|
return { ok: true, descriptor: normalizeDescriptor(input) };
|
|
400
456
|
}
|
|
457
|
+
function validateDescriptor(input) {
|
|
458
|
+
const parsed = parseCourseDescriptorInput(input);
|
|
459
|
+
if (parsed === null) {
|
|
460
|
+
return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
|
|
461
|
+
}
|
|
462
|
+
return validateDescriptorParsed(parsed);
|
|
463
|
+
}
|
|
464
|
+
function validateDescriptorForTarget(input, target) {
|
|
465
|
+
const result = validateDescriptor(input);
|
|
466
|
+
if (!result.ok || !target) return result;
|
|
467
|
+
const targetIssues = validateDescriptorForExportTarget(result.descriptor, target);
|
|
468
|
+
if (targetIssues.length) {
|
|
469
|
+
return { ok: false, issues: targetIssues };
|
|
470
|
+
}
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
401
473
|
|
|
402
474
|
// src/validateProjectPaths.ts
|
|
403
475
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
@@ -506,6 +578,19 @@ function assessmentDescriptorToLxpack(assessment) {
|
|
|
506
578
|
if (kind === "fillInBlanks") {
|
|
507
579
|
return null;
|
|
508
580
|
}
|
|
581
|
+
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
582
|
+
return mcqToLxpack({
|
|
583
|
+
kind: "mcq",
|
|
584
|
+
checkId: assessment.checkId,
|
|
585
|
+
question: assessment.question,
|
|
586
|
+
choices: [assessment.correctTargetId, "other"],
|
|
587
|
+
answer: assessment.correctTargetId,
|
|
588
|
+
passingScore: assessment.passingScore
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (kind === "findMultipleHotspots") {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
509
594
|
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
510
595
|
return mcqToLxpack(assessment);
|
|
511
596
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/lxpack",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "LXPack export adapter for LessonKit courses (SCORM, standalone, xAPI, cmi5).",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "git+https://github.com/eddiethedean/lessonkit.git",
|
|
10
10
|
"directory": "packages/lxpack"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://
|
|
12
|
+
"homepage": "https://lessonkit.readthedocs.io/en/latest/reference/packaging.html",
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/eddiethedean/lessonkit/issues"
|
|
15
15
|
},
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"lint": "echo \"(no lint configured yet)\""
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@lessonkit/core": "1.
|
|
59
|
-
"@lessonkit/themes": "1.
|
|
58
|
+
"@lessonkit/core": "1.2.0",
|
|
59
|
+
"@lessonkit/themes": "1.2.0",
|
|
60
60
|
"@lxpack/api": "^0.6.2",
|
|
61
61
|
"@lxpack/spa-bridge": "^0.6.2",
|
|
62
62
|
"@lxpack/tracking-schema": "^0.6.2",
|