tessera-learn 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/index.js +144 -9
- package/dist/plugin/index.js.map +1 -1
- package/package.json +9 -6
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/plugin/index.ts +179 -9
- package/src/runtime/App.svelte +51 -32
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/hooks.svelte.ts +10 -10
- package/src/runtime/navigation.svelte.ts +38 -5
- package/src/runtime/progress.svelte.ts +16 -10
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/src/runtime/LoadingSkeleton.svelte +0 -26
package/dist/plugin/index.js
CHANGED
|
@@ -282,15 +282,22 @@ function resolveStylesDir() {
|
|
|
282
282
|
return resolve(resolve(__dirname, "..", ".."), "styles");
|
|
283
283
|
}
|
|
284
284
|
function tesseraPlugin() {
|
|
285
|
+
const manifestRef = {
|
|
286
|
+
current: null,
|
|
287
|
+
root: ""
|
|
288
|
+
};
|
|
285
289
|
return [
|
|
286
|
-
svelte({ compilerOptions: { css: "
|
|
290
|
+
svelte({ compilerOptions: { css: "external" } }),
|
|
287
291
|
tesseraValidationPlugin(),
|
|
288
292
|
tesseraEntryPlugin(),
|
|
289
293
|
tesseraConfigPlugin(),
|
|
290
294
|
tesseraPagesPlugin(),
|
|
291
|
-
tesseraManifestPlugin(),
|
|
295
|
+
tesseraManifestPlugin(manifestRef),
|
|
292
296
|
tesseraLayoutPlugin(),
|
|
293
297
|
tesseraQuizPlugin(),
|
|
298
|
+
tesseraAdapterPlugin(),
|
|
299
|
+
tesseraXAPISetupPlugin(),
|
|
300
|
+
tesseraFirstPagePreloadPlugin(manifestRef),
|
|
294
301
|
tesseraExportPlugin()
|
|
295
302
|
];
|
|
296
303
|
}
|
|
@@ -545,13 +552,13 @@ function tesseraExportPlugin() {
|
|
|
545
552
|
}
|
|
546
553
|
const VIRTUAL_MANIFEST_ID = "virtual:tessera-manifest";
|
|
547
554
|
const RESOLVED_MANIFEST_ID = "\0" + VIRTUAL_MANIFEST_ID;
|
|
548
|
-
function tesseraManifestPlugin() {
|
|
555
|
+
function tesseraManifestPlugin(manifestRef) {
|
|
549
556
|
let projectRoot;
|
|
550
557
|
let pagesDir;
|
|
551
|
-
let currentManifest = null;
|
|
552
558
|
function buildManifest() {
|
|
553
|
-
|
|
554
|
-
|
|
559
|
+
const m = generateManifest(pagesDir);
|
|
560
|
+
manifestRef.current = m;
|
|
561
|
+
return m;
|
|
555
562
|
}
|
|
556
563
|
return {
|
|
557
564
|
name: "tessera:manifest",
|
|
@@ -559,12 +566,13 @@ function tesseraManifestPlugin() {
|
|
|
559
566
|
configResolved(config) {
|
|
560
567
|
projectRoot = config.root;
|
|
561
568
|
pagesDir = resolve(projectRoot, "pages");
|
|
569
|
+
manifestRef.root = projectRoot;
|
|
562
570
|
},
|
|
563
571
|
configureServer(devServer) {
|
|
564
572
|
devServer.watcher.on("all", (event, filePath) => {
|
|
565
573
|
if (!filePath.startsWith(pagesDir)) return;
|
|
566
574
|
if (filePath.endsWith(".svelte") || filePath.endsWith("_meta.js") || event === "addDir" || event === "unlinkDir") {
|
|
567
|
-
|
|
575
|
+
manifestRef.current = null;
|
|
568
576
|
const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
569
577
|
if (mod) {
|
|
570
578
|
devServer.moduleGraph.invalidateModule(mod);
|
|
@@ -583,15 +591,142 @@ function tesseraManifestPlugin() {
|
|
|
583
591
|
},
|
|
584
592
|
load(id) {
|
|
585
593
|
if (id === RESOLVED_MANIFEST_ID) {
|
|
586
|
-
if (!
|
|
594
|
+
if (!manifestRef.current) buildManifest();
|
|
587
595
|
addWatchFiles(this, pagesDir);
|
|
588
|
-
const json = JSON.stringify(
|
|
596
|
+
const json = JSON.stringify(manifestRef.current, (_key, value) => value === Infinity ? 1e9 : value);
|
|
589
597
|
return `export default JSON.parse(atob("${Buffer.from(json).toString("base64")}"));`;
|
|
590
598
|
}
|
|
591
599
|
return null;
|
|
592
600
|
}
|
|
593
601
|
};
|
|
594
602
|
}
|
|
603
|
+
const VIRTUAL_ADAPTER_ID = "virtual:tessera-adapter";
|
|
604
|
+
const RESOLVED_ADAPTER_ID = "\0" + VIRTUAL_ADAPTER_ID;
|
|
605
|
+
function tesseraAdapterPlugin() {
|
|
606
|
+
let projectRoot;
|
|
607
|
+
let isBuild = false;
|
|
608
|
+
return {
|
|
609
|
+
name: "tessera:adapter",
|
|
610
|
+
enforce: "pre",
|
|
611
|
+
configResolved(config) {
|
|
612
|
+
projectRoot = config.root;
|
|
613
|
+
isBuild = config.command === "build";
|
|
614
|
+
},
|
|
615
|
+
resolveId(id) {
|
|
616
|
+
if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;
|
|
617
|
+
return null;
|
|
618
|
+
},
|
|
619
|
+
load(id) {
|
|
620
|
+
if (id !== RESOLVED_ADAPTER_ID) return null;
|
|
621
|
+
if (!isBuild) return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
|
|
622
|
+
let standard = "web";
|
|
623
|
+
const configPath = resolve(projectRoot, "course.config.js");
|
|
624
|
+
if (existsSync(configPath)) {
|
|
625
|
+
const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, "utf-8"));
|
|
626
|
+
if (objectStr) try {
|
|
627
|
+
const parsed = JSON5.parse(objectStr);
|
|
628
|
+
if (typeof parsed?.export?.standard === "string") standard = parsed.export.standard;
|
|
629
|
+
} catch {}
|
|
630
|
+
}
|
|
631
|
+
switch (standard) {
|
|
632
|
+
case "scorm12": return `
|
|
633
|
+
import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
|
|
634
|
+
import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
635
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
636
|
+
export function createAdapter() {
|
|
637
|
+
const api = findSCORM12API();
|
|
638
|
+
if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');
|
|
639
|
+
return new SCORM12Adapter(api);
|
|
640
|
+
}
|
|
641
|
+
`;
|
|
642
|
+
case "scorm2004": return `
|
|
643
|
+
import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
|
|
644
|
+
import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
645
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
646
|
+
export function createAdapter() {
|
|
647
|
+
const api = findSCORM2004API();
|
|
648
|
+
if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');
|
|
649
|
+
return new SCORM2004Adapter(api);
|
|
650
|
+
}
|
|
651
|
+
`;
|
|
652
|
+
case "cmi5": return `
|
|
653
|
+
import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
|
|
654
|
+
import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
655
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
656
|
+
export function createAdapter() {
|
|
657
|
+
if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
|
|
658
|
+
return new CMI5Adapter();
|
|
659
|
+
}
|
|
660
|
+
`;
|
|
661
|
+
default: return `
|
|
662
|
+
import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
|
|
663
|
+
export function createAdapter(config) {
|
|
664
|
+
return new WebAdapter(config);
|
|
665
|
+
}
|
|
666
|
+
`;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const VIRTUAL_XAPI_SETUP_ID = "virtual:tessera-xapi-setup";
|
|
672
|
+
const RESOLVED_XAPI_SETUP_ID = "\0" + VIRTUAL_XAPI_SETUP_ID;
|
|
673
|
+
function tesseraXAPISetupPlugin() {
|
|
674
|
+
let projectRoot;
|
|
675
|
+
let isBuild = false;
|
|
676
|
+
return {
|
|
677
|
+
name: "tessera:xapi-setup",
|
|
678
|
+
enforce: "pre",
|
|
679
|
+
configResolved(config) {
|
|
680
|
+
projectRoot = config.root;
|
|
681
|
+
isBuild = config.command === "build";
|
|
682
|
+
},
|
|
683
|
+
resolveId(id) {
|
|
684
|
+
if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;
|
|
685
|
+
return null;
|
|
686
|
+
},
|
|
687
|
+
load(id) {
|
|
688
|
+
if (id !== RESOLVED_XAPI_SETUP_ID) return null;
|
|
689
|
+
if (!isBuild) return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
690
|
+
let standard = "web";
|
|
691
|
+
let hasXapi = false;
|
|
692
|
+
const configPath = resolve(projectRoot, "course.config.js");
|
|
693
|
+
if (existsSync(configPath)) {
|
|
694
|
+
const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, "utf-8"));
|
|
695
|
+
if (objectStr) try {
|
|
696
|
+
const parsed = JSON5.parse(objectStr);
|
|
697
|
+
if (typeof parsed?.export?.standard === "string") standard = parsed.export.standard;
|
|
698
|
+
hasXapi = parsed?.xapi != null;
|
|
699
|
+
} catch {}
|
|
700
|
+
}
|
|
701
|
+
if (hasXapi || standard === "cmi5") return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
702
|
+
return `export async function buildXAPIClient() { return null; }`;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function tesseraFirstPagePreloadPlugin(manifestRef) {
|
|
707
|
+
return {
|
|
708
|
+
name: "tessera:first-page-preload",
|
|
709
|
+
apply: "build",
|
|
710
|
+
transformIndexHtml: {
|
|
711
|
+
order: "post",
|
|
712
|
+
handler(_html, ctx) {
|
|
713
|
+
const firstPagePath = manifestRef.current?.pages[0]?.importPath;
|
|
714
|
+
if (!firstPagePath || !ctx.bundle) return;
|
|
715
|
+
const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\//, "")).replace(/\\/g, "/");
|
|
716
|
+
const chunk = Object.values(ctx.bundle).find((c) => c.type === "chunk" && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, "/") === normalized);
|
|
717
|
+
if (!chunk) return;
|
|
718
|
+
return [{
|
|
719
|
+
tag: "link",
|
|
720
|
+
attrs: {
|
|
721
|
+
rel: "modulepreload",
|
|
722
|
+
href: `./${chunk.fileName}`
|
|
723
|
+
},
|
|
724
|
+
injectTo: "head"
|
|
725
|
+
}];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
}
|
|
595
730
|
//#endregion
|
|
596
731
|
export { tesseraPlugin };
|
|
597
732
|
|
package/dist/plugin/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["__dirname"],"sources":["../../src/runtime/slugify.ts","../../src/plugin/export.ts","../../src/plugin/layout.ts","../../src/plugin/quiz.ts","../../src/plugin/index.ts"],"sourcesContent":["/**\n * Slugify a string for use as a URL-safe / filename-safe identifier.\n * \"My Course Title\" → \"my-course-title\"\n *\n * Shared by the runtime (`WebAdapter` localStorage key) and the build-time\n * exporter (`runExport` zip filename). Both want identical, deterministic\n * output so a course's storage key matches its package name.\n */\nexport function slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '')\n .replace(/[\\s_]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '');\n}\n","import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { createWriteStream } from 'node:fs';\nimport { createHash } from 'node:crypto';\nimport { ZipArchive } from 'archiver';\nimport { slugify } from '../runtime/slugify.js';\n\n// ---------- Types ----------\n\ninterface ExportConfig {\n title: string;\n description?: string;\n version?: string;\n scoring?: { passingScore?: number };\n completion?: { mode?: 'quiz' | 'percentage' };\n export?: { standard?: string };\n}\n\n// ---------- Helpers ----------\n\nfunction escapeXml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/**\n * Recursively collect all file paths relative to a directory.\n */\nfunction collectFiles(dir: string, base: string = ''): string[] {\n const files: string[] = [];\n if (!existsSync(dir)) return files;\n\n for (const entry of readdirSync(dir)) {\n const fullPath = resolve(dir, entry);\n const relPath = base ? `${base}/${entry}` : entry;\n if (statSync(fullPath).isDirectory()) {\n files.push(...collectFiles(fullPath, relPath));\n } else {\n files.push(relPath);\n }\n }\n return files;\n}\n\n/**\n * Derive a stable URN IRI from a seed string. cmi5 §13.1 / xs:anyURI\n * require course / AU ids to be IRIs — bare hex or UUID-shaped strings\n * (without correct version/variant bits) aren't conformant URNs and may\n * be rejected by strict LMS importers.\n *\n * Hash the seed so the id survives rebuilds, then format as\n * `urn:tessera:<kind>:<hex>`. The same seed always produces the same\n * IRI, so existing LRS records are not orphaned by re-export.\n */\nfunction stableUrn(kind: 'course' | 'au', seed: string): string {\n const h = createHash('sha256').update(seed).digest('hex');\n // 32 hex chars (128 bits of entropy) is plenty; trim to keep ids short.\n return `urn:tessera:${kind}:${h.slice(0, 32)}`;\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n// ---------- Manifest Generators ----------\n\n/** Per-version XML differences in imsmanifest.xml between SCORM 1.2 and 2004. */\ninterface ScormManifestDialect {\n rootNs: string;\n adlcpNs: string;\n schemaversion: string;\n /** Attribute name on <resource>: SCORM 1.2 uses lowercase, 2004 uses camelCase. */\n scormTypeAttr: 'scormtype' | 'scormType';\n /** Whitespace-separated namespace+XSD pairs for xsi:schemaLocation. */\n schemaLocation: string;\n}\n\nconst SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {\n '1.2': {\n rootNs: 'http://www.imsproject.org/xsd/imscp_rootv1p1p2',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_rootv1p2',\n schemaversion: '1.2',\n scormTypeAttr: 'scormtype',\n schemaLocation:\n 'http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd ' +\n 'http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd',\n },\n '2004': {\n rootNs: 'http://www.imsglobal.org/xsd/imscp_v1p1',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_v1p3',\n schemaversion: '2004 4th Edition',\n scormTypeAttr: 'scormType',\n schemaLocation:\n 'http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd',\n },\n};\n\nexport function generateScormManifest(\n version: '1.2' | '2004',\n config: ExportConfig,\n distDir: string\n): string {\n const dialect = SCORM_DIALECTS[version];\n const title = escapeXml(config.title || 'Tessera Course');\n const files = collectFiles(distDir);\n const fileElements = files\n .map((f) => ` <file href=\"${escapeXml(f)}\" />`)\n .join('\\n');\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest identifier=\"tessera-course\" version=\"1.0\"\n xmlns=\"${dialect.rootNs}\"\n xmlns:adlcp=\"${dialect.adlcpNs}\"\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:schemaLocation=\"${dialect.schemaLocation}\">\n <metadata>\n <schema>ADL SCORM</schema>\n <schemaversion>${dialect.schemaversion}</schemaversion>\n </metadata>\n <organizations default=\"org-1\">\n <organization identifier=\"org-1\">\n <title>${title}</title>\n <item identifier=\"item-1\" identifierref=\"res-1\">\n <title>${title}</title>\n </item>\n </organization>\n </organizations>\n <resources>\n <resource identifier=\"res-1\" type=\"webcontent\" adlcp:${dialect.scormTypeAttr}=\"sco\" href=\"index.html\">\n${fileElements}\n </resource>\n </resources>\n</manifest>`;\n}\n\nexport function generateSCORM12Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('1.2', config, distDir);\n}\n\nexport function generateSCORM2004Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('2004', config, distDir);\n}\n\nexport function generateCMI5Xml(config: ExportConfig): string {\n const title = escapeXml(config.title || 'Tessera Course');\n const description = escapeXml(config.description || '');\n // Derive stable IDs from the course title so they survive rebuilds without\n // orphaning existing learner records in the LRS.\n const courseId = stableUrn('course', `tessera-course:${config.title || ''}`);\n const auId = stableUrn('au', `tessera-au:${config.title || ''}`);\n // cmi5 §10.2.4 caps masteryScore at 4 decimals; avoid float drift like 0.7000000000000001.\n const masteryScore = Number(\n ((config.scoring?.passingScore ?? 70) / 100).toFixed(4)\n );\n // cmi5 §13.1.4 — `moveOn` decides which verb(s) the LMS treats as\n // satisfying the AU. For graded courses (completion gated on a quiz)\n // a learner who completes without passing should NOT receive credit, so\n // the LMS needs both a Completed AND a Passed before satisfaction.\n // Percentage-mode courses don't surface pass/fail, so completion alone\n // is the right signal.\n const moveOn =\n config.completion?.mode === 'quiz' ? 'CompletedAndPassed' : 'Completed';\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<courseStructure xmlns=\"https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd\">\n <course id=\"${courseId}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n </course>\n <au id=\"${auId}\" launchMethod=\"AnyWindow\" moveOn=\"${moveOn}\" masteryScore=\"${masteryScore}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n <url>index.html</url>\n </au>\n</courseStructure>`;\n}\n\n// ---------- ZIP Packaging ----------\n\nexport async function createZip(\n distDir: string,\n outputPath: string\n): Promise<number> {\n return new Promise((res, reject) => {\n const output = createWriteStream(outputPath);\n const archive = new ZipArchive({ zlib: { level: 9 } });\n\n output.on('close', () => {\n res(archive.pointer());\n });\n output.on('error', reject);\n archive.on('error', reject);\n\n archive.pipe(output);\n archive.directory(distDir, false);\n archive.finalize();\n });\n}\n\n// ---------- Main Export ----------\n\n/**\n * Run the export process after Vite build completes.\n * Writes manifest XML into dist/, then packages into ZIP if needed.\n */\n/** Remove any previously built zips for this package to prevent accumulation. */\nfunction cleanOldZips(projectRoot: string, slug: string): void {\n try {\n for (const f of readdirSync(projectRoot)) {\n if (f.startsWith(`${slug}-`) && f.endsWith('.zip')) {\n try { unlinkSync(resolve(projectRoot, f)); } catch {}\n }\n }\n } catch {}\n}\n\nexport async function runExport(\n projectRoot: string,\n config: ExportConfig\n): Promise<void> {\n const distDir = resolve(projectRoot, 'dist');\n const standard = config.export?.standard || 'web';\n const slug = slugify(config.title || 'tessera-course') || 'tessera-course';\n const version = config.version || '1.0.0';\n const zipName = `${slug}-${version}.zip`;\n const zipPath = resolve(projectRoot, zipName);\n\n switch (standard) {\n case 'web': {\n // Compute dist size\n const files = collectFiles(distDir);\n let totalSize = 0;\n for (const f of files) {\n totalSize += statSync(resolve(distDir, f)).size;\n }\n console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);\n break;\n }\n\n case 'scorm12': {\n const manifest = generateSCORM12Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'scorm2004': {\n const manifest = generateSCORM2004Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'cmi5': {\n const xml = generateCMI5Xml(config);\n writeFileSync(resolve(distDir, 'cmi5.xml'), xml, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);\n break;\n }\n }\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\n\nconst VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';\nconst RESOLVED_LAYOUT_ID = '\\0' + VIRTUAL_LAYOUT_ID;\n\nexport function tesseraLayoutPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:layout',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_LAYOUT_ID) return null;\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n if (existsSync(layoutPath)) {\n // Register the file with Vite so edits trigger HMR / build --watch\n // re-runs. Only add when the file actually exists — calling\n // addWatchFile on a non-existent path makes Vite's importAnalysis\n // try to resolve it as a real import.\n this.addWatchFile(layoutPath);\n const normalized = layoutPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n return `export default null;`;\n },\n\n configureServer(server: ViteDevServer) {\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n // Only react to add/unlink: those flip the virtual module's load() output\n // between `export default null` and `export { default } from '...'`. A\n // `change` event leaves that output identical and is handled by Svelte's\n // own HMR for the underlying file — full-reloading on every edit would\n // wipe in-page state for no reason.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== layoutPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';\nconst RESOLVED_QUIZ_ID = '\\0' + VIRTUAL_QUIZ_ID;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n/**\n * Resolve the project's quiz shell.\n * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,\n * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).\n */\nexport function tesseraQuizPlugin(): Plugin {\n let projectRoot: string;\n // Resolve the built-in Quiz.svelte once. The plugin lives in\n // `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —\n // both layouts put `Quiz.svelte` two levels up under `src/components/`.\n const packageRoot = resolve(__dirname, '..', '..');\n const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');\n\n return {\n name: 'tessera:quiz',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_QUIZ_ID) return null;\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n if (existsSync(userQuizPath)) {\n // Watch the user file so add/remove flips through HMR (see below).\n this.addWatchFile(userQuizPath);\n const normalized = userQuizPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n const normalized = builtinQuiz.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n },\n\n configureServer(server: ViteDevServer) {\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n // Only react to add/unlink — those flip the load() output between the\n // user quiz and the built-in. A `change` event leaves the resolved\n // module identical and is handled by Svelte's own HMR.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== userQuizPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nimport { fileURLToPath } from 'node:url';\nimport { dirname, resolve } from 'node:path';\nimport { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';\nimport { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';\nimport JSON5 from 'json5';\nimport type { Manifest } from './manifest.js';\nimport { validateProject } from './validation.js';\nimport { runExport } from './export.js';\nimport { tesseraLayoutPlugin } from './layout.js';\nimport { tesseraQuizPlugin } from './quiz.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// Resolve the runtime directory where App.svelte lives\nfunction resolveRuntimeDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'src', 'runtime');\n}\n\n// Resolve the framework styles directory\nfunction resolveStylesDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'styles');\n}\n\nexport function tesseraPlugin() {\n return [\n svelte({\n compilerOptions: { css: 'injected' },\n }),\n tesseraValidationPlugin(),\n tesseraEntryPlugin(),\n tesseraConfigPlugin(),\n tesseraPagesPlugin(),\n tesseraManifestPlugin(),\n tesseraLayoutPlugin(),\n tesseraQuizPlugin(),\n tesseraExportPlugin(),\n ];\n}\n\n// ---------- Entry Plugin ----------\n\nconst VIRTUAL_ENTRY_ID = 'virtual:tessera-entry';\nconst RESOLVED_ENTRY_ID = '\\0' + VIRTUAL_ENTRY_ID;\nconst VIRTUAL_MAIN_ID = '/virtual:tessera-main';\nconst RESOLVED_MAIN_ID = '\\0virtual:tessera-main';\n\nfunction tesseraEntryPlugin(): Plugin {\n const runtimeDir = resolveRuntimeDir();\n const stylesDir = resolveStylesDir();\n const appSveltePath = resolve(runtimeDir, 'App.svelte');\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:entry',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n // For build mode: write index.html so Rollup can find it\n buildStart() {\n if (isBuild) {\n writeFileSync(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');\n }\n },\n\n // For build mode: clean up temporary index.html and copy assets\n closeBundle() {\n if (isBuild) {\n const htmlPath = resolve(projectRoot, 'index.html');\n if (existsSync(htmlPath)) {\n try { unlinkSync(htmlPath); } catch {}\n }\n\n // Copy assets/ directory to dist/assets/ so $assets/ references resolve\n const assetsDir = resolve(projectRoot, 'assets');\n const distAssetsDir = resolve(projectRoot, 'dist', 'assets');\n if (existsSync(assetsDir)) {\n mkdirSync(distAssetsDir, { recursive: true });\n cpSync(assetsDir, distAssetsDir, { recursive: true });\n }\n }\n },\n\n // Serve index.html for the dev server\n configureServer(server: ViteDevServer) {\n return () => {\n server.middlewares.use(async (req, res, next) => {\n if (req.url === '/' || req.url === '/index.html') {\n const html = generateIndexHtml();\n const transformed = await server.transformIndexHtml(req.url, html);\n res.setHeader('Content-Type', 'text/html');\n res.statusCode = 200;\n res.end(transformed);\n return;\n }\n next();\n });\n };\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;\n if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main') return RESOLVED_MAIN_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_ENTRY_ID || id === RESOLVED_MAIN_ID) {\n return generateEntryScript(appSveltePath, stylesDir, projectRoot);\n }\n return null;\n },\n };\n}\n\nfunction generateIndexHtml(): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Tessera Course</title>\n</head>\n<body>\n <div id=\"tessera-root\"></div>\n <script type=\"module\" src=\"/virtual:tessera-main\"></script>\n</body>\n</html>`;\n}\n\nfunction generateEntryScript(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {\n const normalizedPath = appSveltePath.replace(/\\\\/g, '/');\n\n // Framework CSS imports (theme → base → layout)\n const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];\n const frameworkImports = frameworkCssOrder\n .map(file => resolve(frameworkStylesDir, file).replace(/\\\\/g, '/'))\n .filter(path => existsSync(path))\n .map(path => `import '${path}';`)\n .join('\\n');\n\n // User CSS imports from project's styles/ directory\n const userStylesDir = resolve(projectRoot, 'styles');\n let userImports = '';\n if (existsSync(userStylesDir)) {\n const userCssFiles = readdirSync(userStylesDir)\n .filter(f => f.endsWith('.css'))\n .sort();\n userImports = userCssFiles\n .map(f => resolve(userStylesDir, f).replace(/\\\\/g, '/'))\n .map(path => `import '${path}';`)\n .join('\\n');\n }\n\n return `// Framework styles\n${frameworkImports}\n// User styles\n${userImports}\n\nimport { mount } from 'svelte';\nimport App from '${normalizedPath}';\n\nmount(App, {\n target: document.getElementById('tessera-root'),\n});\n`;\n}\n\n// ---------- Config Plugin ----------\n\nconst VIRTUAL_CONFIG_ID = 'virtual:tessera-config';\nconst RESOLVED_CONFIG_ID = '\\0' + VIRTUAL_CONFIG_ID;\n\nfunction completionDefaults(mode: string | undefined): {\n completion: Record<string, unknown>;\n passingScore: number;\n} {\n if (mode === 'manual') {\n return { completion: { mode: 'manual' }, passingScore: 0 };\n }\n return { completion: { mode: 'percentage', percentageThreshold: 100 }, passingScore: 70 };\n}\n\nfunction tesseraConfigPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:config',\n enforce: 'pre',\n\n config(config) {\n const root = config.root || process.cwd();\n\n return {\n base: './',\n resolve: {\n alias: {\n '$assets': resolve(root, 'assets'),\n },\n },\n // tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer\n // doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.\n optimizeDeps: {\n exclude: ['tessera-learn'],\n },\n };\n },\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_CONFIG_ID) return RESOLVED_CONFIG_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_CONFIG_ID) {\n const configPath = resolve(projectRoot, 'course.config.js');\n let userConfig: Record<string, any> = {};\n\n if (existsSync(configPath)) {\n this.addWatchFile(configPath);\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try { userConfig = JSON5.parse(objectStr); } catch {}\n }\n }\n\n const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);\n const merged = {\n title: userConfig.title || 'Untitled Course',\n ...userConfig,\n navigation: { mode: 'free', ...userConfig.navigation },\n completion: { ...completion, ...userConfig.completion },\n scoring: { passingScore, ...userConfig.scoring },\n export: { standard: 'web', ...userConfig.export },\n };\n\n return `export default ${JSON.stringify(merged)};`;\n }\n return null;\n },\n };\n}\n\n// ---------- Manifest Watch Helpers ----------\n\n/** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */\nfunction addWatchFiles(ctx: { addWatchFile(id: string): void }, dir: string): void {\n if (!existsSync(dir)) return;\n for (const entry of readdirSync(dir)) {\n const full = resolve(dir, entry);\n if (statSync(full).isDirectory()) {\n addWatchFiles(ctx, full);\n } else if (entry.endsWith('.svelte') || entry === '_meta.js') {\n ctx.addWatchFile(full);\n }\n }\n}\n\n// ---------- Pages Plugin ----------\n\nconst VIRTUAL_PAGES_ID = 'virtual:tessera-pages';\nconst RESOLVED_PAGES_ID = '\\0' + VIRTUAL_PAGES_ID;\n\n/**\n * Provides a virtual module that exports an import.meta.glob map for all .svelte\n * pages. This runs in the user's project context so the glob resolves against their\n * pages/ directory, and Vite can statically analyze it for code splitting.\n */\nfunction tesseraPagesPlugin(): Plugin {\n return {\n name: 'tessera:pages',\n enforce: 'pre',\n\n resolveId(id) {\n if (id === VIRTUAL_PAGES_ID) return RESOLVED_PAGES_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_PAGES_ID) {\n return `export default import.meta.glob('/pages/**/*.svelte');`;\n }\n return null;\n },\n };\n}\n\n// ---------- Validation Plugin ----------\n\nfunction tesseraValidationPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:validation',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n // Run validation during dev (configResolved fires before server starts)\n if (!isBuild) {\n runValidation(projectRoot);\n }\n },\n\n buildStart() {\n // Run validation during build (buildStart fires once before bundling)\n if (isBuild) {\n runValidation(projectRoot);\n }\n },\n };\n}\n\nfunction runValidation(projectRoot: string): void {\n const { errors, warnings } = validateProject(projectRoot);\n\n for (const warning of warnings) {\n console.warn(`\\x1b[33m[tessera warning]\\x1b[0m ${warning}`);\n }\n\n if (errors.length > 0) {\n for (const error of errors) {\n console.error(`\\x1b[31m[tessera error]\\x1b[0m ${error}`);\n }\n throw new Error(\n `Tessera validation failed with ${errors.length} error(s). Fix the errors above to continue.`\n );\n }\n}\n\n// ---------- Export Plugin ----------\n\nfunction tesseraExportPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:export',\n enforce: 'post',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n async closeBundle() {\n if (!isBuild) return;\n\n const configPath = resolve(projectRoot, 'course.config.js');\n if (!existsSync(configPath)) {\n // Validation already required course.config.js — getting here means\n // the file vanished mid-build. Surface that loudly rather than\n // shipping a bundle with no LMS export silently.\n throw new Error(\n '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'\n );\n }\n\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (!objectStr) {\n throw new Error(\n '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'\n );\n }\n\n let config: any;\n try {\n config = JSON5.parse(objectStr);\n } catch (err) {\n throw new Error(\n `[tessera:export] course.config.js: failed to parse export-default object literal — ${(err as Error).message}`\n );\n }\n\n await runExport(projectRoot, config);\n },\n };\n}\n\n// ---------- Manifest Plugin ----------\n\nconst VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';\nconst RESOLVED_MANIFEST_ID = '\\0' + VIRTUAL_MANIFEST_ID;\n\nfunction tesseraManifestPlugin(): Plugin {\n let projectRoot: string;\n let pagesDir: string;\n let currentManifest: Manifest | null = null;\n let server: ViteDevServer | null = null;\n\n function buildManifest(): Manifest {\n currentManifest = generateManifest(pagesDir);\n return currentManifest;\n }\n\n return {\n name: 'tessera:manifest',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n pagesDir = resolve(projectRoot, 'pages');\n },\n\n configureServer(devServer: ViteDevServer) {\n server = devServer;\n\n // Watch the pages directory for changes\n devServer.watcher.on('all', (event, filePath) => {\n if (!filePath.startsWith(pagesDir)) return;\n\n // Rebuild manifest on relevant file changes\n const isRelevant =\n filePath.endsWith('.svelte') ||\n filePath.endsWith('_meta.js') ||\n event === 'addDir' ||\n event === 'unlinkDir';\n\n if (isRelevant) {\n currentManifest = null; // invalidate cache\n\n // Invalidate the virtual module to trigger HMR\n const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);\n if (mod) {\n devServer.moduleGraph.invalidateModule(mod);\n devServer.ws.send({ type: 'full-reload' });\n }\n\n console.log(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`);\n }\n });\n },\n\n buildStart() {\n buildManifest();\n },\n\n resolveId(id) {\n if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_MANIFEST_ID) {\n if (!currentManifest) {\n buildManifest();\n }\n\n // Register watch files so Vite's built-in watcher (used in build --watch)\n // knows to re-trigger when pages/ content changes.\n addWatchFiles(this, pagesDir);\n\n // Encode as base64 to prevent Vite's import analysis from\n // scanning .svelte importPath strings as module imports.\n // Replace Infinity with 1e9 since JSON.stringify drops it.\n const json = JSON.stringify(currentManifest, (_key, value) =>\n value === Infinity ? 1e9 : value\n );\n const b64 = Buffer.from(json).toString('base64');\n return `export default JSON.parse(atob(\"${b64}\"));`;\n }\n return null;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAQA,SAAgB,QAAQ,MAAsB;CAC5C,OAAO,KACJ,aAAa,CACb,MAAM,CACN,QAAQ,aAAa,GAAG,CACxB,QAAQ,WAAW,IAAI,CACvB,QAAQ,OAAO,IAAI,CACnB,QAAQ,UAAU,GAAG;;;;ACK1B,SAAS,UAAU,KAAqB;CACtC,OAAO,IACJ,QAAQ,MAAM,QAAQ,CACtB,QAAQ,MAAM,OAAO,CACrB,QAAQ,MAAM,OAAO,CACrB,QAAQ,MAAM,SAAS,CACvB,QAAQ,MAAM,SAAS;;;;;AAM5B,SAAS,aAAa,KAAa,OAAe,IAAc;CAC9D,MAAM,QAAkB,EAAE;CAC1B,IAAI,CAAC,WAAW,IAAI,EAAE,OAAO;CAE7B,KAAK,MAAM,SAAS,YAAY,IAAI,EAAE;EACpC,MAAM,WAAW,QAAQ,KAAK,MAAM;EACpC,MAAM,UAAU,OAAO,GAAG,KAAK,GAAG,UAAU;EAC5C,IAAI,SAAS,SAAS,CAAC,aAAa,EAClC,MAAM,KAAK,GAAG,aAAa,UAAU,QAAQ,CAAC;OAE9C,MAAM,KAAK,QAAQ;;CAGvB,OAAO;;;;;;;;;;;;AAaT,SAAS,UAAU,MAAuB,MAAsB;CAG9D,OAAO,eAAe,KAAK,GAFjB,WAAW,SAAS,CAAC,OAAO,KAAK,CAAC,OAAO,MAEpB,CAAC,MAAM,GAAG,GAAG;;AAG9C,SAAS,WAAW,OAAuB;CACzC,IAAI,QAAQ,MAAM,OAAO,GAAG,MAAM;CAClC,IAAI,QAAQ,OAAO,MAAM,OAAO,IAAI,QAAQ,MAAM,QAAQ,EAAE,CAAC;CAC7D,OAAO,IAAI,SAAS,OAAO,OAAO,QAAQ,EAAE,CAAC;;AAgB/C,MAAM,iBAA+D;CACnE,OAAO;EACL,QAAQ;EACR,SAAS;EACT,eAAe;EACf,eAAe;EACf,gBACE;EAGH;CACD,QAAQ;EACN,QAAQ;EACR,SAAS;EACT,eAAe;EACf,eAAe;EACf,gBACE;EAEH;CACF;AAED,SAAgB,sBACd,SACA,QACA,SACQ;CACR,MAAM,UAAU,eAAe;CAC/B,MAAM,QAAQ,UAAU,OAAO,SAAS,iBAAiB;CAEzD,MAAM,eADQ,aAAa,QACD,CACvB,KAAK,MAAM,qBAAqB,UAAU,EAAE,CAAC,MAAM,CACnD,KAAK,KAAK;CAEb,OAAO;;WAEE,QAAQ,OAAO;iBACT,QAAQ,QAAQ;;wBAET,QAAQ,eAAe;;;qBAG1B,QAAQ,cAAc;;;;eAI5B,MAAM;;iBAEJ,MAAM;;;;;2DAKoC,QAAQ,cAAc;EAC/E,aAAa;;;;;AAMf,SAAgB,wBACd,QACA,SACQ;CACR,OAAO,sBAAsB,OAAO,QAAQ,QAAQ;;AAGtD,SAAgB,0BACd,QACA,SACQ;CACR,OAAO,sBAAsB,QAAQ,QAAQ,QAAQ;;AAGvD,SAAgB,gBAAgB,QAA8B;CAC5D,MAAM,QAAQ,UAAU,OAAO,SAAS,iBAAiB;CACzD,MAAM,cAAc,UAAU,OAAO,eAAe,GAAG;CAGvD,MAAM,WAAW,UAAU,UAAU,kBAAkB,OAAO,SAAS,KAAK;CAC5E,MAAM,OAAO,UAAU,MAAM,cAAc,OAAO,SAAS,KAAK;CAEhE,MAAM,eAAe,SACjB,OAAO,SAAS,gBAAgB,MAAM,KAAK,QAAQ,EAAE,CACxD;CAUD,OAAO;;gBAEO,SAAS;sCACa,MAAM;4CACA,YAAY;;YAE5C,KAAK,qCARb,OAAO,YAAY,SAAS,SAAS,uBAAuB,YAQH,kBAAkB,aAAa;sCACtD,MAAM;4CACA,YAAY;;;;;AAQxD,eAAsB,UACpB,SACA,YACiB;CACjB,OAAO,IAAI,SAAS,KAAK,WAAW;EAClC,MAAM,SAAS,kBAAkB,WAAW;EAC5C,MAAM,UAAU,IAAI,WAAW,EAAE,MAAM,EAAE,OAAO,GAAG,EAAE,CAAC;EAEtD,OAAO,GAAG,eAAe;GACvB,IAAI,QAAQ,SAAS,CAAC;IACtB;EACF,OAAO,GAAG,SAAS,OAAO;EAC1B,QAAQ,GAAG,SAAS,OAAO;EAE3B,QAAQ,KAAK,OAAO;EACpB,QAAQ,UAAU,SAAS,MAAM;EACjC,QAAQ,UAAU;GAClB;;;;;;;AAUJ,SAAS,aAAa,aAAqB,MAAoB;CAC7D,IAAI;EACF,KAAK,MAAM,KAAK,YAAY,YAAY,EACtC,IAAI,EAAE,WAAW,GAAG,KAAK,GAAG,IAAI,EAAE,SAAS,OAAO,EAChD,IAAI;GAAE,WAAW,QAAQ,aAAa,EAAE,CAAC;UAAU;SAGjD;;AAGV,eAAsB,UACpB,aACA,QACe;CACf,MAAM,UAAU,QAAQ,aAAa,OAAO;CAC5C,MAAM,WAAW,OAAO,QAAQ,YAAY;CAC5C,MAAM,OAAO,QAAQ,OAAO,SAAS,iBAAiB,IAAI;CAE1D,MAAM,UAAU,GAAG,KAAK,GADR,OAAO,WAAW,QACC;CACnC,MAAM,UAAU,QAAQ,aAAa,QAAQ;CAE7C,QAAQ,UAAR;EACE,KAAK,OAAO;GAEV,MAAM,QAAQ,aAAa,QAAQ;GACnC,IAAI,YAAY;GAChB,KAAK,MAAM,KAAK,OACd,aAAa,SAAS,QAAQ,SAAS,EAAE,CAAC,CAAC;GAE7C,QAAQ,IAAI,wBAAwB,WAAW,UAAU,CAAC,GAAG;GAC7D;;EAGF,KAAK,WAAW;GACd,MAAM,WAAW,wBAAwB,QAAQ,QAAQ;GACzD,cAAc,QAAQ,SAAS,kBAAkB,EAAE,UAAU,QAAQ;GACrE,aAAa,aAAa,KAAK;GAC/B,MAAM,UAAU,MAAM,UAAU,SAAS,QAAQ;GACjD,QAAQ,IACN,uBAAuB,QAAQ,IAAI,WAAW,QAAQ,CAAC,GACxD;GACD;;EAGF,KAAK,aAAa;GAChB,MAAM,WAAW,0BAA0B,QAAQ,QAAQ;GAC3D,cAAc,QAAQ,SAAS,kBAAkB,EAAE,UAAU,QAAQ;GACrE,aAAa,aAAa,KAAK;GAC/B,MAAM,UAAU,MAAM,UAAU,SAAS,QAAQ;GACjD,QAAQ,IACN,wBAAwB,QAAQ,IAAI,WAAW,QAAQ,CAAC,GACzD;GACD;;EAGF,KAAK,QAAQ;GACX,MAAM,MAAM,gBAAgB,OAAO;GACnC,cAAc,QAAQ,SAAS,WAAW,EAAE,KAAK,QAAQ;GACzD,aAAa,aAAa,KAAK;GAC/B,MAAM,UAAU,MAAM,UAAU,SAAS,QAAQ;GACjD,QAAQ,IAAI,kBAAkB,QAAQ,IAAI,WAAW,QAAQ,CAAC,GAAG;GACjE;;;;;;ACrRN,MAAM,oBAAoB;AAC1B,MAAM,qBAAqB,OAAO;AAElC,SAAgB,sBAA8B;CAC5C,IAAI;CAEJ,OAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,QAAwB;GACrC,cAAc,OAAO;;EAGvB,UAAU,IAAI;GACZ,IAAI,OAAO,mBAAmB,OAAO;GACrC,OAAO;;EAGT,KAAK,IAAI;GACP,IAAI,OAAO,oBAAoB,OAAO;GACtC,MAAM,aAAa,QAAQ,aAAa,gBAAgB;GACxD,IAAI,WAAW,WAAW,EAAE;IAK1B,KAAK,aAAa,WAAW;IAE7B,OAAO,4BADY,WAAW,QAAQ,OAAO,IACA,CAAC;;GAEhD,OAAO;;EAGT,gBAAgB,QAAuB;GACrC,MAAM,aAAa,QAAQ,aAAa,gBAAgB;GAMxD,OAAO,QAAQ,GAAG,QAAQ,OAAO,aAAa;IAC5C,IAAI,aAAa,YAAY;IAC7B,IAAI,UAAU,SAAS,UAAU,UAAU;IAC3C,MAAM,MAAM,OAAO,YAAY,cAAc,mBAAmB;IAChE,IAAI,KAAK,OAAO,YAAY,iBAAiB,IAAI;IACjD,OAAO,GAAG,KAAK,EAAE,MAAM,eAAe,CAAC;KACvC;;EAEL;;;;AChDH,MAAM,kBAAkB;AACxB,MAAM,mBAAmB,OAAO;AAGhC,MAAMA,cAAY,QADC,cAAc,OAAO,KAAK,IACT,CAAC;;;;;;AAOrC,SAAgB,oBAA4B;CAC1C,IAAI;CAKJ,MAAM,cAAc,QADA,QAAQA,aAAW,MAAM,KACN,EAAE,OAAO,cAAc,cAAc;CAE5E,OAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,QAAwB;GACrC,cAAc,OAAO;;EAGvB,UAAU,IAAI;GACZ,IAAI,OAAO,iBAAiB,OAAO;GACnC,OAAO;;EAGT,KAAK,IAAI;GACP,IAAI,OAAO,kBAAkB,OAAO;GACpC,MAAM,eAAe,QAAQ,aAAa,cAAc;GACxD,IAAI,WAAW,aAAa,EAAE;IAE5B,KAAK,aAAa,aAAa;IAE/B,OAAO,4BADY,aAAa,QAAQ,OAAO,IACF,CAAC;;GAGhD,OAAO,4BADY,YAAY,QAAQ,OAAO,IACD,CAAC;;EAGhD,gBAAgB,QAAuB;GACrC,MAAM,eAAe,QAAQ,aAAa,cAAc;GAIxD,OAAO,QAAQ,GAAG,QAAQ,OAAO,aAAa;IAC5C,IAAI,aAAa,cAAc;IAC/B,IAAI,UAAU,SAAS,UAAU,UAAU;IAC3C,MAAM,MAAM,OAAO,YAAY,cAAc,iBAAiB;IAC9D,IAAI,KAAK,OAAO,YAAY,iBAAiB,IAAI;IACjD,OAAO,GAAG,KAAK,EAAE,MAAM,eAAe,CAAC;KACvC;;EAEL;;;;ACjDH,MAAM,YAAY,QADC,cAAc,OAAO,KAAK,IACT,CAAC;AAGrC,SAAS,oBAA4B;CAEnC,OAAO,QADa,QAAQ,WAAW,MAAM,KACnB,EAAE,OAAO,UAAU;;AAI/C,SAAS,mBAA2B;CAElC,OAAO,QADa,QAAQ,WAAW,MAAM,KACnB,EAAE,SAAS;;AAGvC,SAAgB,gBAAgB;CAC9B,OAAO;EACL,OAAO,EACL,iBAAiB,EAAE,KAAK,YAAY,EACrC,CAAC;EACF,yBAAyB;EACzB,oBAAoB;EACpB,qBAAqB;EACrB,oBAAoB;EACpB,uBAAuB;EACvB,qBAAqB;EACrB,mBAAmB;EACnB,qBAAqB;EACtB;;AAKH,MAAM,mBAAmB;AACzB,MAAM,oBAAoB,OAAO;AACjC,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;AAEzB,SAAS,qBAA6B;CACpC,MAAM,aAAa,mBAAmB;CACtC,MAAM,YAAY,kBAAkB;CACpC,MAAM,gBAAgB,QAAQ,YAAY,aAAa;CACvD,IAAI;CACJ,IAAI,UAAU;CAEd,OAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,QAAwB;GACrC,cAAc,OAAO;GACrB,UAAU,OAAO,YAAY;;EAI/B,aAAa;GACX,IAAI,SACF,cAAc,QAAQ,aAAa,aAAa,EAAE,mBAAmB,EAAE,QAAQ;;EAKnF,cAAc;GACZ,IAAI,SAAS;IACX,MAAM,WAAW,QAAQ,aAAa,aAAa;IACnD,IAAI,WAAW,SAAS,EACtB,IAAI;KAAE,WAAW,SAAS;YAAU;IAItC,MAAM,YAAY,QAAQ,aAAa,SAAS;IAChD,MAAM,gBAAgB,QAAQ,aAAa,QAAQ,SAAS;IAC5D,IAAI,WAAW,UAAU,EAAE;KACzB,UAAU,eAAe,EAAE,WAAW,MAAM,CAAC;KAC7C,OAAO,WAAW,eAAe,EAAE,WAAW,MAAM,CAAC;;;;EAM3D,gBAAgB,QAAuB;GACrC,aAAa;IACX,OAAO,YAAY,IAAI,OAAO,KAAK,KAAK,SAAS;KAC/C,IAAI,IAAI,QAAQ,OAAO,IAAI,QAAQ,eAAe;MAChD,MAAM,OAAO,mBAAmB;MAChC,MAAM,cAAc,MAAM,OAAO,mBAAmB,IAAI,KAAK,KAAK;MAClE,IAAI,UAAU,gBAAgB,YAAY;MAC1C,IAAI,aAAa;MACjB,IAAI,IAAI,YAAY;MACpB;;KAEF,MAAM;MACN;;;EAIN,UAAU,IAAI;GACZ,IAAI,OAAO,kBAAkB,OAAO;GACpC,IAAI,OAAO,mBAAmB,OAAO,wBAAwB,OAAO;GACpE,OAAO;;EAGT,KAAK,IAAI;GACP,IAAI,OAAO,qBAAqB,OAAO,kBACrC,OAAO,oBAAoB,eAAe,WAAW,YAAY;GAEnE,OAAO;;EAEV;;AAGH,SAAS,oBAA4B;CACnC,OAAO;;;;;;;;;;;;;AAcT,SAAS,oBAAoB,eAAuB,oBAA4B,aAA6B;CAC3G,MAAM,iBAAiB,cAAc,QAAQ,OAAO,IAAI;CAIxD,MAAM,mBAAmB;EADE;EAAa;EAAY;EACV,CACvC,KAAI,SAAQ,QAAQ,oBAAoB,KAAK,CAAC,QAAQ,OAAO,IAAI,CAAC,CAClE,QAAO,SAAQ,WAAW,KAAK,CAAC,CAChC,KAAI,SAAQ,WAAW,KAAK,IAAI,CAChC,KAAK,KAAK;CAGb,MAAM,gBAAgB,QAAQ,aAAa,SAAS;CACpD,IAAI,cAAc;CAClB,IAAI,WAAW,cAAc,EAI3B,cAHqB,YAAY,cAAc,CAC5C,QAAO,MAAK,EAAE,SAAS,OAAO,CAAC,CAC/B,MACuB,CACvB,KAAI,MAAK,QAAQ,eAAe,EAAE,CAAC,QAAQ,OAAO,IAAI,CAAC,CACvD,KAAI,SAAQ,WAAW,KAAK,IAAI,CAChC,KAAK,KAAK;CAGf,OAAO;EACP,iBAAiB;;EAEjB,YAAY;;;mBAGK,eAAe;;;;;;;AAUlC,MAAM,oBAAoB;AAC1B,MAAM,qBAAqB,OAAO;AAElC,SAAS,mBAAmB,MAG1B;CACA,IAAI,SAAS,UACX,OAAO;EAAE,YAAY,EAAE,MAAM,UAAU;EAAE,cAAc;EAAG;CAE5D,OAAO;EAAE,YAAY;GAAE,MAAM;GAAc,qBAAqB;GAAK;EAAE,cAAc;EAAI;;AAG3F,SAAS,sBAA8B;CACrC,IAAI;CAEJ,OAAO;EACL,MAAM;EACN,SAAS;EAET,OAAO,QAAQ;GAGb,OAAO;IACL,MAAM;IACN,SAAS,EACP,OAAO,EACL,WAAW,QANJ,OAAO,QAAQ,QAAQ,KAAK,EAMV,SAAS,EACnC,EACF;IAGD,cAAc,EACZ,SAAS,CAAC,gBAAgB,EAC3B;IACF;;EAGH,eAAe,QAAwB;GACrC,cAAc,OAAO;;EAGvB,UAAU,IAAI;GACZ,IAAI,OAAO,mBAAmB,OAAO;GACrC,OAAO;;EAGT,KAAK,IAAI;GACP,IAAI,OAAO,oBAAoB;IAC7B,MAAM,aAAa,QAAQ,aAAa,mBAAmB;IAC3D,IAAI,aAAkC,EAAE;IAExC,IAAI,WAAW,WAAW,EAAE;KAC1B,KAAK,aAAa,WAAW;KAC7B,MAAM,YAAY,kCAAkC,aAAa,YAAY,QAAQ,CAAC;KACtF,IAAI,WACF,IAAI;MAAE,aAAa,MAAM,MAAM,UAAU;aAAU;;IAIvD,MAAM,EAAE,YAAY,iBAAiB,mBAAmB,WAAW,YAAY,KAAK;IACpF,MAAM,SAAS;KACb,OAAO,WAAW,SAAS;KAC3B,GAAG;KACH,YAAY;MAAE,MAAM;MAAQ,GAAG,WAAW;MAAY;KACtD,YAAY;MAAE,GAAG;MAAY,GAAG,WAAW;MAAY;KACvD,SAAS;MAAE;MAAc,GAAG,WAAW;MAAS;KAChD,QAAQ;MAAE,UAAU;MAAO,GAAG,WAAW;MAAQ;KAClD;IAED,OAAO,kBAAkB,KAAK,UAAU,OAAO,CAAC;;GAElD,OAAO;;EAEV;;;AAMH,SAAS,cAAc,KAAyC,KAAmB;CACjF,IAAI,CAAC,WAAW,IAAI,EAAE;CACtB,KAAK,MAAM,SAAS,YAAY,IAAI,EAAE;EACpC,MAAM,OAAO,QAAQ,KAAK,MAAM;EAChC,IAAI,SAAS,KAAK,CAAC,aAAa,EAC9B,cAAc,KAAK,KAAK;OACnB,IAAI,MAAM,SAAS,UAAU,IAAI,UAAU,YAChD,IAAI,aAAa,KAAK;;;AAO5B,MAAM,mBAAmB;AACzB,MAAM,oBAAoB,OAAO;;;;;;AAOjC,SAAS,qBAA6B;CACpC,OAAO;EACL,MAAM;EACN,SAAS;EAET,UAAU,IAAI;GACZ,IAAI,OAAO,kBAAkB,OAAO;GACpC,OAAO;;EAGT,KAAK,IAAI;GACP,IAAI,OAAO,mBACT,OAAO;GAET,OAAO;;EAEV;;AAKH,SAAS,0BAAkC;CACzC,IAAI;CACJ,IAAI,UAAU;CAEd,OAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,QAAwB;GACrC,cAAc,OAAO;GACrB,UAAU,OAAO,YAAY;GAE7B,IAAI,CAAC,SACH,cAAc,YAAY;;EAI9B,aAAa;GAEX,IAAI,SACF,cAAc,YAAY;;EAG/B;;AAGH,SAAS,cAAc,aAA2B;CAChD,MAAM,EAAE,QAAQ,aAAa,gBAAgB,YAAY;CAEzD,KAAK,MAAM,WAAW,UACpB,QAAQ,KAAK,oCAAoC,UAAU;CAG7D,IAAI,OAAO,SAAS,GAAG;EACrB,KAAK,MAAM,SAAS,QAClB,QAAQ,MAAM,kCAAkC,QAAQ;EAE1D,MAAM,IAAI,MACR,kCAAkC,OAAO,OAAO,8CACjD;;;AAML,SAAS,sBAA8B;CACrC,IAAI;CACJ,IAAI,UAAU;CAEd,OAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,QAAwB;GACrC,cAAc,OAAO;GACrB,UAAU,OAAO,YAAY;;EAG/B,MAAM,cAAc;GAClB,IAAI,CAAC,SAAS;GAEd,MAAM,aAAa,QAAQ,aAAa,mBAAmB;GAC3D,IAAI,CAAC,WAAW,WAAW,EAIzB,MAAM,IAAI,MACR,8GACD;GAGH,MAAM,YAAY,kCAAkC,aAAa,YAAY,QAAQ,CAAC;GACtF,IAAI,CAAC,WACH,MAAM,IAAI,MACR,kHACD;GAGH,IAAI;GACJ,IAAI;IACF,SAAS,MAAM,MAAM,UAAU;YACxB,KAAK;IACZ,MAAM,IAAI,MACR,sFAAuF,IAAc,UACtG;;GAGH,MAAM,UAAU,aAAa,OAAO;;EAEvC;;AAKH,MAAM,sBAAsB;AAC5B,MAAM,uBAAuB,OAAO;AAEpC,SAAS,wBAAgC;CACvC,IAAI;CACJ,IAAI;CACJ,IAAI,kBAAmC;CAGvC,SAAS,gBAA0B;EACjC,kBAAkB,iBAAiB,SAAS;EAC5C,OAAO;;CAGT,OAAO;EACL,MAAM;EACN,SAAS;EAET,eAAe,QAAwB;GACrC,cAAc,OAAO;GACrB,WAAW,QAAQ,aAAa,QAAQ;;EAG1C,gBAAgB,WAA0B;GAIxC,UAAU,QAAQ,GAAG,QAAQ,OAAO,aAAa;IAC/C,IAAI,CAAC,SAAS,WAAW,SAAS,EAAE;IASpC,IALE,SAAS,SAAS,UAAU,IAC5B,SAAS,SAAS,WAAW,IAC7B,UAAU,YACV,UAAU,aAEI;KACd,kBAAkB;KAGlB,MAAM,MAAM,UAAU,YAAY,cAAc,qBAAqB;KACrE,IAAI,KAAK;MACP,UAAU,YAAY,iBAAiB,IAAI;MAC3C,UAAU,GAAG,KAAK,EAAE,MAAM,eAAe,CAAC;;KAG5C,QAAQ,IAAI,+BAA+B,MAAM,IAAI,SAAS,QAAQ,aAAa,GAAG,CAAC,GAAG;;KAE5F;;EAGJ,aAAa;GACX,eAAe;;EAGjB,UAAU,IAAI;GACZ,IAAI,OAAO,qBAAqB,OAAO;GACvC,OAAO;;EAGT,KAAK,IAAI;GACP,IAAI,OAAO,sBAAsB;IAC/B,IAAI,CAAC,iBACH,eAAe;IAKjB,cAAc,MAAM,SAAS;IAK7B,MAAM,OAAO,KAAK,UAAU,kBAAkB,MAAM,UAClD,UAAU,WAAW,MAAM,MAC5B;IAED,OAAO,mCADK,OAAO,KAAK,KAAK,CAAC,SAAS,SACM,CAAC;;GAEhD,OAAO;;EAEV"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["__dirname"],"sources":["../../src/runtime/slugify.ts","../../src/plugin/export.ts","../../src/plugin/layout.ts","../../src/plugin/quiz.ts","../../src/plugin/index.ts"],"sourcesContent":["/**\n * Slugify a string for use as a URL-safe / filename-safe identifier.\n * \"My Course Title\" → \"my-course-title\"\n *\n * Shared by the runtime (`WebAdapter` localStorage key) and the build-time\n * exporter (`runExport` zip filename). Both want identical, deterministic\n * output so a course's storage key matches its package name.\n */\nexport function slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '')\n .replace(/[\\s_]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '');\n}\n","import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { createWriteStream } from 'node:fs';\nimport { createHash } from 'node:crypto';\nimport { ZipArchive } from 'archiver';\nimport { slugify } from '../runtime/slugify.js';\n\n// ---------- Types ----------\n\ninterface ExportConfig {\n title: string;\n description?: string;\n version?: string;\n scoring?: { passingScore?: number };\n completion?: { mode?: 'quiz' | 'percentage' };\n export?: { standard?: string };\n}\n\n// ---------- Helpers ----------\n\nfunction escapeXml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/**\n * Recursively collect all file paths relative to a directory.\n */\nfunction collectFiles(dir: string, base: string = ''): string[] {\n const files: string[] = [];\n if (!existsSync(dir)) return files;\n\n for (const entry of readdirSync(dir)) {\n const fullPath = resolve(dir, entry);\n const relPath = base ? `${base}/${entry}` : entry;\n if (statSync(fullPath).isDirectory()) {\n files.push(...collectFiles(fullPath, relPath));\n } else {\n files.push(relPath);\n }\n }\n return files;\n}\n\n/**\n * Derive a stable URN IRI from a seed string. cmi5 §13.1 / xs:anyURI\n * require course / AU ids to be IRIs — bare hex or UUID-shaped strings\n * (without correct version/variant bits) aren't conformant URNs and may\n * be rejected by strict LMS importers.\n *\n * Hash the seed so the id survives rebuilds, then format as\n * `urn:tessera:<kind>:<hex>`. The same seed always produces the same\n * IRI, so existing LRS records are not orphaned by re-export.\n */\nfunction stableUrn(kind: 'course' | 'au', seed: string): string {\n const h = createHash('sha256').update(seed).digest('hex');\n // 32 hex chars (128 bits of entropy) is plenty; trim to keep ids short.\n return `urn:tessera:${kind}:${h.slice(0, 32)}`;\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n// ---------- Manifest Generators ----------\n\n/** Per-version XML differences in imsmanifest.xml between SCORM 1.2 and 2004. */\ninterface ScormManifestDialect {\n rootNs: string;\n adlcpNs: string;\n schemaversion: string;\n /** Attribute name on <resource>: SCORM 1.2 uses lowercase, 2004 uses camelCase. */\n scormTypeAttr: 'scormtype' | 'scormType';\n /** Whitespace-separated namespace+XSD pairs for xsi:schemaLocation. */\n schemaLocation: string;\n}\n\nconst SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {\n '1.2': {\n rootNs: 'http://www.imsproject.org/xsd/imscp_rootv1p1p2',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_rootv1p2',\n schemaversion: '1.2',\n scormTypeAttr: 'scormtype',\n schemaLocation:\n 'http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd ' +\n 'http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd',\n },\n '2004': {\n rootNs: 'http://www.imsglobal.org/xsd/imscp_v1p1',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_v1p3',\n schemaversion: '2004 4th Edition',\n scormTypeAttr: 'scormType',\n schemaLocation:\n 'http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd',\n },\n};\n\nexport function generateScormManifest(\n version: '1.2' | '2004',\n config: ExportConfig,\n distDir: string\n): string {\n const dialect = SCORM_DIALECTS[version];\n const title = escapeXml(config.title || 'Tessera Course');\n const files = collectFiles(distDir);\n const fileElements = files\n .map((f) => ` <file href=\"${escapeXml(f)}\" />`)\n .join('\\n');\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest identifier=\"tessera-course\" version=\"1.0\"\n xmlns=\"${dialect.rootNs}\"\n xmlns:adlcp=\"${dialect.adlcpNs}\"\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:schemaLocation=\"${dialect.schemaLocation}\">\n <metadata>\n <schema>ADL SCORM</schema>\n <schemaversion>${dialect.schemaversion}</schemaversion>\n </metadata>\n <organizations default=\"org-1\">\n <organization identifier=\"org-1\">\n <title>${title}</title>\n <item identifier=\"item-1\" identifierref=\"res-1\">\n <title>${title}</title>\n </item>\n </organization>\n </organizations>\n <resources>\n <resource identifier=\"res-1\" type=\"webcontent\" adlcp:${dialect.scormTypeAttr}=\"sco\" href=\"index.html\">\n${fileElements}\n </resource>\n </resources>\n</manifest>`;\n}\n\nexport function generateSCORM12Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('1.2', config, distDir);\n}\n\nexport function generateSCORM2004Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('2004', config, distDir);\n}\n\nexport function generateCMI5Xml(config: ExportConfig): string {\n const title = escapeXml(config.title || 'Tessera Course');\n const description = escapeXml(config.description || '');\n // Derive stable IDs from the course title so they survive rebuilds without\n // orphaning existing learner records in the LRS.\n const courseId = stableUrn('course', `tessera-course:${config.title || ''}`);\n const auId = stableUrn('au', `tessera-au:${config.title || ''}`);\n // cmi5 §10.2.4 caps masteryScore at 4 decimals; avoid float drift like 0.7000000000000001.\n const masteryScore = Number(\n ((config.scoring?.passingScore ?? 70) / 100).toFixed(4)\n );\n // cmi5 §13.1.4 — `moveOn` decides which verb(s) the LMS treats as\n // satisfying the AU. For graded courses (completion gated on a quiz)\n // a learner who completes without passing should NOT receive credit, so\n // the LMS needs both a Completed AND a Passed before satisfaction.\n // Percentage-mode courses don't surface pass/fail, so completion alone\n // is the right signal.\n const moveOn =\n config.completion?.mode === 'quiz' ? 'CompletedAndPassed' : 'Completed';\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<courseStructure xmlns=\"https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd\">\n <course id=\"${courseId}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n </course>\n <au id=\"${auId}\" launchMethod=\"AnyWindow\" moveOn=\"${moveOn}\" masteryScore=\"${masteryScore}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n <url>index.html</url>\n </au>\n</courseStructure>`;\n}\n\n// ---------- ZIP Packaging ----------\n\nexport async function createZip(\n distDir: string,\n outputPath: string\n): Promise<number> {\n return new Promise((res, reject) => {\n const output = createWriteStream(outputPath);\n const archive = new ZipArchive({ zlib: { level: 9 } });\n\n output.on('close', () => {\n res(archive.pointer());\n });\n output.on('error', reject);\n archive.on('error', reject);\n\n archive.pipe(output);\n archive.directory(distDir, false);\n archive.finalize();\n });\n}\n\n// ---------- Main Export ----------\n\n/**\n * Run the export process after Vite build completes.\n * Writes manifest XML into dist/, then packages into ZIP if needed.\n */\n/** Remove any previously built zips for this package to prevent accumulation. */\nfunction cleanOldZips(projectRoot: string, slug: string): void {\n try {\n for (const f of readdirSync(projectRoot)) {\n if (f.startsWith(`${slug}-`) && f.endsWith('.zip')) {\n try { unlinkSync(resolve(projectRoot, f)); } catch {}\n }\n }\n } catch {}\n}\n\nexport async function runExport(\n projectRoot: string,\n config: ExportConfig\n): Promise<void> {\n const distDir = resolve(projectRoot, 'dist');\n const standard = config.export?.standard || 'web';\n const slug = slugify(config.title || 'tessera-course') || 'tessera-course';\n const version = config.version || '1.0.0';\n const zipName = `${slug}-${version}.zip`;\n const zipPath = resolve(projectRoot, zipName);\n\n switch (standard) {\n case 'web': {\n // Compute dist size\n const files = collectFiles(distDir);\n let totalSize = 0;\n for (const f of files) {\n totalSize += statSync(resolve(distDir, f)).size;\n }\n console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);\n break;\n }\n\n case 'scorm12': {\n const manifest = generateSCORM12Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'scorm2004': {\n const manifest = generateSCORM2004Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'cmi5': {\n const xml = generateCMI5Xml(config);\n writeFileSync(resolve(distDir, 'cmi5.xml'), xml, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);\n break;\n }\n }\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\n\nconst VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';\nconst RESOLVED_LAYOUT_ID = '\\0' + VIRTUAL_LAYOUT_ID;\n\nexport function tesseraLayoutPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:layout',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_LAYOUT_ID) return null;\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n if (existsSync(layoutPath)) {\n // Register the file with Vite so edits trigger HMR / build --watch\n // re-runs. Only add when the file actually exists — calling\n // addWatchFile on a non-existent path makes Vite's importAnalysis\n // try to resolve it as a real import.\n this.addWatchFile(layoutPath);\n const normalized = layoutPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n return `export default null;`;\n },\n\n configureServer(server: ViteDevServer) {\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n // Only react to add/unlink: those flip the virtual module's load() output\n // between `export default null` and `export { default } from '...'`. A\n // `change` event leaves that output identical and is handled by Svelte's\n // own HMR for the underlying file — full-reloading on every edit would\n // wipe in-page state for no reason.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== layoutPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';\nconst RESOLVED_QUIZ_ID = '\\0' + VIRTUAL_QUIZ_ID;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n/**\n * Resolve the project's quiz shell.\n * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,\n * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).\n */\nexport function tesseraQuizPlugin(): Plugin {\n let projectRoot: string;\n // Resolve the built-in Quiz.svelte once. The plugin lives in\n // `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —\n // both layouts put `Quiz.svelte` two levels up under `src/components/`.\n const packageRoot = resolve(__dirname, '..', '..');\n const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');\n\n return {\n name: 'tessera:quiz',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_QUIZ_ID) return null;\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n if (existsSync(userQuizPath)) {\n // Watch the user file so add/remove flips through HMR (see below).\n this.addWatchFile(userQuizPath);\n const normalized = userQuizPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n const normalized = builtinQuiz.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n },\n\n configureServer(server: ViteDevServer) {\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n // Only react to add/unlink — those flip the load() output between the\n // user quiz and the built-in. A `change` event leaves the resolved\n // module identical and is handled by Svelte's own HMR.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== userQuizPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nimport { fileURLToPath } from 'node:url';\nimport { dirname, resolve } from 'node:path';\nimport { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';\nimport { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';\nimport JSON5 from 'json5';\nimport type { Manifest } from './manifest.js';\nimport { validateProject } from './validation.js';\nimport { runExport } from './export.js';\nimport { tesseraLayoutPlugin } from './layout.js';\nimport { tesseraQuizPlugin } from './quiz.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// Resolve the runtime directory where App.svelte lives\nfunction resolveRuntimeDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'src', 'runtime');\n}\n\n// Resolve the framework styles directory\nfunction resolveStylesDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'styles');\n}\n\nexport function tesseraPlugin() {\n const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };\n return [\n svelte({\n compilerOptions: { css: 'external' },\n }),\n tesseraValidationPlugin(),\n tesseraEntryPlugin(),\n tesseraConfigPlugin(),\n tesseraPagesPlugin(),\n tesseraManifestPlugin(manifestRef),\n tesseraLayoutPlugin(),\n tesseraQuizPlugin(),\n tesseraAdapterPlugin(),\n tesseraXAPISetupPlugin(),\n tesseraFirstPagePreloadPlugin(manifestRef),\n tesseraExportPlugin(),\n ];\n}\n\n// ---------- Entry Plugin ----------\n\nconst VIRTUAL_ENTRY_ID = 'virtual:tessera-entry';\nconst RESOLVED_ENTRY_ID = '\\0' + VIRTUAL_ENTRY_ID;\nconst VIRTUAL_MAIN_ID = '/virtual:tessera-main';\nconst RESOLVED_MAIN_ID = '\\0virtual:tessera-main';\n\nfunction tesseraEntryPlugin(): Plugin {\n const runtimeDir = resolveRuntimeDir();\n const stylesDir = resolveStylesDir();\n const appSveltePath = resolve(runtimeDir, 'App.svelte');\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:entry',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n // For build mode: write index.html so Rollup can find it\n buildStart() {\n if (isBuild) {\n writeFileSync(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');\n }\n },\n\n // For build mode: clean up temporary index.html and copy assets\n closeBundle() {\n if (isBuild) {\n const htmlPath = resolve(projectRoot, 'index.html');\n if (existsSync(htmlPath)) {\n try { unlinkSync(htmlPath); } catch {}\n }\n\n // Copy assets/ directory to dist/assets/ so $assets/ references resolve\n const assetsDir = resolve(projectRoot, 'assets');\n const distAssetsDir = resolve(projectRoot, 'dist', 'assets');\n if (existsSync(assetsDir)) {\n mkdirSync(distAssetsDir, { recursive: true });\n cpSync(assetsDir, distAssetsDir, { recursive: true });\n }\n }\n },\n\n // Serve index.html for the dev server\n configureServer(server: ViteDevServer) {\n return () => {\n server.middlewares.use(async (req, res, next) => {\n if (req.url === '/' || req.url === '/index.html') {\n const html = generateIndexHtml();\n const transformed = await server.transformIndexHtml(req.url, html);\n res.setHeader('Content-Type', 'text/html');\n res.statusCode = 200;\n res.end(transformed);\n return;\n }\n next();\n });\n };\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;\n if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main') return RESOLVED_MAIN_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_ENTRY_ID || id === RESOLVED_MAIN_ID) {\n return generateEntryScript(appSveltePath, stylesDir, projectRoot);\n }\n return null;\n },\n };\n}\n\nfunction generateIndexHtml(): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Tessera Course</title>\n</head>\n<body>\n <div id=\"tessera-root\"></div>\n <script type=\"module\" src=\"/virtual:tessera-main\"></script>\n</body>\n</html>`;\n}\n\nfunction generateEntryScript(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {\n const normalizedPath = appSveltePath.replace(/\\\\/g, '/');\n\n // Framework CSS imports (theme → base → layout)\n const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];\n const frameworkImports = frameworkCssOrder\n .map(file => resolve(frameworkStylesDir, file).replace(/\\\\/g, '/'))\n .filter(path => existsSync(path))\n .map(path => `import '${path}';`)\n .join('\\n');\n\n // User CSS imports from project's styles/ directory\n const userStylesDir = resolve(projectRoot, 'styles');\n let userImports = '';\n if (existsSync(userStylesDir)) {\n const userCssFiles = readdirSync(userStylesDir)\n .filter(f => f.endsWith('.css'))\n .sort();\n userImports = userCssFiles\n .map(f => resolve(userStylesDir, f).replace(/\\\\/g, '/'))\n .map(path => `import '${path}';`)\n .join('\\n');\n }\n\n return `// Framework styles\n${frameworkImports}\n// User styles\n${userImports}\n\nimport { mount } from 'svelte';\nimport App from '${normalizedPath}';\n\nmount(App, {\n target: document.getElementById('tessera-root'),\n});\n`;\n}\n\n// ---------- Config Plugin ----------\n\nconst VIRTUAL_CONFIG_ID = 'virtual:tessera-config';\nconst RESOLVED_CONFIG_ID = '\\0' + VIRTUAL_CONFIG_ID;\n\nfunction completionDefaults(mode: string | undefined): {\n completion: Record<string, unknown>;\n passingScore: number;\n} {\n if (mode === 'manual') {\n return { completion: { mode: 'manual' }, passingScore: 0 };\n }\n return { completion: { mode: 'percentage', percentageThreshold: 100 }, passingScore: 70 };\n}\n\nfunction tesseraConfigPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:config',\n enforce: 'pre',\n\n config(config) {\n const root = config.root || process.cwd();\n\n return {\n base: './',\n resolve: {\n alias: {\n '$assets': resolve(root, 'assets'),\n },\n },\n // tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer\n // doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.\n optimizeDeps: {\n exclude: ['tessera-learn'],\n },\n };\n },\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_CONFIG_ID) return RESOLVED_CONFIG_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_CONFIG_ID) {\n const configPath = resolve(projectRoot, 'course.config.js');\n let userConfig: Record<string, any> = {};\n\n if (existsSync(configPath)) {\n this.addWatchFile(configPath);\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try { userConfig = JSON5.parse(objectStr); } catch {}\n }\n }\n\n const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);\n const merged = {\n title: userConfig.title || 'Untitled Course',\n ...userConfig,\n navigation: { mode: 'free', ...userConfig.navigation },\n completion: { ...completion, ...userConfig.completion },\n scoring: { passingScore, ...userConfig.scoring },\n export: { standard: 'web', ...userConfig.export },\n };\n\n return `export default ${JSON.stringify(merged)};`;\n }\n return null;\n },\n };\n}\n\n// ---------- Manifest Watch Helpers ----------\n\n/** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */\nfunction addWatchFiles(ctx: { addWatchFile(id: string): void }, dir: string): void {\n if (!existsSync(dir)) return;\n for (const entry of readdirSync(dir)) {\n const full = resolve(dir, entry);\n if (statSync(full).isDirectory()) {\n addWatchFiles(ctx, full);\n } else if (entry.endsWith('.svelte') || entry === '_meta.js') {\n ctx.addWatchFile(full);\n }\n }\n}\n\n// ---------- Pages Plugin ----------\n\nconst VIRTUAL_PAGES_ID = 'virtual:tessera-pages';\nconst RESOLVED_PAGES_ID = '\\0' + VIRTUAL_PAGES_ID;\n\n/**\n * Provides a virtual module that exports an import.meta.glob map for all .svelte\n * pages. This runs in the user's project context so the glob resolves against their\n * pages/ directory, and Vite can statically analyze it for code splitting.\n */\nfunction tesseraPagesPlugin(): Plugin {\n return {\n name: 'tessera:pages',\n enforce: 'pre',\n\n resolveId(id) {\n if (id === VIRTUAL_PAGES_ID) return RESOLVED_PAGES_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_PAGES_ID) {\n return `export default import.meta.glob('/pages/**/*.svelte');`;\n }\n return null;\n },\n };\n}\n\n// ---------- Validation Plugin ----------\n\nfunction tesseraValidationPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:validation',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n // Run validation during dev (configResolved fires before server starts)\n if (!isBuild) {\n runValidation(projectRoot);\n }\n },\n\n buildStart() {\n // Run validation during build (buildStart fires once before bundling)\n if (isBuild) {\n runValidation(projectRoot);\n }\n },\n };\n}\n\nfunction runValidation(projectRoot: string): void {\n const { errors, warnings } = validateProject(projectRoot);\n\n for (const warning of warnings) {\n console.warn(`\\x1b[33m[tessera warning]\\x1b[0m ${warning}`);\n }\n\n if (errors.length > 0) {\n for (const error of errors) {\n console.error(`\\x1b[31m[tessera error]\\x1b[0m ${error}`);\n }\n throw new Error(\n `Tessera validation failed with ${errors.length} error(s). Fix the errors above to continue.`\n );\n }\n}\n\n// ---------- Export Plugin ----------\n\nfunction tesseraExportPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:export',\n enforce: 'post',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n async closeBundle() {\n if (!isBuild) return;\n\n const configPath = resolve(projectRoot, 'course.config.js');\n if (!existsSync(configPath)) {\n // Validation already required course.config.js — getting here means\n // the file vanished mid-build. Surface that loudly rather than\n // shipping a bundle with no LMS export silently.\n throw new Error(\n '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'\n );\n }\n\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (!objectStr) {\n throw new Error(\n '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'\n );\n }\n\n let config: any;\n try {\n config = JSON5.parse(objectStr);\n } catch (err) {\n throw new Error(\n `[tessera:export] course.config.js: failed to parse export-default object literal — ${(err as Error).message}`\n );\n }\n\n await runExport(projectRoot, config);\n },\n };\n}\n\n// ---------- Manifest Plugin ----------\n\nconst VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';\nconst RESOLVED_MANIFEST_ID = '\\0' + VIRTUAL_MANIFEST_ID;\n\nfunction tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {\n let projectRoot: string;\n let pagesDir: string;\n let server: ViteDevServer | null = null;\n\n function buildManifest(): Manifest {\n const m = generateManifest(pagesDir);\n manifestRef.current = m;\n return m;\n }\n\n return {\n name: 'tessera:manifest',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n pagesDir = resolve(projectRoot, 'pages');\n manifestRef.root = projectRoot;\n },\n\n configureServer(devServer: ViteDevServer) {\n server = devServer;\n\n // Watch the pages directory for changes\n devServer.watcher.on('all', (event, filePath) => {\n if (!filePath.startsWith(pagesDir)) return;\n\n // Rebuild manifest on relevant file changes\n const isRelevant =\n filePath.endsWith('.svelte') ||\n filePath.endsWith('_meta.js') ||\n event === 'addDir' ||\n event === 'unlinkDir';\n\n if (isRelevant) {\n manifestRef.current = null; // invalidate cache\n\n // Invalidate the virtual module to trigger HMR\n const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);\n if (mod) {\n devServer.moduleGraph.invalidateModule(mod);\n devServer.ws.send({ type: 'full-reload' });\n }\n\n console.log(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`);\n }\n });\n },\n\n buildStart() {\n buildManifest();\n },\n\n resolveId(id) {\n if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_MANIFEST_ID) {\n if (!manifestRef.current) {\n buildManifest();\n }\n\n // Register watch files so Vite's built-in watcher (used in build --watch)\n // knows to re-trigger when pages/ content changes.\n addWatchFiles(this, pagesDir);\n\n // Encode as base64 to prevent Vite's import analysis from\n // scanning .svelte importPath strings as module imports.\n // Replace Infinity with 1e9 since JSON.stringify drops it.\n const json = JSON.stringify(manifestRef.current, (_key, value) =>\n value === Infinity ? 1e9 : value\n );\n const b64 = Buffer.from(json).toString('base64');\n return `export default JSON.parse(atob(\"${b64}\"));`;\n }\n return null;\n },\n };\n}\n\nconst VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';\nconst RESOLVED_ADAPTER_ID = '\\0' + VIRTUAL_ADAPTER_ID;\n\nfunction tesseraAdapterPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:adapter',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_ADAPTER_ID) return null;\n\n // In dev, defer to the runtime selector so its WebAdapter fallback\n // for unreachable LMS APIs keeps working.\n if (!isBuild) {\n return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;\n }\n\n let standard = 'web';\n const configPath = resolve(projectRoot, 'course.config.js');\n if (existsSync(configPath)) {\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try {\n const parsed = JSON5.parse(objectStr);\n if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;\n } catch {}\n }\n }\n\n switch (standard) {\n case 'scorm12':\n return `\nimport { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';\nimport { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';\nimport { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';\nexport function createAdapter() {\n const api = findSCORM12API();\n if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');\n return new SCORM12Adapter(api);\n}\n`;\n case 'scorm2004':\n return `\nimport { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';\nimport { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';\nimport { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';\nexport function createAdapter() {\n const api = findSCORM2004API();\n if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');\n return new SCORM2004Adapter(api);\n}\n`;\n case 'cmi5':\n return `\nimport { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';\nimport { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';\nimport { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';\nexport function createAdapter() {\n if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');\n return new CMI5Adapter();\n}\n`;\n default:\n return `\nimport { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';\nexport function createAdapter(config) {\n return new WebAdapter(config);\n}\n`;\n }\n },\n };\n}\n\nconst VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';\nconst RESOLVED_XAPI_SETUP_ID = '\\0' + VIRTUAL_XAPI_SETUP_ID;\n\nfunction tesseraXAPISetupPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:xapi-setup',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n resolveId(id) {\n if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_XAPI_SETUP_ID) return null;\n\n if (!isBuild) {\n return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;\n }\n\n let standard = 'web';\n let hasXapi = false;\n const configPath = resolve(projectRoot, 'course.config.js');\n if (existsSync(configPath)) {\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try {\n const parsed = JSON5.parse(objectStr);\n if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;\n hasXapi = parsed?.xapi != null;\n } catch {}\n }\n }\n\n // cmi5 needs the publisher regardless of explicit xapi config (cmi5\n // adapter shares the publisher queue for its own LMS-required statements).\n if (hasXapi || standard === 'cmi5') {\n return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;\n }\n\n return `export async function buildXAPIClient() { return null; }`;\n },\n };\n}\n\nfunction tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {\n return {\n name: 'tessera:first-page-preload',\n apply: 'build',\n transformIndexHtml: {\n order: 'post',\n handler(_html, ctx) {\n const firstPagePath = manifestRef.current?.pages[0]?.importPath;\n if (!firstPagePath || !ctx.bundle) return;\n const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\\//, '')).replace(/\\\\/g, '/');\n const chunk = Object.values(ctx.bundle).find(\n (c): c is import('vite').Rollup.OutputChunk =>\n c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\\\/g, '/') === normalized\n );\n if (!chunk) return;\n return [{\n tag: 'link',\n attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },\n injectTo: 'head',\n }];\n },\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tessera-learn",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "LMS tracking runtime for interactive learning content. One adapter layer (SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web), your choice of components.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
"./runtime/*": "./src/runtime/*"
|
|
52
52
|
},
|
|
53
53
|
"svelte": "./src/index.ts",
|
|
54
|
+
"sideEffects": [
|
|
55
|
+
"**/*.css"
|
|
56
|
+
],
|
|
54
57
|
"bin": {
|
|
55
58
|
"tessera-validate": "./dist/plugin/cli.js"
|
|
56
59
|
},
|
|
@@ -61,16 +64,16 @@
|
|
|
61
64
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
62
65
|
"archiver": "^8.0.0",
|
|
63
66
|
"json5": "^2.0.0",
|
|
64
|
-
"svelte": "^5.55.
|
|
67
|
+
"svelte": "^5.55.7"
|
|
65
68
|
},
|
|
66
69
|
"devDependencies": {
|
|
67
|
-
"@types/node": "^25.
|
|
68
|
-
"@vitest/coverage-v8": "^4.1.
|
|
70
|
+
"@types/node": "^25.8.0",
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.6",
|
|
69
72
|
"jsdom": "^29.0.1",
|
|
70
73
|
"tsdown": "^0.22.0",
|
|
71
74
|
"typescript": "^6.0.3",
|
|
72
|
-
"vite": "^8.0.
|
|
73
|
-
"vitest": "^4.1.
|
|
75
|
+
"vite": "^8.0.13",
|
|
76
|
+
"vitest": "^4.1.6"
|
|
74
77
|
},
|
|
75
78
|
"scripts": {
|
|
76
79
|
"build": "tsdown",
|
package/src/plugin/index.ts
CHANGED
|
@@ -27,17 +27,21 @@ function resolveStylesDir(): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function tesseraPlugin() {
|
|
30
|
+
const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };
|
|
30
31
|
return [
|
|
31
32
|
svelte({
|
|
32
|
-
compilerOptions: { css: '
|
|
33
|
+
compilerOptions: { css: 'external' },
|
|
33
34
|
}),
|
|
34
35
|
tesseraValidationPlugin(),
|
|
35
36
|
tesseraEntryPlugin(),
|
|
36
37
|
tesseraConfigPlugin(),
|
|
37
38
|
tesseraPagesPlugin(),
|
|
38
|
-
tesseraManifestPlugin(),
|
|
39
|
+
tesseraManifestPlugin(manifestRef),
|
|
39
40
|
tesseraLayoutPlugin(),
|
|
40
41
|
tesseraQuizPlugin(),
|
|
42
|
+
tesseraAdapterPlugin(),
|
|
43
|
+
tesseraXAPISetupPlugin(),
|
|
44
|
+
tesseraFirstPagePreloadPlugin(manifestRef),
|
|
41
45
|
tesseraExportPlugin(),
|
|
42
46
|
];
|
|
43
47
|
}
|
|
@@ -397,15 +401,15 @@ function tesseraExportPlugin(): Plugin {
|
|
|
397
401
|
const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
|
|
398
402
|
const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
|
|
399
403
|
|
|
400
|
-
function tesseraManifestPlugin(): Plugin {
|
|
404
|
+
function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
|
|
401
405
|
let projectRoot: string;
|
|
402
406
|
let pagesDir: string;
|
|
403
|
-
let currentManifest: Manifest | null = null;
|
|
404
407
|
let server: ViteDevServer | null = null;
|
|
405
408
|
|
|
406
409
|
function buildManifest(): Manifest {
|
|
407
|
-
|
|
408
|
-
|
|
410
|
+
const m = generateManifest(pagesDir);
|
|
411
|
+
manifestRef.current = m;
|
|
412
|
+
return m;
|
|
409
413
|
}
|
|
410
414
|
|
|
411
415
|
return {
|
|
@@ -415,6 +419,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
415
419
|
configResolved(config: ResolvedConfig) {
|
|
416
420
|
projectRoot = config.root;
|
|
417
421
|
pagesDir = resolve(projectRoot, 'pages');
|
|
422
|
+
manifestRef.root = projectRoot;
|
|
418
423
|
},
|
|
419
424
|
|
|
420
425
|
configureServer(devServer: ViteDevServer) {
|
|
@@ -432,7 +437,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
432
437
|
event === 'unlinkDir';
|
|
433
438
|
|
|
434
439
|
if (isRelevant) {
|
|
435
|
-
|
|
440
|
+
manifestRef.current = null; // invalidate cache
|
|
436
441
|
|
|
437
442
|
// Invalidate the virtual module to trigger HMR
|
|
438
443
|
const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
@@ -457,7 +462,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
457
462
|
|
|
458
463
|
load(id) {
|
|
459
464
|
if (id === RESOLVED_MANIFEST_ID) {
|
|
460
|
-
if (!
|
|
465
|
+
if (!manifestRef.current) {
|
|
461
466
|
buildManifest();
|
|
462
467
|
}
|
|
463
468
|
|
|
@@ -468,7 +473,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
468
473
|
// Encode as base64 to prevent Vite's import analysis from
|
|
469
474
|
// scanning .svelte importPath strings as module imports.
|
|
470
475
|
// Replace Infinity with 1e9 since JSON.stringify drops it.
|
|
471
|
-
const json = JSON.stringify(
|
|
476
|
+
const json = JSON.stringify(manifestRef.current, (_key, value) =>
|
|
472
477
|
value === Infinity ? 1e9 : value
|
|
473
478
|
);
|
|
474
479
|
const b64 = Buffer.from(json).toString('base64');
|
|
@@ -478,3 +483,168 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
478
483
|
},
|
|
479
484
|
};
|
|
480
485
|
}
|
|
486
|
+
|
|
487
|
+
const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
|
|
488
|
+
const RESOLVED_ADAPTER_ID = '\0' + VIRTUAL_ADAPTER_ID;
|
|
489
|
+
|
|
490
|
+
function tesseraAdapterPlugin(): Plugin {
|
|
491
|
+
let projectRoot: string;
|
|
492
|
+
let isBuild = false;
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
name: 'tessera:adapter',
|
|
496
|
+
enforce: 'pre',
|
|
497
|
+
|
|
498
|
+
configResolved(config: ResolvedConfig) {
|
|
499
|
+
projectRoot = config.root;
|
|
500
|
+
isBuild = config.command === 'build';
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
resolveId(id) {
|
|
504
|
+
if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;
|
|
505
|
+
return null;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
load(id) {
|
|
509
|
+
if (id !== RESOLVED_ADAPTER_ID) return null;
|
|
510
|
+
|
|
511
|
+
// In dev, defer to the runtime selector so its WebAdapter fallback
|
|
512
|
+
// for unreachable LMS APIs keeps working.
|
|
513
|
+
if (!isBuild) {
|
|
514
|
+
return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let standard = 'web';
|
|
518
|
+
const configPath = resolve(projectRoot, 'course.config.js');
|
|
519
|
+
if (existsSync(configPath)) {
|
|
520
|
+
const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
|
|
521
|
+
if (objectStr) {
|
|
522
|
+
try {
|
|
523
|
+
const parsed = JSON5.parse(objectStr);
|
|
524
|
+
if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
switch (standard) {
|
|
530
|
+
case 'scorm12':
|
|
531
|
+
return `
|
|
532
|
+
import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
|
|
533
|
+
import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
534
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
535
|
+
export function createAdapter() {
|
|
536
|
+
const api = findSCORM12API();
|
|
537
|
+
if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');
|
|
538
|
+
return new SCORM12Adapter(api);
|
|
539
|
+
}
|
|
540
|
+
`;
|
|
541
|
+
case 'scorm2004':
|
|
542
|
+
return `
|
|
543
|
+
import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
|
|
544
|
+
import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
545
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
546
|
+
export function createAdapter() {
|
|
547
|
+
const api = findSCORM2004API();
|
|
548
|
+
if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');
|
|
549
|
+
return new SCORM2004Adapter(api);
|
|
550
|
+
}
|
|
551
|
+
`;
|
|
552
|
+
case 'cmi5':
|
|
553
|
+
return `
|
|
554
|
+
import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
|
|
555
|
+
import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
556
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
557
|
+
export function createAdapter() {
|
|
558
|
+
if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
|
|
559
|
+
return new CMI5Adapter();
|
|
560
|
+
}
|
|
561
|
+
`;
|
|
562
|
+
default:
|
|
563
|
+
return `
|
|
564
|
+
import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
|
|
565
|
+
export function createAdapter(config) {
|
|
566
|
+
return new WebAdapter(config);
|
|
567
|
+
}
|
|
568
|
+
`;
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
|
|
575
|
+
const RESOLVED_XAPI_SETUP_ID = '\0' + VIRTUAL_XAPI_SETUP_ID;
|
|
576
|
+
|
|
577
|
+
function tesseraXAPISetupPlugin(): Plugin {
|
|
578
|
+
let projectRoot: string;
|
|
579
|
+
let isBuild = false;
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
name: 'tessera:xapi-setup',
|
|
583
|
+
enforce: 'pre',
|
|
584
|
+
|
|
585
|
+
configResolved(config: ResolvedConfig) {
|
|
586
|
+
projectRoot = config.root;
|
|
587
|
+
isBuild = config.command === 'build';
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
resolveId(id) {
|
|
591
|
+
if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;
|
|
592
|
+
return null;
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
load(id) {
|
|
596
|
+
if (id !== RESOLVED_XAPI_SETUP_ID) return null;
|
|
597
|
+
|
|
598
|
+
if (!isBuild) {
|
|
599
|
+
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let standard = 'web';
|
|
603
|
+
let hasXapi = false;
|
|
604
|
+
const configPath = resolve(projectRoot, 'course.config.js');
|
|
605
|
+
if (existsSync(configPath)) {
|
|
606
|
+
const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
|
|
607
|
+
if (objectStr) {
|
|
608
|
+
try {
|
|
609
|
+
const parsed = JSON5.parse(objectStr);
|
|
610
|
+
if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
|
|
611
|
+
hasXapi = parsed?.xapi != null;
|
|
612
|
+
} catch {}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// cmi5 needs the publisher regardless of explicit xapi config (cmi5
|
|
617
|
+
// adapter shares the publisher queue for its own LMS-required statements).
|
|
618
|
+
if (hasXapi || standard === 'cmi5') {
|
|
619
|
+
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return `export async function buildXAPIClient() { return null; }`;
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
|
|
628
|
+
return {
|
|
629
|
+
name: 'tessera:first-page-preload',
|
|
630
|
+
apply: 'build',
|
|
631
|
+
transformIndexHtml: {
|
|
632
|
+
order: 'post',
|
|
633
|
+
handler(_html, ctx) {
|
|
634
|
+
const firstPagePath = manifestRef.current?.pages[0]?.importPath;
|
|
635
|
+
if (!firstPagePath || !ctx.bundle) return;
|
|
636
|
+
const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\//, '')).replace(/\\/g, '/');
|
|
637
|
+
const chunk = Object.values(ctx.bundle).find(
|
|
638
|
+
(c): c is import('vite').Rollup.OutputChunk =>
|
|
639
|
+
c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, '/') === normalized
|
|
640
|
+
);
|
|
641
|
+
if (!chunk) return;
|
|
642
|
+
return [{
|
|
643
|
+
tag: 'link',
|
|
644
|
+
attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
|
|
645
|
+
injectTo: 'head',
|
|
646
|
+
}];
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
package/src/runtime/App.svelte
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
import UserLayout from 'virtual:tessera-layout';
|
|
6
6
|
import Quiz from 'virtual:tessera-quiz';
|
|
7
7
|
import { onMount, onDestroy, setContext, untrack } from 'svelte';
|
|
8
|
-
import
|
|
8
|
+
import LoadingBar from './LoadingBar.svelte';
|
|
9
9
|
import ErrorPage from './ErrorPage.svelte';
|
|
10
10
|
import DefaultLayout from '../components/DefaultLayout.svelte';
|
|
11
11
|
import { NavigationState } from './navigation.svelte.js';
|
|
12
12
|
import { ProgressState } from './progress.svelte.js';
|
|
13
13
|
import { DurationTracker } from './duration.js';
|
|
14
|
-
import { createAdapter } from '
|
|
15
|
-
import { buildXAPIClient } from '
|
|
14
|
+
import { createAdapter } from 'virtual:tessera-adapter';
|
|
15
|
+
import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
|
|
16
16
|
import { registerXAPIClient } from './xapi/registry.js';
|
|
17
17
|
import { TESSERA_PAGE, TESSERA_NAV, TESSERA_ADAPTER, TESSERA_USER_STATE } from './contexts.js';
|
|
18
18
|
|
|
@@ -24,12 +24,19 @@
|
|
|
24
24
|
// can reach it.
|
|
25
25
|
let xapiClient = null;
|
|
26
26
|
|
|
27
|
+
const gradedQuizIndices = new Set(
|
|
28
|
+
manifest.pages.filter(p => p.quiz?.graded).map(p => p.index)
|
|
29
|
+
);
|
|
30
|
+
|
|
27
31
|
// ---- State classes ----
|
|
28
|
-
const progress = new ProgressState();
|
|
32
|
+
const progress = new ProgressState(gradedQuizIndices);
|
|
29
33
|
const nav = new NavigationState(manifest, progress, config);
|
|
34
|
+
nav.setPageModules(pageModules);
|
|
30
35
|
let duration = $state(new DurationTracker(0));
|
|
31
36
|
|
|
32
|
-
const
|
|
37
|
+
const onIdle = typeof window !== 'undefined' && window.requestIdleCallback
|
|
38
|
+
? window.requestIdleCallback.bind(window)
|
|
39
|
+
: (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1);
|
|
33
40
|
|
|
34
41
|
// Page loading state
|
|
35
42
|
let PageComponent = $state(null);
|
|
@@ -84,22 +91,20 @@
|
|
|
84
91
|
|
|
85
92
|
const gen = ++loadGeneration;
|
|
86
93
|
pageLoading = true;
|
|
87
|
-
pageError = null;
|
|
88
|
-
PageComponent = null;
|
|
89
|
-
|
|
90
|
-
// Update context for the new page
|
|
91
|
-
pageContext.quiz = page.quiz;
|
|
92
94
|
|
|
93
95
|
const loader = pageModules[page.importPath];
|
|
94
96
|
if (!loader) {
|
|
95
97
|
console.error(`Tessera: No loader for page ${index} at ${page.importPath}`);
|
|
96
98
|
pageError = new Error(`Page not found: ${page.importPath}`);
|
|
99
|
+
PageComponent = null;
|
|
97
100
|
pageLoading = false;
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
loader().then(mod => {
|
|
102
105
|
if (gen !== loadGeneration) return; // stale
|
|
106
|
+
pageError = null;
|
|
107
|
+
pageContext.quiz = page.quiz;
|
|
103
108
|
PageComponent = mod.default;
|
|
104
109
|
pageLoading = false;
|
|
105
110
|
progress.markVisited(index);
|
|
@@ -109,8 +114,9 @@
|
|
|
109
114
|
) {
|
|
110
115
|
progress.markCompleteManually();
|
|
111
116
|
}
|
|
112
|
-
progress.recalculateCompletion(manifest, config);
|
|
113
|
-
progress.recalculateSuccess(
|
|
117
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
118
|
+
progress.recalculateSuccess(config);
|
|
119
|
+
onIdle(() => nav.prefetch(index + 1));
|
|
114
120
|
}).catch(err => {
|
|
115
121
|
if (gen !== loadGeneration) return; // stale
|
|
116
122
|
console.error(`Tessera: Failed to load page ${index}`, err);
|
|
@@ -132,18 +138,25 @@
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
// ---- Branding ----
|
|
141
|
+
// Two sentinels so the validity check doesn't false-positive when the
|
|
142
|
+
// input happens to normalize to the initial fillStyle ("#000000").
|
|
135
143
|
function parseColor(color) {
|
|
136
144
|
if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
|
|
137
145
|
return null;
|
|
138
146
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
148
|
+
if (!ctx) return null;
|
|
149
|
+
ctx.fillStyle = '#000';
|
|
150
|
+
ctx.fillStyle = color;
|
|
151
|
+
const onBlack = ctx.fillStyle;
|
|
152
|
+
ctx.fillStyle = '#fff';
|
|
153
|
+
ctx.fillStyle = color;
|
|
154
|
+
const onWhite = ctx.fillStyle;
|
|
155
|
+
if (onBlack !== onWhite) return null;
|
|
156
|
+
const hex = String(onBlack).match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
157
|
+
if (hex) return { r: parseInt(hex[1], 16), g: parseInt(hex[2], 16), b: parseInt(hex[3], 16) };
|
|
158
|
+
const rgba = String(onBlack).match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
159
|
+
return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
function rgbToHsl(r, g, b) {
|
|
@@ -181,8 +194,8 @@
|
|
|
181
194
|
const { score } = e.detail;
|
|
182
195
|
const pageIndex = nav.currentPageIndex;
|
|
183
196
|
progress.quizCompleted(pageIndex, score);
|
|
184
|
-
progress.recalculateCompletion(manifest, config);
|
|
185
|
-
progress.recalculateSuccess(
|
|
197
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
198
|
+
progress.recalculateSuccess(config);
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
// ---- Persistence: serialize / restore ----
|
|
@@ -251,8 +264,8 @@
|
|
|
251
264
|
progress.markCompleteManually();
|
|
252
265
|
}
|
|
253
266
|
// Recalculate derived state
|
|
254
|
-
progress.recalculateCompletion(manifest, config);
|
|
255
|
-
progress.recalculateSuccess(
|
|
267
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
268
|
+
progress.recalculateSuccess(config);
|
|
256
269
|
// Navigate to bookmark (after state is restored so locking is correct)
|
|
257
270
|
if (saved.b > 0 && saved.b < manifest.totalPages) {
|
|
258
271
|
nav.goToPage(saved.b);
|
|
@@ -296,14 +309,21 @@
|
|
|
296
309
|
$effect(() => {
|
|
297
310
|
const scores = progress.quizScores;
|
|
298
311
|
if (!persistenceReady || scores.size === 0) return;
|
|
299
|
-
if (gradedQuizIndices.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
312
|
+
if (gradedQuizIndices.size === 0) return;
|
|
313
|
+
|
|
314
|
+
let sum = 0;
|
|
315
|
+
let attempted = false;
|
|
316
|
+
for (const i of gradedQuizIndices) {
|
|
317
|
+
if (scores.has(i)) {
|
|
318
|
+
sum += scores.get(i) ?? 0;
|
|
319
|
+
attempted = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!attempted) return;
|
|
303
323
|
|
|
304
324
|
// Divide by total graded count — incomplete quizzes count as 0, matching
|
|
305
325
|
// the recalculateSuccess logic in progress.svelte.ts.
|
|
306
|
-
const average =
|
|
326
|
+
const average = sum / gradedQuizIndices.size;
|
|
307
327
|
|
|
308
328
|
untrack(() => {
|
|
309
329
|
adapter.setScore(Math.round(average));
|
|
@@ -463,9 +483,7 @@
|
|
|
463
483
|
</script>
|
|
464
484
|
|
|
465
485
|
{#snippet page()}
|
|
466
|
-
{#if
|
|
467
|
-
<LoadingSkeleton />
|
|
468
|
-
{:else if pageError}
|
|
486
|
+
{#if pageError}
|
|
469
487
|
<ErrorPage error={pageError} onretry={retryPage} />
|
|
470
488
|
{:else if PageComponent}
|
|
471
489
|
{#if pageContext.quiz}
|
|
@@ -479,6 +497,7 @@
|
|
|
479
497
|
{/snippet}
|
|
480
498
|
|
|
481
499
|
<div id="tessera-app" data-chrome={chromeMode}>
|
|
500
|
+
<LoadingBar active={pageLoading} />
|
|
482
501
|
{#if UserLayout}
|
|
483
502
|
<UserLayout {page} />
|
|
484
503
|
{:else if chromeMode === 'custom'}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let { active = false } = $props();
|
|
5
|
+
|
|
6
|
+
let visible = $state(false);
|
|
7
|
+
let appeared = $state(false);
|
|
8
|
+
let complete = $state(false);
|
|
9
|
+
let showSlowMessage = $state(false);
|
|
10
|
+
|
|
11
|
+
$effect(() => {
|
|
12
|
+
if (active) {
|
|
13
|
+
// Defer the bar so sub-100ms loads never flash. Add `.appear` on the
|
|
14
|
+
// next frame so the CSS transition from width:0 → 90% actually fires.
|
|
15
|
+
const appearTimer = setTimeout(() => {
|
|
16
|
+
visible = true;
|
|
17
|
+
requestAnimationFrame(() => { appeared = true; });
|
|
18
|
+
}, 100);
|
|
19
|
+
const slowTimer = setTimeout(() => { showSlowMessage = true; }, 5000);
|
|
20
|
+
return () => {
|
|
21
|
+
clearTimeout(appearTimer);
|
|
22
|
+
clearTimeout(slowTimer);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Completing. If the bar never appeared we have nothing to finish.
|
|
27
|
+
// untrack so flipping `visible` doesn't re-trigger this effect.
|
|
28
|
+
if (!untrack(() => visible)) return;
|
|
29
|
+
complete = true;
|
|
30
|
+
const hideTimer = setTimeout(() => {
|
|
31
|
+
visible = false;
|
|
32
|
+
appeared = false;
|
|
33
|
+
complete = false;
|
|
34
|
+
showSlowMessage = false;
|
|
35
|
+
}, 220);
|
|
36
|
+
return () => clearTimeout(hideTimer);
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
{#if visible}
|
|
41
|
+
<div class="tessera-loading-bar" class:appear={appeared} class:complete aria-hidden="true">
|
|
42
|
+
<div class="tessera-loading-bar-fill"></div>
|
|
43
|
+
</div>
|
|
44
|
+
{#if showSlowMessage}
|
|
45
|
+
<p class="tessera-loading-bar-message" role="status">Still loading…</p>
|
|
46
|
+
{/if}
|
|
47
|
+
{/if}
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
aria-current={page.index === currentPageIndex ? 'page' : undefined}
|
|
61
61
|
aria-disabled={locked ? 'true' : undefined}
|
|
62
62
|
onclick={() => handlePageClick(page.index)}
|
|
63
|
+
onpointerenter={() => !locked && nav.prefetch(page.index)}
|
|
64
|
+
onfocusin={() => !locked && nav.prefetch(page.index)}
|
|
63
65
|
>
|
|
64
66
|
{#if locked}
|
|
65
67
|
<svg class="tessera-nav-lock-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" width="12" height="12">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
3
|
import type { Interaction } from './interaction.js';
|
|
3
4
|
import { isCorrect as isCorrectInteraction } from './interaction.js';
|
|
4
5
|
import {
|
|
@@ -185,8 +186,8 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
185
186
|
if (opts.graded && navCtx) {
|
|
186
187
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
187
188
|
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
|
|
188
|
-
navCtx.progress.recalculateCompletion(navCtx.manifest, navCtx.config);
|
|
189
|
-
navCtx.progress.recalculateSuccess(navCtx.
|
|
189
|
+
navCtx.progress.recalculateCompletion(navCtx.manifest.totalPages, navCtx.config);
|
|
190
|
+
navCtx.progress.recalculateSuccess(navCtx.config);
|
|
190
191
|
} else if (navCtx) {
|
|
191
192
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
192
193
|
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, false);
|
|
@@ -291,7 +292,7 @@ export function useCompletion(): {
|
|
|
291
292
|
return;
|
|
292
293
|
}
|
|
293
294
|
progress.markCompleteManually();
|
|
294
|
-
progress.recalculateSuccess(
|
|
295
|
+
progress.recalculateSuccess(config);
|
|
295
296
|
},
|
|
296
297
|
get completionStatus() {
|
|
297
298
|
return progress.completionStatus;
|
|
@@ -422,8 +423,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
422
423
|
let reviewing = $state(false);
|
|
423
424
|
let score = $state(0);
|
|
424
425
|
let attemptCount = $state(0);
|
|
425
|
-
|
|
426
|
-
|
|
426
|
+
const feedbackShown = new SvelteSet<number>();
|
|
427
|
+
const lockedCorrect = new SvelteSet<number>();
|
|
427
428
|
let submitCalled = false;
|
|
428
429
|
|
|
429
430
|
const seenIds = new Set<string>();
|
|
@@ -490,9 +491,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
490
491
|
|
|
491
492
|
function revealFeedbackInternal(index: number): void {
|
|
492
493
|
if (policyCfg.feedbackMode === 'never') return;
|
|
493
|
-
|
|
494
|
-
next.add(index);
|
|
495
|
-
feedbackShown = next;
|
|
494
|
+
feedbackShown.add(index);
|
|
496
495
|
}
|
|
497
496
|
|
|
498
497
|
function isLockedCorrectInternal(index: number): boolean {
|
|
@@ -618,7 +617,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
618
617
|
for (const i of newLocked) {
|
|
619
618
|
if (answers.has(i)) preserved.set(i, answers.get(i));
|
|
620
619
|
}
|
|
621
|
-
lockedCorrect
|
|
620
|
+
lockedCorrect.clear();
|
|
621
|
+
for (const i of newLocked) lockedCorrect.add(i);
|
|
622
622
|
answers.clear();
|
|
623
623
|
reportedAnswers.clear();
|
|
624
624
|
for (const [i, a] of preserved) answers.set(i, a);
|
|
@@ -626,7 +626,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
626
626
|
if (!newLocked.has(i) && internalQuestions[i].reset) internalQuestions[i].reset!();
|
|
627
627
|
}
|
|
628
628
|
answersVersion++;
|
|
629
|
-
feedbackShown
|
|
629
|
+
feedbackShown.clear();
|
|
630
630
|
submitted = false;
|
|
631
631
|
reviewing = false;
|
|
632
632
|
score = 0;
|
|
@@ -22,10 +22,13 @@ export function isPageComplete(
|
|
|
22
22
|
return (progress.quizScores.get(index) ?? 0) >= config.scoring.passingScore;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export type PageModuleMap = Record<string, () => Promise<unknown>>;
|
|
26
|
+
|
|
25
27
|
export class NavigationState {
|
|
26
28
|
manifest = $state<Manifest>(null!);
|
|
27
29
|
#progress: ProgressState;
|
|
28
30
|
#config: CourseConfig;
|
|
31
|
+
#pageModules: PageModuleMap | null = null;
|
|
29
32
|
currentPageIndex = $state(0);
|
|
30
33
|
|
|
31
34
|
canGoPrev = $derived(this.currentPageIndex > 0);
|
|
@@ -36,11 +39,24 @@ export class NavigationState {
|
|
|
36
39
|
return !this.isPageLocked(next);
|
|
37
40
|
});
|
|
38
41
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
#
|
|
42
|
+
// Memo cache so the derived can return a stable Set reference when
|
|
43
|
+
// membership is unchanged (two Sets with identical contents are not `===`).
|
|
44
|
+
// Must NOT be `$state` — that would make this a reactive-state mutation
|
|
45
|
+
// from inside a derived.
|
|
46
|
+
#prevLockedSet: Set<number> | null = null;
|
|
47
|
+
#lockedSet = $derived.by<Set<number>>(() => {
|
|
48
|
+
const next = this.#computeLockedSet();
|
|
49
|
+
const prev = this.#prevLockedSet;
|
|
50
|
+
if (prev && prev.size === next.size) {
|
|
51
|
+
let same = true;
|
|
52
|
+
for (const i of next) {
|
|
53
|
+
if (!prev.has(i)) { same = false; break; }
|
|
54
|
+
}
|
|
55
|
+
if (same) return prev;
|
|
56
|
+
}
|
|
57
|
+
this.#prevLockedSet = next;
|
|
58
|
+
return next;
|
|
59
|
+
});
|
|
44
60
|
|
|
45
61
|
constructor(manifest: Manifest, progress: ProgressState, config: CourseConfig) {
|
|
46
62
|
this.manifest = manifest;
|
|
@@ -48,6 +64,23 @@ export class NavigationState {
|
|
|
48
64
|
this.#config = config;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
setPageModules(modules: PageModuleMap) {
|
|
68
|
+
this.#pageModules = modules;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Warm the browser module cache for a page chunk. Idempotent — repeated
|
|
73
|
+
* calls for the same index hit the existing cache. Bails on locked pages
|
|
74
|
+
* so callers don't need to guard.
|
|
75
|
+
*/
|
|
76
|
+
prefetch(index: number) {
|
|
77
|
+
if (!this.#pageModules) return;
|
|
78
|
+
if (index < 0 || index >= this.manifest.totalPages) return;
|
|
79
|
+
if (this.isPageLocked(index)) return;
|
|
80
|
+
const page = this.manifest.pages[index];
|
|
81
|
+
this.#pageModules[page.importPath]?.();
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
goToPage(index: number) {
|
|
52
85
|
if (index < 0 || index >= this.manifest.totalPages) return;
|
|
53
86
|
if (this.isPageLocked(index)) return;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
2
|
-
import type { Manifest } from '../plugin/manifest.js';
|
|
3
2
|
import type { CourseConfig } from './types.js';
|
|
4
3
|
|
|
5
4
|
export class ProgressState {
|
|
5
|
+
#quizGradedIndices: ReadonlySet<number>;
|
|
6
|
+
|
|
7
|
+
constructor(quizGradedIndices: ReadonlySet<number>) {
|
|
8
|
+
this.#quizGradedIndices = quizGradedIndices;
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
visitedPages = $state(new SvelteSet<number>());
|
|
7
12
|
quizScores = $state(new SvelteMap<number, number>());
|
|
8
13
|
/**
|
|
@@ -114,17 +119,17 @@ export class ProgressState {
|
|
|
114
119
|
return sum / pageMap.size;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
recalculateCompletion(
|
|
122
|
+
recalculateCompletion(totalPages: number, config: CourseConfig) {
|
|
118
123
|
if (this.#manuallyCompleted) return;
|
|
119
124
|
if (config.completion.mode === 'manual') return;
|
|
120
125
|
if (config.completion.mode === 'percentage') {
|
|
121
126
|
const threshold = config.completion.percentageThreshold ?? 100;
|
|
122
|
-
const percent =
|
|
123
|
-
? (this.visitedPages.size /
|
|
127
|
+
const percent = totalPages > 0
|
|
128
|
+
? (this.visitedPages.size / totalPages) * 100
|
|
124
129
|
: 0;
|
|
125
130
|
this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
|
|
126
131
|
} else if (config.completion.mode === 'quiz') {
|
|
127
|
-
const { indices } = this.#gradedPages(
|
|
132
|
+
const { indices } = this.#gradedPages();
|
|
128
133
|
if (indices.length === 0) {
|
|
129
134
|
this.completionStatus = 'incomplete';
|
|
130
135
|
return;
|
|
@@ -134,7 +139,7 @@ export class ProgressState {
|
|
|
134
139
|
}
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
recalculateSuccess(
|
|
142
|
+
recalculateSuccess(config: CourseConfig) {
|
|
138
143
|
if (config.completion.mode === 'manual') {
|
|
139
144
|
const want = config.completion.requireSuccessStatus;
|
|
140
145
|
// Stay 'unknown' until manual mark fires, so a learner who never
|
|
@@ -143,7 +148,7 @@ export class ProgressState {
|
|
|
143
148
|
return;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
const { indices, attempted } = this.#gradedPages(
|
|
151
|
+
const { indices, attempted } = this.#gradedPages();
|
|
147
152
|
|
|
148
153
|
if (indices.length === 0) {
|
|
149
154
|
this.successStatus = 'unknown';
|
|
@@ -163,9 +168,10 @@ export class ProgressState {
|
|
|
163
168
|
* plus pages with at least one graded standalone question (deduped).
|
|
164
169
|
* `attempted` is true if any of those pages has a recorded score.
|
|
165
170
|
*/
|
|
166
|
-
#gradedPages(
|
|
167
|
-
const
|
|
168
|
-
const
|
|
171
|
+
#gradedPages(): { indices: number[]; attempted: boolean } {
|
|
172
|
+
const merged = new Set(this.#quizGradedIndices);
|
|
173
|
+
for (const i of this.gradedStandalonePages) merged.add(i);
|
|
174
|
+
const indices = [...merged];
|
|
169
175
|
const attempted = indices.some(i => this.#hasScore(i));
|
|
170
176
|
return { indices, attempted };
|
|
171
177
|
}
|
package/src/virtual.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ declare module 'virtual:tessera-layout' {
|
|
|
4
4
|
export default layout;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
declare module 'virtual:tessera-adapter' {
|
|
8
|
+
import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
|
|
9
|
+
import type { CourseConfig } from 'tessera-learn/runtime/types.js';
|
|
10
|
+
export function createAdapter(config: CourseConfig): PersistenceAdapter;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare module 'virtual:tessera-xapi-setup' {
|
|
14
|
+
import type { CourseConfig } from 'tessera-learn/runtime/types.js';
|
|
15
|
+
import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
|
|
16
|
+
import type { XAPIClient } from 'tessera-learn/runtime/xapi/client.js';
|
|
17
|
+
export function buildXAPIClient(config: CourseConfig, adapter: PersistenceAdapter): Promise<XAPIClient | null>;
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
interface ImportMetaEnv {
|
|
8
21
|
readonly DEV: boolean;
|
|
9
22
|
readonly PROD: boolean;
|
package/styles/layout.css
CHANGED
|
@@ -281,38 +281,48 @@
|
|
|
281
281
|
z-index: 999;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
/* ---- Loading
|
|
285
|
-
.tessera-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
/* ---- Loading Bar ---- */
|
|
285
|
+
.tessera-loading-bar {
|
|
286
|
+
position: fixed;
|
|
287
|
+
top: 0;
|
|
288
|
+
left: 0;
|
|
289
|
+
right: 0;
|
|
290
|
+
height: 2px;
|
|
291
|
+
z-index: 2000;
|
|
292
|
+
pointer-events: none;
|
|
293
|
+
background-color: transparent;
|
|
290
294
|
}
|
|
291
295
|
|
|
292
|
-
.tessera-
|
|
293
|
-
height:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
296
|
+
.tessera-loading-bar-fill {
|
|
297
|
+
height: 100%;
|
|
298
|
+
width: 0;
|
|
299
|
+
background-color: var(--tessera-primary);
|
|
300
|
+
box-shadow: 0 0 8px var(--tessera-primary);
|
|
301
|
+
transition: width 12s cubic-bezier(0.1, 0.7, 0.1, 1);
|
|
297
302
|
}
|
|
298
303
|
|
|
299
|
-
.tessera-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.tessera-skeleton-line:nth-child(4) { width: 78%; }
|
|
303
|
-
.tessera-skeleton-line:nth-child(5) { width: 85%; }
|
|
304
|
-
.tessera-skeleton-line:nth-child(6) { width: 60%; }
|
|
304
|
+
.tessera-loading-bar.appear .tessera-loading-bar-fill {
|
|
305
|
+
width: 90%;
|
|
306
|
+
}
|
|
305
307
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
308
|
+
.tessera-loading-bar.complete .tessera-loading-bar-fill {
|
|
309
|
+
width: 100%;
|
|
310
|
+
transition: width 180ms ease-out;
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
.tessera-
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
.tessera-loading-bar-message {
|
|
314
|
+
position: fixed;
|
|
315
|
+
top: var(--tessera-spacing-md);
|
|
316
|
+
left: 50%;
|
|
317
|
+
transform: translateX(-50%);
|
|
318
|
+
z-index: 2000;
|
|
319
|
+
margin: 0;
|
|
320
|
+
padding: 4px 12px;
|
|
321
|
+
font-size: 0.8125rem;
|
|
314
322
|
color: var(--tessera-text-light);
|
|
315
|
-
|
|
323
|
+
background-color: var(--tessera-bg-secondary);
|
|
324
|
+
border: 1px solid var(--tessera-border);
|
|
325
|
+
border-radius: 999px;
|
|
316
326
|
}
|
|
317
327
|
|
|
318
328
|
/* ---- Buttons ---- */
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
<script>
|
|
2
|
-
import { onMount } from 'svelte';
|
|
3
|
-
|
|
4
|
-
let showSlowMessage = $state(false);
|
|
5
|
-
let timer;
|
|
6
|
-
|
|
7
|
-
onMount(() => {
|
|
8
|
-
timer = setTimeout(() => {
|
|
9
|
-
showSlowMessage = true;
|
|
10
|
-
}, 5000);
|
|
11
|
-
|
|
12
|
-
return () => clearTimeout(timer);
|
|
13
|
-
});
|
|
14
|
-
</script>
|
|
15
|
-
|
|
16
|
-
<div class="tessera-skeleton" aria-busy="true" aria-label="Loading page content">
|
|
17
|
-
<div class="tessera-skeleton-line"></div>
|
|
18
|
-
<div class="tessera-skeleton-line"></div>
|
|
19
|
-
<div class="tessera-skeleton-line"></div>
|
|
20
|
-
<div class="tessera-skeleton-line"></div>
|
|
21
|
-
<div class="tessera-skeleton-line"></div>
|
|
22
|
-
<div class="tessera-skeleton-line"></div>
|
|
23
|
-
{#if showSlowMessage}
|
|
24
|
-
<p class="tessera-skeleton-message">Still loading…</p>
|
|
25
|
-
{/if}
|
|
26
|
-
</div>
|