tessera-learn 0.0.1 → 0.0.2
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/AGENTS.md +93 -75
- package/README.md +11 -0
- package/dist/plugin/index.js +79 -78
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +19 -69
- package/src/components/LockedBanner.svelte +30 -0
- package/src/components/Matching.svelte +44 -80
- package/src/components/MultipleChoice.svelte +14 -43
- package/src/components/Quiz.svelte +69 -263
- package/src/components/ResultIcon.svelte +13 -0
- package/src/components/RetryButton.svelte +25 -0
- package/src/components/Sorting.svelte +33 -76
- package/src/components/util.ts +10 -0
- package/src/plugin/export.ts +39 -33
- package/src/plugin/manifest.ts +38 -12
- package/src/plugin/validation.ts +36 -69
- package/src/runtime/App.svelte +15 -20
- package/src/runtime/ErrorPage.svelte +1 -1
- package/src/runtime/adapters/retry.ts +48 -41
- package/src/runtime/adapters/scorm-base.ts +143 -0
- package/src/runtime/adapters/scorm12.ts +37 -117
- package/src/runtime/adapters/scorm2004.ts +34 -115
- package/src/runtime/hooks.svelte.ts +63 -29
- package/src/runtime/xapi/client.ts +2 -2
- package/src/runtime/xapi/publisher.ts +15 -6
- package/src/runtime/xapi/setup.ts +8 -15
- package/styles/layout.css +21 -10
- package/styles/theme.css +4 -0
package/dist/plugin/index.js
CHANGED
|
@@ -6,6 +6,10 @@ import JSON5 from "json5";
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { ZipArchive } from "archiver";
|
|
8
8
|
//#region src/plugin/manifest.ts
|
|
9
|
+
/** Append `.svelte` if not already present. Both bare and suffixed names are accepted in author config. */
|
|
10
|
+
function ensureSvelteSuffix(name) {
|
|
11
|
+
return name.endsWith(".svelte") ? name : `${name}.svelte`;
|
|
12
|
+
}
|
|
9
13
|
/**
|
|
10
14
|
* Module-level cache of source file contents keyed by absolute path with
|
|
11
15
|
* mtime invalidation. Both `validateProject` and `generateManifest` read the
|
|
@@ -71,25 +75,34 @@ function readMetaFile(metaPath) {
|
|
|
71
75
|
return {};
|
|
72
76
|
}
|
|
73
77
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const moduleScriptMatch = readSourceFileCached(filePath).match(MODULE_SCRIPT_RE);
|
|
79
|
-
if (!moduleScriptMatch) return {};
|
|
78
|
+
/** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
|
|
79
|
+
function parsePageConfigFromSource(content) {
|
|
80
|
+
const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
|
|
81
|
+
if (!moduleScriptMatch) return { kind: "none" };
|
|
80
82
|
const scriptContent = moduleScriptMatch[1];
|
|
81
83
|
const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
|
|
82
|
-
if (!configMatch || configMatch.index === void 0) return {};
|
|
84
|
+
if (!configMatch || configMatch.index === void 0) return { kind: "none" };
|
|
85
|
+
if (!scriptContent.slice(configMatch.index + configMatch[0].length).trimStart().startsWith("{")) return { kind: "invalid" };
|
|
83
86
|
const startIndex = scriptContent.indexOf("{", configMatch.index + configMatch[0].length);
|
|
84
|
-
if (startIndex < 0) return {};
|
|
87
|
+
if (startIndex < 0) return { kind: "invalid" };
|
|
85
88
|
const objectStr = extractObjectLiteral(scriptContent, startIndex);
|
|
86
|
-
if (!objectStr) return {};
|
|
89
|
+
if (!objectStr) return { kind: "invalid" };
|
|
87
90
|
try {
|
|
88
|
-
return
|
|
91
|
+
return {
|
|
92
|
+
kind: "ok",
|
|
93
|
+
value: JSON5.parse(objectStr)
|
|
94
|
+
};
|
|
89
95
|
} catch {
|
|
90
|
-
|
|
96
|
+
return { kind: "invalid" };
|
|
91
97
|
}
|
|
92
98
|
}
|
|
99
|
+
/** Extract pageConfig from a .svelte file. Throws on parse failure. */
|
|
100
|
+
function extractPageConfig(filePath) {
|
|
101
|
+
const result = parsePageConfigFromSource(readSourceFileCached(filePath));
|
|
102
|
+
if (result.kind === "ok") return result.value;
|
|
103
|
+
if (result.kind === "invalid") throw new Error(`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
93
106
|
/**
|
|
94
107
|
* Extract an object literal from source starting at the opening brace.
|
|
95
108
|
* Tracks brace depth to find the matching closing brace.
|
|
@@ -215,7 +228,7 @@ function generateManifest(pagesDir) {
|
|
|
215
228
|
*/
|
|
216
229
|
function orderPageFiles(allFiles, pagesArray) {
|
|
217
230
|
if (!pagesArray || pagesArray.length === 0) return allFiles;
|
|
218
|
-
const listed = pagesArray.map(
|
|
231
|
+
const listed = pagesArray.map(ensureSvelteSuffix);
|
|
219
232
|
const listedSet = new Set(listed);
|
|
220
233
|
const unlisted = allFiles.filter((f) => !listedSet.has(f)).sort();
|
|
221
234
|
return [...listed.filter((f) => allFiles.includes(f)), ...unlisted];
|
|
@@ -441,7 +454,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
441
454
|
if (actor === void 0) {
|
|
442
455
|
if (standard === "web") errors.push(`course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. Provide either a static actor object or a function that resolves one (e.g. from your auth system).`);
|
|
443
456
|
} else if (typeof actor === "object" && actor !== null) {
|
|
444
|
-
const err =
|
|
457
|
+
const err = validateAgent(actor);
|
|
445
458
|
if (err) {
|
|
446
459
|
const joined = err.startsWith(".") ? `${label}.actor${err}` : `${label}.actor ${err}`;
|
|
447
460
|
errors.push(`course.config.js: ${joined}`);
|
|
@@ -474,18 +487,13 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
474
487
|
if (standard !== "cmi5") warnings.push(`course.config.js: ${label}.registration is a cmi5 concept; the LRS will accept it under "${standard}" but most analytics tools won't know what to do with it.`);
|
|
475
488
|
}
|
|
476
489
|
}
|
|
477
|
-
/**
|
|
478
|
-
* Build-time alias for the shared `validateAgent` rules. Suffixes are already
|
|
479
|
-
* prefix-friendly (no leading "actor"), so this is a straight pass-through —
|
|
480
|
-
* kept named so the call sites in this file stay readable.
|
|
481
|
-
*/
|
|
482
|
-
const validateStaticAgent = validateAgent;
|
|
483
490
|
function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
484
491
|
const errors = [];
|
|
485
492
|
const warnings = [];
|
|
486
493
|
let totalPages = 0;
|
|
487
494
|
let totalQuizzes = 0;
|
|
488
495
|
let hasGradedQuiz = false;
|
|
496
|
+
const assetExistsCache = /* @__PURE__ */ new Map();
|
|
489
497
|
if (!existsSync(pagesDir)) {
|
|
490
498
|
errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
491
499
|
return {
|
|
@@ -527,7 +535,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
527
535
|
return name.endsWith(".svelte") && statSync(full).isFile();
|
|
528
536
|
}).sort();
|
|
529
537
|
if (sectionMeta?.pages) for (const pageName of sectionMeta.pages) {
|
|
530
|
-
const fileName = pageName
|
|
538
|
+
const fileName = ensureSvelteSuffix(pageName);
|
|
531
539
|
if (!sectionSvelteFiles.includes(fileName)) {
|
|
532
540
|
const metaRel = relative(projectRoot, resolve(sectionPath, "_meta.js"));
|
|
533
541
|
errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
|
|
@@ -544,7 +552,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
544
552
|
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
545
553
|
if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
|
|
546
554
|
}
|
|
547
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings);
|
|
555
|
+
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
548
556
|
}
|
|
549
557
|
const lessonDirs = sectionEntries.filter((name) => {
|
|
550
558
|
return statSync(resolve(sectionPath, name)).isDirectory() && !name.startsWith(".");
|
|
@@ -555,14 +563,14 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
555
563
|
const meta = validateMetaFile(resolve(lessonPath, "_meta.js"), lessonRel, errors);
|
|
556
564
|
const svelteFiles = readdirSync(lessonPath).filter((name) => name.endsWith(".svelte")).sort();
|
|
557
565
|
if (meta?.pages) for (const pageName of meta.pages) {
|
|
558
|
-
const fileName = pageName
|
|
566
|
+
const fileName = ensureSvelteSuffix(pageName);
|
|
559
567
|
if (!svelteFiles.includes(fileName)) {
|
|
560
568
|
const metaRel = relative(projectRoot, resolve(lessonPath, "_meta.js"));
|
|
561
569
|
errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
|
|
562
570
|
}
|
|
563
571
|
}
|
|
564
572
|
if (meta?.pages && meta.pages.length > 0) {
|
|
565
|
-
const listedSet = new Set(meta.pages.map(
|
|
573
|
+
const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
|
|
566
574
|
for (const file of svelteFiles) if (!listedSet.has(file)) {
|
|
567
575
|
const relPath = relative(projectRoot, resolve(lessonPath, file));
|
|
568
576
|
warnings.push(`${relPath}: not listed in _meta.js pages array — will be appended at end`);
|
|
@@ -579,7 +587,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
579
587
|
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
580
588
|
if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
|
|
581
589
|
}
|
|
582
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings);
|
|
590
|
+
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
583
591
|
}
|
|
584
592
|
}
|
|
585
593
|
}
|
|
@@ -611,26 +619,10 @@ function validateMetaFile(metaPath, parentRel, errors) {
|
|
|
611
619
|
return meta;
|
|
612
620
|
}
|
|
613
621
|
function validatePageConfig(content, fileRel, errors) {
|
|
614
|
-
const
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (!exportMatch || exportMatch.index === void 0) return null;
|
|
619
|
-
if (!scriptContent.slice(exportMatch.index + exportMatch[0].length).trimStart().startsWith("{")) {
|
|
620
|
-
errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
const objectStr = extractObjectLiteral(scriptContent, scriptContent.indexOf("{", exportMatch.index + exportMatch[0].length));
|
|
624
|
-
if (!objectStr) {
|
|
625
|
-
errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
try {
|
|
629
|
-
return JSON5.parse(objectStr);
|
|
630
|
-
} catch {
|
|
631
|
-
errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
622
|
+
const result = parsePageConfigFromSource(content);
|
|
623
|
+
if (result.kind === "ok") return result.value;
|
|
624
|
+
if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
625
|
+
return null;
|
|
634
626
|
}
|
|
635
627
|
function validateQuizConfig(quiz, fileRel, errors) {
|
|
636
628
|
if (!quiz || typeof quiz !== "object") return;
|
|
@@ -641,12 +633,24 @@ function validateQuizConfig(quiz, fileRel, errors) {
|
|
|
641
633
|
}
|
|
642
634
|
if (cfg.graded !== void 0 && typeof cfg.graded !== "boolean") errors.push(`${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`);
|
|
643
635
|
}
|
|
644
|
-
|
|
645
|
-
|
|
636
|
+
const ASSET_REF_RE = /\$assets\/([^\s"'`)]+)/g;
|
|
637
|
+
/** Match $assets/... refs in any context (src attrs, import statements, url() etc) and dedupe. */
|
|
638
|
+
function collectAssetRefs(content) {
|
|
639
|
+
const seen = /* @__PURE__ */ new Set();
|
|
646
640
|
let match;
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
641
|
+
ASSET_REF_RE.lastIndex = 0;
|
|
642
|
+
while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1]);
|
|
643
|
+
return [...seen];
|
|
644
|
+
}
|
|
645
|
+
function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
646
|
+
for (const assetPath of collectAssetRefs(content)) {
|
|
647
|
+
const fullAssetPath = resolve(assetsDir, assetPath);
|
|
648
|
+
let exists = existsCache.get(fullAssetPath);
|
|
649
|
+
if (exists === void 0) {
|
|
650
|
+
exists = existsSync(fullAssetPath);
|
|
651
|
+
existsCache.set(fullAssetPath, exists);
|
|
652
|
+
}
|
|
653
|
+
if (!exists) warnings.push(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
|
|
650
654
|
}
|
|
651
655
|
}
|
|
652
656
|
function crossValidate(config, pageResults, errors, warnings) {
|
|
@@ -712,15 +716,31 @@ function formatSize(bytes) {
|
|
|
712
716
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
713
717
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
714
718
|
}
|
|
715
|
-
|
|
719
|
+
const SCORM_DIALECTS = {
|
|
720
|
+
"1.2": {
|
|
721
|
+
rootNs: "http://www.imsproject.org/xsd/imscp_rootv1p1p2",
|
|
722
|
+
adlcpNs: "http://www.adlnet.org/xsd/adlcp_rootv1p2",
|
|
723
|
+
schemaversion: "1.2",
|
|
724
|
+
scormTypeAttr: "scormtype"
|
|
725
|
+
},
|
|
726
|
+
"2004": {
|
|
727
|
+
rootNs: "http://www.imsglobal.org/xsd/imscp_v1p1",
|
|
728
|
+
adlcpNs: "http://www.adlnet.org/xsd/adlcp_v1p3",
|
|
729
|
+
schemaversion: "2004 4th Edition",
|
|
730
|
+
scormTypeAttr: "scormType"
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
function generateScormManifest(version, config, distDir) {
|
|
734
|
+
const dialect = SCORM_DIALECTS[version];
|
|
716
735
|
const title = escapeXml(config.title || "Tessera Course");
|
|
736
|
+
const fileElements = collectFiles(distDir).map((f) => ` <file href="${escapeXml(f)}" />`).join("\n");
|
|
717
737
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
718
738
|
<manifest identifier="tessera-course" version="1.0"
|
|
719
|
-
xmlns="
|
|
720
|
-
xmlns:adlcp="
|
|
739
|
+
xmlns="${dialect.rootNs}"
|
|
740
|
+
xmlns:adlcp="${dialect.adlcpNs}">
|
|
721
741
|
<metadata>
|
|
722
742
|
<schema>ADL SCORM</schema>
|
|
723
|
-
<schemaversion
|
|
743
|
+
<schemaversion>${dialect.schemaversion}</schemaversion>
|
|
724
744
|
</metadata>
|
|
725
745
|
<organizations default="org-1">
|
|
726
746
|
<organization identifier="org-1">
|
|
@@ -731,36 +751,17 @@ function generateSCORM12Manifest(config, distDir) {
|
|
|
731
751
|
</organization>
|
|
732
752
|
</organizations>
|
|
733
753
|
<resources>
|
|
734
|
-
<resource identifier="res-1" type="webcontent" adlcp
|
|
735
|
-
${
|
|
754
|
+
<resource identifier="res-1" type="webcontent" adlcp:${dialect.scormTypeAttr}="sco" href="index.html">
|
|
755
|
+
${fileElements}
|
|
736
756
|
</resource>
|
|
737
757
|
</resources>
|
|
738
758
|
</manifest>`;
|
|
739
759
|
}
|
|
760
|
+
function generateSCORM12Manifest(config, distDir) {
|
|
761
|
+
return generateScormManifest("1.2", config, distDir);
|
|
762
|
+
}
|
|
740
763
|
function generateSCORM2004Manifest(config, distDir) {
|
|
741
|
-
|
|
742
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
743
|
-
<manifest identifier="tessera-course" version="1.0"
|
|
744
|
-
xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
|
|
745
|
-
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3">
|
|
746
|
-
<metadata>
|
|
747
|
-
<schema>ADL SCORM</schema>
|
|
748
|
-
<schemaversion>2004 4th Edition</schemaversion>
|
|
749
|
-
</metadata>
|
|
750
|
-
<organizations default="org-1">
|
|
751
|
-
<organization identifier="org-1">
|
|
752
|
-
<title>${title}</title>
|
|
753
|
-
<item identifier="item-1" identifierref="res-1">
|
|
754
|
-
<title>${title}</title>
|
|
755
|
-
</item>
|
|
756
|
-
</organization>
|
|
757
|
-
</organizations>
|
|
758
|
-
<resources>
|
|
759
|
-
<resource identifier="res-1" type="webcontent" adlcp:scormType="sco" href="index.html">
|
|
760
|
-
${collectFiles(distDir).map((f) => ` <file href="${escapeXml(f)}" />`).join("\n")}
|
|
761
|
-
</resource>
|
|
762
|
-
</resources>
|
|
763
|
-
</manifest>`;
|
|
764
|
+
return generateScormManifest("2004", config, distDir);
|
|
764
765
|
}
|
|
765
766
|
function generateCMI5Xml(config) {
|
|
766
767
|
const title = escapeXml(config.title || "Tessera Course");
|