@lessonkit/lxpack 1.5.0 → 1.7.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/README.md +15 -1
- package/block-tree.v1.json +40 -0
- package/dist/bridge.cjs +81 -22
- package/dist/bridge.d.cts +34 -4
- package/dist/bridge.d.ts +34 -4
- package/dist/bridge.js +78 -23
- package/dist/index.cjs +1479 -187
- package/dist/index.d.cts +212 -2
- package/dist/index.d.ts +212 -2
- package/dist/index.js +1481 -199
- package/lessonkit-manifest.v1.json +75 -5
- package/lkcourse-format.v1.json +21 -0
- package/package.json +17 -11
package/dist/index.cjs
CHANGED
|
@@ -31,22 +31,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
LESSONKIT_TELEMETRY_EVENTS: () => import_tracking_schema2.LESSONKIT_TELEMETRY_EVENTS,
|
|
34
|
+
assertSpaDistContentsSafe: () => assertSpaDistContentsSafe,
|
|
34
35
|
assessmentDescriptorToLxpack: () => assessmentDescriptorToLxpack,
|
|
35
36
|
buildLessonkitProject: () => buildLessonkitProject,
|
|
36
37
|
buildStagingPackage: () => buildStagingPackage,
|
|
37
38
|
descriptorToInterchange: () => descriptorToInterchange,
|
|
38
39
|
ensureOutDirParent: () => ensureOutDirParent,
|
|
39
40
|
escapeShellText: () => escapeShellText,
|
|
41
|
+
exportLkcourse: () => exportLkcourse,
|
|
40
42
|
extractAssessments: () => extractAssessments,
|
|
41
|
-
|
|
43
|
+
extractBlockTree: () => extractBlockTree,
|
|
44
|
+
importLkcourse: () => importLkcourse,
|
|
45
|
+
lessonkitInterchangeSchema: () => import_validators4.lessonkitInterchangeSchema,
|
|
42
46
|
loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
|
|
43
47
|
mapLessonkitIds: () => mapLessonkitIds,
|
|
44
48
|
mapLessonkitTelemetryToBridgeAction: () => import_tracking_schema2.mapLessonkitTelemetryToBridgeAction,
|
|
45
49
|
mapLessonkitTelemetryToLxpack: () => import_tracking_schema2.mapLessonkitTelemetryToLxpack,
|
|
46
|
-
materializeLessonkitProject: () =>
|
|
50
|
+
materializeLessonkitProject: () => import_validators4.materializeLessonkitProject,
|
|
47
51
|
packageLessonkitCourse: () => packageLessonkitCourse,
|
|
48
|
-
parseLessonkitInterchange: () =>
|
|
52
|
+
parseLessonkitInterchange: () => import_validators4.parseLessonkitInterchange,
|
|
49
53
|
parseLessonkitManifest: () => parseLessonkitManifest,
|
|
54
|
+
parseLkcourseEnvelope: () => parseLkcourseEnvelope,
|
|
50
55
|
promoteStagingToOutDir: () => promoteStagingToOutDir,
|
|
51
56
|
remapArtifactPaths: () => remapArtifactPaths,
|
|
52
57
|
resolveSafePackageOutputOverride: () => resolveSafePackageOutputOverride,
|
|
@@ -56,6 +61,9 @@ __export(index_exports, {
|
|
|
56
61
|
validateDescriptor: () => validateDescriptor,
|
|
57
62
|
validateDescriptorForTarget: () => validateDescriptorForTarget,
|
|
58
63
|
validateLessonkitProject: () => validateLessonkitProject,
|
|
64
|
+
validateLkcourse: () => validateLkcourse,
|
|
65
|
+
validateLkcourseArchiveEntries: () => validateLkcourseArchiveEntries,
|
|
66
|
+
validateManifestName: () => validateManifestName,
|
|
59
67
|
validatePackageInputs: () => validatePackageInputs,
|
|
60
68
|
validateProjectPaths: () => validateProjectPaths,
|
|
61
69
|
validateReactManifestParity: () => validateReactManifestParity,
|
|
@@ -123,13 +131,40 @@ function normalizeDescriptor(input) {
|
|
|
123
131
|
correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
|
|
124
132
|
};
|
|
125
133
|
}
|
|
134
|
+
if (assessment.kind === "sortParagraphs") {
|
|
135
|
+
return {
|
|
136
|
+
...assessment,
|
|
137
|
+
checkId: check.id,
|
|
138
|
+
question,
|
|
139
|
+
paragraphs: assessment.paragraphs.map((p) => p.trim()).filter((p) => p.length > 0),
|
|
140
|
+
correctOrder: [...assessment.correctOrder]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (assessment.kind === "guessTheAnswer") {
|
|
144
|
+
return {
|
|
145
|
+
...assessment,
|
|
146
|
+
checkId: check.id,
|
|
147
|
+
question,
|
|
148
|
+
answer: assessment.answer.trim()
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (assessment.kind === "multimediaChoice") {
|
|
152
|
+
return {
|
|
153
|
+
...assessment,
|
|
154
|
+
checkId: check.id,
|
|
155
|
+
question,
|
|
156
|
+
choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
157
|
+
answer: assessment.answer.trim()
|
|
158
|
+
};
|
|
159
|
+
}
|
|
126
160
|
const mcq = assessment;
|
|
127
161
|
return {
|
|
128
162
|
...mcq,
|
|
129
163
|
checkId: check.id,
|
|
130
164
|
question,
|
|
131
165
|
choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
132
|
-
answer: mcq.answer.trim()
|
|
166
|
+
answer: mcq.answer.trim(),
|
|
167
|
+
answers: mcq.answers?.map((a) => a.trim()).filter((a) => a.length > 0)
|
|
133
168
|
};
|
|
134
169
|
})
|
|
135
170
|
};
|
|
@@ -160,10 +195,18 @@ function parseAssessmentDescriptor(raw) {
|
|
|
160
195
|
};
|
|
161
196
|
const kind = raw.kind;
|
|
162
197
|
if (kind === "trueFalse") {
|
|
198
|
+
let answer;
|
|
199
|
+
if (typeof raw.answer === "boolean") {
|
|
200
|
+
answer = raw.answer;
|
|
201
|
+
} else if (raw.answer === "true") {
|
|
202
|
+
answer = true;
|
|
203
|
+
} else if (raw.answer === "false") {
|
|
204
|
+
answer = false;
|
|
205
|
+
}
|
|
163
206
|
return {
|
|
164
207
|
kind: "trueFalse",
|
|
165
208
|
...base,
|
|
166
|
-
answer
|
|
209
|
+
answer
|
|
167
210
|
};
|
|
168
211
|
}
|
|
169
212
|
if (kind === "fillInBlanks") {
|
|
@@ -195,7 +238,30 @@ function parseAssessmentDescriptor(raw) {
|
|
|
195
238
|
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
196
239
|
};
|
|
197
240
|
}
|
|
198
|
-
if (
|
|
241
|
+
if (kind === "sortParagraphs") {
|
|
242
|
+
return {
|
|
243
|
+
kind: "sortParagraphs",
|
|
244
|
+
...base,
|
|
245
|
+
paragraphs: Array.isArray(raw.paragraphs) ? raw.paragraphs.filter((p) => typeof p === "string") : [],
|
|
246
|
+
correctOrder: Array.isArray(raw.correctOrder) ? raw.correctOrder.filter((n) => typeof n === "number" && Number.isFinite(n)) : []
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (kind === "guessTheAnswer") {
|
|
250
|
+
return {
|
|
251
|
+
kind: "guessTheAnswer",
|
|
252
|
+
...base,
|
|
253
|
+
answer: typeof raw.answer === "string" ? raw.answer : ""
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (kind === "multimediaChoice") {
|
|
257
|
+
return {
|
|
258
|
+
kind: "multimediaChoice",
|
|
259
|
+
...base,
|
|
260
|
+
choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
|
|
261
|
+
answer: typeof raw.answer === "string" ? raw.answer : ""
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots" && kind !== "sortParagraphs" && kind !== "guessTheAnswer" && kind !== "multimediaChoice") {
|
|
199
265
|
return {
|
|
200
266
|
kind,
|
|
201
267
|
...base,
|
|
@@ -207,7 +273,15 @@ function parseAssessmentDescriptor(raw) {
|
|
|
207
273
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
208
274
|
...base,
|
|
209
275
|
choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
|
|
210
|
-
answer: typeof raw.answer === "string" ? raw.answer : ""
|
|
276
|
+
answer: typeof raw.answer === "string" ? raw.answer : "",
|
|
277
|
+
answers: Array.isArray(raw.answers) ? raw.answers.filter((a) => typeof a === "string") : void 0,
|
|
278
|
+
shuffleChoices: typeof raw.shuffleChoices === "boolean" ? raw.shuffleChoices : void 0,
|
|
279
|
+
shuffleSeed: typeof raw.shuffleSeed === "string" || typeof raw.shuffleSeed === "number" ? raw.shuffleSeed : void 0,
|
|
280
|
+
choiceFeedback: raw.choiceFeedback && typeof raw.choiceFeedback === "object" && !Array.isArray(raw.choiceFeedback) ? Object.fromEntries(
|
|
281
|
+
Object.entries(raw.choiceFeedback).filter(
|
|
282
|
+
(entry) => typeof entry[1] === "string"
|
|
283
|
+
)
|
|
284
|
+
) : void 0
|
|
211
285
|
};
|
|
212
286
|
}
|
|
213
287
|
function parseCourseDescriptorInput(input) {
|
|
@@ -251,7 +325,7 @@ function parseCourseDescriptorInput(input) {
|
|
|
251
325
|
}
|
|
252
326
|
|
|
253
327
|
// src/descriptor/validateCourse.ts
|
|
254
|
-
var
|
|
328
|
+
var import_core4 = require("@lessonkit/core");
|
|
255
329
|
var import_themes2 = require("@lessonkit/themes");
|
|
256
330
|
|
|
257
331
|
// src/spaPath.ts
|
|
@@ -282,43 +356,32 @@ function assertResolvedPathUnderRoot(root, target) {
|
|
|
282
356
|
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
283
357
|
}
|
|
284
358
|
}
|
|
285
|
-
function
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const next = (0, import_node_path.join)(current, segment);
|
|
294
|
-
if ((0, import_node_fs.existsSync)(next)) {
|
|
359
|
+
function resolvePhysicalPathForCheck(p) {
|
|
360
|
+
const resolved = resolveComparablePath(p);
|
|
361
|
+
try {
|
|
362
|
+
return import_node_fs.realpathSync.native(resolved);
|
|
363
|
+
} catch {
|
|
364
|
+
let probe = resolved;
|
|
365
|
+
let suffix = "";
|
|
366
|
+
while (true) {
|
|
295
367
|
try {
|
|
296
|
-
|
|
368
|
+
const physical = import_node_fs.realpathSync.native(probe);
|
|
369
|
+
return suffix ? (0, import_node_path.join)(physical, suffix) : physical;
|
|
297
370
|
} catch {
|
|
298
|
-
|
|
371
|
+
if (probe === (0, import_node_path.dirname)(probe)) {
|
|
372
|
+
return resolved;
|
|
373
|
+
}
|
|
374
|
+
const segment = (0, import_node_path.basename)(probe);
|
|
375
|
+
suffix = suffix ? (0, import_node_path.join)(segment, suffix) : segment;
|
|
376
|
+
probe = (0, import_node_path.dirname)(probe);
|
|
299
377
|
}
|
|
300
|
-
} else {
|
|
301
|
-
current = next;
|
|
302
378
|
}
|
|
303
|
-
assertResolvedPathUnderRoot(rootReal, current);
|
|
304
379
|
}
|
|
305
|
-
return current;
|
|
306
380
|
}
|
|
307
381
|
function assertRealPathUnderRoot(root, target) {
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
try {
|
|
312
|
-
rootReal = (0, import_node_fs.realpathSync)(rootResolved);
|
|
313
|
-
} catch {
|
|
314
|
-
rootReal = rootResolved;
|
|
315
|
-
}
|
|
316
|
-
try {
|
|
317
|
-
const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
318
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
319
|
-
} catch {
|
|
320
|
-
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
321
|
-
}
|
|
382
|
+
const rootPhysical = resolvePhysicalPathForCheck(root);
|
|
383
|
+
const targetPhysical = resolvePhysicalPathForCheck(target);
|
|
384
|
+
assertResolvedPathUnderRoot(rootPhysical, targetPhysical);
|
|
322
385
|
}
|
|
323
386
|
function normalizePathForComparison(p) {
|
|
324
387
|
const resolved = resolveComparablePath(p);
|
|
@@ -341,6 +404,113 @@ function isResolvedPathUnderRoot(root, target) {
|
|
|
341
404
|
return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
|
|
342
405
|
}
|
|
343
406
|
|
|
407
|
+
// src/validateProjectPaths.ts
|
|
408
|
+
var import_node_fs2 = require("fs");
|
|
409
|
+
var import_node_path2 = require("path");
|
|
410
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
411
|
+
function isReservedOutputPath(value) {
|
|
412
|
+
let normalized = value.replace(/\\/g, "/");
|
|
413
|
+
while (normalized.startsWith("/")) normalized = normalized.slice(1);
|
|
414
|
+
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
415
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
416
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
417
|
+
}
|
|
418
|
+
function validateManifestName(name) {
|
|
419
|
+
if (!name.length) {
|
|
420
|
+
return "must be a non-empty string";
|
|
421
|
+
}
|
|
422
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
423
|
+
return "must not contain path separators";
|
|
424
|
+
}
|
|
425
|
+
if (!isSafeRelativeSpaPath(name)) {
|
|
426
|
+
return "must be a safe relative name without '..' segments or absolute prefixes";
|
|
427
|
+
}
|
|
428
|
+
if (isReservedOutputPath(name) || isReservedOutputPath(`${name}.lkcourse`)) {
|
|
429
|
+
return "must not target reserved directories (.git, node_modules, .github)";
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
function isReservedResolvedOutputPath(projectRoot, resolved) {
|
|
434
|
+
const rootResolved = resolveComparablePath(projectRoot);
|
|
435
|
+
const targetResolved = resolveComparablePath(resolved);
|
|
436
|
+
try {
|
|
437
|
+
const rootReal = (0, import_node_fs2.existsSync)(rootResolved) ? (0, import_node_fs2.realpathSync)(rootResolved) : rootResolved;
|
|
438
|
+
const targetReal = (0, import_node_fs2.existsSync)(targetResolved) ? (0, import_node_fs2.realpathSync)(targetResolved) : targetResolved;
|
|
439
|
+
const rel = relativePathUnderRoot(rootReal, targetReal);
|
|
440
|
+
return isReservedOutputPath(rel);
|
|
441
|
+
} catch {
|
|
442
|
+
return isReservedOutputPath(resolved);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
446
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
447
|
+
issues.push({
|
|
448
|
+
path: fieldPath,
|
|
449
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
454
|
+
issues.push({
|
|
455
|
+
path: fieldPath,
|
|
456
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
|
|
462
|
+
} catch {
|
|
463
|
+
issues.push({
|
|
464
|
+
path: fieldPath,
|
|
465
|
+
message: "path must resolve inside the project root"
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function validateProjectPaths(projectRoot, paths) {
|
|
470
|
+
const issues = [];
|
|
471
|
+
const root = (0, import_node_path2.resolve)(projectRoot);
|
|
472
|
+
if (paths.spaDistDir?.trim()) {
|
|
473
|
+
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
|
|
474
|
+
rejectReserved: true
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (paths.lxpackOutDir?.trim()) {
|
|
478
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
479
|
+
rejectReserved: true
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (paths.outputBaseDir?.trim()) {
|
|
483
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
484
|
+
rejectReserved: true
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return issues;
|
|
488
|
+
}
|
|
489
|
+
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
490
|
+
const root = (0, import_node_path2.resolve)(projectRoot);
|
|
491
|
+
const trimmed = override.trim();
|
|
492
|
+
if (!trimmed) {
|
|
493
|
+
throw new Error("output override must be a non-empty path");
|
|
494
|
+
}
|
|
495
|
+
if ((0, import_node_path2.isAbsolute)(trimmed)) {
|
|
496
|
+
const resolved2 = (0, import_node_path2.resolve)(trimmed);
|
|
497
|
+
assertRealPathUnderRoot(root, resolved2);
|
|
498
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
|
|
499
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
500
|
+
}
|
|
501
|
+
return resolved2;
|
|
502
|
+
}
|
|
503
|
+
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
504
|
+
throw new Error(`unsafe output path: ${override}`);
|
|
505
|
+
}
|
|
506
|
+
const resolved = (0, import_node_path2.resolve)(root, trimmed);
|
|
507
|
+
assertRealPathUnderRoot(root, resolved);
|
|
508
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
|
|
509
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
510
|
+
}
|
|
511
|
+
return resolved;
|
|
512
|
+
}
|
|
513
|
+
|
|
344
514
|
// src/theme.ts
|
|
345
515
|
var import_themes = require("@lessonkit/themes");
|
|
346
516
|
function themeToLxpackRuntime(input) {
|
|
@@ -358,6 +528,7 @@ function themeToLxpackRuntime(input) {
|
|
|
358
528
|
|
|
359
529
|
// src/descriptor/validateAssessments.ts
|
|
360
530
|
var import_core2 = require("@lessonkit/core");
|
|
531
|
+
var import_core3 = require("@lessonkit/core");
|
|
361
532
|
var validateMcqLike = (assessment, path, issues) => {
|
|
362
533
|
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
363
534
|
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
@@ -373,9 +544,44 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
373
544
|
}
|
|
374
545
|
if (!assessment.answer.trim()) {
|
|
375
546
|
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
376
|
-
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
547
|
+
} else if (!("answers" in assessment && (0, import_core3.isMultiSelectMcq)({ answers: assessment.answers })) && trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
377
548
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
378
549
|
}
|
|
550
|
+
if ("answers" in assessment && assessment.answers !== void 0) {
|
|
551
|
+
if (!Array.isArray(assessment.answers)) {
|
|
552
|
+
issues.push({ path: `${path}.answers`, message: "answers must be an array when provided" });
|
|
553
|
+
} else {
|
|
554
|
+
const trimmedAnswers = assessment.answers.map((a) => a.trim()).filter((a) => a.length > 0);
|
|
555
|
+
if (assessment.answers.length > 0 && trimmedAnswers.length === 0) {
|
|
556
|
+
issues.push({ path: `${path}.answers`, message: "answers must include non-empty strings" });
|
|
557
|
+
}
|
|
558
|
+
const uniqueAnswers = new Set(trimmedAnswers);
|
|
559
|
+
if (trimmedAnswers.length !== uniqueAnswers.size) {
|
|
560
|
+
issues.push({ path: `${path}.answers`, message: "answers must be unique" });
|
|
561
|
+
}
|
|
562
|
+
for (const ans of trimmedAnswers) {
|
|
563
|
+
if (trimmedChoices.length && !trimmedChoices.includes(ans)) {
|
|
564
|
+
issues.push({ path: `${path}.answers`, message: "each answer must match a choice" });
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if ("choiceFeedback" in assessment && assessment.choiceFeedback !== void 0) {
|
|
571
|
+
if (typeof assessment.choiceFeedback !== "object" || assessment.choiceFeedback === null) {
|
|
572
|
+
issues.push({ path: `${path}.choiceFeedback`, message: "choiceFeedback must be an object" });
|
|
573
|
+
} else {
|
|
574
|
+
for (const key of Object.keys(assessment.choiceFeedback)) {
|
|
575
|
+
if (!trimmedChoices.includes(key.trim())) {
|
|
576
|
+
issues.push({
|
|
577
|
+
path: `${path}.choiceFeedback`,
|
|
578
|
+
message: "choiceFeedback keys must match choice labels"
|
|
579
|
+
});
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
379
585
|
const uniqueChoices = new Set(trimmedChoices);
|
|
380
586
|
if (trimmedChoices.length !== uniqueChoices.size) {
|
|
381
587
|
issues.push({ path: `${path}.choices`, message: "choices must be unique" });
|
|
@@ -395,6 +601,12 @@ function maxAchievableAssessmentScore(assessment) {
|
|
|
395
601
|
if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
|
|
396
602
|
return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
|
|
397
603
|
}
|
|
604
|
+
if (kind === "sortParagraphs" && assessment.kind === "sortParagraphs") {
|
|
605
|
+
return assessment.paragraphs?.length ?? assessment.correctOrder?.length ?? 0;
|
|
606
|
+
}
|
|
607
|
+
if ("answers" in assessment && Array.isArray(assessment.answers) && assessment.answers.length > 1) {
|
|
608
|
+
return assessment.answers.filter((a) => a.trim().length > 0).length;
|
|
609
|
+
}
|
|
398
610
|
return 1;
|
|
399
611
|
}
|
|
400
612
|
var ASSESSMENT_VALIDATORS = {
|
|
@@ -417,8 +629,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
417
629
|
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
418
630
|
});
|
|
419
631
|
}
|
|
420
|
-
const explicitBlanks =
|
|
421
|
-
if (assessment.blanks !== void 0
|
|
632
|
+
const explicitBlanks = [];
|
|
633
|
+
if (assessment.blanks !== void 0) {
|
|
634
|
+
for (let i = 0; i < assessment.blanks.length; i++) {
|
|
635
|
+
const blank = assessment.blanks[i];
|
|
636
|
+
if (!blank || typeof blank !== "object") {
|
|
637
|
+
issues.push({
|
|
638
|
+
path: `${path}.blanks[${i}]`,
|
|
639
|
+
message: "blank entry must be an object with non-empty id and answer"
|
|
640
|
+
});
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const id = blank.id?.trim() ?? "";
|
|
644
|
+
const answer = blank.answer?.trim() ?? "";
|
|
645
|
+
if (!id || !answer) {
|
|
646
|
+
issues.push({
|
|
647
|
+
path: `${path}.blanks[${i}]`,
|
|
648
|
+
message: "blank entry must include non-empty id and answer"
|
|
649
|
+
});
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
explicitBlanks.push({ id, answer });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
|
|
422
656
|
issues.push({
|
|
423
657
|
path: `${path}.blanks`,
|
|
424
658
|
message: "blanks must include at least one entry with non-empty id and answer"
|
|
@@ -458,7 +692,31 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
458
692
|
message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
|
|
459
693
|
});
|
|
460
694
|
}
|
|
461
|
-
}
|
|
695
|
+
},
|
|
696
|
+
sortParagraphs: (assessment, path, issues) => {
|
|
697
|
+
if (assessment.kind !== "sortParagraphs") return;
|
|
698
|
+
if (!Array.isArray(assessment.paragraphs) || assessment.paragraphs.length === 0) {
|
|
699
|
+
issues.push({ path: `${path}.paragraphs`, message: "paragraphs is required for sortParagraphs" });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (!Array.isArray(assessment.correctOrder) || assessment.correctOrder.length === 0) {
|
|
703
|
+
issues.push({ path: `${path}.correctOrder`, message: "correctOrder is required for sortParagraphs" });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (assessment.correctOrder.length !== assessment.paragraphs.length) {
|
|
707
|
+
issues.push({
|
|
708
|
+
path: `${path}.correctOrder`,
|
|
709
|
+
message: "correctOrder length must match paragraphs length for sortParagraphs"
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
guessTheAnswer: (assessment, path, issues) => {
|
|
714
|
+
if (assessment.kind !== "guessTheAnswer") return;
|
|
715
|
+
if (!assessment.answer?.trim()) {
|
|
716
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for guessTheAnswer" });
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
multimediaChoice: validateMcqLike
|
|
462
720
|
};
|
|
463
721
|
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
464
722
|
const path = `assessments[${index}]`;
|
|
@@ -513,7 +771,7 @@ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
|
|
|
513
771
|
var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
|
|
514
772
|
function validateCourseDescriptor(input) {
|
|
515
773
|
const issues = [];
|
|
516
|
-
const course = (0,
|
|
774
|
+
const course = (0, import_core4.validateId)(input.courseId, "courseId");
|
|
517
775
|
if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
518
776
|
if (!input.title?.trim()) {
|
|
519
777
|
issues.push({ path: "title", message: "title is required" });
|
|
@@ -566,6 +824,20 @@ function validateCourseDescriptor(input) {
|
|
|
566
824
|
});
|
|
567
825
|
}
|
|
568
826
|
}
|
|
827
|
+
const descriptorSpaDistDir = input.spaDistDir?.trim();
|
|
828
|
+
if (descriptorSpaDistDir) {
|
|
829
|
+
if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
|
|
830
|
+
issues.push({
|
|
831
|
+
path: "spaDistDir",
|
|
832
|
+
message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
|
|
833
|
+
});
|
|
834
|
+
} else if (isReservedOutputPath(descriptorSpaDistDir)) {
|
|
835
|
+
issues.push({
|
|
836
|
+
path: "spaDistDir",
|
|
837
|
+
message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
569
841
|
if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
|
|
570
842
|
issues.push({
|
|
571
843
|
path: "lessons",
|
|
@@ -576,7 +848,7 @@ function validateCourseDescriptor(input) {
|
|
|
576
848
|
const spaPaths = /* @__PURE__ */ new Set();
|
|
577
849
|
for (const [index, lesson] of (input.lessons ?? []).entries()) {
|
|
578
850
|
const path = `lessons[${index}]`;
|
|
579
|
-
const lessonResult = (0,
|
|
851
|
+
const lessonResult = (0, import_core4.validateId)(lesson.id, `${path}.id`);
|
|
580
852
|
if (!lessonResult.ok) {
|
|
581
853
|
issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
582
854
|
} else if (lessonIds.has(lessonResult.id)) {
|
|
@@ -608,7 +880,7 @@ function validateCourseDescriptor(input) {
|
|
|
608
880
|
}
|
|
609
881
|
if (layout === "single-spa" && input.spaLessonId?.trim()) {
|
|
610
882
|
const spaId = input.spaLessonId.trim();
|
|
611
|
-
const spaResult = (0,
|
|
883
|
+
const spaResult = (0, import_core4.validateId)(spaId, "spaLessonId");
|
|
612
884
|
if (!spaResult.ok) {
|
|
613
885
|
issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
|
|
614
886
|
} else if (!lessonIds.has(spaResult.id)) {
|
|
@@ -626,6 +898,8 @@ function validateCourseDescriptor(input) {
|
|
|
626
898
|
}
|
|
627
899
|
|
|
628
900
|
// src/assessments.ts
|
|
901
|
+
var import_core5 = require("@lessonkit/core");
|
|
902
|
+
var DEFAULT_SHELL_PASSING_SCORE = 1;
|
|
629
903
|
function escapeShellText(text) {
|
|
630
904
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
631
905
|
}
|
|
@@ -634,7 +908,7 @@ function decodeShellEntities(text) {
|
|
|
634
908
|
}
|
|
635
909
|
function containsUnsafeShellMarkup(text) {
|
|
636
910
|
const decoded = decodeShellEntities(text);
|
|
637
|
-
return /<\/script/i.test(decoded) || /<!--/.test(decoded) ||
|
|
911
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
|
|
638
912
|
}
|
|
639
913
|
function sanitizeShellField(text) {
|
|
640
914
|
if (containsUnsafeShellMarkup(text)) return null;
|
|
@@ -649,6 +923,8 @@ function mcqToLxpack(assessment) {
|
|
|
649
923
|
const checkId = sanitizeShellField(assessment.checkId);
|
|
650
924
|
const prompt = sanitizeShellField(assessment.question);
|
|
651
925
|
if (!checkId || !prompt) return null;
|
|
926
|
+
const normalizedAnswer = assessment.answer.trim();
|
|
927
|
+
const multiCorrect = assessment.answers && assessment.answers.length > 1 ? new Set(assessment.answers.map((a) => a.trim())) : /* @__PURE__ */ new Set([normalizedAnswer]);
|
|
652
928
|
const choices = assessment.choices.map((text, index) => {
|
|
653
929
|
const sanitizedText = sanitizeShellField(text);
|
|
654
930
|
if (!sanitizedText) return null;
|
|
@@ -656,17 +932,21 @@ function mcqToLxpack(assessment) {
|
|
|
656
932
|
return {
|
|
657
933
|
id,
|
|
658
934
|
text: sanitizedText,
|
|
659
|
-
correct: text
|
|
935
|
+
correct: multiCorrect.has(text.trim())
|
|
660
936
|
};
|
|
661
937
|
});
|
|
662
938
|
if (choices.some((choice) => choice === null)) return null;
|
|
939
|
+
const multiSelect = (0, import_core5.isMultiSelectMcq)(assessment);
|
|
663
940
|
return {
|
|
664
941
|
id: checkId,
|
|
665
|
-
passingScore: assessment.passingScore ??
|
|
942
|
+
passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
|
|
943
|
+
shuffleChoices: assessment.shuffleChoices === true ? true : void 0,
|
|
944
|
+
showFeedback: assessment.choiceFeedback && Object.keys(assessment.choiceFeedback).length > 0 ? "immediate" : void 0,
|
|
666
945
|
questions: [
|
|
667
946
|
{
|
|
668
947
|
id: "q1",
|
|
669
948
|
prompt,
|
|
949
|
+
...multiSelect ? { selectionMode: "multiple" } : {},
|
|
670
950
|
choices
|
|
671
951
|
}
|
|
672
952
|
]
|
|
@@ -695,7 +975,20 @@ function assessmentDescriptorToLxpack(assessment) {
|
|
|
695
975
|
if (kind === "findMultipleHotspots") {
|
|
696
976
|
return null;
|
|
697
977
|
}
|
|
698
|
-
if (
|
|
978
|
+
if (kind === "sortParagraphs" || kind === "guessTheAnswer") {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
if (kind === "multimediaChoice" && assessment.kind === "multimediaChoice") {
|
|
982
|
+
return mcqToLxpack({
|
|
983
|
+
kind: "mcq",
|
|
984
|
+
checkId: assessment.checkId,
|
|
985
|
+
question: assessment.question,
|
|
986
|
+
choices: assessment.choices,
|
|
987
|
+
answer: assessment.answer,
|
|
988
|
+
passingScore: assessment.passingScore
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
if ((kind === "mcq" || assessment.kind === void 0) && "choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
699
992
|
return mcqToLxpack(assessment);
|
|
700
993
|
}
|
|
701
994
|
return null;
|
|
@@ -707,11 +1000,20 @@ function extractAssessments(descriptor) {
|
|
|
707
1000
|
// src/descriptor/validateInjectableAssessments.ts
|
|
708
1001
|
function validateInjectableAssessments(descriptor) {
|
|
709
1002
|
const issues = [];
|
|
1003
|
+
const spaOnlyKinds = /* @__PURE__ */ new Set([
|
|
1004
|
+
"fillInBlanks",
|
|
1005
|
+
"findHotspot",
|
|
1006
|
+
"findMultipleHotspots",
|
|
1007
|
+
"sortParagraphs",
|
|
1008
|
+
"guessTheAnswer"
|
|
1009
|
+
]);
|
|
710
1010
|
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
711
1011
|
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
1012
|
+
const kind = assessment.kind ?? "mcq";
|
|
1013
|
+
const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
|
|
712
1014
|
issues.push({
|
|
713
1015
|
path: `assessments[${index}]`,
|
|
714
|
-
message: `assessment kind "${
|
|
1016
|
+
message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
|
|
715
1017
|
});
|
|
716
1018
|
}
|
|
717
1019
|
});
|
|
@@ -727,7 +1029,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
727
1029
|
"cmi5"
|
|
728
1030
|
]);
|
|
729
1031
|
function appendActivityIriIssues(issues, descriptor, target) {
|
|
730
|
-
const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
|
|
1032
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
|
|
731
1033
|
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
732
1034
|
if (!hasXapiTracking && !requiresForTarget) return;
|
|
733
1035
|
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
@@ -782,26 +1084,26 @@ function validateDescriptorForTarget(input, target) {
|
|
|
782
1084
|
}
|
|
783
1085
|
|
|
784
1086
|
// src/validateReactParity.ts
|
|
785
|
-
var
|
|
786
|
-
var
|
|
1087
|
+
var import_node_fs3 = require("fs");
|
|
1088
|
+
var import_node_path3 = require("path");
|
|
787
1089
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
788
1090
|
function collectSourceUnderSrc(projectRoot, issues) {
|
|
789
|
-
const srcDir = (0,
|
|
790
|
-
if (!(0,
|
|
1091
|
+
const srcDir = (0, import_node_path3.join)(projectRoot, "src");
|
|
1092
|
+
if (!(0, import_node_fs3.existsSync)(srcDir)) return [];
|
|
791
1093
|
const results = [];
|
|
792
1094
|
const walk = (dir) => {
|
|
793
|
-
for (const entry of (0,
|
|
794
|
-
const abs = (0,
|
|
1095
|
+
for (const entry of (0, import_node_fs3.readdirSync)(dir)) {
|
|
1096
|
+
const abs = (0, import_node_path3.join)(dir, entry);
|
|
795
1097
|
let stat2;
|
|
796
1098
|
try {
|
|
797
|
-
stat2 = (0,
|
|
1099
|
+
stat2 = (0, import_node_fs3.lstatSync)(abs);
|
|
798
1100
|
} catch {
|
|
799
1101
|
continue;
|
|
800
1102
|
}
|
|
801
1103
|
if (stat2.isSymbolicLink()) {
|
|
802
1104
|
issues.push({
|
|
803
|
-
path: (0,
|
|
804
|
-
message: `Source tree contains symlink (rejected for parity scan): ${(0,
|
|
1105
|
+
path: (0, import_node_path3.relative)(projectRoot, abs),
|
|
1106
|
+
message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path3.relative)(projectRoot, abs)}`,
|
|
805
1107
|
severity: "error"
|
|
806
1108
|
});
|
|
807
1109
|
continue;
|
|
@@ -811,8 +1113,8 @@ function collectSourceUnderSrc(projectRoot, issues) {
|
|
|
811
1113
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
812
1114
|
} catch {
|
|
813
1115
|
issues.push({
|
|
814
|
-
path: (0,
|
|
815
|
-
message: `Source directory escapes project root: ${(0,
|
|
1116
|
+
path: (0, import_node_path3.relative)(projectRoot, abs),
|
|
1117
|
+
message: `Source directory escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
|
|
816
1118
|
severity: "error"
|
|
817
1119
|
});
|
|
818
1120
|
continue;
|
|
@@ -823,13 +1125,13 @@ function collectSourceUnderSrc(projectRoot, issues) {
|
|
|
823
1125
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
824
1126
|
} catch {
|
|
825
1127
|
issues.push({
|
|
826
|
-
path: (0,
|
|
827
|
-
message: `Source file escapes project root: ${(0,
|
|
1128
|
+
path: (0, import_node_path3.relative)(projectRoot, abs),
|
|
1129
|
+
message: `Source file escapes project root: ${(0, import_node_path3.relative)(projectRoot, abs)}`,
|
|
828
1130
|
severity: "error"
|
|
829
1131
|
});
|
|
830
1132
|
continue;
|
|
831
1133
|
}
|
|
832
|
-
results.push((0,
|
|
1134
|
+
results.push((0, import_node_path3.relative)(projectRoot, abs));
|
|
833
1135
|
}
|
|
834
1136
|
}
|
|
835
1137
|
};
|
|
@@ -848,10 +1150,10 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
|
|
|
848
1150
|
}
|
|
849
1151
|
return null;
|
|
850
1152
|
}
|
|
851
|
-
const abs = (0,
|
|
1153
|
+
const abs = (0, import_node_path3.join)(projectRoot, rel);
|
|
852
1154
|
try {
|
|
853
1155
|
assertRealPathUnderRoot(projectRoot, abs);
|
|
854
|
-
if ((0,
|
|
1156
|
+
if ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.lstatSync)(abs).isSymbolicLink()) {
|
|
855
1157
|
issues.push({
|
|
856
1158
|
path: rel,
|
|
857
1159
|
message: `appSources path is a symlink: ${rel}`,
|
|
@@ -867,8 +1169,8 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
|
|
|
867
1169
|
});
|
|
868
1170
|
return null;
|
|
869
1171
|
}
|
|
870
|
-
if (!(0,
|
|
871
|
-
return (0,
|
|
1172
|
+
if (!(0, import_node_fs3.existsSync)(abs)) return null;
|
|
1173
|
+
return (0, import_node_fs3.readFileSync)(abs, "utf8");
|
|
872
1174
|
}).filter((content) => content != null).join("\n");
|
|
873
1175
|
}
|
|
874
1176
|
function stripComments(source) {
|
|
@@ -943,9 +1245,20 @@ function courseConfigCourseIdPresent(source, courseId) {
|
|
|
943
1245
|
if (literalPattern.test(stripped)) return true;
|
|
944
1246
|
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
945
1247
|
}
|
|
1248
|
+
function courseMetaCourseIdPresent(source, courseId) {
|
|
1249
|
+
const constants = extractStringConstants(source);
|
|
1250
|
+
const stripped = stripComments(source);
|
|
1251
|
+
for (const [name, value] of constants) {
|
|
1252
|
+
if (value !== courseId) continue;
|
|
1253
|
+
if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
|
|
1254
|
+
if (/\blessons\s*:\s*\S/.test(stripped)) return true;
|
|
1255
|
+
}
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
946
1258
|
function courseIdPresent(source, courseId) {
|
|
947
1259
|
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
948
1260
|
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
1261
|
+
if (courseMetaCourseIdPresent(source, courseId)) return true;
|
|
949
1262
|
return courseConfigCourseIdPresent(source, courseId);
|
|
950
1263
|
}
|
|
951
1264
|
function checkIdPresent(source, checkId) {
|
|
@@ -1014,88 +1327,13 @@ function validateReactManifestParity(opts) {
|
|
|
1014
1327
|
return issues;
|
|
1015
1328
|
}
|
|
1016
1329
|
|
|
1017
|
-
// src/validateProjectPaths.ts
|
|
1018
|
-
var import_node_path3 = require("path");
|
|
1019
|
-
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
1020
|
-
function isReservedOutputPath(value) {
|
|
1021
|
-
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
1022
|
-
const segments = normalized.split("/").filter(Boolean);
|
|
1023
|
-
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
1024
|
-
}
|
|
1025
|
-
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
1026
|
-
if (!isSafeRelativeSpaPath(value)) {
|
|
1027
|
-
issues.push({
|
|
1028
|
-
path: fieldPath,
|
|
1029
|
-
message: "path must be relative without '..' segments or absolute prefixes"
|
|
1030
|
-
});
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
1034
|
-
issues.push({
|
|
1035
|
-
path: fieldPath,
|
|
1036
|
-
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
1037
|
-
});
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
try {
|
|
1041
|
-
assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
|
|
1042
|
-
} catch {
|
|
1043
|
-
issues.push({
|
|
1044
|
-
path: fieldPath,
|
|
1045
|
-
message: "path must resolve inside the project root"
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
function validateProjectPaths(projectRoot, paths) {
|
|
1050
|
-
const issues = [];
|
|
1051
|
-
const root = (0, import_node_path3.resolve)(projectRoot);
|
|
1052
|
-
if (paths.spaDistDir?.trim()) {
|
|
1053
|
-
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
1054
|
-
}
|
|
1055
|
-
if (paths.lxpackOutDir?.trim()) {
|
|
1056
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
1057
|
-
rejectReserved: true
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
if (paths.outputBaseDir?.trim()) {
|
|
1061
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1062
|
-
rejectReserved: true
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
return issues;
|
|
1066
|
-
}
|
|
1067
|
-
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
1068
|
-
const root = (0, import_node_path3.resolve)(projectRoot);
|
|
1069
|
-
const trimmed = override.trim();
|
|
1070
|
-
if (!trimmed) {
|
|
1071
|
-
throw new Error("output override must be a non-empty path");
|
|
1072
|
-
}
|
|
1073
|
-
if ((0, import_node_path3.isAbsolute)(trimmed)) {
|
|
1074
|
-
const resolved2 = (0, import_node_path3.resolve)(trimmed);
|
|
1075
|
-
assertRealPathUnderRoot(root, resolved2);
|
|
1076
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1077
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1078
|
-
}
|
|
1079
|
-
return resolved2;
|
|
1080
|
-
}
|
|
1081
|
-
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
1082
|
-
throw new Error(`unsafe output path: ${override}`);
|
|
1083
|
-
}
|
|
1084
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1085
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1086
|
-
}
|
|
1087
|
-
const resolved = (0, import_node_path3.resolve)(root, trimmed);
|
|
1088
|
-
assertRealPathUnderRoot(root, resolved);
|
|
1089
|
-
return resolved;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1330
|
// src/mapIds.ts
|
|
1093
|
-
var
|
|
1331
|
+
var import_core6 = require("@lessonkit/core");
|
|
1094
1332
|
function mapLessonkitIds(descriptor) {
|
|
1095
|
-
const courseId = (0,
|
|
1096
|
-
const lessonIds = descriptor.lessons.map((l) => (0,
|
|
1333
|
+
const courseId = (0, import_core6.assertValidId)(descriptor.courseId, "courseId");
|
|
1334
|
+
const lessonIds = descriptor.lessons.map((l) => (0, import_core6.assertValidId)(l.id, "lessonId"));
|
|
1097
1335
|
const checkIds = (descriptor.assessments ?? []).map(
|
|
1098
|
-
(a) => (0,
|
|
1336
|
+
(a) => (0, import_core6.assertValidId)(a.checkId, "checkId")
|
|
1099
1337
|
);
|
|
1100
1338
|
return { courseId, lessonIds, checkIds };
|
|
1101
1339
|
}
|
|
@@ -1220,7 +1458,7 @@ async function resolveSpaDirs(options) {
|
|
|
1220
1458
|
|
|
1221
1459
|
// src/spaDistValidation.ts
|
|
1222
1460
|
var import_promises2 = require("fs/promises");
|
|
1223
|
-
var
|
|
1461
|
+
var import_node_fs4 = require("fs");
|
|
1224
1462
|
var import_node_path5 = require("path");
|
|
1225
1463
|
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
1226
1464
|
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
@@ -1231,7 +1469,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
|
1231
1469
|
}
|
|
1232
1470
|
let rootReal;
|
|
1233
1471
|
try {
|
|
1234
|
-
rootReal = (0,
|
|
1472
|
+
rootReal = (0, import_node_fs4.realpathSync)(dirResolved);
|
|
1235
1473
|
} catch {
|
|
1236
1474
|
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
1237
1475
|
}
|
|
@@ -1260,7 +1498,7 @@ async function walkDistDir(rootReal, current, label) {
|
|
|
1260
1498
|
}
|
|
1261
1499
|
let entryReal;
|
|
1262
1500
|
try {
|
|
1263
|
-
entryReal = (0,
|
|
1501
|
+
entryReal = (0, import_node_fs4.realpathSync)(entryPath);
|
|
1264
1502
|
} catch (err) {
|
|
1265
1503
|
throw new Error(
|
|
1266
1504
|
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
@@ -1285,8 +1523,10 @@ async function writeLxpackProject(options) {
|
|
|
1285
1523
|
const descriptor = validation.descriptor;
|
|
1286
1524
|
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1287
1525
|
if (injectableIssues.length > 0) {
|
|
1288
|
-
throw new Error(
|
|
1289
|
-
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1290
1530
|
const outDir = (0, import_node_path6.resolve)(options.outDir);
|
|
1291
1531
|
assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
|
|
1292
1532
|
const spaDirs = await resolveSpaDirs({ ...options, descriptor });
|
|
@@ -1312,8 +1552,8 @@ async function writeLxpackProject(options) {
|
|
|
1312
1552
|
}
|
|
1313
1553
|
|
|
1314
1554
|
// src/packageCourse.ts
|
|
1315
|
-
var
|
|
1316
|
-
var
|
|
1555
|
+
var import_node_path11 = require("path");
|
|
1556
|
+
var fsp4 = __toESM(require("fs/promises"), 1);
|
|
1317
1557
|
var import_api2 = require("@lxpack/api");
|
|
1318
1558
|
|
|
1319
1559
|
// src/packaging/validateInputs.ts
|
|
@@ -1348,6 +1588,19 @@ function validatePackageInputs(options) {
|
|
|
1348
1588
|
]
|
|
1349
1589
|
};
|
|
1350
1590
|
}
|
|
1591
|
+
if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
|
|
1592
|
+
return {
|
|
1593
|
+
ok: false,
|
|
1594
|
+
courseDir: outDir,
|
|
1595
|
+
target,
|
|
1596
|
+
issues: [
|
|
1597
|
+
{
|
|
1598
|
+
path: "outDir",
|
|
1599
|
+
message: "outDir must not target reserved directories (.git, node_modules, .github)"
|
|
1600
|
+
}
|
|
1601
|
+
]
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1351
1604
|
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
1352
1605
|
return {
|
|
1353
1606
|
ok: false,
|
|
@@ -1405,6 +1658,19 @@ function validatePackageInputs(options) {
|
|
|
1405
1658
|
]
|
|
1406
1659
|
};
|
|
1407
1660
|
}
|
|
1661
|
+
if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
|
|
1662
|
+
return {
|
|
1663
|
+
ok: false,
|
|
1664
|
+
courseDir: outDir,
|
|
1665
|
+
target,
|
|
1666
|
+
issues: [
|
|
1667
|
+
{
|
|
1668
|
+
path: "outputBaseDir",
|
|
1669
|
+
message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
|
|
1670
|
+
}
|
|
1671
|
+
]
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1408
1674
|
}
|
|
1409
1675
|
if (output) {
|
|
1410
1676
|
const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
|
|
@@ -1426,6 +1692,20 @@ function validatePackageInputs(options) {
|
|
|
1426
1692
|
]
|
|
1427
1693
|
};
|
|
1428
1694
|
}
|
|
1695
|
+
const outputRel = (0, import_node_path7.isAbsolute)(output) ? output : output;
|
|
1696
|
+
if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
|
|
1697
|
+
return {
|
|
1698
|
+
ok: false,
|
|
1699
|
+
courseDir: outDir,
|
|
1700
|
+
target,
|
|
1701
|
+
issues: [
|
|
1702
|
+
{
|
|
1703
|
+
path: "output",
|
|
1704
|
+
message: "output must not target reserved directories (.git, node_modules, .github)"
|
|
1705
|
+
}
|
|
1706
|
+
]
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1429
1709
|
}
|
|
1430
1710
|
return { ok: true, outDir, projectRoot };
|
|
1431
1711
|
}
|
|
@@ -1518,11 +1798,14 @@ async function isStalePromoteLock(lockPath) {
|
|
|
1518
1798
|
return true;
|
|
1519
1799
|
}
|
|
1520
1800
|
}
|
|
1801
|
+
var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
|
|
1521
1802
|
async function withPromoteLock(outDir, fn) {
|
|
1522
1803
|
const lockPath = promoteLockPath(outDir);
|
|
1523
1804
|
await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
|
|
1524
1805
|
let lockHandle;
|
|
1525
|
-
|
|
1806
|
+
const maxAttempts = 400;
|
|
1807
|
+
const started = Date.now();
|
|
1808
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1526
1809
|
try {
|
|
1527
1810
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1528
1811
|
await lockHandle.writeFile(`${process.pid}
|
|
@@ -1540,7 +1823,9 @@ ${Date.now()}
|
|
|
1540
1823
|
);
|
|
1541
1824
|
continue;
|
|
1542
1825
|
}
|
|
1543
|
-
|
|
1826
|
+
if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
|
|
1827
|
+
const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
|
|
1828
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
|
|
1544
1829
|
}
|
|
1545
1830
|
}
|
|
1546
1831
|
if (!lockHandle) {
|
|
@@ -1709,14 +1994,29 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
|
|
|
1709
1994
|
});
|
|
1710
1995
|
}
|
|
1711
1996
|
|
|
1712
|
-
// src/packaging/
|
|
1997
|
+
// src/packaging/relocateOutput.ts
|
|
1713
1998
|
var fsp2 = __toESM(require("fs/promises"), 1);
|
|
1714
1999
|
var import_node_path9 = require("path");
|
|
2000
|
+
async function relocatePackageOutput(builtOutputPath, requestedOutputPath, projectRoot) {
|
|
2001
|
+
if (!builtOutputPath || !requestedOutputPath) return builtOutputPath;
|
|
2002
|
+
const resolvedBuilt = resolveComparablePath(builtOutputPath);
|
|
2003
|
+
const resolvedRequested = resolveComparablePath(requestedOutputPath);
|
|
2004
|
+
if (resolvedBuilt === resolvedRequested) return builtOutputPath;
|
|
2005
|
+
const root = (0, import_node_path9.resolve)(projectRoot);
|
|
2006
|
+
assertRealPathUnderRoot(root, resolvedRequested);
|
|
2007
|
+
await fsp2.mkdir((0, import_node_path9.dirname)(resolvedRequested), { recursive: true });
|
|
2008
|
+
await renameOrCopy(resolvedBuilt, resolvedRequested);
|
|
2009
|
+
return resolvedRequested;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// src/packaging/staging.ts
|
|
2013
|
+
var fsp3 = __toESM(require("fs/promises"), 1);
|
|
2014
|
+
var import_node_path10 = require("path");
|
|
1715
2015
|
var import_node_os = require("os");
|
|
1716
2016
|
var import_api = require("@lxpack/api");
|
|
1717
2017
|
async function buildStagingPackage(options) {
|
|
1718
2018
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1719
|
-
const stagingDir = await
|
|
2019
|
+
const stagingDir = await fsp3.mkdtemp((0, import_node_path10.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
|
|
1720
2020
|
let succeeded = false;
|
|
1721
2021
|
try {
|
|
1722
2022
|
let spaDirs;
|
|
@@ -1748,14 +2048,28 @@ async function buildStagingPackage(options) {
|
|
|
1748
2048
|
}
|
|
1749
2049
|
const interchange = descriptorToInterchange(descriptor);
|
|
1750
2050
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
1751
|
-
await
|
|
1752
|
-
const defaultOutput =
|
|
2051
|
+
await fsp3.mkdir((0, import_node_path10.join)(stagingDir, outputBase), { recursive: true });
|
|
2052
|
+
const defaultOutput = dir ? (0, import_node_path10.join)(outputBase, target) : (0, import_node_path10.join)(outputBase, `course-${target}.zip`);
|
|
2053
|
+
let packageOutput = output ?? defaultOutput;
|
|
2054
|
+
let requestedOutputPath;
|
|
2055
|
+
let requestedOutputDir;
|
|
2056
|
+
if (output) {
|
|
2057
|
+
const projectRoot = (0, import_node_path10.resolve)(writeOpts.projectRoot);
|
|
2058
|
+
const requested = (0, import_node_path10.isAbsolute)(output) ? (0, import_node_path10.resolve)(output) : (0, import_node_path10.resolve)(projectRoot, output);
|
|
2059
|
+
if (dir) {
|
|
2060
|
+
requestedOutputDir = requested;
|
|
2061
|
+
packageOutput = defaultOutput;
|
|
2062
|
+
} else {
|
|
2063
|
+
requestedOutputPath = requested;
|
|
2064
|
+
packageOutput = defaultOutput;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
1753
2067
|
const build = await (0, import_api.packageLessonkit)({
|
|
1754
2068
|
interchange,
|
|
1755
2069
|
spaDirs,
|
|
1756
2070
|
target,
|
|
1757
2071
|
courseDir: stagingDir,
|
|
1758
|
-
output:
|
|
2072
|
+
output: packageOutput,
|
|
1759
2073
|
dir,
|
|
1760
2074
|
outputBaseDir,
|
|
1761
2075
|
outputAnchorDir: stagingDir,
|
|
@@ -1779,17 +2093,19 @@ async function buildStagingPackage(options) {
|
|
|
1779
2093
|
stagingDir,
|
|
1780
2094
|
build,
|
|
1781
2095
|
outputPath: "outputPath" in build ? build.outputPath : void 0,
|
|
1782
|
-
outputDir: "outputDir" in build ? build.outputDir : void 0
|
|
2096
|
+
outputDir: "outputDir" in build ? build.outputDir : void 0,
|
|
2097
|
+
requestedOutputPath,
|
|
2098
|
+
requestedOutputDir
|
|
1783
2099
|
};
|
|
1784
2100
|
} catch (err) {
|
|
1785
|
-
await
|
|
2101
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1786
2102
|
/* v8 ignore next */
|
|
1787
2103
|
() => void 0
|
|
1788
2104
|
);
|
|
1789
2105
|
throw err;
|
|
1790
2106
|
} finally {
|
|
1791
2107
|
if (!succeeded) {
|
|
1792
|
-
await
|
|
2108
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1793
2109
|
/* v8 ignore next */
|
|
1794
2110
|
() => void 0
|
|
1795
2111
|
);
|
|
@@ -1797,7 +2113,7 @@ async function buildStagingPackage(options) {
|
|
|
1797
2113
|
}
|
|
1798
2114
|
}
|
|
1799
2115
|
async function ensureOutDirParent(outDir) {
|
|
1800
|
-
await
|
|
2116
|
+
await fsp3.mkdir((0, import_node_path10.dirname)(outDir), { recursive: true });
|
|
1801
2117
|
}
|
|
1802
2118
|
|
|
1803
2119
|
// src/packaging/issueSeverity.ts
|
|
@@ -1808,17 +2124,23 @@ function isPackagingErrorIssue(issue) {
|
|
|
1808
2124
|
function findPackagingErrorIssues(issues) {
|
|
1809
2125
|
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1810
2126
|
}
|
|
2127
|
+
function isPackagingWarningIssue(issue) {
|
|
2128
|
+
return issue.severity?.toLowerCase() === "warning";
|
|
2129
|
+
}
|
|
2130
|
+
function findPackagingWarningIssues(issues) {
|
|
2131
|
+
return (issues ?? []).filter(isPackagingWarningIssue);
|
|
2132
|
+
}
|
|
1811
2133
|
|
|
1812
2134
|
// src/packageCourse.ts
|
|
1813
2135
|
async function validateLessonkitProject(options) {
|
|
1814
2136
|
return (0, import_api2.validateCourse)({
|
|
1815
|
-
courseDir: (0,
|
|
2137
|
+
courseDir: (0, import_node_path11.resolve)(options.courseDir),
|
|
1816
2138
|
target: options.target
|
|
1817
2139
|
});
|
|
1818
2140
|
}
|
|
1819
2141
|
async function buildLessonkitProject(options) {
|
|
1820
2142
|
const buildOptions = {
|
|
1821
|
-
courseDir: (0,
|
|
2143
|
+
courseDir: (0, import_node_path11.resolve)(options.courseDir),
|
|
1822
2144
|
target: options.target,
|
|
1823
2145
|
output: options.output,
|
|
1824
2146
|
dir: options.dir,
|
|
@@ -1849,7 +2171,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1849
2171
|
if (!descriptorValidation.ok) {
|
|
1850
2172
|
return {
|
|
1851
2173
|
ok: false,
|
|
1852
|
-
courseDir: (0,
|
|
2174
|
+
courseDir: (0, import_node_path11.resolve)(writeOpts.outDir),
|
|
1853
2175
|
target,
|
|
1854
2176
|
issues: descriptorValidation.issues.map((i) => ({
|
|
1855
2177
|
path: i.path,
|
|
@@ -1875,16 +2197,31 @@ async function packageLessonkitCourse(options) {
|
|
|
1875
2197
|
}))
|
|
1876
2198
|
};
|
|
1877
2199
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2200
|
+
let staged;
|
|
2201
|
+
try {
|
|
2202
|
+
staged = await buildStagingPackage({
|
|
2203
|
+
...writeOpts,
|
|
2204
|
+
descriptor,
|
|
2205
|
+
target,
|
|
2206
|
+
output,
|
|
2207
|
+
dir,
|
|
2208
|
+
outputBaseDir
|
|
2209
|
+
});
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
return {
|
|
2212
|
+
ok: false,
|
|
2213
|
+
courseDir: outDir,
|
|
2214
|
+
target,
|
|
2215
|
+
issues: [
|
|
2216
|
+
{
|
|
2217
|
+
path: "staging",
|
|
2218
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2219
|
+
}
|
|
2220
|
+
]
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
1886
2223
|
if (!staged.ok) {
|
|
1887
|
-
await
|
|
2224
|
+
await fsp4.rm(staged.stagingDir, { recursive: true, force: true }).catch(
|
|
1888
2225
|
/* v8 ignore next */
|
|
1889
2226
|
() => void 0
|
|
1890
2227
|
);
|
|
@@ -1901,7 +2238,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1901
2238
|
const { stagingDir, build } = staged;
|
|
1902
2239
|
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1903
2240
|
if (buildErrorIssues.length > 0) {
|
|
1904
|
-
await
|
|
2241
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1905
2242
|
/* v8 ignore next */
|
|
1906
2243
|
() => void 0
|
|
1907
2244
|
);
|
|
@@ -1918,13 +2255,13 @@ async function packageLessonkitCourse(options) {
|
|
|
1918
2255
|
}))
|
|
1919
2256
|
};
|
|
1920
2257
|
}
|
|
1921
|
-
const stagingRoot = await
|
|
2258
|
+
const stagingRoot = await fsp4.realpath(stagingDir);
|
|
1922
2259
|
const artifactIssues = [
|
|
1923
2260
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
1924
2261
|
validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
|
|
1925
2262
|
].filter((issue) => issue != null);
|
|
1926
2263
|
if (artifactIssues.length > 0) {
|
|
1927
|
-
await
|
|
2264
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1928
2265
|
/* v8 ignore next */
|
|
1929
2266
|
() => void 0
|
|
1930
2267
|
);
|
|
@@ -1937,8 +2274,27 @@ async function packageLessonkitCourse(options) {
|
|
|
1937
2274
|
issues: artifactIssues
|
|
1938
2275
|
};
|
|
1939
2276
|
}
|
|
1940
|
-
const
|
|
1941
|
-
|
|
2277
|
+
const buildWarningIssues = findPackagingWarningIssues(build.issues);
|
|
2278
|
+
if (options.strictBuild && buildWarningIssues.length > 0) {
|
|
2279
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
2280
|
+
/* v8 ignore next */
|
|
2281
|
+
() => void 0
|
|
2282
|
+
);
|
|
2283
|
+
return {
|
|
2284
|
+
ok: false,
|
|
2285
|
+
courseDir: outDir,
|
|
2286
|
+
target,
|
|
2287
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
2288
|
+
build,
|
|
2289
|
+
issues: buildWarningIssues.map((i) => ({
|
|
2290
|
+
path: i.path ?? "build",
|
|
2291
|
+
message: i.message ?? "build warning",
|
|
2292
|
+
severity: i.severity
|
|
2293
|
+
}))
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
let remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
|
|
2297
|
+
let remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
|
|
1942
2298
|
const validation = {
|
|
1943
2299
|
ok: true,
|
|
1944
2300
|
manifest: build.manifest,
|
|
@@ -1951,7 +2307,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1951
2307
|
projectRoot: writeOpts.projectRoot
|
|
1952
2308
|
});
|
|
1953
2309
|
} catch (err) {
|
|
1954
|
-
await
|
|
2310
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1955
2311
|
/* v8 ignore next */
|
|
1956
2312
|
() => void 0
|
|
1957
2313
|
);
|
|
@@ -1969,6 +2325,32 @@ async function packageLessonkitCourse(options) {
|
|
|
1969
2325
|
]
|
|
1970
2326
|
};
|
|
1971
2327
|
}
|
|
2328
|
+
try {
|
|
2329
|
+
remappedOutputPath = await relocatePackageOutput(
|
|
2330
|
+
remappedOutputPath,
|
|
2331
|
+
staged.requestedOutputPath,
|
|
2332
|
+
writeOpts.projectRoot
|
|
2333
|
+
);
|
|
2334
|
+
remappedOutputDir = await relocatePackageOutput(
|
|
2335
|
+
remappedOutputDir,
|
|
2336
|
+
staged.requestedOutputDir,
|
|
2337
|
+
writeOpts.projectRoot
|
|
2338
|
+
);
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
return {
|
|
2341
|
+
ok: false,
|
|
2342
|
+
courseDir: outDir,
|
|
2343
|
+
target,
|
|
2344
|
+
validation,
|
|
2345
|
+
build,
|
|
2346
|
+
issues: [
|
|
2347
|
+
{
|
|
2348
|
+
path: "output",
|
|
2349
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2350
|
+
}
|
|
2351
|
+
]
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
1972
2354
|
const remappedBuild = { ...build };
|
|
1973
2355
|
if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
|
|
1974
2356
|
remappedBuild.outputPath = remappedOutputPath;
|
|
@@ -2012,8 +2394,9 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
2012
2394
|
}
|
|
2013
2395
|
const nameRaw = config.name;
|
|
2014
2396
|
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
|
|
2015
|
-
|
|
2016
|
-
|
|
2397
|
+
const nameIssue = validateManifestName(name);
|
|
2398
|
+
if (nameIssue) {
|
|
2399
|
+
issues.push({ path: "name", message: nameIssue });
|
|
2017
2400
|
}
|
|
2018
2401
|
const courseRaw = config.course;
|
|
2019
2402
|
if (Array.isArray(courseRaw)) {
|
|
@@ -2079,7 +2462,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
2079
2462
|
path: `paths.${key}`,
|
|
2080
2463
|
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2081
2464
|
});
|
|
2082
|
-
} else if (
|
|
2465
|
+
} else if (isReservedOutputPath(value)) {
|
|
2083
2466
|
issues.push({
|
|
2084
2467
|
path: `paths.${key}`,
|
|
2085
2468
|
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
@@ -2173,17 +2556,922 @@ function telemetryEventToLessonkit(event) {
|
|
|
2173
2556
|
}
|
|
2174
2557
|
|
|
2175
2558
|
// src/index.ts
|
|
2559
|
+
var import_validators4 = require("@lxpack/validators");
|
|
2560
|
+
|
|
2561
|
+
// src/lkcourse/zip.ts
|
|
2562
|
+
var import_node_fs5 = require("fs");
|
|
2563
|
+
var import_node_path12 = require("path");
|
|
2564
|
+
var import_fflate = require("fflate");
|
|
2565
|
+
var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
2566
|
+
function canonicalZipEntryPath(entryPath) {
|
|
2567
|
+
const slashNormalized = entryPath.replace(/\\/g, "/");
|
|
2568
|
+
const canonical = (0, import_node_path12.normalize)(slashNormalized).replace(/\\/g, "/");
|
|
2569
|
+
if (canonical !== slashNormalized) return null;
|
|
2570
|
+
return canonical;
|
|
2571
|
+
}
|
|
2572
|
+
function isSafeZipEntryPath(entryPath) {
|
|
2573
|
+
const canonical = canonicalZipEntryPath(entryPath);
|
|
2574
|
+
if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
|
|
2575
|
+
return false;
|
|
2576
|
+
}
|
|
2577
|
+
const segments = canonical.split("/").filter((s) => s.length > 0);
|
|
2578
|
+
if (segments.some((s) => s === "..")) return false;
|
|
2579
|
+
return segments.length > 0;
|
|
2580
|
+
}
|
|
2581
|
+
function createZip(entries) {
|
|
2582
|
+
const zipped = {};
|
|
2583
|
+
for (const [path, data] of entries) {
|
|
2584
|
+
if (!isSafeZipEntryPath(path)) {
|
|
2585
|
+
throw new Error(`unsafe zip entry path: ${path}`);
|
|
2586
|
+
}
|
|
2587
|
+
zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2588
|
+
}
|
|
2589
|
+
return (0, import_fflate.zipSync)(zipped, { level: 6 });
|
|
2590
|
+
}
|
|
2591
|
+
function readZip(archivePath) {
|
|
2592
|
+
const issues = [];
|
|
2593
|
+
let raw;
|
|
2594
|
+
try {
|
|
2595
|
+
raw = (0, import_node_fs5.readFileSync)(archivePath);
|
|
2596
|
+
} catch {
|
|
2597
|
+
return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
|
|
2598
|
+
}
|
|
2599
|
+
if (!raw.length) {
|
|
2600
|
+
return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
|
|
2601
|
+
}
|
|
2602
|
+
let unzipped;
|
|
2603
|
+
try {
|
|
2604
|
+
unzipped = (0, import_fflate.unzipSync)(raw);
|
|
2605
|
+
} catch {
|
|
2606
|
+
return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
|
|
2607
|
+
}
|
|
2608
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2609
|
+
let totalUncompressed = 0;
|
|
2610
|
+
for (const [path, data] of Object.entries(unzipped)) {
|
|
2611
|
+
const canonical = canonicalZipEntryPath(path);
|
|
2612
|
+
if (!canonical || !isSafeZipEntryPath(canonical)) {
|
|
2613
|
+
issues.push({ path, message: "unsafe zip entry path" });
|
|
2614
|
+
continue;
|
|
2615
|
+
}
|
|
2616
|
+
if (entries.has(canonical)) {
|
|
2617
|
+
issues.push({ path: canonical, message: "duplicate zip entry path" });
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
totalUncompressed += data.byteLength;
|
|
2621
|
+
if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
|
|
2622
|
+
return {
|
|
2623
|
+
ok: false,
|
|
2624
|
+
issues: [
|
|
2625
|
+
{
|
|
2626
|
+
path: archivePath,
|
|
2627
|
+
message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
|
|
2628
|
+
}
|
|
2629
|
+
]
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
entries.set(canonical, data);
|
|
2633
|
+
}
|
|
2634
|
+
if (issues.length) return { ok: false, issues };
|
|
2635
|
+
return { ok: true, entries };
|
|
2636
|
+
}
|
|
2637
|
+
async function collectDistEntries(distDir, spaDistRelative) {
|
|
2638
|
+
const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
|
|
2639
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2640
|
+
const walk = async (absDir, relPrefix) => {
|
|
2641
|
+
const dirEntries = await readdir4(absDir, { withFileTypes: true });
|
|
2642
|
+
for (const entry of dirEntries) {
|
|
2643
|
+
const abs = (0, import_node_path12.join)(absDir, entry.name);
|
|
2644
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
2645
|
+
const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
|
|
2646
|
+
if (!isSafeRelativeSpaPath(zipPath)) {
|
|
2647
|
+
throw new Error(`unsafe dist path: ${zipPath}`);
|
|
2648
|
+
}
|
|
2649
|
+
const stat2 = await lstat2(abs);
|
|
2650
|
+
if (stat2.isSymbolicLink()) {
|
|
2651
|
+
throw new Error(`dist contains symlink: ${abs}`);
|
|
2652
|
+
}
|
|
2653
|
+
if (stat2.isDirectory()) {
|
|
2654
|
+
await walk(abs, rel);
|
|
2655
|
+
} else if (stat2.isFile()) {
|
|
2656
|
+
entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
await walk(distDir, "");
|
|
2661
|
+
return entries;
|
|
2662
|
+
}
|
|
2663
|
+
function entryToUtf8(data) {
|
|
2664
|
+
return (0, import_fflate.strFromU8)(data);
|
|
2665
|
+
}
|
|
2666
|
+
function utf8ToEntry(text) {
|
|
2667
|
+
return (0, import_fflate.strToU8)(text);
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// src/lkcourse/parseEnvelope.ts
|
|
2671
|
+
function parseLkcourseEnvelope(raw, label = "manifest.json") {
|
|
2672
|
+
const issues = [];
|
|
2673
|
+
if (!raw || typeof raw !== "object") {
|
|
2674
|
+
return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
|
|
2675
|
+
}
|
|
2676
|
+
const obj = raw;
|
|
2677
|
+
if (obj.format !== "lkcourse") {
|
|
2678
|
+
issues.push({
|
|
2679
|
+
path: "format",
|
|
2680
|
+
message: `must be "lkcourse" (got ${String(obj.format)})`
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
let schemaVersion = obj.schemaVersion;
|
|
2684
|
+
if (schemaVersion === "1") schemaVersion = 1;
|
|
2685
|
+
if (schemaVersion !== 1) {
|
|
2686
|
+
issues.push({
|
|
2687
|
+
path: "schemaVersion",
|
|
2688
|
+
message: `must be 1 (got ${String(obj.schemaVersion)})`
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
|
|
2692
|
+
if (!lessonkitVersion) {
|
|
2693
|
+
issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
|
|
2694
|
+
}
|
|
2695
|
+
const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
|
|
2696
|
+
if (!exportedAt) {
|
|
2697
|
+
issues.push({ path: "exportedAt", message: "must be a non-empty string" });
|
|
2698
|
+
}
|
|
2699
|
+
const entriesRaw = obj.entries;
|
|
2700
|
+
const entries = [];
|
|
2701
|
+
if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
|
|
2702
|
+
issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
|
|
2703
|
+
} else {
|
|
2704
|
+
for (let i = 0; i < entriesRaw.length; i++) {
|
|
2705
|
+
const entry = entriesRaw[i];
|
|
2706
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
2707
|
+
issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
|
|
2708
|
+
} else {
|
|
2709
|
+
const trimmed = entry.trim();
|
|
2710
|
+
if (!isSafeZipEntryPath(trimmed)) {
|
|
2711
|
+
issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
|
|
2712
|
+
} else {
|
|
2713
|
+
entries.push(trimmed);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
if (issues.length) return { ok: false, issues };
|
|
2719
|
+
const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
|
|
2720
|
+
if (!manifestParsed.ok) {
|
|
2721
|
+
return {
|
|
2722
|
+
ok: false,
|
|
2723
|
+
issues: manifestParsed.issues.map((issue) => ({
|
|
2724
|
+
path: `sourceManifest.${issue.path}`,
|
|
2725
|
+
message: issue.message
|
|
2726
|
+
}))
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
return {
|
|
2730
|
+
ok: true,
|
|
2731
|
+
envelope: {
|
|
2732
|
+
format: "lkcourse",
|
|
2733
|
+
schemaVersion: 1,
|
|
2734
|
+
lessonkitVersion,
|
|
2735
|
+
exportedAt,
|
|
2736
|
+
sourceManifest: manifestParsed.manifest,
|
|
2737
|
+
entries
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// src/lkcourse/blockTree.ts
|
|
2743
|
+
var import_node_fs6 = require("fs");
|
|
2744
|
+
var import_node_module = require("module");
|
|
2745
|
+
var import_node_path13 = require("path");
|
|
2746
|
+
var import_core7 = require("@lessonkit/core");
|
|
2747
|
+
var import_meta = {};
|
|
2748
|
+
var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
|
|
2749
|
+
var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
|
|
2750
|
+
function stripComments2(source) {
|
|
2751
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
2752
|
+
}
|
|
2753
|
+
function collectSourceUnderSrc2(projectRoot) {
|
|
2754
|
+
const srcDir = (0, import_node_path13.join)(projectRoot, "src");
|
|
2755
|
+
if (!(0, import_node_fs6.existsSync)(srcDir)) return [];
|
|
2756
|
+
const results = [];
|
|
2757
|
+
const walk = (dir) => {
|
|
2758
|
+
for (const entry of (0, import_node_fs6.readdirSync)(dir)) {
|
|
2759
|
+
const abs = (0, import_node_path13.join)(dir, entry);
|
|
2760
|
+
try {
|
|
2761
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
2762
|
+
} catch {
|
|
2763
|
+
continue;
|
|
2764
|
+
}
|
|
2765
|
+
const stat2 = (0, import_node_fs6.lstatSync)(abs);
|
|
2766
|
+
if (stat2.isSymbolicLink()) continue;
|
|
2767
|
+
if (stat2.isDirectory()) {
|
|
2768
|
+
walk(abs);
|
|
2769
|
+
} else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
|
|
2770
|
+
results.push((0, import_node_path13.relative)(projectRoot, abs));
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
};
|
|
2774
|
+
walk(srcDir);
|
|
2775
|
+
return results;
|
|
2776
|
+
}
|
|
2777
|
+
function loadCatalogBlockTypes(blockTypes) {
|
|
2778
|
+
if (blockTypes?.length) return blockTypes;
|
|
2779
|
+
try {
|
|
2780
|
+
const require2 = (0, import_node_module.createRequire)(import_meta.url);
|
|
2781
|
+
const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
|
|
2782
|
+
const catalog = JSON.parse((0, import_node_fs6.readFileSync)(catalogPath, "utf8"));
|
|
2783
|
+
return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
|
|
2784
|
+
} catch {
|
|
2785
|
+
return [
|
|
2786
|
+
"Course",
|
|
2787
|
+
"Lesson",
|
|
2788
|
+
"Scenario",
|
|
2789
|
+
"Quiz",
|
|
2790
|
+
"KnowledgeCheck",
|
|
2791
|
+
"ProgressTracker",
|
|
2792
|
+
"Reflection",
|
|
2793
|
+
"TrueFalse",
|
|
2794
|
+
"MarkTheWords",
|
|
2795
|
+
"FillInTheBlanks",
|
|
2796
|
+
"DragTheWords",
|
|
2797
|
+
"DragAndDrop",
|
|
2798
|
+
"AssessmentSequence",
|
|
2799
|
+
"Text",
|
|
2800
|
+
"Heading",
|
|
2801
|
+
"Image",
|
|
2802
|
+
"Video",
|
|
2803
|
+
"Page",
|
|
2804
|
+
"InteractiveBook",
|
|
2805
|
+
"Slide",
|
|
2806
|
+
"SlideDeck",
|
|
2807
|
+
"TimedCue",
|
|
2808
|
+
"InteractiveVideo",
|
|
2809
|
+
"Summary",
|
|
2810
|
+
"BranchingScenario",
|
|
2811
|
+
"BranchNode",
|
|
2812
|
+
"BranchChoice",
|
|
2813
|
+
"Embed",
|
|
2814
|
+
"Chart"
|
|
2815
|
+
];
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
function extractIdProp(tagSource, prop) {
|
|
2819
|
+
const re = new RegExp(
|
|
2820
|
+
`\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
|
|
2821
|
+
);
|
|
2822
|
+
const match = tagSource.match(re);
|
|
2823
|
+
if (!match) return void 0;
|
|
2824
|
+
return match[1] ?? match[2] ?? match[3];
|
|
2825
|
+
}
|
|
2826
|
+
function parseJsxBlocks(source, blockTypes) {
|
|
2827
|
+
const stripped = stripComments2(source);
|
|
2828
|
+
const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
|
|
2829
|
+
const stack = [];
|
|
2830
|
+
const roots = [];
|
|
2831
|
+
for (const match of stripped.matchAll(tagRe)) {
|
|
2832
|
+
const rawTag = match[1];
|
|
2833
|
+
const attrs = match[2] ?? "";
|
|
2834
|
+
const selfClosing = match[3] === "/";
|
|
2835
|
+
if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
|
|
2836
|
+
const known = blockTypes.has(rawTag);
|
|
2837
|
+
const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
|
|
2838
|
+
for (const prop of ID_PROPS) {
|
|
2839
|
+
const value = extractIdProp(attrs, prop);
|
|
2840
|
+
if (value) node[prop] = value;
|
|
2841
|
+
}
|
|
2842
|
+
if (selfClosing) {
|
|
2843
|
+
if (stack.length) {
|
|
2844
|
+
const parent = stack[stack.length - 1];
|
|
2845
|
+
parent.children = parent.children ?? [];
|
|
2846
|
+
parent.children.push(node);
|
|
2847
|
+
} else {
|
|
2848
|
+
roots.push(node);
|
|
2849
|
+
}
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
const closeRe = new RegExp(`</${rawTag}>`);
|
|
2853
|
+
const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
|
|
2854
|
+
if (!closeMatch) {
|
|
2855
|
+
if (stack.length) {
|
|
2856
|
+
const parent = stack[stack.length - 1];
|
|
2857
|
+
parent.children = parent.children ?? [];
|
|
2858
|
+
parent.children.push(node);
|
|
2859
|
+
} else {
|
|
2860
|
+
roots.push(node);
|
|
2861
|
+
}
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2864
|
+
stack.push(node);
|
|
2865
|
+
const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
|
|
2866
|
+
const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
|
|
2867
|
+
if (!inner.includes("<")) {
|
|
2868
|
+
stack.pop();
|
|
2869
|
+
if (stack.length) {
|
|
2870
|
+
const parent = stack[stack.length - 1];
|
|
2871
|
+
parent.children = parent.children ?? [];
|
|
2872
|
+
parent.children.push(node);
|
|
2873
|
+
} else {
|
|
2874
|
+
roots.push(node);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
return roots.length ? roots : stack;
|
|
2879
|
+
}
|
|
2880
|
+
function validateNodeIds(node, pathPrefix, issues) {
|
|
2881
|
+
for (const prop of ID_PROPS) {
|
|
2882
|
+
const value = node[prop];
|
|
2883
|
+
if (value === void 0) continue;
|
|
2884
|
+
const validated = (0, import_core7.validateId)(value, prop);
|
|
2885
|
+
if (!validated.ok) {
|
|
2886
|
+
issues.push({
|
|
2887
|
+
path: `${pathPrefix}.${prop}`,
|
|
2888
|
+
message: validated.issues[0]?.message ?? `invalid ${prop}`
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
node.children?.forEach((child, index) => {
|
|
2893
|
+
validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
function validateBlockTreeIds(tree) {
|
|
2897
|
+
const issues = [];
|
|
2898
|
+
tree.blocks.forEach((block, index) => {
|
|
2899
|
+
validateNodeIds(block, `blocks[${index}]`, issues);
|
|
2900
|
+
});
|
|
2901
|
+
return issues;
|
|
2902
|
+
}
|
|
2903
|
+
function extractBlockTree(options) {
|
|
2904
|
+
const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
|
|
2905
|
+
const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
|
|
2906
|
+
const blocks = [];
|
|
2907
|
+
for (const rel of sources) {
|
|
2908
|
+
const abs = (0, import_node_path13.join)(options.projectRoot, rel);
|
|
2909
|
+
if (!(0, import_node_fs6.existsSync)(abs)) continue;
|
|
2910
|
+
const source = (0, import_node_fs6.readFileSync)(abs, "utf8");
|
|
2911
|
+
const parsed = parseJsxBlocks(source, blockTypes);
|
|
2912
|
+
blocks.push(...parsed);
|
|
2913
|
+
}
|
|
2914
|
+
return {
|
|
2915
|
+
schemaVersion: 1,
|
|
2916
|
+
sources,
|
|
2917
|
+
blocks
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// src/lkcourse/export.ts
|
|
2922
|
+
var import_promises3 = require("fs/promises");
|
|
2923
|
+
var import_node_module2 = require("module");
|
|
2924
|
+
var import_node_path14 = require("path");
|
|
2176
2925
|
var import_validators2 = require("@lxpack/validators");
|
|
2926
|
+
var import_meta2 = {};
|
|
2927
|
+
function resolveLessonkitVersion(explicit) {
|
|
2928
|
+
if (explicit?.trim()) return explicit.trim();
|
|
2929
|
+
try {
|
|
2930
|
+
const require2 = (0, import_node_module2.createRequire)(import_meta2.url);
|
|
2931
|
+
const pkg = require2("../../package.json");
|
|
2932
|
+
return pkg.version ?? "0.0.0";
|
|
2933
|
+
} catch {
|
|
2934
|
+
return "0.0.0";
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
async function exportLkcourse(options) {
|
|
2938
|
+
const projectRoot = (0, import_node_path14.resolve)(options.projectRoot);
|
|
2939
|
+
const manifest = options.manifest;
|
|
2940
|
+
const spaDistDir = (0, import_node_path14.join)(projectRoot, manifest.paths.spaDistDir);
|
|
2941
|
+
try {
|
|
2942
|
+
assertRealPathUnderRoot(projectRoot, spaDistDir);
|
|
2943
|
+
await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
|
|
2944
|
+
} catch (err) {
|
|
2945
|
+
return {
|
|
2946
|
+
ok: false,
|
|
2947
|
+
issues: [
|
|
2948
|
+
{
|
|
2949
|
+
path: manifest.paths.spaDistDir,
|
|
2950
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2951
|
+
}
|
|
2952
|
+
]
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
const injectableIssues = validateInjectableAssessments(manifest.course);
|
|
2956
|
+
if (injectableIssues.length > 0) {
|
|
2957
|
+
return {
|
|
2958
|
+
ok: false,
|
|
2959
|
+
issues: injectableIssues.map((issue) => ({
|
|
2960
|
+
path: issue.path,
|
|
2961
|
+
message: issue.message
|
|
2962
|
+
}))
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
const interchange = descriptorToInterchange(manifest.course);
|
|
2966
|
+
const interchangeParsed = (0, import_validators2.parseLessonkitInterchange)(interchange);
|
|
2967
|
+
if (!interchangeParsed.ok) {
|
|
2968
|
+
return {
|
|
2969
|
+
ok: false,
|
|
2970
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2971
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2972
|
+
message: i.message
|
|
2973
|
+
}))
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
const validatedInterchange = interchangeParsed.data;
|
|
2977
|
+
const interchangeCourseId = validatedInterchange.course?.id;
|
|
2978
|
+
if (!interchangeCourseId) {
|
|
2979
|
+
return {
|
|
2980
|
+
ok: false,
|
|
2981
|
+
issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
if (manifest.course.courseId !== interchangeCourseId) {
|
|
2985
|
+
return {
|
|
2986
|
+
ok: false,
|
|
2987
|
+
issues: [
|
|
2988
|
+
{
|
|
2989
|
+
path: "course.courseId",
|
|
2990
|
+
message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
|
|
2991
|
+
}
|
|
2992
|
+
]
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
const zipEntries = /* @__PURE__ */ new Map();
|
|
2996
|
+
const interchangeJson = JSON.stringify(interchange, null, 2);
|
|
2997
|
+
zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
|
|
2998
|
+
let blockTreeJson;
|
|
2999
|
+
if (options.includeBlockTree) {
|
|
3000
|
+
const blockTree = extractBlockTree({ projectRoot });
|
|
3001
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
3002
|
+
if (blockTreeIssues.length) {
|
|
3003
|
+
return {
|
|
3004
|
+
ok: false,
|
|
3005
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
3006
|
+
path: `block-tree.${issue.path}`,
|
|
3007
|
+
message: issue.message
|
|
3008
|
+
}))
|
|
3009
|
+
};
|
|
3010
|
+
}
|
|
3011
|
+
blockTreeJson = JSON.stringify(blockTree, null, 2);
|
|
3012
|
+
zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
|
|
3013
|
+
}
|
|
3014
|
+
let distEntries;
|
|
3015
|
+
try {
|
|
3016
|
+
distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
|
|
3017
|
+
} catch (err) {
|
|
3018
|
+
return {
|
|
3019
|
+
ok: false,
|
|
3020
|
+
issues: [
|
|
3021
|
+
{
|
|
3022
|
+
path: manifest.paths.spaDistDir,
|
|
3023
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3024
|
+
}
|
|
3025
|
+
]
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
|
|
3029
|
+
return {
|
|
3030
|
+
ok: false,
|
|
3031
|
+
issues: [
|
|
3032
|
+
{
|
|
3033
|
+
path: `${manifest.paths.spaDistDir}/index.html`,
|
|
3034
|
+
message: "dist must contain index.html before export"
|
|
3035
|
+
}
|
|
3036
|
+
]
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
for (const [path, data] of distEntries) {
|
|
3040
|
+
zipEntries.set(path, data);
|
|
3041
|
+
}
|
|
3042
|
+
const entryPaths = [...zipEntries.keys()].sort();
|
|
3043
|
+
const envelope = {
|
|
3044
|
+
format: "lkcourse",
|
|
3045
|
+
schemaVersion: 1,
|
|
3046
|
+
lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
|
|
3047
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3048
|
+
sourceManifest: manifest,
|
|
3049
|
+
entries: entryPaths
|
|
3050
|
+
};
|
|
3051
|
+
const envelopeCheck = parseLkcourseEnvelope(envelope);
|
|
3052
|
+
if (!envelopeCheck.ok) {
|
|
3053
|
+
return { ok: false, issues: envelopeCheck.issues };
|
|
3054
|
+
}
|
|
3055
|
+
zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
|
|
3056
|
+
const archivePath = (0, import_node_path14.resolve)(
|
|
3057
|
+
projectRoot,
|
|
3058
|
+
options.outPath ?? `${manifest.name}.lkcourse`
|
|
3059
|
+
);
|
|
3060
|
+
const archiveRel = options.outPath ?? `${manifest.name}.lkcourse`;
|
|
3061
|
+
try {
|
|
3062
|
+
assertRealPathUnderRoot(projectRoot, archivePath);
|
|
3063
|
+
} catch (err) {
|
|
3064
|
+
return {
|
|
3065
|
+
ok: false,
|
|
3066
|
+
issues: [
|
|
3067
|
+
{
|
|
3068
|
+
path: archiveRel,
|
|
3069
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3070
|
+
}
|
|
3071
|
+
]
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
if (isReservedResolvedOutputPath(projectRoot, archivePath)) {
|
|
3075
|
+
return {
|
|
3076
|
+
ok: false,
|
|
3077
|
+
issues: [
|
|
3078
|
+
{
|
|
3079
|
+
path: archiveRel,
|
|
3080
|
+
message: "output path must not target reserved directories (.git, node_modules, .github)"
|
|
3081
|
+
}
|
|
3082
|
+
]
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
if (!isSafeZipEntryPath(archiveRel)) {
|
|
3086
|
+
return {
|
|
3087
|
+
ok: false,
|
|
3088
|
+
issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
try {
|
|
3092
|
+
await (0, import_promises3.mkdir)((0, import_node_path14.dirname)(archivePath), { recursive: true });
|
|
3093
|
+
const zipped = createZip(zipEntries);
|
|
3094
|
+
await (0, import_promises3.writeFile)(archivePath, zipped);
|
|
3095
|
+
} catch (err) {
|
|
3096
|
+
return {
|
|
3097
|
+
ok: false,
|
|
3098
|
+
issues: [
|
|
3099
|
+
{
|
|
3100
|
+
path: archivePath,
|
|
3101
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3102
|
+
}
|
|
3103
|
+
]
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
return {
|
|
3107
|
+
ok: true,
|
|
3108
|
+
archivePath,
|
|
3109
|
+
fileCount: zipEntries.size,
|
|
3110
|
+
includeBlockTree: Boolean(options.includeBlockTree)
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// src/lkcourse/validate.ts
|
|
3115
|
+
var import_validators3 = require("@lxpack/validators");
|
|
3116
|
+
|
|
3117
|
+
// src/lkcourse/assessmentParity.ts
|
|
3118
|
+
function validateLkcourseAssessmentConsistency(descriptor, interchange) {
|
|
3119
|
+
const issues = [];
|
|
3120
|
+
for (const issue of validateInjectableAssessments(descriptor)) {
|
|
3121
|
+
issues.push({
|
|
3122
|
+
path: `sourceManifest.course.${issue.path}`,
|
|
3123
|
+
message: issue.message
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
const expectedIds = extractAssessments(descriptor).map((a) => a.id).sort();
|
|
3127
|
+
const interchangeIds = (interchange.assessments ?? []).map((a) => a.id).sort();
|
|
3128
|
+
const matches = expectedIds.length === interchangeIds.length && expectedIds.every((id, index) => id === interchangeIds[index]);
|
|
3129
|
+
if (!matches) {
|
|
3130
|
+
issues.push({
|
|
3131
|
+
path: "interchange.assessments",
|
|
3132
|
+
message: `injectable assessment ids [${expectedIds.join(", ")}] do not match interchange [${interchangeIds.join(", ")}]`
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
return issues;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// src/lkcourse/validate.ts
|
|
3139
|
+
function validateLkcourseArchiveEntries(entries, _archiveLabel) {
|
|
3140
|
+
const issues = [];
|
|
3141
|
+
const manifestData = entries.get("manifest.json");
|
|
3142
|
+
if (!manifestData) {
|
|
3143
|
+
return {
|
|
3144
|
+
ok: false,
|
|
3145
|
+
issues: [{ path: "manifest.json", message: "required file missing from archive" }]
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
let envelopeRaw;
|
|
3149
|
+
try {
|
|
3150
|
+
envelopeRaw = JSON.parse(entryToUtf8(manifestData));
|
|
3151
|
+
} catch {
|
|
3152
|
+
return {
|
|
3153
|
+
ok: false,
|
|
3154
|
+
issues: [{ path: "manifest.json", message: "invalid JSON" }]
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
3157
|
+
const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
|
|
3158
|
+
if (!envelopeParsed.ok) {
|
|
3159
|
+
return { ok: false, issues: envelopeParsed.issues };
|
|
3160
|
+
}
|
|
3161
|
+
const envelope = envelopeParsed.envelope;
|
|
3162
|
+
const interchangeData = entries.get("interchange.json");
|
|
3163
|
+
if (!interchangeData) {
|
|
3164
|
+
issues.push({ path: "interchange.json", message: "required file missing from archive" });
|
|
3165
|
+
}
|
|
3166
|
+
const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
3167
|
+
const spaIndexPath = `${spaDistDir}/index.html`;
|
|
3168
|
+
if (!entries.has(spaIndexPath)) {
|
|
3169
|
+
issues.push({ path: spaIndexPath, message: "required file missing from archive" });
|
|
3170
|
+
}
|
|
3171
|
+
const allowlisted = new Set(envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")));
|
|
3172
|
+
const spaDistPrefix = `${spaDistDir}/`;
|
|
3173
|
+
for (const entryPath of envelope.entries) {
|
|
3174
|
+
if (!entries.has(entryPath)) {
|
|
3175
|
+
issues.push({
|
|
3176
|
+
path: entryPath,
|
|
3177
|
+
message: "listed in manifest.entries but missing from archive"
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
for (const zipPath of entries.keys()) {
|
|
3182
|
+
const normalized = zipPath.replace(/\\/g, "/");
|
|
3183
|
+
if (!normalized.startsWith(spaDistPrefix)) continue;
|
|
3184
|
+
if (!allowlisted.has(normalized)) {
|
|
3185
|
+
issues.push({
|
|
3186
|
+
path: zipPath,
|
|
3187
|
+
message: "unlisted file under spaDistDir; not in manifest.entries"
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
if (issues.length) return { ok: false, issues };
|
|
3192
|
+
let interchangeRaw;
|
|
3193
|
+
try {
|
|
3194
|
+
interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
|
|
3195
|
+
} catch {
|
|
3196
|
+
return {
|
|
3197
|
+
ok: false,
|
|
3198
|
+
issues: [{ path: "interchange.json", message: "invalid JSON" }]
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
const interchangeParsed = (0, import_validators3.parseLessonkitInterchange)(interchangeRaw);
|
|
3202
|
+
if (!interchangeParsed.ok) {
|
|
3203
|
+
return {
|
|
3204
|
+
ok: false,
|
|
3205
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
3206
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
3207
|
+
message: i.message
|
|
3208
|
+
}))
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
const interchange = interchangeParsed.data;
|
|
3212
|
+
const interchangeCourseId = interchange.course?.id;
|
|
3213
|
+
if (!interchangeCourseId) {
|
|
3214
|
+
issues.push({
|
|
3215
|
+
path: "interchange.course.id",
|
|
3216
|
+
message: "missing course id in interchange"
|
|
3217
|
+
});
|
|
3218
|
+
} else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
|
|
3219
|
+
issues.push({
|
|
3220
|
+
path: "sourceManifest.course.courseId",
|
|
3221
|
+
message: `does not match interchange.course.id (${interchangeCourseId})`
|
|
3222
|
+
});
|
|
3223
|
+
}
|
|
3224
|
+
issues.push(
|
|
3225
|
+
...validateLkcourseAssessmentConsistency(
|
|
3226
|
+
envelope.sourceManifest.course,
|
|
3227
|
+
interchange
|
|
3228
|
+
)
|
|
3229
|
+
);
|
|
3230
|
+
if (issues.length) return { ok: false, issues };
|
|
3231
|
+
const blockTreeData = entries.get("block-tree.json");
|
|
3232
|
+
if (blockTreeData) {
|
|
3233
|
+
let blockTreeRaw;
|
|
3234
|
+
try {
|
|
3235
|
+
blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
|
|
3236
|
+
} catch {
|
|
3237
|
+
return {
|
|
3238
|
+
ok: false,
|
|
3239
|
+
issues: [{ path: "block-tree.json", message: "invalid JSON" }]
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
const blockTree = blockTreeRaw;
|
|
3243
|
+
if (Array.isArray(blockTree?.blocks)) {
|
|
3244
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
3245
|
+
if (blockTreeIssues.length) {
|
|
3246
|
+
return {
|
|
3247
|
+
ok: false,
|
|
3248
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
3249
|
+
path: `block-tree.${issue.path}`,
|
|
3250
|
+
message: issue.message
|
|
3251
|
+
}))
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return {
|
|
3257
|
+
ok: true,
|
|
3258
|
+
envelope,
|
|
3259
|
+
interchange
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
function validateLkcourse(archivePath) {
|
|
3263
|
+
const read = readZip(archivePath);
|
|
3264
|
+
if (!read.ok) return read;
|
|
3265
|
+
return validateLkcourseArchiveEntries(read.entries, archivePath);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
// src/lkcourse/import.ts
|
|
3269
|
+
var import_promises4 = require("fs/promises");
|
|
3270
|
+
var import_node_path15 = require("path");
|
|
3271
|
+
var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
|
|
3272
|
+
async function pathExists2(path) {
|
|
3273
|
+
try {
|
|
3274
|
+
await (0, import_promises4.access)(path);
|
|
3275
|
+
return true;
|
|
3276
|
+
} catch {
|
|
3277
|
+
return false;
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
async function renameOrCopy2(from, to, opts) {
|
|
3281
|
+
const renameFn = opts?.renameFn ?? import_promises4.rename;
|
|
3282
|
+
try {
|
|
3283
|
+
await renameFn(from, to);
|
|
3284
|
+
} catch (err) {
|
|
3285
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
3286
|
+
if (code !== "EXDEV") throw err;
|
|
3287
|
+
await (0, import_promises4.cp)(from, to, { recursive: true });
|
|
3288
|
+
await (0, import_promises4.rm)(from, { recursive: true, force: true });
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
async function writeImportTree(stagingDir, manifest, entries, spaDistDir, allowlistedSpaPaths) {
|
|
3292
|
+
let fileCount = 0;
|
|
3293
|
+
await (0, import_promises4.writeFile)(
|
|
3294
|
+
(0, import_node_path15.join)(stagingDir, "lessonkit.json"),
|
|
3295
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
3296
|
+
`,
|
|
3297
|
+
"utf8"
|
|
3298
|
+
);
|
|
3299
|
+
fileCount += 1;
|
|
3300
|
+
for (const [entryPath, data] of entries) {
|
|
3301
|
+
const normalized = entryPath.replace(/\\/g, "/");
|
|
3302
|
+
if (!normalized.startsWith(`${spaDistDir}/`)) continue;
|
|
3303
|
+
if (!allowlistedSpaPaths.has(normalized)) {
|
|
3304
|
+
throw new Error(`unlisted spaDist entry rejected: ${entryPath}`);
|
|
3305
|
+
}
|
|
3306
|
+
const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
|
|
3307
|
+
const outPath = (0, import_node_path15.join)(stagingDir, spaDistDir, relativeUnderSpa);
|
|
3308
|
+
const resolvedOut = (0, import_node_path15.resolve)(outPath);
|
|
3309
|
+
assertRealPathUnderRoot(stagingDir, resolvedOut);
|
|
3310
|
+
if (!isSafeZipEntryPath((0, import_node_path15.join)(spaDistDir, relativeUnderSpa))) {
|
|
3311
|
+
throw new Error(`unsafe extraction path: ${entryPath}`);
|
|
3312
|
+
}
|
|
3313
|
+
await (0, import_promises4.mkdir)((0, import_node_path15.dirname)(resolvedOut), { recursive: true });
|
|
3314
|
+
await (0, import_promises4.writeFile)(resolvedOut, data);
|
|
3315
|
+
fileCount += 1;
|
|
3316
|
+
}
|
|
3317
|
+
return fileCount;
|
|
3318
|
+
}
|
|
3319
|
+
async function backupImportArtifacts(targetDir) {
|
|
3320
|
+
const existing = [];
|
|
3321
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3322
|
+
if (await pathExists2((0, import_node_path15.join)(targetDir, name))) {
|
|
3323
|
+
existing.push(name);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
if (!existing.length) return void 0;
|
|
3327
|
+
const backupDir = await (0, import_promises4.mkdtemp)((0, import_node_path15.join)(targetDir, ".lkcourse-backup-"));
|
|
3328
|
+
for (const name of existing) {
|
|
3329
|
+
await renameOrCopy2((0, import_node_path15.join)(targetDir, name), (0, import_node_path15.join)(backupDir, name));
|
|
3330
|
+
}
|
|
3331
|
+
return backupDir;
|
|
3332
|
+
}
|
|
3333
|
+
async function restoreImportBackup(targetDir, backupDir) {
|
|
3334
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3335
|
+
const backupPath = (0, import_node_path15.join)(backupDir, name);
|
|
3336
|
+
if (!await pathExists2(backupPath)) continue;
|
|
3337
|
+
const destPath = (0, import_node_path15.join)(targetDir, name);
|
|
3338
|
+
if (await pathExists2(destPath)) {
|
|
3339
|
+
await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
|
|
3340
|
+
}
|
|
3341
|
+
await renameOrCopy2(backupPath, destPath);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
async function snapshotPreExistingImportArtifacts(targetDir) {
|
|
3345
|
+
const existing = /* @__PURE__ */ new Set();
|
|
3346
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3347
|
+
if (await pathExists2((0, import_node_path15.join)(targetDir, name))) {
|
|
3348
|
+
existing.add(name);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
return existing;
|
|
3352
|
+
}
|
|
3353
|
+
async function rollbackFailedImport(targetDir, backupDir, preExisting) {
|
|
3354
|
+
if (backupDir) {
|
|
3355
|
+
await restoreImportBackup(targetDir, backupDir);
|
|
3356
|
+
}
|
|
3357
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3358
|
+
if (preExisting.has(name)) continue;
|
|
3359
|
+
const destPath = (0, import_node_path15.join)(targetDir, name);
|
|
3360
|
+
if (await pathExists2(destPath)) {
|
|
3361
|
+
await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
async function promoteImportStaging(stagingDir, targetDir) {
|
|
3366
|
+
await (0, import_promises4.mkdir)(targetDir, { recursive: true });
|
|
3367
|
+
const entries = await (0, import_promises4.readdir)(stagingDir, { withFileTypes: true });
|
|
3368
|
+
for (const entry of entries) {
|
|
3369
|
+
const srcPath = (0, import_node_path15.join)(stagingDir, entry.name);
|
|
3370
|
+
const destPath = (0, import_node_path15.join)(targetDir, entry.name);
|
|
3371
|
+
if (entry.isDirectory() || entry.isFile()) {
|
|
3372
|
+
await renameOrCopy2(srcPath, destPath);
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
var promoteImportStagingImpl = promoteImportStaging;
|
|
3377
|
+
async function importLkcourse(options) {
|
|
3378
|
+
const archivePath = (0, import_node_path15.resolve)(options.archivePath);
|
|
3379
|
+
const targetDir = (0, import_node_path15.resolve)(options.targetDir);
|
|
3380
|
+
const validated = validateLkcourse(archivePath);
|
|
3381
|
+
if (!validated.ok) return validated;
|
|
3382
|
+
const { envelope, interchange } = validated;
|
|
3383
|
+
const manifest = envelope.sourceManifest;
|
|
3384
|
+
const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
3385
|
+
try {
|
|
3386
|
+
await (0, import_promises4.mkdir)(targetDir, { recursive: true });
|
|
3387
|
+
assertRealPathUnderRoot(targetDir, targetDir);
|
|
3388
|
+
} catch (err) {
|
|
3389
|
+
return {
|
|
3390
|
+
ok: false,
|
|
3391
|
+
issues: [
|
|
3392
|
+
{
|
|
3393
|
+
path: targetDir,
|
|
3394
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3395
|
+
}
|
|
3396
|
+
]
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
const read = readZip(archivePath);
|
|
3400
|
+
if (!read.ok) return read;
|
|
3401
|
+
let stagingDir;
|
|
3402
|
+
let backupDir;
|
|
3403
|
+
let preExisting;
|
|
3404
|
+
try {
|
|
3405
|
+
stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path15.join)(targetDir, ".lkcourse-import-"));
|
|
3406
|
+
const allowlistedSpaPaths = new Set(
|
|
3407
|
+
envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")).filter((entryPath) => entryPath.startsWith(`${spaDistDir}/`))
|
|
3408
|
+
);
|
|
3409
|
+
const fileCount = await writeImportTree(
|
|
3410
|
+
stagingDir,
|
|
3411
|
+
manifest,
|
|
3412
|
+
read.entries,
|
|
3413
|
+
spaDistDir,
|
|
3414
|
+
allowlistedSpaPaths
|
|
3415
|
+
);
|
|
3416
|
+
preExisting = await snapshotPreExistingImportArtifacts(targetDir);
|
|
3417
|
+
backupDir = await backupImportArtifacts(targetDir);
|
|
3418
|
+
try {
|
|
3419
|
+
await promoteImportStagingImpl(stagingDir, targetDir);
|
|
3420
|
+
} catch (promoteError) {
|
|
3421
|
+
await rollbackFailedImport(targetDir, backupDir, preExisting);
|
|
3422
|
+
backupDir = void 0;
|
|
3423
|
+
throw promoteError;
|
|
3424
|
+
}
|
|
3425
|
+
if (backupDir) {
|
|
3426
|
+
await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3427
|
+
backupDir = void 0;
|
|
3428
|
+
}
|
|
3429
|
+
await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true });
|
|
3430
|
+
stagingDir = void 0;
|
|
3431
|
+
return {
|
|
3432
|
+
ok: true,
|
|
3433
|
+
targetDir,
|
|
3434
|
+
manifest,
|
|
3435
|
+
interchange,
|
|
3436
|
+
fileCount
|
|
3437
|
+
};
|
|
3438
|
+
} catch (err) {
|
|
3439
|
+
if (preExisting) {
|
|
3440
|
+
await rollbackFailedImport(targetDir, backupDir, preExisting).catch(() => void 0);
|
|
3441
|
+
} else if (backupDir) {
|
|
3442
|
+
await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
|
|
3443
|
+
}
|
|
3444
|
+
if (backupDir) {
|
|
3445
|
+
await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3446
|
+
}
|
|
3447
|
+
if (stagingDir) {
|
|
3448
|
+
await (0, import_promises4.rm)(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3449
|
+
}
|
|
3450
|
+
return {
|
|
3451
|
+
ok: false,
|
|
3452
|
+
issues: [
|
|
3453
|
+
{
|
|
3454
|
+
path: targetDir,
|
|
3455
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3456
|
+
}
|
|
3457
|
+
]
|
|
3458
|
+
};
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
2177
3461
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2178
3462
|
0 && (module.exports = {
|
|
2179
3463
|
LESSONKIT_TELEMETRY_EVENTS,
|
|
3464
|
+
assertSpaDistContentsSafe,
|
|
2180
3465
|
assessmentDescriptorToLxpack,
|
|
2181
3466
|
buildLessonkitProject,
|
|
2182
3467
|
buildStagingPackage,
|
|
2183
3468
|
descriptorToInterchange,
|
|
2184
3469
|
ensureOutDirParent,
|
|
2185
3470
|
escapeShellText,
|
|
3471
|
+
exportLkcourse,
|
|
2186
3472
|
extractAssessments,
|
|
3473
|
+
extractBlockTree,
|
|
3474
|
+
importLkcourse,
|
|
2187
3475
|
lessonkitInterchangeSchema,
|
|
2188
3476
|
loadLessonkitManifestFromFile,
|
|
2189
3477
|
mapLessonkitIds,
|
|
@@ -2193,6 +3481,7 @@ var import_validators2 = require("@lxpack/validators");
|
|
|
2193
3481
|
packageLessonkitCourse,
|
|
2194
3482
|
parseLessonkitInterchange,
|
|
2195
3483
|
parseLessonkitManifest,
|
|
3484
|
+
parseLkcourseEnvelope,
|
|
2196
3485
|
promoteStagingToOutDir,
|
|
2197
3486
|
remapArtifactPaths,
|
|
2198
3487
|
resolveSafePackageOutputOverride,
|
|
@@ -2202,6 +3491,9 @@ var import_validators2 = require("@lxpack/validators");
|
|
|
2202
3491
|
validateDescriptor,
|
|
2203
3492
|
validateDescriptorForTarget,
|
|
2204
3493
|
validateLessonkitProject,
|
|
3494
|
+
validateLkcourse,
|
|
3495
|
+
validateLkcourseArchiveEntries,
|
|
3496
|
+
validateManifestName,
|
|
2205
3497
|
validatePackageInputs,
|
|
2206
3498
|
validateProjectPaths,
|
|
2207
3499
|
validateReactManifestParity,
|