@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.js
CHANGED
|
@@ -62,13 +62,40 @@ function normalizeDescriptor(input) {
|
|
|
62
62
|
correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
if (assessment.kind === "sortParagraphs") {
|
|
66
|
+
return {
|
|
67
|
+
...assessment,
|
|
68
|
+
checkId: check.id,
|
|
69
|
+
question,
|
|
70
|
+
paragraphs: assessment.paragraphs.map((p) => p.trim()).filter((p) => p.length > 0),
|
|
71
|
+
correctOrder: [...assessment.correctOrder]
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (assessment.kind === "guessTheAnswer") {
|
|
75
|
+
return {
|
|
76
|
+
...assessment,
|
|
77
|
+
checkId: check.id,
|
|
78
|
+
question,
|
|
79
|
+
answer: assessment.answer.trim()
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (assessment.kind === "multimediaChoice") {
|
|
83
|
+
return {
|
|
84
|
+
...assessment,
|
|
85
|
+
checkId: check.id,
|
|
86
|
+
question,
|
|
87
|
+
choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
88
|
+
answer: assessment.answer.trim()
|
|
89
|
+
};
|
|
90
|
+
}
|
|
65
91
|
const mcq = assessment;
|
|
66
92
|
return {
|
|
67
93
|
...mcq,
|
|
68
94
|
checkId: check.id,
|
|
69
95
|
question,
|
|
70
96
|
choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
|
|
71
|
-
answer: mcq.answer.trim()
|
|
97
|
+
answer: mcq.answer.trim(),
|
|
98
|
+
answers: mcq.answers?.map((a) => a.trim()).filter((a) => a.length > 0)
|
|
72
99
|
};
|
|
73
100
|
})
|
|
74
101
|
};
|
|
@@ -99,10 +126,18 @@ function parseAssessmentDescriptor(raw) {
|
|
|
99
126
|
};
|
|
100
127
|
const kind = raw.kind;
|
|
101
128
|
if (kind === "trueFalse") {
|
|
129
|
+
let answer;
|
|
130
|
+
if (typeof raw.answer === "boolean") {
|
|
131
|
+
answer = raw.answer;
|
|
132
|
+
} else if (raw.answer === "true") {
|
|
133
|
+
answer = true;
|
|
134
|
+
} else if (raw.answer === "false") {
|
|
135
|
+
answer = false;
|
|
136
|
+
}
|
|
102
137
|
return {
|
|
103
138
|
kind: "trueFalse",
|
|
104
139
|
...base,
|
|
105
|
-
answer
|
|
140
|
+
answer
|
|
106
141
|
};
|
|
107
142
|
}
|
|
108
143
|
if (kind === "fillInBlanks") {
|
|
@@ -134,7 +169,30 @@ function parseAssessmentDescriptor(raw) {
|
|
|
134
169
|
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
135
170
|
};
|
|
136
171
|
}
|
|
137
|
-
if (
|
|
172
|
+
if (kind === "sortParagraphs") {
|
|
173
|
+
return {
|
|
174
|
+
kind: "sortParagraphs",
|
|
175
|
+
...base,
|
|
176
|
+
paragraphs: Array.isArray(raw.paragraphs) ? raw.paragraphs.filter((p) => typeof p === "string") : [],
|
|
177
|
+
correctOrder: Array.isArray(raw.correctOrder) ? raw.correctOrder.filter((n) => typeof n === "number" && Number.isFinite(n)) : []
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (kind === "guessTheAnswer") {
|
|
181
|
+
return {
|
|
182
|
+
kind: "guessTheAnswer",
|
|
183
|
+
...base,
|
|
184
|
+
answer: typeof raw.answer === "string" ? raw.answer : ""
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (kind === "multimediaChoice") {
|
|
188
|
+
return {
|
|
189
|
+
kind: "multimediaChoice",
|
|
190
|
+
...base,
|
|
191
|
+
choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
|
|
192
|
+
answer: typeof raw.answer === "string" ? raw.answer : ""
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots" && kind !== "sortParagraphs" && kind !== "guessTheAnswer" && kind !== "multimediaChoice") {
|
|
138
196
|
return {
|
|
139
197
|
kind,
|
|
140
198
|
...base,
|
|
@@ -146,7 +204,15 @@ function parseAssessmentDescriptor(raw) {
|
|
|
146
204
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
147
205
|
...base,
|
|
148
206
|
choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
|
|
149
|
-
answer: typeof raw.answer === "string" ? raw.answer : ""
|
|
207
|
+
answer: typeof raw.answer === "string" ? raw.answer : "",
|
|
208
|
+
answers: Array.isArray(raw.answers) ? raw.answers.filter((a) => typeof a === "string") : void 0,
|
|
209
|
+
shuffleChoices: typeof raw.shuffleChoices === "boolean" ? raw.shuffleChoices : void 0,
|
|
210
|
+
shuffleSeed: typeof raw.shuffleSeed === "string" || typeof raw.shuffleSeed === "number" ? raw.shuffleSeed : void 0,
|
|
211
|
+
choiceFeedback: raw.choiceFeedback && typeof raw.choiceFeedback === "object" && !Array.isArray(raw.choiceFeedback) ? Object.fromEntries(
|
|
212
|
+
Object.entries(raw.choiceFeedback).filter(
|
|
213
|
+
(entry) => typeof entry[1] === "string"
|
|
214
|
+
)
|
|
215
|
+
) : void 0
|
|
150
216
|
};
|
|
151
217
|
}
|
|
152
218
|
function parseCourseDescriptorInput(input) {
|
|
@@ -194,8 +260,8 @@ import { validateId as validateId3 } from "@lessonkit/core";
|
|
|
194
260
|
import { validateTheme } from "@lessonkit/themes";
|
|
195
261
|
|
|
196
262
|
// src/spaPath.ts
|
|
197
|
-
import {
|
|
198
|
-
import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
|
|
263
|
+
import { realpathSync } from "fs";
|
|
264
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from "path";
|
|
199
265
|
function resolveComparablePath(p) {
|
|
200
266
|
if (/^[a-zA-Z]:[/\\]/.test(p)) {
|
|
201
267
|
return win32.resolve(p);
|
|
@@ -221,43 +287,32 @@ function assertResolvedPathUnderRoot(root, target) {
|
|
|
221
287
|
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
222
288
|
}
|
|
223
289
|
}
|
|
224
|
-
function
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const next = join(current, segment);
|
|
233
|
-
if (existsSync(next)) {
|
|
290
|
+
function resolvePhysicalPathForCheck(p) {
|
|
291
|
+
const resolved = resolveComparablePath(p);
|
|
292
|
+
try {
|
|
293
|
+
return realpathSync.native(resolved);
|
|
294
|
+
} catch {
|
|
295
|
+
let probe = resolved;
|
|
296
|
+
let suffix = "";
|
|
297
|
+
while (true) {
|
|
234
298
|
try {
|
|
235
|
-
|
|
299
|
+
const physical = realpathSync.native(probe);
|
|
300
|
+
return suffix ? join(physical, suffix) : physical;
|
|
236
301
|
} catch {
|
|
237
|
-
|
|
302
|
+
if (probe === dirname(probe)) {
|
|
303
|
+
return resolved;
|
|
304
|
+
}
|
|
305
|
+
const segment = basename(probe);
|
|
306
|
+
suffix = suffix ? join(segment, suffix) : segment;
|
|
307
|
+
probe = dirname(probe);
|
|
238
308
|
}
|
|
239
|
-
} else {
|
|
240
|
-
current = next;
|
|
241
309
|
}
|
|
242
|
-
assertResolvedPathUnderRoot(rootReal, current);
|
|
243
310
|
}
|
|
244
|
-
return current;
|
|
245
311
|
}
|
|
246
312
|
function assertRealPathUnderRoot(root, target) {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
rootReal = realpathSync(rootResolved);
|
|
252
|
-
} catch {
|
|
253
|
-
rootReal = rootResolved;
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
const targetCheck = realpathSync(targetResolved);
|
|
257
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
258
|
-
} catch {
|
|
259
|
-
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
260
|
-
}
|
|
313
|
+
const rootPhysical = resolvePhysicalPathForCheck(root);
|
|
314
|
+
const targetPhysical = resolvePhysicalPathForCheck(target);
|
|
315
|
+
assertResolvedPathUnderRoot(rootPhysical, targetPhysical);
|
|
261
316
|
}
|
|
262
317
|
function normalizePathForComparison(p) {
|
|
263
318
|
const resolved = resolveComparablePath(p);
|
|
@@ -280,6 +335,113 @@ function isResolvedPathUnderRoot(root, target) {
|
|
|
280
335
|
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
281
336
|
}
|
|
282
337
|
|
|
338
|
+
// src/validateProjectPaths.ts
|
|
339
|
+
import { existsSync, realpathSync as realpathSync2 } from "fs";
|
|
340
|
+
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
341
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
342
|
+
function isReservedOutputPath(value) {
|
|
343
|
+
let normalized = value.replace(/\\/g, "/");
|
|
344
|
+
while (normalized.startsWith("/")) normalized = normalized.slice(1);
|
|
345
|
+
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
346
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
347
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
348
|
+
}
|
|
349
|
+
function validateManifestName(name) {
|
|
350
|
+
if (!name.length) {
|
|
351
|
+
return "must be a non-empty string";
|
|
352
|
+
}
|
|
353
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
354
|
+
return "must not contain path separators";
|
|
355
|
+
}
|
|
356
|
+
if (!isSafeRelativeSpaPath(name)) {
|
|
357
|
+
return "must be a safe relative name without '..' segments or absolute prefixes";
|
|
358
|
+
}
|
|
359
|
+
if (isReservedOutputPath(name) || isReservedOutputPath(`${name}.lkcourse`)) {
|
|
360
|
+
return "must not target reserved directories (.git, node_modules, .github)";
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
function isReservedResolvedOutputPath(projectRoot, resolved) {
|
|
365
|
+
const rootResolved = resolveComparablePath(projectRoot);
|
|
366
|
+
const targetResolved = resolveComparablePath(resolved);
|
|
367
|
+
try {
|
|
368
|
+
const rootReal = existsSync(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
|
|
369
|
+
const targetReal = existsSync(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
|
|
370
|
+
const rel = relativePathUnderRoot(rootReal, targetReal);
|
|
371
|
+
return isReservedOutputPath(rel);
|
|
372
|
+
} catch {
|
|
373
|
+
return isReservedOutputPath(resolved);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
377
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
378
|
+
issues.push({
|
|
379
|
+
path: fieldPath,
|
|
380
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
385
|
+
issues.push({
|
|
386
|
+
path: fieldPath,
|
|
387
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
393
|
+
} catch {
|
|
394
|
+
issues.push({
|
|
395
|
+
path: fieldPath,
|
|
396
|
+
message: "path must resolve inside the project root"
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function validateProjectPaths(projectRoot, paths) {
|
|
401
|
+
const issues = [];
|
|
402
|
+
const root = resolve2(projectRoot);
|
|
403
|
+
if (paths.spaDistDir?.trim()) {
|
|
404
|
+
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
|
|
405
|
+
rejectReserved: true
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (paths.lxpackOutDir?.trim()) {
|
|
409
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
410
|
+
rejectReserved: true
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (paths.outputBaseDir?.trim()) {
|
|
414
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
415
|
+
rejectReserved: true
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return issues;
|
|
419
|
+
}
|
|
420
|
+
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
421
|
+
const root = resolve2(projectRoot);
|
|
422
|
+
const trimmed = override.trim();
|
|
423
|
+
if (!trimmed) {
|
|
424
|
+
throw new Error("output override must be a non-empty path");
|
|
425
|
+
}
|
|
426
|
+
if (isAbsolute2(trimmed)) {
|
|
427
|
+
const resolved2 = resolve2(trimmed);
|
|
428
|
+
assertRealPathUnderRoot(root, resolved2);
|
|
429
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
|
|
430
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
431
|
+
}
|
|
432
|
+
return resolved2;
|
|
433
|
+
}
|
|
434
|
+
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
435
|
+
throw new Error(`unsafe output path: ${override}`);
|
|
436
|
+
}
|
|
437
|
+
const resolved = resolve2(root, trimmed);
|
|
438
|
+
assertRealPathUnderRoot(root, resolved);
|
|
439
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
|
|
440
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
441
|
+
}
|
|
442
|
+
return resolved;
|
|
443
|
+
}
|
|
444
|
+
|
|
283
445
|
// src/theme.ts
|
|
284
446
|
import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
|
|
285
447
|
function themeToLxpackRuntime(input) {
|
|
@@ -297,6 +459,7 @@ function themeToLxpackRuntime(input) {
|
|
|
297
459
|
|
|
298
460
|
// src/descriptor/validateAssessments.ts
|
|
299
461
|
import { validateId as validateId2 } from "@lessonkit/core";
|
|
462
|
+
import { isMultiSelectMcq } from "@lessonkit/core";
|
|
300
463
|
var validateMcqLike = (assessment, path, issues) => {
|
|
301
464
|
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
302
465
|
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
@@ -312,9 +475,44 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
312
475
|
}
|
|
313
476
|
if (!assessment.answer.trim()) {
|
|
314
477
|
issues.push({ path: `${path}.answer`, message: "answer is required" });
|
|
315
|
-
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
478
|
+
} else if (!("answers" in assessment && isMultiSelectMcq({ answers: assessment.answers })) && trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
316
479
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
317
480
|
}
|
|
481
|
+
if ("answers" in assessment && assessment.answers !== void 0) {
|
|
482
|
+
if (!Array.isArray(assessment.answers)) {
|
|
483
|
+
issues.push({ path: `${path}.answers`, message: "answers must be an array when provided" });
|
|
484
|
+
} else {
|
|
485
|
+
const trimmedAnswers = assessment.answers.map((a) => a.trim()).filter((a) => a.length > 0);
|
|
486
|
+
if (assessment.answers.length > 0 && trimmedAnswers.length === 0) {
|
|
487
|
+
issues.push({ path: `${path}.answers`, message: "answers must include non-empty strings" });
|
|
488
|
+
}
|
|
489
|
+
const uniqueAnswers = new Set(trimmedAnswers);
|
|
490
|
+
if (trimmedAnswers.length !== uniqueAnswers.size) {
|
|
491
|
+
issues.push({ path: `${path}.answers`, message: "answers must be unique" });
|
|
492
|
+
}
|
|
493
|
+
for (const ans of trimmedAnswers) {
|
|
494
|
+
if (trimmedChoices.length && !trimmedChoices.includes(ans)) {
|
|
495
|
+
issues.push({ path: `${path}.answers`, message: "each answer must match a choice" });
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if ("choiceFeedback" in assessment && assessment.choiceFeedback !== void 0) {
|
|
502
|
+
if (typeof assessment.choiceFeedback !== "object" || assessment.choiceFeedback === null) {
|
|
503
|
+
issues.push({ path: `${path}.choiceFeedback`, message: "choiceFeedback must be an object" });
|
|
504
|
+
} else {
|
|
505
|
+
for (const key of Object.keys(assessment.choiceFeedback)) {
|
|
506
|
+
if (!trimmedChoices.includes(key.trim())) {
|
|
507
|
+
issues.push({
|
|
508
|
+
path: `${path}.choiceFeedback`,
|
|
509
|
+
message: "choiceFeedback keys must match choice labels"
|
|
510
|
+
});
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
318
516
|
const uniqueChoices = new Set(trimmedChoices);
|
|
319
517
|
if (trimmedChoices.length !== uniqueChoices.size) {
|
|
320
518
|
issues.push({ path: `${path}.choices`, message: "choices must be unique" });
|
|
@@ -334,6 +532,12 @@ function maxAchievableAssessmentScore(assessment) {
|
|
|
334
532
|
if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
|
|
335
533
|
return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
|
|
336
534
|
}
|
|
535
|
+
if (kind === "sortParagraphs" && assessment.kind === "sortParagraphs") {
|
|
536
|
+
return assessment.paragraphs?.length ?? assessment.correctOrder?.length ?? 0;
|
|
537
|
+
}
|
|
538
|
+
if ("answers" in assessment && Array.isArray(assessment.answers) && assessment.answers.length > 1) {
|
|
539
|
+
return assessment.answers.filter((a) => a.trim().length > 0).length;
|
|
540
|
+
}
|
|
337
541
|
return 1;
|
|
338
542
|
}
|
|
339
543
|
var ASSESSMENT_VALIDATORS = {
|
|
@@ -356,8 +560,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
356
560
|
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
357
561
|
});
|
|
358
562
|
}
|
|
359
|
-
const explicitBlanks =
|
|
360
|
-
if (assessment.blanks !== void 0
|
|
563
|
+
const explicitBlanks = [];
|
|
564
|
+
if (assessment.blanks !== void 0) {
|
|
565
|
+
for (let i = 0; i < assessment.blanks.length; i++) {
|
|
566
|
+
const blank = assessment.blanks[i];
|
|
567
|
+
if (!blank || typeof blank !== "object") {
|
|
568
|
+
issues.push({
|
|
569
|
+
path: `${path}.blanks[${i}]`,
|
|
570
|
+
message: "blank entry must be an object with non-empty id and answer"
|
|
571
|
+
});
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const id = blank.id?.trim() ?? "";
|
|
575
|
+
const answer = blank.answer?.trim() ?? "";
|
|
576
|
+
if (!id || !answer) {
|
|
577
|
+
issues.push({
|
|
578
|
+
path: `${path}.blanks[${i}]`,
|
|
579
|
+
message: "blank entry must include non-empty id and answer"
|
|
580
|
+
});
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
explicitBlanks.push({ id, answer });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
|
|
361
587
|
issues.push({
|
|
362
588
|
path: `${path}.blanks`,
|
|
363
589
|
message: "blanks must include at least one entry with non-empty id and answer"
|
|
@@ -397,7 +623,31 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
397
623
|
message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
|
|
398
624
|
});
|
|
399
625
|
}
|
|
400
|
-
}
|
|
626
|
+
},
|
|
627
|
+
sortParagraphs: (assessment, path, issues) => {
|
|
628
|
+
if (assessment.kind !== "sortParagraphs") return;
|
|
629
|
+
if (!Array.isArray(assessment.paragraphs) || assessment.paragraphs.length === 0) {
|
|
630
|
+
issues.push({ path: `${path}.paragraphs`, message: "paragraphs is required for sortParagraphs" });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (!Array.isArray(assessment.correctOrder) || assessment.correctOrder.length === 0) {
|
|
634
|
+
issues.push({ path: `${path}.correctOrder`, message: "correctOrder is required for sortParagraphs" });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (assessment.correctOrder.length !== assessment.paragraphs.length) {
|
|
638
|
+
issues.push({
|
|
639
|
+
path: `${path}.correctOrder`,
|
|
640
|
+
message: "correctOrder length must match paragraphs length for sortParagraphs"
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
guessTheAnswer: (assessment, path, issues) => {
|
|
645
|
+
if (assessment.kind !== "guessTheAnswer") return;
|
|
646
|
+
if (!assessment.answer?.trim()) {
|
|
647
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for guessTheAnswer" });
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
multimediaChoice: validateMcqLike
|
|
401
651
|
};
|
|
402
652
|
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
403
653
|
const path = `assessments[${index}]`;
|
|
@@ -505,6 +755,20 @@ function validateCourseDescriptor(input) {
|
|
|
505
755
|
});
|
|
506
756
|
}
|
|
507
757
|
}
|
|
758
|
+
const descriptorSpaDistDir = input.spaDistDir?.trim();
|
|
759
|
+
if (descriptorSpaDistDir) {
|
|
760
|
+
if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
|
|
761
|
+
issues.push({
|
|
762
|
+
path: "spaDistDir",
|
|
763
|
+
message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
|
|
764
|
+
});
|
|
765
|
+
} else if (isReservedOutputPath(descriptorSpaDistDir)) {
|
|
766
|
+
issues.push({
|
|
767
|
+
path: "spaDistDir",
|
|
768
|
+
message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
508
772
|
if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
|
|
509
773
|
issues.push({
|
|
510
774
|
path: "lessons",
|
|
@@ -565,6 +829,8 @@ function validateCourseDescriptor(input) {
|
|
|
565
829
|
}
|
|
566
830
|
|
|
567
831
|
// src/assessments.ts
|
|
832
|
+
import { isMultiSelectMcq as isMultiSelectMcq2 } from "@lessonkit/core";
|
|
833
|
+
var DEFAULT_SHELL_PASSING_SCORE = 1;
|
|
568
834
|
function escapeShellText(text) {
|
|
569
835
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
570
836
|
}
|
|
@@ -573,7 +839,7 @@ function decodeShellEntities(text) {
|
|
|
573
839
|
}
|
|
574
840
|
function containsUnsafeShellMarkup(text) {
|
|
575
841
|
const decoded = decodeShellEntities(text);
|
|
576
|
-
return /<\/script/i.test(decoded) || /<!--/.test(decoded) ||
|
|
842
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
|
|
577
843
|
}
|
|
578
844
|
function sanitizeShellField(text) {
|
|
579
845
|
if (containsUnsafeShellMarkup(text)) return null;
|
|
@@ -588,6 +854,8 @@ function mcqToLxpack(assessment) {
|
|
|
588
854
|
const checkId = sanitizeShellField(assessment.checkId);
|
|
589
855
|
const prompt = sanitizeShellField(assessment.question);
|
|
590
856
|
if (!checkId || !prompt) return null;
|
|
857
|
+
const normalizedAnswer = assessment.answer.trim();
|
|
858
|
+
const multiCorrect = assessment.answers && assessment.answers.length > 1 ? new Set(assessment.answers.map((a) => a.trim())) : /* @__PURE__ */ new Set([normalizedAnswer]);
|
|
591
859
|
const choices = assessment.choices.map((text, index) => {
|
|
592
860
|
const sanitizedText = sanitizeShellField(text);
|
|
593
861
|
if (!sanitizedText) return null;
|
|
@@ -595,17 +863,21 @@ function mcqToLxpack(assessment) {
|
|
|
595
863
|
return {
|
|
596
864
|
id,
|
|
597
865
|
text: sanitizedText,
|
|
598
|
-
correct: text
|
|
866
|
+
correct: multiCorrect.has(text.trim())
|
|
599
867
|
};
|
|
600
868
|
});
|
|
601
869
|
if (choices.some((choice) => choice === null)) return null;
|
|
870
|
+
const multiSelect = isMultiSelectMcq2(assessment);
|
|
602
871
|
return {
|
|
603
872
|
id: checkId,
|
|
604
|
-
passingScore: assessment.passingScore ??
|
|
873
|
+
passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
|
|
874
|
+
shuffleChoices: assessment.shuffleChoices === true ? true : void 0,
|
|
875
|
+
showFeedback: assessment.choiceFeedback && Object.keys(assessment.choiceFeedback).length > 0 ? "immediate" : void 0,
|
|
605
876
|
questions: [
|
|
606
877
|
{
|
|
607
878
|
id: "q1",
|
|
608
879
|
prompt,
|
|
880
|
+
...multiSelect ? { selectionMode: "multiple" } : {},
|
|
609
881
|
choices
|
|
610
882
|
}
|
|
611
883
|
]
|
|
@@ -634,7 +906,20 @@ function assessmentDescriptorToLxpack(assessment) {
|
|
|
634
906
|
if (kind === "findMultipleHotspots") {
|
|
635
907
|
return null;
|
|
636
908
|
}
|
|
637
|
-
if (
|
|
909
|
+
if (kind === "sortParagraphs" || kind === "guessTheAnswer") {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
if (kind === "multimediaChoice" && assessment.kind === "multimediaChoice") {
|
|
913
|
+
return mcqToLxpack({
|
|
914
|
+
kind: "mcq",
|
|
915
|
+
checkId: assessment.checkId,
|
|
916
|
+
question: assessment.question,
|
|
917
|
+
choices: assessment.choices,
|
|
918
|
+
answer: assessment.answer,
|
|
919
|
+
passingScore: assessment.passingScore
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
if ((kind === "mcq" || assessment.kind === void 0) && "choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
638
923
|
return mcqToLxpack(assessment);
|
|
639
924
|
}
|
|
640
925
|
return null;
|
|
@@ -646,11 +931,20 @@ function extractAssessments(descriptor) {
|
|
|
646
931
|
// src/descriptor/validateInjectableAssessments.ts
|
|
647
932
|
function validateInjectableAssessments(descriptor) {
|
|
648
933
|
const issues = [];
|
|
934
|
+
const spaOnlyKinds = /* @__PURE__ */ new Set([
|
|
935
|
+
"fillInBlanks",
|
|
936
|
+
"findHotspot",
|
|
937
|
+
"findMultipleHotspots",
|
|
938
|
+
"sortParagraphs",
|
|
939
|
+
"guessTheAnswer"
|
|
940
|
+
]);
|
|
649
941
|
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
650
942
|
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
943
|
+
const kind = assessment.kind ?? "mcq";
|
|
944
|
+
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)" : "";
|
|
651
945
|
issues.push({
|
|
652
946
|
path: `assessments[${index}]`,
|
|
653
|
-
message: `assessment kind "${
|
|
947
|
+
message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
|
|
654
948
|
});
|
|
655
949
|
}
|
|
656
950
|
});
|
|
@@ -666,7 +960,7 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
666
960
|
"cmi5"
|
|
667
961
|
]);
|
|
668
962
|
function appendActivityIriIssues(issues, descriptor, target) {
|
|
669
|
-
const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
|
|
963
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
|
|
670
964
|
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
671
965
|
if (!hasXapiTracking && !requiresForTarget) return;
|
|
672
966
|
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
@@ -882,9 +1176,20 @@ function courseConfigCourseIdPresent(source, courseId) {
|
|
|
882
1176
|
if (literalPattern.test(stripped)) return true;
|
|
883
1177
|
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
884
1178
|
}
|
|
1179
|
+
function courseMetaCourseIdPresent(source, courseId) {
|
|
1180
|
+
const constants = extractStringConstants(source);
|
|
1181
|
+
const stripped = stripComments(source);
|
|
1182
|
+
for (const [name, value] of constants) {
|
|
1183
|
+
if (value !== courseId) continue;
|
|
1184
|
+
if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
|
|
1185
|
+
if (/\blessons\s*:\s*\S/.test(stripped)) return true;
|
|
1186
|
+
}
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
885
1189
|
function courseIdPresent(source, courseId) {
|
|
886
1190
|
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
887
1191
|
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
1192
|
+
if (courseMetaCourseIdPresent(source, courseId)) return true;
|
|
888
1193
|
return courseConfigCourseIdPresent(source, courseId);
|
|
889
1194
|
}
|
|
890
1195
|
function checkIdPresent(source, checkId) {
|
|
@@ -953,117 +1258,42 @@ function validateReactManifestParity(opts) {
|
|
|
953
1258
|
return issues;
|
|
954
1259
|
}
|
|
955
1260
|
|
|
956
|
-
// src/
|
|
957
|
-
import {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
const
|
|
961
|
-
const
|
|
962
|
-
|
|
1261
|
+
// src/mapIds.ts
|
|
1262
|
+
import { assertValidId } from "@lessonkit/core";
|
|
1263
|
+
function mapLessonkitIds(descriptor) {
|
|
1264
|
+
const courseId = assertValidId(descriptor.courseId, "courseId");
|
|
1265
|
+
const lessonIds = descriptor.lessons.map((l) => assertValidId(l.id, "lessonId"));
|
|
1266
|
+
const checkIds = (descriptor.assessments ?? []).map(
|
|
1267
|
+
(a) => assertValidId(a.checkId, "checkId")
|
|
1268
|
+
);
|
|
1269
|
+
return { courseId, lessonIds, checkIds };
|
|
963
1270
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
}
|
|
972
|
-
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
973
|
-
issues.push({
|
|
974
|
-
path: fieldPath,
|
|
975
|
-
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
976
|
-
});
|
|
977
|
-
return;
|
|
1271
|
+
|
|
1272
|
+
// src/interchange.ts
|
|
1273
|
+
function mapDescriptorTracking(tracking) {
|
|
1274
|
+
if (!tracking) return void 0;
|
|
1275
|
+
const mapped = {};
|
|
1276
|
+
if (tracking.completion?.threshold !== void 0) {
|
|
1277
|
+
mapped.completion = { threshold: tracking.completion.threshold };
|
|
978
1278
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
issues.push({
|
|
983
|
-
path: fieldPath,
|
|
984
|
-
message: "path must resolve inside the project root"
|
|
985
|
-
});
|
|
1279
|
+
const activityIri = tracking.xapi?.activityIri?.trim();
|
|
1280
|
+
if (activityIri) {
|
|
1281
|
+
mapped.xapi = { activityIri };
|
|
986
1282
|
}
|
|
1283
|
+
return Object.keys(mapped).length > 0 ? mapped : void 0;
|
|
987
1284
|
}
|
|
988
|
-
function
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1001
|
-
rejectReserved: true
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
return issues;
|
|
1005
|
-
}
|
|
1006
|
-
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
1007
|
-
const root = resolve2(projectRoot);
|
|
1008
|
-
const trimmed = override.trim();
|
|
1009
|
-
if (!trimmed) {
|
|
1010
|
-
throw new Error("output override must be a non-empty path");
|
|
1011
|
-
}
|
|
1012
|
-
if (isAbsolute2(trimmed)) {
|
|
1013
|
-
const resolved2 = resolve2(trimmed);
|
|
1014
|
-
assertRealPathUnderRoot(root, resolved2);
|
|
1015
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1016
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1017
|
-
}
|
|
1018
|
-
return resolved2;
|
|
1019
|
-
}
|
|
1020
|
-
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
1021
|
-
throw new Error(`unsafe output path: ${override}`);
|
|
1022
|
-
}
|
|
1023
|
-
if (isReservedOutputPath(trimmed)) {
|
|
1024
|
-
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1025
|
-
}
|
|
1026
|
-
const resolved = resolve2(root, trimmed);
|
|
1027
|
-
assertRealPathUnderRoot(root, resolved);
|
|
1028
|
-
return resolved;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
// src/mapIds.ts
|
|
1032
|
-
import { assertValidId } from "@lessonkit/core";
|
|
1033
|
-
function mapLessonkitIds(descriptor) {
|
|
1034
|
-
const courseId = assertValidId(descriptor.courseId, "courseId");
|
|
1035
|
-
const lessonIds = descriptor.lessons.map((l) => assertValidId(l.id, "lessonId"));
|
|
1036
|
-
const checkIds = (descriptor.assessments ?? []).map(
|
|
1037
|
-
(a) => assertValidId(a.checkId, "checkId")
|
|
1038
|
-
);
|
|
1039
|
-
return { courseId, lessonIds, checkIds };
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// src/interchange.ts
|
|
1043
|
-
function mapDescriptorTracking(tracking) {
|
|
1044
|
-
if (!tracking) return void 0;
|
|
1045
|
-
const mapped = {};
|
|
1046
|
-
if (tracking.completion?.threshold !== void 0) {
|
|
1047
|
-
mapped.completion = { threshold: tracking.completion.threshold };
|
|
1048
|
-
}
|
|
1049
|
-
const activityIri = tracking.xapi?.activityIri?.trim();
|
|
1050
|
-
if (activityIri) {
|
|
1051
|
-
mapped.xapi = { activityIri };
|
|
1052
|
-
}
|
|
1053
|
-
return Object.keys(mapped).length > 0 ? mapped : void 0;
|
|
1054
|
-
}
|
|
1055
|
-
function resolveSpaLessons(descriptor) {
|
|
1056
|
-
const mapped = mapLessonkitIds(descriptor);
|
|
1057
|
-
if (descriptor.layout === "single-spa") {
|
|
1058
|
-
const spaLessonId = descriptor.spaLessonId ?? mapped.lessonIds[0] ?? "main";
|
|
1059
|
-
const firstLesson = descriptor.lessons.find((l) => l.id === spaLessonId);
|
|
1060
|
-
return [
|
|
1061
|
-
{
|
|
1062
|
-
id: spaLessonId,
|
|
1063
|
-
title: firstLesson?.title ?? descriptor.title,
|
|
1064
|
-
path: "dist"
|
|
1065
|
-
}
|
|
1066
|
-
];
|
|
1285
|
+
function resolveSpaLessons(descriptor) {
|
|
1286
|
+
const mapped = mapLessonkitIds(descriptor);
|
|
1287
|
+
if (descriptor.layout === "single-spa") {
|
|
1288
|
+
const spaLessonId = descriptor.spaLessonId ?? mapped.lessonIds[0] ?? "main";
|
|
1289
|
+
const firstLesson = descriptor.lessons.find((l) => l.id === spaLessonId);
|
|
1290
|
+
return [
|
|
1291
|
+
{
|
|
1292
|
+
id: spaLessonId,
|
|
1293
|
+
title: firstLesson?.title ?? descriptor.title,
|
|
1294
|
+
path: "dist"
|
|
1295
|
+
}
|
|
1296
|
+
];
|
|
1067
1297
|
}
|
|
1068
1298
|
return descriptor.lessons.map((lesson) => ({
|
|
1069
1299
|
id: lesson.id,
|
|
@@ -1159,7 +1389,7 @@ async function resolveSpaDirs(options) {
|
|
|
1159
1389
|
|
|
1160
1390
|
// src/spaDistValidation.ts
|
|
1161
1391
|
import { lstat, readdir } from "fs/promises";
|
|
1162
|
-
import { realpathSync as
|
|
1392
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
1163
1393
|
import { join as join4 } from "path";
|
|
1164
1394
|
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
1165
1395
|
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
@@ -1170,7 +1400,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
|
1170
1400
|
}
|
|
1171
1401
|
let rootReal;
|
|
1172
1402
|
try {
|
|
1173
|
-
rootReal =
|
|
1403
|
+
rootReal = realpathSync3(dirResolved);
|
|
1174
1404
|
} catch {
|
|
1175
1405
|
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
1176
1406
|
}
|
|
@@ -1199,7 +1429,7 @@ async function walkDistDir(rootReal, current, label) {
|
|
|
1199
1429
|
}
|
|
1200
1430
|
let entryReal;
|
|
1201
1431
|
try {
|
|
1202
|
-
entryReal =
|
|
1432
|
+
entryReal = realpathSync3(entryPath);
|
|
1203
1433
|
} catch (err) {
|
|
1204
1434
|
throw new Error(
|
|
1205
1435
|
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
@@ -1224,7 +1454,9 @@ async function writeLxpackProject(options) {
|
|
|
1224
1454
|
const descriptor = validation.descriptor;
|
|
1225
1455
|
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1226
1456
|
if (injectableIssues.length > 0) {
|
|
1227
|
-
throw new Error(
|
|
1457
|
+
throw new Error(
|
|
1458
|
+
injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
|
|
1459
|
+
);
|
|
1228
1460
|
}
|
|
1229
1461
|
const outDir = resolve4(options.outDir);
|
|
1230
1462
|
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
@@ -1251,8 +1483,8 @@ async function writeLxpackProject(options) {
|
|
|
1251
1483
|
}
|
|
1252
1484
|
|
|
1253
1485
|
// src/packageCourse.ts
|
|
1254
|
-
import { resolve as
|
|
1255
|
-
import * as
|
|
1486
|
+
import { resolve as resolve9 } from "path";
|
|
1487
|
+
import * as fsp4 from "fs/promises";
|
|
1256
1488
|
import {
|
|
1257
1489
|
buildCourse,
|
|
1258
1490
|
validateCourse
|
|
@@ -1290,6 +1522,19 @@ function validatePackageInputs(options) {
|
|
|
1290
1522
|
]
|
|
1291
1523
|
};
|
|
1292
1524
|
}
|
|
1525
|
+
if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
|
|
1526
|
+
return {
|
|
1527
|
+
ok: false,
|
|
1528
|
+
courseDir: outDir,
|
|
1529
|
+
target,
|
|
1530
|
+
issues: [
|
|
1531
|
+
{
|
|
1532
|
+
path: "outDir",
|
|
1533
|
+
message: "outDir must not target reserved directories (.git, node_modules, .github)"
|
|
1534
|
+
}
|
|
1535
|
+
]
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1293
1538
|
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
1294
1539
|
return {
|
|
1295
1540
|
ok: false,
|
|
@@ -1347,6 +1592,19 @@ function validatePackageInputs(options) {
|
|
|
1347
1592
|
]
|
|
1348
1593
|
};
|
|
1349
1594
|
}
|
|
1595
|
+
if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
|
|
1596
|
+
return {
|
|
1597
|
+
ok: false,
|
|
1598
|
+
courseDir: outDir,
|
|
1599
|
+
target,
|
|
1600
|
+
issues: [
|
|
1601
|
+
{
|
|
1602
|
+
path: "outputBaseDir",
|
|
1603
|
+
message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
|
|
1604
|
+
}
|
|
1605
|
+
]
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1350
1608
|
}
|
|
1351
1609
|
if (output) {
|
|
1352
1610
|
const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
|
|
@@ -1368,6 +1626,20 @@ function validatePackageInputs(options) {
|
|
|
1368
1626
|
]
|
|
1369
1627
|
};
|
|
1370
1628
|
}
|
|
1629
|
+
const outputRel = isAbsolute3(output) ? output : output;
|
|
1630
|
+
if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
|
|
1631
|
+
return {
|
|
1632
|
+
ok: false,
|
|
1633
|
+
courseDir: outDir,
|
|
1634
|
+
target,
|
|
1635
|
+
issues: [
|
|
1636
|
+
{
|
|
1637
|
+
path: "output",
|
|
1638
|
+
message: "output must not target reserved directories (.git, node_modules, .github)"
|
|
1639
|
+
}
|
|
1640
|
+
]
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1371
1643
|
}
|
|
1372
1644
|
return { ok: true, outDir, projectRoot };
|
|
1373
1645
|
}
|
|
@@ -1402,7 +1674,7 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
1402
1674
|
// src/packaging/promote.ts
|
|
1403
1675
|
import * as fsp from "fs/promises";
|
|
1404
1676
|
import { createHash, randomUUID } from "crypto";
|
|
1405
|
-
import { dirname, join as join7, resolve as resolve6 } from "path";
|
|
1677
|
+
import { dirname as dirname2, join as join7, resolve as resolve6 } from "path";
|
|
1406
1678
|
async function pathExists(path) {
|
|
1407
1679
|
try {
|
|
1408
1680
|
await fsp.access(path);
|
|
@@ -1422,7 +1694,7 @@ async function renameOrCopy(from, to) {
|
|
|
1422
1694
|
}
|
|
1423
1695
|
}
|
|
1424
1696
|
function promoteLockPath(outDir) {
|
|
1425
|
-
const parent =
|
|
1697
|
+
const parent = dirname2(outDir);
|
|
1426
1698
|
const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
|
|
1427
1699
|
return join7(parent, `.lk-promote-lock-${hash}`);
|
|
1428
1700
|
}
|
|
@@ -1460,11 +1732,14 @@ async function isStalePromoteLock(lockPath) {
|
|
|
1460
1732
|
return true;
|
|
1461
1733
|
}
|
|
1462
1734
|
}
|
|
1735
|
+
var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
|
|
1463
1736
|
async function withPromoteLock(outDir, fn) {
|
|
1464
1737
|
const lockPath = promoteLockPath(outDir);
|
|
1465
|
-
await fsp.mkdir(
|
|
1738
|
+
await fsp.mkdir(dirname2(outDir), { recursive: true });
|
|
1466
1739
|
let lockHandle;
|
|
1467
|
-
|
|
1740
|
+
const maxAttempts = 400;
|
|
1741
|
+
const started = Date.now();
|
|
1742
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1468
1743
|
try {
|
|
1469
1744
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1470
1745
|
await lockHandle.writeFile(`${process.pid}
|
|
@@ -1482,7 +1757,9 @@ ${Date.now()}
|
|
|
1482
1757
|
);
|
|
1483
1758
|
continue;
|
|
1484
1759
|
}
|
|
1485
|
-
|
|
1760
|
+
if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
|
|
1761
|
+
const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
|
|
1762
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
|
|
1486
1763
|
}
|
|
1487
1764
|
}
|
|
1488
1765
|
if (!lockHandle) {
|
|
@@ -1547,7 +1824,7 @@ async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, n
|
|
|
1547
1824
|
if (newArtifactPaths.has(rel)) continue;
|
|
1548
1825
|
const src = join7(priorArtifactsDir, rel);
|
|
1549
1826
|
const dest = join7(destArtifactsDir, rel);
|
|
1550
|
-
await fsp.mkdir(
|
|
1827
|
+
await fsp.mkdir(dirname2(dest), { recursive: true });
|
|
1551
1828
|
await fsp.cp(src, dest, { force: true });
|
|
1552
1829
|
}
|
|
1553
1830
|
}
|
|
@@ -1565,7 +1842,7 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
|
|
|
1565
1842
|
newArtifactPaths.add(rel);
|
|
1566
1843
|
}
|
|
1567
1844
|
}
|
|
1568
|
-
const parent =
|
|
1845
|
+
const parent = dirname2(outDir);
|
|
1569
1846
|
let priorArtifactsBackup;
|
|
1570
1847
|
const existingArtifactsDir = join7(outDir, outputBaseDir);
|
|
1571
1848
|
if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
|
|
@@ -1651,14 +1928,29 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
|
|
|
1651
1928
|
});
|
|
1652
1929
|
}
|
|
1653
1930
|
|
|
1654
|
-
// src/packaging/
|
|
1931
|
+
// src/packaging/relocateOutput.ts
|
|
1655
1932
|
import * as fsp2 from "fs/promises";
|
|
1656
|
-
import { dirname as
|
|
1933
|
+
import { dirname as dirname3, resolve as resolve7 } from "path";
|
|
1934
|
+
async function relocatePackageOutput(builtOutputPath, requestedOutputPath, projectRoot) {
|
|
1935
|
+
if (!builtOutputPath || !requestedOutputPath) return builtOutputPath;
|
|
1936
|
+
const resolvedBuilt = resolveComparablePath(builtOutputPath);
|
|
1937
|
+
const resolvedRequested = resolveComparablePath(requestedOutputPath);
|
|
1938
|
+
if (resolvedBuilt === resolvedRequested) return builtOutputPath;
|
|
1939
|
+
const root = resolve7(projectRoot);
|
|
1940
|
+
assertRealPathUnderRoot(root, resolvedRequested);
|
|
1941
|
+
await fsp2.mkdir(dirname3(resolvedRequested), { recursive: true });
|
|
1942
|
+
await renameOrCopy(resolvedBuilt, resolvedRequested);
|
|
1943
|
+
return resolvedRequested;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// src/packaging/staging.ts
|
|
1947
|
+
import * as fsp3 from "fs/promises";
|
|
1948
|
+
import { dirname as dirname4, isAbsolute as isAbsolute4, join as join8, resolve as resolve8 } from "path";
|
|
1657
1949
|
import { tmpdir } from "os";
|
|
1658
1950
|
import { packageLessonkit } from "@lxpack/api";
|
|
1659
1951
|
async function buildStagingPackage(options) {
|
|
1660
1952
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1661
|
-
const stagingDir = await
|
|
1953
|
+
const stagingDir = await fsp3.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
|
|
1662
1954
|
let succeeded = false;
|
|
1663
1955
|
try {
|
|
1664
1956
|
let spaDirs;
|
|
@@ -1690,14 +1982,28 @@ async function buildStagingPackage(options) {
|
|
|
1690
1982
|
}
|
|
1691
1983
|
const interchange = descriptorToInterchange(descriptor);
|
|
1692
1984
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
1693
|
-
await
|
|
1694
|
-
const defaultOutput =
|
|
1985
|
+
await fsp3.mkdir(join8(stagingDir, outputBase), { recursive: true });
|
|
1986
|
+
const defaultOutput = dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`);
|
|
1987
|
+
let packageOutput = output ?? defaultOutput;
|
|
1988
|
+
let requestedOutputPath;
|
|
1989
|
+
let requestedOutputDir;
|
|
1990
|
+
if (output) {
|
|
1991
|
+
const projectRoot = resolve8(writeOpts.projectRoot);
|
|
1992
|
+
const requested = isAbsolute4(output) ? resolve8(output) : resolve8(projectRoot, output);
|
|
1993
|
+
if (dir) {
|
|
1994
|
+
requestedOutputDir = requested;
|
|
1995
|
+
packageOutput = defaultOutput;
|
|
1996
|
+
} else {
|
|
1997
|
+
requestedOutputPath = requested;
|
|
1998
|
+
packageOutput = defaultOutput;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
1695
2001
|
const build = await packageLessonkit({
|
|
1696
2002
|
interchange,
|
|
1697
2003
|
spaDirs,
|
|
1698
2004
|
target,
|
|
1699
2005
|
courseDir: stagingDir,
|
|
1700
|
-
output:
|
|
2006
|
+
output: packageOutput,
|
|
1701
2007
|
dir,
|
|
1702
2008
|
outputBaseDir,
|
|
1703
2009
|
outputAnchorDir: stagingDir,
|
|
@@ -1721,17 +2027,19 @@ async function buildStagingPackage(options) {
|
|
|
1721
2027
|
stagingDir,
|
|
1722
2028
|
build,
|
|
1723
2029
|
outputPath: "outputPath" in build ? build.outputPath : void 0,
|
|
1724
|
-
outputDir: "outputDir" in build ? build.outputDir : void 0
|
|
2030
|
+
outputDir: "outputDir" in build ? build.outputDir : void 0,
|
|
2031
|
+
requestedOutputPath,
|
|
2032
|
+
requestedOutputDir
|
|
1725
2033
|
};
|
|
1726
2034
|
} catch (err) {
|
|
1727
|
-
await
|
|
2035
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1728
2036
|
/* v8 ignore next */
|
|
1729
2037
|
() => void 0
|
|
1730
2038
|
);
|
|
1731
2039
|
throw err;
|
|
1732
2040
|
} finally {
|
|
1733
2041
|
if (!succeeded) {
|
|
1734
|
-
await
|
|
2042
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1735
2043
|
/* v8 ignore next */
|
|
1736
2044
|
() => void 0
|
|
1737
2045
|
);
|
|
@@ -1739,7 +2047,7 @@ async function buildStagingPackage(options) {
|
|
|
1739
2047
|
}
|
|
1740
2048
|
}
|
|
1741
2049
|
async function ensureOutDirParent(outDir) {
|
|
1742
|
-
await
|
|
2050
|
+
await fsp3.mkdir(dirname4(outDir), { recursive: true });
|
|
1743
2051
|
}
|
|
1744
2052
|
|
|
1745
2053
|
// src/packaging/issueSeverity.ts
|
|
@@ -1750,17 +2058,23 @@ function isPackagingErrorIssue(issue) {
|
|
|
1750
2058
|
function findPackagingErrorIssues(issues) {
|
|
1751
2059
|
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1752
2060
|
}
|
|
2061
|
+
function isPackagingWarningIssue(issue) {
|
|
2062
|
+
return issue.severity?.toLowerCase() === "warning";
|
|
2063
|
+
}
|
|
2064
|
+
function findPackagingWarningIssues(issues) {
|
|
2065
|
+
return (issues ?? []).filter(isPackagingWarningIssue);
|
|
2066
|
+
}
|
|
1753
2067
|
|
|
1754
2068
|
// src/packageCourse.ts
|
|
1755
2069
|
async function validateLessonkitProject(options) {
|
|
1756
2070
|
return validateCourse({
|
|
1757
|
-
courseDir:
|
|
2071
|
+
courseDir: resolve9(options.courseDir),
|
|
1758
2072
|
target: options.target
|
|
1759
2073
|
});
|
|
1760
2074
|
}
|
|
1761
2075
|
async function buildLessonkitProject(options) {
|
|
1762
2076
|
const buildOptions = {
|
|
1763
|
-
courseDir:
|
|
2077
|
+
courseDir: resolve9(options.courseDir),
|
|
1764
2078
|
target: options.target,
|
|
1765
2079
|
output: options.output,
|
|
1766
2080
|
dir: options.dir,
|
|
@@ -1791,7 +2105,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1791
2105
|
if (!descriptorValidation.ok) {
|
|
1792
2106
|
return {
|
|
1793
2107
|
ok: false,
|
|
1794
|
-
courseDir:
|
|
2108
|
+
courseDir: resolve9(writeOpts.outDir),
|
|
1795
2109
|
target,
|
|
1796
2110
|
issues: descriptorValidation.issues.map((i) => ({
|
|
1797
2111
|
path: i.path,
|
|
@@ -1817,16 +2131,31 @@ async function packageLessonkitCourse(options) {
|
|
|
1817
2131
|
}))
|
|
1818
2132
|
};
|
|
1819
2133
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2134
|
+
let staged;
|
|
2135
|
+
try {
|
|
2136
|
+
staged = await buildStagingPackage({
|
|
2137
|
+
...writeOpts,
|
|
2138
|
+
descriptor,
|
|
2139
|
+
target,
|
|
2140
|
+
output,
|
|
2141
|
+
dir,
|
|
2142
|
+
outputBaseDir
|
|
2143
|
+
});
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
return {
|
|
2146
|
+
ok: false,
|
|
2147
|
+
courseDir: outDir,
|
|
2148
|
+
target,
|
|
2149
|
+
issues: [
|
|
2150
|
+
{
|
|
2151
|
+
path: "staging",
|
|
2152
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2153
|
+
}
|
|
2154
|
+
]
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
1828
2157
|
if (!staged.ok) {
|
|
1829
|
-
await
|
|
2158
|
+
await fsp4.rm(staged.stagingDir, { recursive: true, force: true }).catch(
|
|
1830
2159
|
/* v8 ignore next */
|
|
1831
2160
|
() => void 0
|
|
1832
2161
|
);
|
|
@@ -1843,7 +2172,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1843
2172
|
const { stagingDir, build } = staged;
|
|
1844
2173
|
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1845
2174
|
if (buildErrorIssues.length > 0) {
|
|
1846
|
-
await
|
|
2175
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1847
2176
|
/* v8 ignore next */
|
|
1848
2177
|
() => void 0
|
|
1849
2178
|
);
|
|
@@ -1860,13 +2189,13 @@ async function packageLessonkitCourse(options) {
|
|
|
1860
2189
|
}))
|
|
1861
2190
|
};
|
|
1862
2191
|
}
|
|
1863
|
-
const stagingRoot = await
|
|
2192
|
+
const stagingRoot = await fsp4.realpath(stagingDir);
|
|
1864
2193
|
const artifactIssues = [
|
|
1865
2194
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
1866
2195
|
validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
|
|
1867
2196
|
].filter((issue) => issue != null);
|
|
1868
2197
|
if (artifactIssues.length > 0) {
|
|
1869
|
-
await
|
|
2198
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1870
2199
|
/* v8 ignore next */
|
|
1871
2200
|
() => void 0
|
|
1872
2201
|
);
|
|
@@ -1879,8 +2208,27 @@ async function packageLessonkitCourse(options) {
|
|
|
1879
2208
|
issues: artifactIssues
|
|
1880
2209
|
};
|
|
1881
2210
|
}
|
|
1882
|
-
const
|
|
1883
|
-
|
|
2211
|
+
const buildWarningIssues = findPackagingWarningIssues(build.issues);
|
|
2212
|
+
if (options.strictBuild && buildWarningIssues.length > 0) {
|
|
2213
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
2214
|
+
/* v8 ignore next */
|
|
2215
|
+
() => void 0
|
|
2216
|
+
);
|
|
2217
|
+
return {
|
|
2218
|
+
ok: false,
|
|
2219
|
+
courseDir: outDir,
|
|
2220
|
+
target,
|
|
2221
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
2222
|
+
build,
|
|
2223
|
+
issues: buildWarningIssues.map((i) => ({
|
|
2224
|
+
path: i.path ?? "build",
|
|
2225
|
+
message: i.message ?? "build warning",
|
|
2226
|
+
severity: i.severity
|
|
2227
|
+
}))
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
let remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
|
|
2231
|
+
let remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
|
|
1884
2232
|
const validation = {
|
|
1885
2233
|
ok: true,
|
|
1886
2234
|
manifest: build.manifest,
|
|
@@ -1893,7 +2241,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1893
2241
|
projectRoot: writeOpts.projectRoot
|
|
1894
2242
|
});
|
|
1895
2243
|
} catch (err) {
|
|
1896
|
-
await
|
|
2244
|
+
await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1897
2245
|
/* v8 ignore next */
|
|
1898
2246
|
() => void 0
|
|
1899
2247
|
);
|
|
@@ -1911,6 +2259,32 @@ async function packageLessonkitCourse(options) {
|
|
|
1911
2259
|
]
|
|
1912
2260
|
};
|
|
1913
2261
|
}
|
|
2262
|
+
try {
|
|
2263
|
+
remappedOutputPath = await relocatePackageOutput(
|
|
2264
|
+
remappedOutputPath,
|
|
2265
|
+
staged.requestedOutputPath,
|
|
2266
|
+
writeOpts.projectRoot
|
|
2267
|
+
);
|
|
2268
|
+
remappedOutputDir = await relocatePackageOutput(
|
|
2269
|
+
remappedOutputDir,
|
|
2270
|
+
staged.requestedOutputDir,
|
|
2271
|
+
writeOpts.projectRoot
|
|
2272
|
+
);
|
|
2273
|
+
} catch (err) {
|
|
2274
|
+
return {
|
|
2275
|
+
ok: false,
|
|
2276
|
+
courseDir: outDir,
|
|
2277
|
+
target,
|
|
2278
|
+
validation,
|
|
2279
|
+
build,
|
|
2280
|
+
issues: [
|
|
2281
|
+
{
|
|
2282
|
+
path: "output",
|
|
2283
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2284
|
+
}
|
|
2285
|
+
]
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
1914
2288
|
const remappedBuild = { ...build };
|
|
1915
2289
|
if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
|
|
1916
2290
|
remappedBuild.outputPath = remappedOutputPath;
|
|
@@ -1954,8 +2328,9 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
1954
2328
|
}
|
|
1955
2329
|
const nameRaw = config.name;
|
|
1956
2330
|
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
|
|
1957
|
-
|
|
1958
|
-
|
|
2331
|
+
const nameIssue = validateManifestName(name);
|
|
2332
|
+
if (nameIssue) {
|
|
2333
|
+
issues.push({ path: "name", message: nameIssue });
|
|
1959
2334
|
}
|
|
1960
2335
|
const courseRaw = config.course;
|
|
1961
2336
|
if (Array.isArray(courseRaw)) {
|
|
@@ -2021,7 +2396,7 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
2021
2396
|
path: `paths.${key}`,
|
|
2022
2397
|
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2023
2398
|
});
|
|
2024
|
-
} else if (
|
|
2399
|
+
} else if (isReservedOutputPath(value)) {
|
|
2025
2400
|
issues.push({
|
|
2026
2401
|
path: `paths.${key}`,
|
|
2027
2402
|
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
@@ -2063,17 +2438,920 @@ import {
|
|
|
2063
2438
|
import {
|
|
2064
2439
|
lessonkitInterchangeSchema,
|
|
2065
2440
|
materializeLessonkitProject as materializeLessonkitProject2,
|
|
2066
|
-
parseLessonkitInterchange
|
|
2441
|
+
parseLessonkitInterchange as parseLessonkitInterchange3
|
|
2067
2442
|
} from "@lxpack/validators";
|
|
2443
|
+
|
|
2444
|
+
// src/lkcourse/zip.ts
|
|
2445
|
+
import { readFileSync as readFileSync2, statSync } from "fs";
|
|
2446
|
+
import { dirname as dirname5, join as join9, normalize } from "path";
|
|
2447
|
+
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
|
2448
|
+
var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
2449
|
+
function canonicalZipEntryPath(entryPath) {
|
|
2450
|
+
const slashNormalized = entryPath.replace(/\\/g, "/");
|
|
2451
|
+
const canonical = normalize(slashNormalized).replace(/\\/g, "/");
|
|
2452
|
+
if (canonical !== slashNormalized) return null;
|
|
2453
|
+
return canonical;
|
|
2454
|
+
}
|
|
2455
|
+
function isSafeZipEntryPath(entryPath) {
|
|
2456
|
+
const canonical = canonicalZipEntryPath(entryPath);
|
|
2457
|
+
if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
|
|
2458
|
+
return false;
|
|
2459
|
+
}
|
|
2460
|
+
const segments = canonical.split("/").filter((s) => s.length > 0);
|
|
2461
|
+
if (segments.some((s) => s === "..")) return false;
|
|
2462
|
+
return segments.length > 0;
|
|
2463
|
+
}
|
|
2464
|
+
function createZip(entries) {
|
|
2465
|
+
const zipped = {};
|
|
2466
|
+
for (const [path, data] of entries) {
|
|
2467
|
+
if (!isSafeZipEntryPath(path)) {
|
|
2468
|
+
throw new Error(`unsafe zip entry path: ${path}`);
|
|
2469
|
+
}
|
|
2470
|
+
zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2471
|
+
}
|
|
2472
|
+
return zipSync(zipped, { level: 6 });
|
|
2473
|
+
}
|
|
2474
|
+
function readZip(archivePath) {
|
|
2475
|
+
const issues = [];
|
|
2476
|
+
let raw;
|
|
2477
|
+
try {
|
|
2478
|
+
raw = readFileSync2(archivePath);
|
|
2479
|
+
} catch {
|
|
2480
|
+
return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
|
|
2481
|
+
}
|
|
2482
|
+
if (!raw.length) {
|
|
2483
|
+
return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
|
|
2484
|
+
}
|
|
2485
|
+
let unzipped;
|
|
2486
|
+
try {
|
|
2487
|
+
unzipped = unzipSync(raw);
|
|
2488
|
+
} catch {
|
|
2489
|
+
return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
|
|
2490
|
+
}
|
|
2491
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2492
|
+
let totalUncompressed = 0;
|
|
2493
|
+
for (const [path, data] of Object.entries(unzipped)) {
|
|
2494
|
+
const canonical = canonicalZipEntryPath(path);
|
|
2495
|
+
if (!canonical || !isSafeZipEntryPath(canonical)) {
|
|
2496
|
+
issues.push({ path, message: "unsafe zip entry path" });
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
if (entries.has(canonical)) {
|
|
2500
|
+
issues.push({ path: canonical, message: "duplicate zip entry path" });
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2503
|
+
totalUncompressed += data.byteLength;
|
|
2504
|
+
if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
|
|
2505
|
+
return {
|
|
2506
|
+
ok: false,
|
|
2507
|
+
issues: [
|
|
2508
|
+
{
|
|
2509
|
+
path: archivePath,
|
|
2510
|
+
message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
|
|
2511
|
+
}
|
|
2512
|
+
]
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
entries.set(canonical, data);
|
|
2516
|
+
}
|
|
2517
|
+
if (issues.length) return { ok: false, issues };
|
|
2518
|
+
return { ok: true, entries };
|
|
2519
|
+
}
|
|
2520
|
+
async function collectDistEntries(distDir, spaDistRelative) {
|
|
2521
|
+
const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
|
|
2522
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2523
|
+
const walk = async (absDir, relPrefix) => {
|
|
2524
|
+
const dirEntries = await readdir4(absDir, { withFileTypes: true });
|
|
2525
|
+
for (const entry of dirEntries) {
|
|
2526
|
+
const abs = join9(absDir, entry.name);
|
|
2527
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
2528
|
+
const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
|
|
2529
|
+
if (!isSafeRelativeSpaPath(zipPath)) {
|
|
2530
|
+
throw new Error(`unsafe dist path: ${zipPath}`);
|
|
2531
|
+
}
|
|
2532
|
+
const stat2 = await lstat2(abs);
|
|
2533
|
+
if (stat2.isSymbolicLink()) {
|
|
2534
|
+
throw new Error(`dist contains symlink: ${abs}`);
|
|
2535
|
+
}
|
|
2536
|
+
if (stat2.isDirectory()) {
|
|
2537
|
+
await walk(abs, rel);
|
|
2538
|
+
} else if (stat2.isFile()) {
|
|
2539
|
+
entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
};
|
|
2543
|
+
await walk(distDir, "");
|
|
2544
|
+
return entries;
|
|
2545
|
+
}
|
|
2546
|
+
function entryToUtf8(data) {
|
|
2547
|
+
return strFromU8(data);
|
|
2548
|
+
}
|
|
2549
|
+
function utf8ToEntry(text) {
|
|
2550
|
+
return strToU8(text);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// src/lkcourse/parseEnvelope.ts
|
|
2554
|
+
function parseLkcourseEnvelope(raw, label = "manifest.json") {
|
|
2555
|
+
const issues = [];
|
|
2556
|
+
if (!raw || typeof raw !== "object") {
|
|
2557
|
+
return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
|
|
2558
|
+
}
|
|
2559
|
+
const obj = raw;
|
|
2560
|
+
if (obj.format !== "lkcourse") {
|
|
2561
|
+
issues.push({
|
|
2562
|
+
path: "format",
|
|
2563
|
+
message: `must be "lkcourse" (got ${String(obj.format)})`
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
let schemaVersion = obj.schemaVersion;
|
|
2567
|
+
if (schemaVersion === "1") schemaVersion = 1;
|
|
2568
|
+
if (schemaVersion !== 1) {
|
|
2569
|
+
issues.push({
|
|
2570
|
+
path: "schemaVersion",
|
|
2571
|
+
message: `must be 1 (got ${String(obj.schemaVersion)})`
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
|
|
2575
|
+
if (!lessonkitVersion) {
|
|
2576
|
+
issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
|
|
2577
|
+
}
|
|
2578
|
+
const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
|
|
2579
|
+
if (!exportedAt) {
|
|
2580
|
+
issues.push({ path: "exportedAt", message: "must be a non-empty string" });
|
|
2581
|
+
}
|
|
2582
|
+
const entriesRaw = obj.entries;
|
|
2583
|
+
const entries = [];
|
|
2584
|
+
if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
|
|
2585
|
+
issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
|
|
2586
|
+
} else {
|
|
2587
|
+
for (let i = 0; i < entriesRaw.length; i++) {
|
|
2588
|
+
const entry = entriesRaw[i];
|
|
2589
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
2590
|
+
issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
|
|
2591
|
+
} else {
|
|
2592
|
+
const trimmed = entry.trim();
|
|
2593
|
+
if (!isSafeZipEntryPath(trimmed)) {
|
|
2594
|
+
issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
|
|
2595
|
+
} else {
|
|
2596
|
+
entries.push(trimmed);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
if (issues.length) return { ok: false, issues };
|
|
2602
|
+
const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
|
|
2603
|
+
if (!manifestParsed.ok) {
|
|
2604
|
+
return {
|
|
2605
|
+
ok: false,
|
|
2606
|
+
issues: manifestParsed.issues.map((issue) => ({
|
|
2607
|
+
path: `sourceManifest.${issue.path}`,
|
|
2608
|
+
message: issue.message
|
|
2609
|
+
}))
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
return {
|
|
2613
|
+
ok: true,
|
|
2614
|
+
envelope: {
|
|
2615
|
+
format: "lkcourse",
|
|
2616
|
+
schemaVersion: 1,
|
|
2617
|
+
lessonkitVersion,
|
|
2618
|
+
exportedAt,
|
|
2619
|
+
sourceManifest: manifestParsed.manifest,
|
|
2620
|
+
entries
|
|
2621
|
+
}
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// src/lkcourse/blockTree.ts
|
|
2626
|
+
import { existsSync as existsSync3, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
|
|
2627
|
+
import { createRequire } from "module";
|
|
2628
|
+
import { join as join10, relative as relative3 } from "path";
|
|
2629
|
+
import { validateId as validateId4 } from "@lessonkit/core";
|
|
2630
|
+
var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
|
|
2631
|
+
var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
|
|
2632
|
+
function stripComments2(source) {
|
|
2633
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
2634
|
+
}
|
|
2635
|
+
function collectSourceUnderSrc2(projectRoot) {
|
|
2636
|
+
const srcDir = join10(projectRoot, "src");
|
|
2637
|
+
if (!existsSync3(srcDir)) return [];
|
|
2638
|
+
const results = [];
|
|
2639
|
+
const walk = (dir) => {
|
|
2640
|
+
for (const entry of readdirSync2(dir)) {
|
|
2641
|
+
const abs = join10(dir, entry);
|
|
2642
|
+
try {
|
|
2643
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
2644
|
+
} catch {
|
|
2645
|
+
continue;
|
|
2646
|
+
}
|
|
2647
|
+
const stat2 = lstatSync2(abs);
|
|
2648
|
+
if (stat2.isSymbolicLink()) continue;
|
|
2649
|
+
if (stat2.isDirectory()) {
|
|
2650
|
+
walk(abs);
|
|
2651
|
+
} else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
|
|
2652
|
+
results.push(relative3(projectRoot, abs));
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
walk(srcDir);
|
|
2657
|
+
return results;
|
|
2658
|
+
}
|
|
2659
|
+
function loadCatalogBlockTypes(blockTypes) {
|
|
2660
|
+
if (blockTypes?.length) return blockTypes;
|
|
2661
|
+
try {
|
|
2662
|
+
const require2 = createRequire(import.meta.url);
|
|
2663
|
+
const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
|
|
2664
|
+
const catalog = JSON.parse(readFileSync3(catalogPath, "utf8"));
|
|
2665
|
+
return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
|
|
2666
|
+
} catch {
|
|
2667
|
+
return [
|
|
2668
|
+
"Course",
|
|
2669
|
+
"Lesson",
|
|
2670
|
+
"Scenario",
|
|
2671
|
+
"Quiz",
|
|
2672
|
+
"KnowledgeCheck",
|
|
2673
|
+
"ProgressTracker",
|
|
2674
|
+
"Reflection",
|
|
2675
|
+
"TrueFalse",
|
|
2676
|
+
"MarkTheWords",
|
|
2677
|
+
"FillInTheBlanks",
|
|
2678
|
+
"DragTheWords",
|
|
2679
|
+
"DragAndDrop",
|
|
2680
|
+
"AssessmentSequence",
|
|
2681
|
+
"Text",
|
|
2682
|
+
"Heading",
|
|
2683
|
+
"Image",
|
|
2684
|
+
"Video",
|
|
2685
|
+
"Page",
|
|
2686
|
+
"InteractiveBook",
|
|
2687
|
+
"Slide",
|
|
2688
|
+
"SlideDeck",
|
|
2689
|
+
"TimedCue",
|
|
2690
|
+
"InteractiveVideo",
|
|
2691
|
+
"Summary",
|
|
2692
|
+
"BranchingScenario",
|
|
2693
|
+
"BranchNode",
|
|
2694
|
+
"BranchChoice",
|
|
2695
|
+
"Embed",
|
|
2696
|
+
"Chart"
|
|
2697
|
+
];
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
function extractIdProp(tagSource, prop) {
|
|
2701
|
+
const re = new RegExp(
|
|
2702
|
+
`\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
|
|
2703
|
+
);
|
|
2704
|
+
const match = tagSource.match(re);
|
|
2705
|
+
if (!match) return void 0;
|
|
2706
|
+
return match[1] ?? match[2] ?? match[3];
|
|
2707
|
+
}
|
|
2708
|
+
function parseJsxBlocks(source, blockTypes) {
|
|
2709
|
+
const stripped = stripComments2(source);
|
|
2710
|
+
const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
|
|
2711
|
+
const stack = [];
|
|
2712
|
+
const roots = [];
|
|
2713
|
+
for (const match of stripped.matchAll(tagRe)) {
|
|
2714
|
+
const rawTag = match[1];
|
|
2715
|
+
const attrs = match[2] ?? "";
|
|
2716
|
+
const selfClosing = match[3] === "/";
|
|
2717
|
+
if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
|
|
2718
|
+
const known = blockTypes.has(rawTag);
|
|
2719
|
+
const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
|
|
2720
|
+
for (const prop of ID_PROPS) {
|
|
2721
|
+
const value = extractIdProp(attrs, prop);
|
|
2722
|
+
if (value) node[prop] = value;
|
|
2723
|
+
}
|
|
2724
|
+
if (selfClosing) {
|
|
2725
|
+
if (stack.length) {
|
|
2726
|
+
const parent = stack[stack.length - 1];
|
|
2727
|
+
parent.children = parent.children ?? [];
|
|
2728
|
+
parent.children.push(node);
|
|
2729
|
+
} else {
|
|
2730
|
+
roots.push(node);
|
|
2731
|
+
}
|
|
2732
|
+
continue;
|
|
2733
|
+
}
|
|
2734
|
+
const closeRe = new RegExp(`</${rawTag}>`);
|
|
2735
|
+
const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
|
|
2736
|
+
if (!closeMatch) {
|
|
2737
|
+
if (stack.length) {
|
|
2738
|
+
const parent = stack[stack.length - 1];
|
|
2739
|
+
parent.children = parent.children ?? [];
|
|
2740
|
+
parent.children.push(node);
|
|
2741
|
+
} else {
|
|
2742
|
+
roots.push(node);
|
|
2743
|
+
}
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2746
|
+
stack.push(node);
|
|
2747
|
+
const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
|
|
2748
|
+
const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
|
|
2749
|
+
if (!inner.includes("<")) {
|
|
2750
|
+
stack.pop();
|
|
2751
|
+
if (stack.length) {
|
|
2752
|
+
const parent = stack[stack.length - 1];
|
|
2753
|
+
parent.children = parent.children ?? [];
|
|
2754
|
+
parent.children.push(node);
|
|
2755
|
+
} else {
|
|
2756
|
+
roots.push(node);
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
return roots.length ? roots : stack;
|
|
2761
|
+
}
|
|
2762
|
+
function validateNodeIds(node, pathPrefix, issues) {
|
|
2763
|
+
for (const prop of ID_PROPS) {
|
|
2764
|
+
const value = node[prop];
|
|
2765
|
+
if (value === void 0) continue;
|
|
2766
|
+
const validated = validateId4(value, prop);
|
|
2767
|
+
if (!validated.ok) {
|
|
2768
|
+
issues.push({
|
|
2769
|
+
path: `${pathPrefix}.${prop}`,
|
|
2770
|
+
message: validated.issues[0]?.message ?? `invalid ${prop}`
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
node.children?.forEach((child, index) => {
|
|
2775
|
+
validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
function validateBlockTreeIds(tree) {
|
|
2779
|
+
const issues = [];
|
|
2780
|
+
tree.blocks.forEach((block, index) => {
|
|
2781
|
+
validateNodeIds(block, `blocks[${index}]`, issues);
|
|
2782
|
+
});
|
|
2783
|
+
return issues;
|
|
2784
|
+
}
|
|
2785
|
+
function extractBlockTree(options) {
|
|
2786
|
+
const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
|
|
2787
|
+
const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
|
|
2788
|
+
const blocks = [];
|
|
2789
|
+
for (const rel of sources) {
|
|
2790
|
+
const abs = join10(options.projectRoot, rel);
|
|
2791
|
+
if (!existsSync3(abs)) continue;
|
|
2792
|
+
const source = readFileSync3(abs, "utf8");
|
|
2793
|
+
const parsed = parseJsxBlocks(source, blockTypes);
|
|
2794
|
+
blocks.push(...parsed);
|
|
2795
|
+
}
|
|
2796
|
+
return {
|
|
2797
|
+
schemaVersion: 1,
|
|
2798
|
+
sources,
|
|
2799
|
+
blocks
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
// src/lkcourse/export.ts
|
|
2804
|
+
import { mkdir as mkdir4, writeFile } from "fs/promises";
|
|
2805
|
+
import { createRequire as createRequire2 } from "module";
|
|
2806
|
+
import { dirname as dirname6, join as join11, resolve as resolve10 } from "path";
|
|
2807
|
+
import { parseLessonkitInterchange } from "@lxpack/validators";
|
|
2808
|
+
function resolveLessonkitVersion(explicit) {
|
|
2809
|
+
if (explicit?.trim()) return explicit.trim();
|
|
2810
|
+
try {
|
|
2811
|
+
const require2 = createRequire2(import.meta.url);
|
|
2812
|
+
const pkg = require2("../../package.json");
|
|
2813
|
+
return pkg.version ?? "0.0.0";
|
|
2814
|
+
} catch {
|
|
2815
|
+
return "0.0.0";
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
async function exportLkcourse(options) {
|
|
2819
|
+
const projectRoot = resolve10(options.projectRoot);
|
|
2820
|
+
const manifest = options.manifest;
|
|
2821
|
+
const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
|
|
2822
|
+
try {
|
|
2823
|
+
assertRealPathUnderRoot(projectRoot, spaDistDir);
|
|
2824
|
+
await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
|
|
2825
|
+
} catch (err) {
|
|
2826
|
+
return {
|
|
2827
|
+
ok: false,
|
|
2828
|
+
issues: [
|
|
2829
|
+
{
|
|
2830
|
+
path: manifest.paths.spaDistDir,
|
|
2831
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2832
|
+
}
|
|
2833
|
+
]
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2836
|
+
const injectableIssues = validateInjectableAssessments(manifest.course);
|
|
2837
|
+
if (injectableIssues.length > 0) {
|
|
2838
|
+
return {
|
|
2839
|
+
ok: false,
|
|
2840
|
+
issues: injectableIssues.map((issue) => ({
|
|
2841
|
+
path: issue.path,
|
|
2842
|
+
message: issue.message
|
|
2843
|
+
}))
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
const interchange = descriptorToInterchange(manifest.course);
|
|
2847
|
+
const interchangeParsed = parseLessonkitInterchange(interchange);
|
|
2848
|
+
if (!interchangeParsed.ok) {
|
|
2849
|
+
return {
|
|
2850
|
+
ok: false,
|
|
2851
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2852
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2853
|
+
message: i.message
|
|
2854
|
+
}))
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
const validatedInterchange = interchangeParsed.data;
|
|
2858
|
+
const interchangeCourseId = validatedInterchange.course?.id;
|
|
2859
|
+
if (!interchangeCourseId) {
|
|
2860
|
+
return {
|
|
2861
|
+
ok: false,
|
|
2862
|
+
issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
if (manifest.course.courseId !== interchangeCourseId) {
|
|
2866
|
+
return {
|
|
2867
|
+
ok: false,
|
|
2868
|
+
issues: [
|
|
2869
|
+
{
|
|
2870
|
+
path: "course.courseId",
|
|
2871
|
+
message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
|
|
2872
|
+
}
|
|
2873
|
+
]
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
const zipEntries = /* @__PURE__ */ new Map();
|
|
2877
|
+
const interchangeJson = JSON.stringify(interchange, null, 2);
|
|
2878
|
+
zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
|
|
2879
|
+
let blockTreeJson;
|
|
2880
|
+
if (options.includeBlockTree) {
|
|
2881
|
+
const blockTree = extractBlockTree({ projectRoot });
|
|
2882
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2883
|
+
if (blockTreeIssues.length) {
|
|
2884
|
+
return {
|
|
2885
|
+
ok: false,
|
|
2886
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2887
|
+
path: `block-tree.${issue.path}`,
|
|
2888
|
+
message: issue.message
|
|
2889
|
+
}))
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
2892
|
+
blockTreeJson = JSON.stringify(blockTree, null, 2);
|
|
2893
|
+
zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
|
|
2894
|
+
}
|
|
2895
|
+
let distEntries;
|
|
2896
|
+
try {
|
|
2897
|
+
distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
|
|
2898
|
+
} catch (err) {
|
|
2899
|
+
return {
|
|
2900
|
+
ok: false,
|
|
2901
|
+
issues: [
|
|
2902
|
+
{
|
|
2903
|
+
path: manifest.paths.spaDistDir,
|
|
2904
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2905
|
+
}
|
|
2906
|
+
]
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
|
|
2910
|
+
return {
|
|
2911
|
+
ok: false,
|
|
2912
|
+
issues: [
|
|
2913
|
+
{
|
|
2914
|
+
path: `${manifest.paths.spaDistDir}/index.html`,
|
|
2915
|
+
message: "dist must contain index.html before export"
|
|
2916
|
+
}
|
|
2917
|
+
]
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
for (const [path, data] of distEntries) {
|
|
2921
|
+
zipEntries.set(path, data);
|
|
2922
|
+
}
|
|
2923
|
+
const entryPaths = [...zipEntries.keys()].sort();
|
|
2924
|
+
const envelope = {
|
|
2925
|
+
format: "lkcourse",
|
|
2926
|
+
schemaVersion: 1,
|
|
2927
|
+
lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
|
|
2928
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2929
|
+
sourceManifest: manifest,
|
|
2930
|
+
entries: entryPaths
|
|
2931
|
+
};
|
|
2932
|
+
const envelopeCheck = parseLkcourseEnvelope(envelope);
|
|
2933
|
+
if (!envelopeCheck.ok) {
|
|
2934
|
+
return { ok: false, issues: envelopeCheck.issues };
|
|
2935
|
+
}
|
|
2936
|
+
zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
|
|
2937
|
+
const archivePath = resolve10(
|
|
2938
|
+
projectRoot,
|
|
2939
|
+
options.outPath ?? `${manifest.name}.lkcourse`
|
|
2940
|
+
);
|
|
2941
|
+
const archiveRel = options.outPath ?? `${manifest.name}.lkcourse`;
|
|
2942
|
+
try {
|
|
2943
|
+
assertRealPathUnderRoot(projectRoot, archivePath);
|
|
2944
|
+
} catch (err) {
|
|
2945
|
+
return {
|
|
2946
|
+
ok: false,
|
|
2947
|
+
issues: [
|
|
2948
|
+
{
|
|
2949
|
+
path: archiveRel,
|
|
2950
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2951
|
+
}
|
|
2952
|
+
]
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
if (isReservedResolvedOutputPath(projectRoot, archivePath)) {
|
|
2956
|
+
return {
|
|
2957
|
+
ok: false,
|
|
2958
|
+
issues: [
|
|
2959
|
+
{
|
|
2960
|
+
path: archiveRel,
|
|
2961
|
+
message: "output path must not target reserved directories (.git, node_modules, .github)"
|
|
2962
|
+
}
|
|
2963
|
+
]
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
if (!isSafeZipEntryPath(archiveRel)) {
|
|
2967
|
+
return {
|
|
2968
|
+
ok: false,
|
|
2969
|
+
issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
try {
|
|
2973
|
+
await mkdir4(dirname6(archivePath), { recursive: true });
|
|
2974
|
+
const zipped = createZip(zipEntries);
|
|
2975
|
+
await writeFile(archivePath, zipped);
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
return {
|
|
2978
|
+
ok: false,
|
|
2979
|
+
issues: [
|
|
2980
|
+
{
|
|
2981
|
+
path: archivePath,
|
|
2982
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2983
|
+
}
|
|
2984
|
+
]
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
return {
|
|
2988
|
+
ok: true,
|
|
2989
|
+
archivePath,
|
|
2990
|
+
fileCount: zipEntries.size,
|
|
2991
|
+
includeBlockTree: Boolean(options.includeBlockTree)
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
// src/lkcourse/validate.ts
|
|
2996
|
+
import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
|
|
2997
|
+
|
|
2998
|
+
// src/lkcourse/assessmentParity.ts
|
|
2999
|
+
function validateLkcourseAssessmentConsistency(descriptor, interchange) {
|
|
3000
|
+
const issues = [];
|
|
3001
|
+
for (const issue of validateInjectableAssessments(descriptor)) {
|
|
3002
|
+
issues.push({
|
|
3003
|
+
path: `sourceManifest.course.${issue.path}`,
|
|
3004
|
+
message: issue.message
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
const expectedIds = extractAssessments(descriptor).map((a) => a.id).sort();
|
|
3008
|
+
const interchangeIds = (interchange.assessments ?? []).map((a) => a.id).sort();
|
|
3009
|
+
const matches = expectedIds.length === interchangeIds.length && expectedIds.every((id, index) => id === interchangeIds[index]);
|
|
3010
|
+
if (!matches) {
|
|
3011
|
+
issues.push({
|
|
3012
|
+
path: "interchange.assessments",
|
|
3013
|
+
message: `injectable assessment ids [${expectedIds.join(", ")}] do not match interchange [${interchangeIds.join(", ")}]`
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
return issues;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
// src/lkcourse/validate.ts
|
|
3020
|
+
function validateLkcourseArchiveEntries(entries, _archiveLabel) {
|
|
3021
|
+
const issues = [];
|
|
3022
|
+
const manifestData = entries.get("manifest.json");
|
|
3023
|
+
if (!manifestData) {
|
|
3024
|
+
return {
|
|
3025
|
+
ok: false,
|
|
3026
|
+
issues: [{ path: "manifest.json", message: "required file missing from archive" }]
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
let envelopeRaw;
|
|
3030
|
+
try {
|
|
3031
|
+
envelopeRaw = JSON.parse(entryToUtf8(manifestData));
|
|
3032
|
+
} catch {
|
|
3033
|
+
return {
|
|
3034
|
+
ok: false,
|
|
3035
|
+
issues: [{ path: "manifest.json", message: "invalid JSON" }]
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
|
|
3039
|
+
if (!envelopeParsed.ok) {
|
|
3040
|
+
return { ok: false, issues: envelopeParsed.issues };
|
|
3041
|
+
}
|
|
3042
|
+
const envelope = envelopeParsed.envelope;
|
|
3043
|
+
const interchangeData = entries.get("interchange.json");
|
|
3044
|
+
if (!interchangeData) {
|
|
3045
|
+
issues.push({ path: "interchange.json", message: "required file missing from archive" });
|
|
3046
|
+
}
|
|
3047
|
+
const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
3048
|
+
const spaIndexPath = `${spaDistDir}/index.html`;
|
|
3049
|
+
if (!entries.has(spaIndexPath)) {
|
|
3050
|
+
issues.push({ path: spaIndexPath, message: "required file missing from archive" });
|
|
3051
|
+
}
|
|
3052
|
+
const allowlisted = new Set(envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")));
|
|
3053
|
+
const spaDistPrefix = `${spaDistDir}/`;
|
|
3054
|
+
for (const entryPath of envelope.entries) {
|
|
3055
|
+
if (!entries.has(entryPath)) {
|
|
3056
|
+
issues.push({
|
|
3057
|
+
path: entryPath,
|
|
3058
|
+
message: "listed in manifest.entries but missing from archive"
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
for (const zipPath of entries.keys()) {
|
|
3063
|
+
const normalized = zipPath.replace(/\\/g, "/");
|
|
3064
|
+
if (!normalized.startsWith(spaDistPrefix)) continue;
|
|
3065
|
+
if (!allowlisted.has(normalized)) {
|
|
3066
|
+
issues.push({
|
|
3067
|
+
path: zipPath,
|
|
3068
|
+
message: "unlisted file under spaDistDir; not in manifest.entries"
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
if (issues.length) return { ok: false, issues };
|
|
3073
|
+
let interchangeRaw;
|
|
3074
|
+
try {
|
|
3075
|
+
interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
|
|
3076
|
+
} catch {
|
|
3077
|
+
return {
|
|
3078
|
+
ok: false,
|
|
3079
|
+
issues: [{ path: "interchange.json", message: "invalid JSON" }]
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
const interchangeParsed = parseLessonkitInterchange2(interchangeRaw);
|
|
3083
|
+
if (!interchangeParsed.ok) {
|
|
3084
|
+
return {
|
|
3085
|
+
ok: false,
|
|
3086
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
3087
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
3088
|
+
message: i.message
|
|
3089
|
+
}))
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
const interchange = interchangeParsed.data;
|
|
3093
|
+
const interchangeCourseId = interchange.course?.id;
|
|
3094
|
+
if (!interchangeCourseId) {
|
|
3095
|
+
issues.push({
|
|
3096
|
+
path: "interchange.course.id",
|
|
3097
|
+
message: "missing course id in interchange"
|
|
3098
|
+
});
|
|
3099
|
+
} else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
|
|
3100
|
+
issues.push({
|
|
3101
|
+
path: "sourceManifest.course.courseId",
|
|
3102
|
+
message: `does not match interchange.course.id (${interchangeCourseId})`
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
issues.push(
|
|
3106
|
+
...validateLkcourseAssessmentConsistency(
|
|
3107
|
+
envelope.sourceManifest.course,
|
|
3108
|
+
interchange
|
|
3109
|
+
)
|
|
3110
|
+
);
|
|
3111
|
+
if (issues.length) return { ok: false, issues };
|
|
3112
|
+
const blockTreeData = entries.get("block-tree.json");
|
|
3113
|
+
if (blockTreeData) {
|
|
3114
|
+
let blockTreeRaw;
|
|
3115
|
+
try {
|
|
3116
|
+
blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
|
|
3117
|
+
} catch {
|
|
3118
|
+
return {
|
|
3119
|
+
ok: false,
|
|
3120
|
+
issues: [{ path: "block-tree.json", message: "invalid JSON" }]
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
const blockTree = blockTreeRaw;
|
|
3124
|
+
if (Array.isArray(blockTree?.blocks)) {
|
|
3125
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
3126
|
+
if (blockTreeIssues.length) {
|
|
3127
|
+
return {
|
|
3128
|
+
ok: false,
|
|
3129
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
3130
|
+
path: `block-tree.${issue.path}`,
|
|
3131
|
+
message: issue.message
|
|
3132
|
+
}))
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
return {
|
|
3138
|
+
ok: true,
|
|
3139
|
+
envelope,
|
|
3140
|
+
interchange
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
function validateLkcourse(archivePath) {
|
|
3144
|
+
const read = readZip(archivePath);
|
|
3145
|
+
if (!read.ok) return read;
|
|
3146
|
+
return validateLkcourseArchiveEntries(read.entries, archivePath);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
// src/lkcourse/import.ts
|
|
3150
|
+
import { access as access3, cp as cp2, mkdir as mkdir5, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
3151
|
+
import { dirname as dirname7, join as join12, resolve as resolve11 } from "path";
|
|
3152
|
+
var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
|
|
3153
|
+
async function pathExists2(path) {
|
|
3154
|
+
try {
|
|
3155
|
+
await access3(path);
|
|
3156
|
+
return true;
|
|
3157
|
+
} catch {
|
|
3158
|
+
return false;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
async function renameOrCopy2(from, to, opts) {
|
|
3162
|
+
const renameFn = opts?.renameFn ?? rename2;
|
|
3163
|
+
try {
|
|
3164
|
+
await renameFn(from, to);
|
|
3165
|
+
} catch (err) {
|
|
3166
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
3167
|
+
if (code !== "EXDEV") throw err;
|
|
3168
|
+
await cp2(from, to, { recursive: true });
|
|
3169
|
+
await rm4(from, { recursive: true, force: true });
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
async function writeImportTree(stagingDir, manifest, entries, spaDistDir, allowlistedSpaPaths) {
|
|
3173
|
+
let fileCount = 0;
|
|
3174
|
+
await writeFile2(
|
|
3175
|
+
join12(stagingDir, "lessonkit.json"),
|
|
3176
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
3177
|
+
`,
|
|
3178
|
+
"utf8"
|
|
3179
|
+
);
|
|
3180
|
+
fileCount += 1;
|
|
3181
|
+
for (const [entryPath, data] of entries) {
|
|
3182
|
+
const normalized = entryPath.replace(/\\/g, "/");
|
|
3183
|
+
if (!normalized.startsWith(`${spaDistDir}/`)) continue;
|
|
3184
|
+
if (!allowlistedSpaPaths.has(normalized)) {
|
|
3185
|
+
throw new Error(`unlisted spaDist entry rejected: ${entryPath}`);
|
|
3186
|
+
}
|
|
3187
|
+
const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
|
|
3188
|
+
const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
|
|
3189
|
+
const resolvedOut = resolve11(outPath);
|
|
3190
|
+
assertRealPathUnderRoot(stagingDir, resolvedOut);
|
|
3191
|
+
if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
|
|
3192
|
+
throw new Error(`unsafe extraction path: ${entryPath}`);
|
|
3193
|
+
}
|
|
3194
|
+
await mkdir5(dirname7(resolvedOut), { recursive: true });
|
|
3195
|
+
await writeFile2(resolvedOut, data);
|
|
3196
|
+
fileCount += 1;
|
|
3197
|
+
}
|
|
3198
|
+
return fileCount;
|
|
3199
|
+
}
|
|
3200
|
+
async function backupImportArtifacts(targetDir) {
|
|
3201
|
+
const existing = [];
|
|
3202
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3203
|
+
if (await pathExists2(join12(targetDir, name))) {
|
|
3204
|
+
existing.push(name);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
if (!existing.length) return void 0;
|
|
3208
|
+
const backupDir = await mkdtemp3(join12(targetDir, ".lkcourse-backup-"));
|
|
3209
|
+
for (const name of existing) {
|
|
3210
|
+
await renameOrCopy2(join12(targetDir, name), join12(backupDir, name));
|
|
3211
|
+
}
|
|
3212
|
+
return backupDir;
|
|
3213
|
+
}
|
|
3214
|
+
async function restoreImportBackup(targetDir, backupDir) {
|
|
3215
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3216
|
+
const backupPath = join12(backupDir, name);
|
|
3217
|
+
if (!await pathExists2(backupPath)) continue;
|
|
3218
|
+
const destPath = join12(targetDir, name);
|
|
3219
|
+
if (await pathExists2(destPath)) {
|
|
3220
|
+
await rm4(destPath, { recursive: true, force: true });
|
|
3221
|
+
}
|
|
3222
|
+
await renameOrCopy2(backupPath, destPath);
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
async function snapshotPreExistingImportArtifacts(targetDir) {
|
|
3226
|
+
const existing = /* @__PURE__ */ new Set();
|
|
3227
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3228
|
+
if (await pathExists2(join12(targetDir, name))) {
|
|
3229
|
+
existing.add(name);
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
return existing;
|
|
3233
|
+
}
|
|
3234
|
+
async function rollbackFailedImport(targetDir, backupDir, preExisting) {
|
|
3235
|
+
if (backupDir) {
|
|
3236
|
+
await restoreImportBackup(targetDir, backupDir);
|
|
3237
|
+
}
|
|
3238
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
3239
|
+
if (preExisting.has(name)) continue;
|
|
3240
|
+
const destPath = join12(targetDir, name);
|
|
3241
|
+
if (await pathExists2(destPath)) {
|
|
3242
|
+
await rm4(destPath, { recursive: true, force: true });
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
async function promoteImportStaging(stagingDir, targetDir) {
|
|
3247
|
+
await mkdir5(targetDir, { recursive: true });
|
|
3248
|
+
const entries = await readdir3(stagingDir, { withFileTypes: true });
|
|
3249
|
+
for (const entry of entries) {
|
|
3250
|
+
const srcPath = join12(stagingDir, entry.name);
|
|
3251
|
+
const destPath = join12(targetDir, entry.name);
|
|
3252
|
+
if (entry.isDirectory() || entry.isFile()) {
|
|
3253
|
+
await renameOrCopy2(srcPath, destPath);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
var promoteImportStagingImpl = promoteImportStaging;
|
|
3258
|
+
async function importLkcourse(options) {
|
|
3259
|
+
const archivePath = resolve11(options.archivePath);
|
|
3260
|
+
const targetDir = resolve11(options.targetDir);
|
|
3261
|
+
const validated = validateLkcourse(archivePath);
|
|
3262
|
+
if (!validated.ok) return validated;
|
|
3263
|
+
const { envelope, interchange } = validated;
|
|
3264
|
+
const manifest = envelope.sourceManifest;
|
|
3265
|
+
const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
3266
|
+
try {
|
|
3267
|
+
await mkdir5(targetDir, { recursive: true });
|
|
3268
|
+
assertRealPathUnderRoot(targetDir, targetDir);
|
|
3269
|
+
} catch (err) {
|
|
3270
|
+
return {
|
|
3271
|
+
ok: false,
|
|
3272
|
+
issues: [
|
|
3273
|
+
{
|
|
3274
|
+
path: targetDir,
|
|
3275
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3276
|
+
}
|
|
3277
|
+
]
|
|
3278
|
+
};
|
|
3279
|
+
}
|
|
3280
|
+
const read = readZip(archivePath);
|
|
3281
|
+
if (!read.ok) return read;
|
|
3282
|
+
let stagingDir;
|
|
3283
|
+
let backupDir;
|
|
3284
|
+
let preExisting;
|
|
3285
|
+
try {
|
|
3286
|
+
stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
|
|
3287
|
+
const allowlistedSpaPaths = new Set(
|
|
3288
|
+
envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")).filter((entryPath) => entryPath.startsWith(`${spaDistDir}/`))
|
|
3289
|
+
);
|
|
3290
|
+
const fileCount = await writeImportTree(
|
|
3291
|
+
stagingDir,
|
|
3292
|
+
manifest,
|
|
3293
|
+
read.entries,
|
|
3294
|
+
spaDistDir,
|
|
3295
|
+
allowlistedSpaPaths
|
|
3296
|
+
);
|
|
3297
|
+
preExisting = await snapshotPreExistingImportArtifacts(targetDir);
|
|
3298
|
+
backupDir = await backupImportArtifacts(targetDir);
|
|
3299
|
+
try {
|
|
3300
|
+
await promoteImportStagingImpl(stagingDir, targetDir);
|
|
3301
|
+
} catch (promoteError) {
|
|
3302
|
+
await rollbackFailedImport(targetDir, backupDir, preExisting);
|
|
3303
|
+
backupDir = void 0;
|
|
3304
|
+
throw promoteError;
|
|
3305
|
+
}
|
|
3306
|
+
if (backupDir) {
|
|
3307
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3308
|
+
backupDir = void 0;
|
|
3309
|
+
}
|
|
3310
|
+
await rm4(stagingDir, { recursive: true, force: true });
|
|
3311
|
+
stagingDir = void 0;
|
|
3312
|
+
return {
|
|
3313
|
+
ok: true,
|
|
3314
|
+
targetDir,
|
|
3315
|
+
manifest,
|
|
3316
|
+
interchange,
|
|
3317
|
+
fileCount
|
|
3318
|
+
};
|
|
3319
|
+
} catch (err) {
|
|
3320
|
+
if (preExisting) {
|
|
3321
|
+
await rollbackFailedImport(targetDir, backupDir, preExisting).catch(() => void 0);
|
|
3322
|
+
} else if (backupDir) {
|
|
3323
|
+
await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
|
|
3324
|
+
}
|
|
3325
|
+
if (backupDir) {
|
|
3326
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3327
|
+
}
|
|
3328
|
+
if (stagingDir) {
|
|
3329
|
+
await rm4(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3330
|
+
}
|
|
3331
|
+
return {
|
|
3332
|
+
ok: false,
|
|
3333
|
+
issues: [
|
|
3334
|
+
{
|
|
3335
|
+
path: targetDir,
|
|
3336
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3337
|
+
}
|
|
3338
|
+
]
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
2068
3342
|
export {
|
|
2069
3343
|
LESSONKIT_TELEMETRY_EVENTS,
|
|
3344
|
+
assertSpaDistContentsSafe,
|
|
2070
3345
|
assessmentDescriptorToLxpack,
|
|
2071
3346
|
buildLessonkitProject,
|
|
2072
3347
|
buildStagingPackage,
|
|
2073
3348
|
descriptorToInterchange,
|
|
2074
3349
|
ensureOutDirParent,
|
|
2075
3350
|
escapeShellText,
|
|
3351
|
+
exportLkcourse,
|
|
2076
3352
|
extractAssessments,
|
|
3353
|
+
extractBlockTree,
|
|
3354
|
+
importLkcourse,
|
|
2077
3355
|
lessonkitInterchangeSchema,
|
|
2078
3356
|
loadLessonkitManifestFromFile,
|
|
2079
3357
|
mapLessonkitIds,
|
|
@@ -2081,8 +3359,9 @@ export {
|
|
|
2081
3359
|
mapLessonkitTelemetryToLxpack,
|
|
2082
3360
|
materializeLessonkitProject2 as materializeLessonkitProject,
|
|
2083
3361
|
packageLessonkitCourse,
|
|
2084
|
-
parseLessonkitInterchange,
|
|
3362
|
+
parseLessonkitInterchange3 as parseLessonkitInterchange,
|
|
2085
3363
|
parseLessonkitManifest,
|
|
3364
|
+
parseLkcourseEnvelope,
|
|
2086
3365
|
promoteStagingToOutDir,
|
|
2087
3366
|
remapArtifactPaths,
|
|
2088
3367
|
resolveSafePackageOutputOverride,
|
|
@@ -2092,6 +3371,9 @@ export {
|
|
|
2092
3371
|
validateDescriptor,
|
|
2093
3372
|
validateDescriptorForTarget,
|
|
2094
3373
|
validateLessonkitProject,
|
|
3374
|
+
validateLkcourse,
|
|
3375
|
+
validateLkcourseArchiveEntries,
|
|
3376
|
+
validateManifestName,
|
|
2095
3377
|
validatePackageInputs,
|
|
2096
3378
|
validateProjectPaths,
|
|
2097
3379
|
validateReactManifestParity,
|