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.
@@ -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: "injected" } }),
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
- currentManifest = generateManifest(pagesDir);
554
- return currentManifest;
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
- currentManifest = null;
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 (!currentManifest) buildManifest();
594
+ if (!manifestRef.current) buildManifest();
587
595
  addWatchFiles(this, pagesDir);
588
- const json = JSON.stringify(currentManifest, (_key, value) => value === Infinity ? 1e9 : value);
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
 
@@ -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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;');\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":"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"}
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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;');\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.9",
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.5"
67
+ "svelte": "^5.55.7"
65
68
  },
66
69
  "devDependencies": {
67
- "@types/node": "^25.6.2",
68
- "@vitest/coverage-v8": "^4.1.5",
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.0",
73
- "vitest": "^4.1.5"
75
+ "vite": "^8.0.13",
76
+ "vitest": "^4.1.6"
74
77
  },
75
78
  "scripts": {
76
79
  "build": "tsdown",
@@ -91,6 +91,8 @@
91
91
  class="tessera-page-nav-btn"
92
92
  disabled={!nav.canGoNext}
93
93
  onclick={() => nav.goNext()}
94
+ onpointerenter={() => nav.prefetch(nav.currentPageIndex + 1)}
95
+ onfocusin={() => nav.prefetch(nav.currentPageIndex + 1)}
94
96
  >
95
97
  Next →
96
98
  </button>
@@ -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: 'injected' },
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
- currentManifest = generateManifest(pagesDir);
408
- return currentManifest;
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
- currentManifest = null; // invalidate cache
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 (!currentManifest) {
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(currentManifest, (_key, value) =>
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
+ }
@@ -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 LoadingSkeleton from './LoadingSkeleton.svelte';
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 './adapters/index.js';
15
- import { buildXAPIClient } from './xapi/setup.js';
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 gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
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(manifest, config);
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 el = document.createElement('span');
140
- el.style.color = color;
141
- document.documentElement.appendChild(el);
142
- const computed = getComputedStyle(el).color;
143
- el.remove();
144
- const match = computed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
145
- if (!match) return null;
146
- return { r: +match[1], g: +match[2], b: +match[3] };
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(manifest, config);
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(manifest, config);
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.length === 0) return;
300
-
301
- const completedGraded = gradedQuizIndices.filter(i => scores.has(i));
302
- if (completedGraded.length === 0) return;
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 = completedGraded.reduce((sum, i) => sum + (scores.get(i) ?? 0), 0) / gradedQuizIndices.length;
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 pageLoading}
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.manifest, navCtx.config);
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(manifest, config);
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
- let feedbackShown = $state(new Set<number>());
426
- let lockedCorrect = $state(new Set<number>());
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
- const next = new Set(feedbackShown);
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 = newLocked;
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 = new Set();
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
- // Cache locked-page lookup as a single derived Set so the sidebar's
40
- // per-page `isPageLocked` calls stay O(1). Without this, sequential mode
41
- // is O(n²) per render (each `isPageLocked` walks all earlier pages).
42
- // Recomputed once per relevant state change.
43
- #lockedSet = $derived.by<Set<number>>(() => this.#computeLockedSet());
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(manifest: Manifest, config: CourseConfig) {
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 = manifest.totalPages > 0
123
- ? (this.visitedPages.size / manifest.totalPages) * 100
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(manifest);
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(manifest: Manifest, config: CourseConfig) {
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(manifest);
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(manifest: Manifest): { indices: number[]; attempted: boolean } {
167
- const quizPages = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
168
- const indices = [...new Set([...quizPages, ...this.gradedStandalonePages])];
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 Skeleton ---- */
285
- .tessera-skeleton {
286
- display: flex;
287
- flex-direction: column;
288
- gap: var(--tessera-spacing-md);
289
- padding: var(--tessera-spacing-md) 0;
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-skeleton-line {
293
- height: 16px;
294
- background-color: var(--tessera-border);
295
- border-radius: 4px;
296
- animation: tessera-pulse 1.5s ease-in-out infinite;
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-skeleton-line:nth-child(1) { width: 45%; height: 28px; }
300
- .tessera-skeleton-line:nth-child(2) { width: 100%; }
301
- .tessera-skeleton-line:nth-child(3) { width: 92%; }
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
- @keyframes tessera-pulse {
307
- 0%, 100% { opacity: 1; }
308
- 50% { opacity: 0.4; }
308
+ .tessera-loading-bar.complete .tessera-loading-bar-fill {
309
+ width: 100%;
310
+ transition: width 180ms ease-out;
309
311
  }
310
312
 
311
- .tessera-skeleton-message {
312
- margin-top: var(--tessera-spacing-md);
313
- font-size: 0.875rem;
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
- text-align: center;
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>