tessera-learn 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/cli.js +1 -1
- package/dist/plugin/index.js +147 -11
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-B4UhCY5y.js → validation-BxWAMMnJ.js} +4 -7
- package/dist/validation-BxWAMMnJ.js.map +1 -0
- package/package.json +9 -6
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +32 -37
- package/src/components/Matching.svelte +35 -68
- package/src/components/MultipleChoice.svelte +25 -38
- package/src/components/Quiz.svelte +22 -26
- package/src/components/Sorting.svelte +40 -42
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +184 -9
- package/src/plugin/validation.ts +7 -2
- package/src/runtime/App.svelte +53 -39
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +44 -14
- package/src/runtime/hooks.svelte.ts +269 -227
- package/src/runtime/interaction-format.ts +40 -8
- package/src/runtime/interaction.ts +3 -3
- package/src/runtime/navigation.svelte.ts +38 -5
- package/src/runtime/persistence.ts +5 -0
- package/src/runtime/progress.svelte.ts +16 -10
- package/src/runtime/quiz-policy.ts +16 -16
- package/src/runtime/types.ts +1 -2
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-B4UhCY5y.js.map +0 -1
- package/src/components/quiz-payload.ts +0 -71
- package/src/runtime/LoadingSkeleton.svelte +0 -26
package/dist/plugin/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as validateProject } from "../validation-
|
|
2
|
+
import { t as validateProject } from "../validation-BxWAMMnJ.js";
|
|
3
3
|
//#region src/plugin/cli.ts
|
|
4
4
|
const { errors, warnings } = validateProject(process.cwd());
|
|
5
5
|
for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
|
package/dist/plugin/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as extractDefaultExportObjectLiteral, r as generateManifest, t as validateProject } from "../validation-
|
|
1
|
+
import { n as extractDefaultExportObjectLiteral, r as generateManifest, t as validateProject } from "../validation-BxWAMMnJ.js";
|
|
2
2
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
@@ -282,15 +282,22 @@ function resolveStylesDir() {
|
|
|
282
282
|
return resolve(resolve(__dirname, "..", ".."), "styles");
|
|
283
283
|
}
|
|
284
284
|
function tesseraPlugin() {
|
|
285
|
+
const manifestRef = {
|
|
286
|
+
current: null,
|
|
287
|
+
root: ""
|
|
288
|
+
};
|
|
285
289
|
return [
|
|
286
|
-
svelte({ compilerOptions: { css: "
|
|
290
|
+
svelte({ compilerOptions: { css: "external" } }),
|
|
287
291
|
tesseraValidationPlugin(),
|
|
288
292
|
tesseraEntryPlugin(),
|
|
289
293
|
tesseraConfigPlugin(),
|
|
290
294
|
tesseraPagesPlugin(),
|
|
291
|
-
tesseraManifestPlugin(),
|
|
295
|
+
tesseraManifestPlugin(manifestRef),
|
|
292
296
|
tesseraLayoutPlugin(),
|
|
293
297
|
tesseraQuizPlugin(),
|
|
298
|
+
tesseraAdapterPlugin(),
|
|
299
|
+
tesseraXAPISetupPlugin(),
|
|
300
|
+
tesseraFirstPagePreloadPlugin(manifestRef),
|
|
294
301
|
tesseraExportPlugin()
|
|
295
302
|
];
|
|
296
303
|
}
|
|
@@ -414,7 +421,8 @@ function tesseraConfigPlugin() {
|
|
|
414
421
|
config(config) {
|
|
415
422
|
return {
|
|
416
423
|
base: "./",
|
|
417
|
-
resolve: { alias: { "$assets": resolve(config.root || process.cwd(), "assets") } }
|
|
424
|
+
resolve: { alias: { "$assets": resolve(config.root || process.cwd(), "assets") } },
|
|
425
|
+
optimizeDeps: { exclude: ["tessera-learn"] }
|
|
418
426
|
};
|
|
419
427
|
},
|
|
420
428
|
configResolved(config) {
|
|
@@ -544,13 +552,13 @@ function tesseraExportPlugin() {
|
|
|
544
552
|
}
|
|
545
553
|
const VIRTUAL_MANIFEST_ID = "virtual:tessera-manifest";
|
|
546
554
|
const RESOLVED_MANIFEST_ID = "\0" + VIRTUAL_MANIFEST_ID;
|
|
547
|
-
function tesseraManifestPlugin() {
|
|
555
|
+
function tesseraManifestPlugin(manifestRef) {
|
|
548
556
|
let projectRoot;
|
|
549
557
|
let pagesDir;
|
|
550
|
-
let currentManifest = null;
|
|
551
558
|
function buildManifest() {
|
|
552
|
-
|
|
553
|
-
|
|
559
|
+
const m = generateManifest(pagesDir);
|
|
560
|
+
manifestRef.current = m;
|
|
561
|
+
return m;
|
|
554
562
|
}
|
|
555
563
|
return {
|
|
556
564
|
name: "tessera:manifest",
|
|
@@ -558,12 +566,13 @@ function tesseraManifestPlugin() {
|
|
|
558
566
|
configResolved(config) {
|
|
559
567
|
projectRoot = config.root;
|
|
560
568
|
pagesDir = resolve(projectRoot, "pages");
|
|
569
|
+
manifestRef.root = projectRoot;
|
|
561
570
|
},
|
|
562
571
|
configureServer(devServer) {
|
|
563
572
|
devServer.watcher.on("all", (event, filePath) => {
|
|
564
573
|
if (!filePath.startsWith(pagesDir)) return;
|
|
565
574
|
if (filePath.endsWith(".svelte") || filePath.endsWith("_meta.js") || event === "addDir" || event === "unlinkDir") {
|
|
566
|
-
|
|
575
|
+
manifestRef.current = null;
|
|
567
576
|
const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
568
577
|
if (mod) {
|
|
569
578
|
devServer.moduleGraph.invalidateModule(mod);
|
|
@@ -582,15 +591,142 @@ function tesseraManifestPlugin() {
|
|
|
582
591
|
},
|
|
583
592
|
load(id) {
|
|
584
593
|
if (id === RESOLVED_MANIFEST_ID) {
|
|
585
|
-
if (!
|
|
594
|
+
if (!manifestRef.current) buildManifest();
|
|
586
595
|
addWatchFiles(this, pagesDir);
|
|
587
|
-
const json = JSON.stringify(
|
|
596
|
+
const json = JSON.stringify(manifestRef.current, (_key, value) => value === Infinity ? 1e9 : value);
|
|
588
597
|
return `export default JSON.parse(atob("${Buffer.from(json).toString("base64")}"));`;
|
|
589
598
|
}
|
|
590
599
|
return null;
|
|
591
600
|
}
|
|
592
601
|
};
|
|
593
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
|
+
}
|
|
594
730
|
//#endregion
|
|
595
731
|
export { tesseraPlugin };
|
|
596
732
|
|
package/dist/plugin/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["__dirname"],"sources":["../../src/runtime/slugify.ts","../../src/plugin/export.ts","../../src/plugin/layout.ts","../../src/plugin/quiz.ts","../../src/plugin/index.ts"],"sourcesContent":["/**\n * Slugify a string for use as a URL-safe / filename-safe identifier.\n * \"My Course Title\" → \"my-course-title\"\n *\n * Shared by the runtime (`WebAdapter` localStorage key) and the build-time\n * exporter (`runExport` zip filename). Both want identical, deterministic\n * output so a course's storage key matches its package name.\n */\nexport function slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '')\n .replace(/[\\s_]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '');\n}\n","import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { createWriteStream } from 'node:fs';\nimport { createHash } from 'node:crypto';\nimport { ZipArchive } from 'archiver';\nimport { slugify } from '../runtime/slugify.js';\n\n// ---------- Types ----------\n\ninterface ExportConfig {\n title: string;\n description?: string;\n version?: string;\n scoring?: { passingScore?: number };\n completion?: { mode?: 'quiz' | 'percentage' };\n export?: { standard?: string };\n}\n\n// ---------- Helpers ----------\n\nfunction escapeXml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/**\n * Recursively collect all file paths relative to a directory.\n */\nfunction collectFiles(dir: string, base: string = ''): string[] {\n const files: string[] = [];\n if (!existsSync(dir)) return files;\n\n for (const entry of readdirSync(dir)) {\n const fullPath = resolve(dir, entry);\n const relPath = base ? `${base}/${entry}` : entry;\n if (statSync(fullPath).isDirectory()) {\n files.push(...collectFiles(fullPath, relPath));\n } else {\n files.push(relPath);\n }\n }\n return files;\n}\n\n/**\n * Derive a stable URN IRI from a seed string. cmi5 §13.1 / xs:anyURI\n * require course / AU ids to be IRIs — bare hex or UUID-shaped strings\n * (without correct version/variant bits) aren't conformant URNs and may\n * be rejected by strict LMS importers.\n *\n * Hash the seed so the id survives rebuilds, then format as\n * `urn:tessera:<kind>:<hex>`. The same seed always produces the same\n * IRI, so existing LRS records are not orphaned by re-export.\n */\nfunction stableUrn(kind: 'course' | 'au', seed: string): string {\n const h = createHash('sha256').update(seed).digest('hex');\n // 32 hex chars (128 bits of entropy) is plenty; trim to keep ids short.\n return `urn:tessera:${kind}:${h.slice(0, 32)}`;\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n// ---------- Manifest Generators ----------\n\n/** Per-version XML differences in imsmanifest.xml between SCORM 1.2 and 2004. */\ninterface ScormManifestDialect {\n rootNs: string;\n adlcpNs: string;\n schemaversion: string;\n /** Attribute name on <resource>: SCORM 1.2 uses lowercase, 2004 uses camelCase. */\n scormTypeAttr: 'scormtype' | 'scormType';\n /** Whitespace-separated namespace+XSD pairs for xsi:schemaLocation. */\n schemaLocation: string;\n}\n\nconst SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {\n '1.2': {\n rootNs: 'http://www.imsproject.org/xsd/imscp_rootv1p1p2',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_rootv1p2',\n schemaversion: '1.2',\n scormTypeAttr: 'scormtype',\n schemaLocation:\n 'http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd ' +\n 'http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd',\n },\n '2004': {\n rootNs: 'http://www.imsglobal.org/xsd/imscp_v1p1',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_v1p3',\n schemaversion: '2004 4th Edition',\n scormTypeAttr: 'scormType',\n schemaLocation:\n 'http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd',\n },\n};\n\nexport function generateScormManifest(\n version: '1.2' | '2004',\n config: ExportConfig,\n distDir: string\n): string {\n const dialect = SCORM_DIALECTS[version];\n const title = escapeXml(config.title || 'Tessera Course');\n const files = collectFiles(distDir);\n const fileElements = files\n .map((f) => ` <file href=\"${escapeXml(f)}\" />`)\n .join('\\n');\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest identifier=\"tessera-course\" version=\"1.0\"\n xmlns=\"${dialect.rootNs}\"\n xmlns:adlcp=\"${dialect.adlcpNs}\"\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:schemaLocation=\"${dialect.schemaLocation}\">\n <metadata>\n <schema>ADL SCORM</schema>\n <schemaversion>${dialect.schemaversion}</schemaversion>\n </metadata>\n <organizations default=\"org-1\">\n <organization identifier=\"org-1\">\n <title>${title}</title>\n <item identifier=\"item-1\" identifierref=\"res-1\">\n <title>${title}</title>\n </item>\n </organization>\n </organizations>\n <resources>\n <resource identifier=\"res-1\" type=\"webcontent\" adlcp:${dialect.scormTypeAttr}=\"sco\" href=\"index.html\">\n${fileElements}\n </resource>\n </resources>\n</manifest>`;\n}\n\nexport function generateSCORM12Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('1.2', config, distDir);\n}\n\nexport function generateSCORM2004Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('2004', config, distDir);\n}\n\nexport function generateCMI5Xml(config: ExportConfig): string {\n const title = escapeXml(config.title || 'Tessera Course');\n const description = escapeXml(config.description || '');\n // Derive stable IDs from the course title so they survive rebuilds without\n // orphaning existing learner records in the LRS.\n const courseId = stableUrn('course', `tessera-course:${config.title || ''}`);\n const auId = stableUrn('au', `tessera-au:${config.title || ''}`);\n // cmi5 §10.2.4 caps masteryScore at 4 decimals; avoid float drift like 0.7000000000000001.\n const masteryScore = Number(\n ((config.scoring?.passingScore ?? 70) / 100).toFixed(4)\n );\n // cmi5 §13.1.4 — `moveOn` decides which verb(s) the LMS treats as\n // satisfying the AU. For graded courses (completion gated on a quiz)\n // a learner who completes without passing should NOT receive credit, so\n // the LMS needs both a Completed AND a Passed before satisfaction.\n // Percentage-mode courses don't surface pass/fail, so completion alone\n // is the right signal.\n const moveOn =\n config.completion?.mode === 'quiz' ? 'CompletedAndPassed' : 'Completed';\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<courseStructure xmlns=\"https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd\">\n <course id=\"${courseId}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n </course>\n <au id=\"${auId}\" launchMethod=\"AnyWindow\" moveOn=\"${moveOn}\" masteryScore=\"${masteryScore}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n <url>index.html</url>\n </au>\n</courseStructure>`;\n}\n\n// ---------- ZIP Packaging ----------\n\nexport async function createZip(\n distDir: string,\n outputPath: string\n): Promise<number> {\n return new Promise((res, reject) => {\n const output = createWriteStream(outputPath);\n const archive = new ZipArchive({ zlib: { level: 9 } });\n\n output.on('close', () => {\n res(archive.pointer());\n });\n output.on('error', reject);\n archive.on('error', reject);\n\n archive.pipe(output);\n archive.directory(distDir, false);\n archive.finalize();\n });\n}\n\n// ---------- Main Export ----------\n\n/**\n * Run the export process after Vite build completes.\n * Writes manifest XML into dist/, then packages into ZIP if needed.\n */\n/** Remove any previously built zips for this package to prevent accumulation. */\nfunction cleanOldZips(projectRoot: string, slug: string): void {\n try {\n for (const f of readdirSync(projectRoot)) {\n if (f.startsWith(`${slug}-`) && f.endsWith('.zip')) {\n try { unlinkSync(resolve(projectRoot, f)); } catch {}\n }\n }\n } catch {}\n}\n\nexport async function runExport(\n projectRoot: string,\n config: ExportConfig\n): Promise<void> {\n const distDir = resolve(projectRoot, 'dist');\n const standard = config.export?.standard || 'web';\n const slug = slugify(config.title || 'tessera-course') || 'tessera-course';\n const version = config.version || '1.0.0';\n const zipName = `${slug}-${version}.zip`;\n const zipPath = resolve(projectRoot, zipName);\n\n switch (standard) {\n case 'web': {\n // Compute dist size\n const files = collectFiles(distDir);\n let totalSize = 0;\n for (const f of files) {\n totalSize += statSync(resolve(distDir, f)).size;\n }\n console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);\n break;\n }\n\n case 'scorm12': {\n const manifest = generateSCORM12Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'scorm2004': {\n const manifest = generateSCORM2004Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'cmi5': {\n const xml = generateCMI5Xml(config);\n writeFileSync(resolve(distDir, 'cmi5.xml'), xml, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);\n break;\n }\n }\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\n\nconst VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';\nconst RESOLVED_LAYOUT_ID = '\\0' + VIRTUAL_LAYOUT_ID;\n\nexport function tesseraLayoutPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:layout',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_LAYOUT_ID) return null;\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n if (existsSync(layoutPath)) {\n // Register the file with Vite so edits trigger HMR / build --watch\n // re-runs. Only add when the file actually exists — calling\n // addWatchFile on a non-existent path makes Vite's importAnalysis\n // try to resolve it as a real import.\n this.addWatchFile(layoutPath);\n const normalized = layoutPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n return `export default null;`;\n },\n\n configureServer(server: ViteDevServer) {\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n // Only react to add/unlink: those flip the virtual module's load() output\n // between `export default null` and `export { default } from '...'`. A\n // `change` event leaves that output identical and is handled by Svelte's\n // own HMR for the underlying file — full-reloading on every edit would\n // wipe in-page state for no reason.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== layoutPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';\nconst RESOLVED_QUIZ_ID = '\\0' + VIRTUAL_QUIZ_ID;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n/**\n * Resolve the project's quiz shell.\n * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,\n * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).\n */\nexport function tesseraQuizPlugin(): Plugin {\n let projectRoot: string;\n // Resolve the built-in Quiz.svelte once. The plugin lives in\n // `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —\n // both layouts put `Quiz.svelte` two levels up under `src/components/`.\n const packageRoot = resolve(__dirname, '..', '..');\n const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');\n\n return {\n name: 'tessera:quiz',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_QUIZ_ID) return null;\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n if (existsSync(userQuizPath)) {\n // Watch the user file so add/remove flips through HMR (see below).\n this.addWatchFile(userQuizPath);\n const normalized = userQuizPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n const normalized = builtinQuiz.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n },\n\n configureServer(server: ViteDevServer) {\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n // Only react to add/unlink — those flip the load() output between the\n // user quiz and the built-in. A `change` event leaves the resolved\n // module identical and is handled by Svelte's own HMR.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== userQuizPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nimport { fileURLToPath } from 'node:url';\nimport { dirname, resolve } from 'node:path';\nimport { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';\nimport { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';\nimport JSON5 from 'json5';\nimport type { Manifest } from './manifest.js';\nimport { validateProject } from './validation.js';\nimport { runExport } from './export.js';\nimport { tesseraLayoutPlugin } from './layout.js';\nimport { tesseraQuizPlugin } from './quiz.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// Resolve the runtime directory where App.svelte lives\nfunction resolveRuntimeDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'src', 'runtime');\n}\n\n// Resolve the framework styles directory\nfunction resolveStylesDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'styles');\n}\n\nexport function tesseraPlugin() {\n return [\n svelte({\n compilerOptions: { css: 'injected' },\n }),\n tesseraValidationPlugin(),\n tesseraEntryPlugin(),\n tesseraConfigPlugin(),\n tesseraPagesPlugin(),\n tesseraManifestPlugin(),\n tesseraLayoutPlugin(),\n tesseraQuizPlugin(),\n tesseraExportPlugin(),\n ];\n}\n\n// ---------- Entry Plugin ----------\n\nconst VIRTUAL_ENTRY_ID = 'virtual:tessera-entry';\nconst RESOLVED_ENTRY_ID = '\\0' + VIRTUAL_ENTRY_ID;\nconst VIRTUAL_MAIN_ID = '/virtual:tessera-main';\nconst RESOLVED_MAIN_ID = '\\0virtual:tessera-main';\n\nfunction tesseraEntryPlugin(): Plugin {\n const runtimeDir = resolveRuntimeDir();\n const stylesDir = resolveStylesDir();\n const appSveltePath = resolve(runtimeDir, 'App.svelte');\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:entry',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n // For build mode: write index.html so Rollup can find it\n buildStart() {\n if (isBuild) {\n writeFileSync(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');\n }\n },\n\n // For build mode: clean up temporary index.html and copy assets\n closeBundle() {\n if (isBuild) {\n const htmlPath = resolve(projectRoot, 'index.html');\n if (existsSync(htmlPath)) {\n try { unlinkSync(htmlPath); } catch {}\n }\n\n // Copy assets/ directory to dist/assets/ so $assets/ references resolve\n const assetsDir = resolve(projectRoot, 'assets');\n const distAssetsDir = resolve(projectRoot, 'dist', 'assets');\n if (existsSync(assetsDir)) {\n mkdirSync(distAssetsDir, { recursive: true });\n cpSync(assetsDir, distAssetsDir, { recursive: true });\n }\n }\n },\n\n // Serve index.html for the dev server\n configureServer(server: ViteDevServer) {\n return () => {\n server.middlewares.use(async (req, res, next) => {\n if (req.url === '/' || req.url === '/index.html') {\n const html = generateIndexHtml();\n const transformed = await server.transformIndexHtml(req.url, html);\n res.setHeader('Content-Type', 'text/html');\n res.statusCode = 200;\n res.end(transformed);\n return;\n }\n next();\n });\n };\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;\n if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main') return RESOLVED_MAIN_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_ENTRY_ID || id === RESOLVED_MAIN_ID) {\n return generateEntryScript(appSveltePath, stylesDir, projectRoot);\n }\n return null;\n },\n };\n}\n\nfunction generateIndexHtml(): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Tessera Course</title>\n</head>\n<body>\n <div id=\"tessera-root\"></div>\n <script type=\"module\" src=\"/virtual:tessera-main\"></script>\n</body>\n</html>`;\n}\n\nfunction generateEntryScript(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {\n const normalizedPath = appSveltePath.replace(/\\\\/g, '/');\n\n // Framework CSS imports (theme → base → layout)\n const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];\n const frameworkImports = frameworkCssOrder\n .map(file => resolve(frameworkStylesDir, file).replace(/\\\\/g, '/'))\n .filter(path => existsSync(path))\n .map(path => `import '${path}';`)\n .join('\\n');\n\n // User CSS imports from project's styles/ directory\n const userStylesDir = resolve(projectRoot, 'styles');\n let userImports = '';\n if (existsSync(userStylesDir)) {\n const userCssFiles = readdirSync(userStylesDir)\n .filter(f => f.endsWith('.css'))\n .sort();\n userImports = userCssFiles\n .map(f => resolve(userStylesDir, f).replace(/\\\\/g, '/'))\n .map(path => `import '${path}';`)\n .join('\\n');\n }\n\n return `// Framework styles\n${frameworkImports}\n// User styles\n${userImports}\n\nimport { mount } from 'svelte';\nimport App from '${normalizedPath}';\n\nmount(App, {\n target: document.getElementById('tessera-root'),\n});\n`;\n}\n\n// ---------- Config Plugin ----------\n\nconst VIRTUAL_CONFIG_ID = 'virtual:tessera-config';\nconst RESOLVED_CONFIG_ID = '\\0' + VIRTUAL_CONFIG_ID;\n\nfunction completionDefaults(mode: string | undefined): {\n completion: Record<string, unknown>;\n passingScore: number;\n} {\n if (mode === 'manual') {\n return { completion: { mode: 'manual' }, passingScore: 0 };\n }\n return { completion: { mode: 'percentage', percentageThreshold: 100 }, passingScore: 70 };\n}\n\nfunction tesseraConfigPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:config',\n enforce: 'pre',\n\n config(config) {\n const root = config.root || process.cwd();\n\n return {\n base: './',\n resolve: {\n alias: {\n '$assets': resolve(root, 'assets'),\n },\n },\n };\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, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/**\n * Recursively collect all file paths relative to a directory.\n */\nfunction collectFiles(dir: string, base: string = ''): string[] {\n const files: string[] = [];\n if (!existsSync(dir)) return files;\n\n for (const entry of readdirSync(dir)) {\n const fullPath = resolve(dir, entry);\n const relPath = base ? `${base}/${entry}` : entry;\n if (statSync(fullPath).isDirectory()) {\n files.push(...collectFiles(fullPath, relPath));\n } else {\n files.push(relPath);\n }\n }\n return files;\n}\n\n/**\n * Derive a stable URN IRI from a seed string. cmi5 §13.1 / xs:anyURI\n * require course / AU ids to be IRIs — bare hex or UUID-shaped strings\n * (without correct version/variant bits) aren't conformant URNs and may\n * be rejected by strict LMS importers.\n *\n * Hash the seed so the id survives rebuilds, then format as\n * `urn:tessera:<kind>:<hex>`. The same seed always produces the same\n * IRI, so existing LRS records are not orphaned by re-export.\n */\nfunction stableUrn(kind: 'course' | 'au', seed: string): string {\n const h = createHash('sha256').update(seed).digest('hex');\n // 32 hex chars (128 bits of entropy) is plenty; trim to keep ids short.\n return `urn:tessera:${kind}:${h.slice(0, 32)}`;\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n// ---------- Manifest Generators ----------\n\n/** Per-version XML differences in imsmanifest.xml between SCORM 1.2 and 2004. */\ninterface ScormManifestDialect {\n rootNs: string;\n adlcpNs: string;\n schemaversion: string;\n /** Attribute name on <resource>: SCORM 1.2 uses lowercase, 2004 uses camelCase. */\n scormTypeAttr: 'scormtype' | 'scormType';\n /** Whitespace-separated namespace+XSD pairs for xsi:schemaLocation. */\n schemaLocation: string;\n}\n\nconst SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {\n '1.2': {\n rootNs: 'http://www.imsproject.org/xsd/imscp_rootv1p1p2',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_rootv1p2',\n schemaversion: '1.2',\n scormTypeAttr: 'scormtype',\n schemaLocation:\n 'http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd ' +\n 'http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd',\n },\n '2004': {\n rootNs: 'http://www.imsglobal.org/xsd/imscp_v1p1',\n adlcpNs: 'http://www.adlnet.org/xsd/adlcp_v1p3',\n schemaversion: '2004 4th Edition',\n scormTypeAttr: 'scormType',\n schemaLocation:\n 'http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd ' +\n 'http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd',\n },\n};\n\nexport function generateScormManifest(\n version: '1.2' | '2004',\n config: ExportConfig,\n distDir: string\n): string {\n const dialect = SCORM_DIALECTS[version];\n const title = escapeXml(config.title || 'Tessera Course');\n const files = collectFiles(distDir);\n const fileElements = files\n .map((f) => ` <file href=\"${escapeXml(f)}\" />`)\n .join('\\n');\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest identifier=\"tessera-course\" version=\"1.0\"\n xmlns=\"${dialect.rootNs}\"\n xmlns:adlcp=\"${dialect.adlcpNs}\"\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:schemaLocation=\"${dialect.schemaLocation}\">\n <metadata>\n <schema>ADL SCORM</schema>\n <schemaversion>${dialect.schemaversion}</schemaversion>\n </metadata>\n <organizations default=\"org-1\">\n <organization identifier=\"org-1\">\n <title>${title}</title>\n <item identifier=\"item-1\" identifierref=\"res-1\">\n <title>${title}</title>\n </item>\n </organization>\n </organizations>\n <resources>\n <resource identifier=\"res-1\" type=\"webcontent\" adlcp:${dialect.scormTypeAttr}=\"sco\" href=\"index.html\">\n${fileElements}\n </resource>\n </resources>\n</manifest>`;\n}\n\nexport function generateSCORM12Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('1.2', config, distDir);\n}\n\nexport function generateSCORM2004Manifest(\n config: ExportConfig,\n distDir: string\n): string {\n return generateScormManifest('2004', config, distDir);\n}\n\nexport function generateCMI5Xml(config: ExportConfig): string {\n const title = escapeXml(config.title || 'Tessera Course');\n const description = escapeXml(config.description || '');\n // Derive stable IDs from the course title so they survive rebuilds without\n // orphaning existing learner records in the LRS.\n const courseId = stableUrn('course', `tessera-course:${config.title || ''}`);\n const auId = stableUrn('au', `tessera-au:${config.title || ''}`);\n // cmi5 §10.2.4 caps masteryScore at 4 decimals; avoid float drift like 0.7000000000000001.\n const masteryScore = Number(\n ((config.scoring?.passingScore ?? 70) / 100).toFixed(4)\n );\n // cmi5 §13.1.4 — `moveOn` decides which verb(s) the LMS treats as\n // satisfying the AU. For graded courses (completion gated on a quiz)\n // a learner who completes without passing should NOT receive credit, so\n // the LMS needs both a Completed AND a Passed before satisfaction.\n // Percentage-mode courses don't surface pass/fail, so completion alone\n // is the right signal.\n const moveOn =\n config.completion?.mode === 'quiz' ? 'CompletedAndPassed' : 'Completed';\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<courseStructure xmlns=\"https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd\">\n <course id=\"${courseId}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n </course>\n <au id=\"${auId}\" launchMethod=\"AnyWindow\" moveOn=\"${moveOn}\" masteryScore=\"${masteryScore}\">\n <title><langstring lang=\"en-US\">${title}</langstring></title>\n <description><langstring lang=\"en-US\">${description}</langstring></description>\n <url>index.html</url>\n </au>\n</courseStructure>`;\n}\n\n// ---------- ZIP Packaging ----------\n\nexport async function createZip(\n distDir: string,\n outputPath: string\n): Promise<number> {\n return new Promise((res, reject) => {\n const output = createWriteStream(outputPath);\n const archive = new ZipArchive({ zlib: { level: 9 } });\n\n output.on('close', () => {\n res(archive.pointer());\n });\n output.on('error', reject);\n archive.on('error', reject);\n\n archive.pipe(output);\n archive.directory(distDir, false);\n archive.finalize();\n });\n}\n\n// ---------- Main Export ----------\n\n/**\n * Run the export process after Vite build completes.\n * Writes manifest XML into dist/, then packages into ZIP if needed.\n */\n/** Remove any previously built zips for this package to prevent accumulation. */\nfunction cleanOldZips(projectRoot: string, slug: string): void {\n try {\n for (const f of readdirSync(projectRoot)) {\n if (f.startsWith(`${slug}-`) && f.endsWith('.zip')) {\n try { unlinkSync(resolve(projectRoot, f)); } catch {}\n }\n }\n } catch {}\n}\n\nexport async function runExport(\n projectRoot: string,\n config: ExportConfig\n): Promise<void> {\n const distDir = resolve(projectRoot, 'dist');\n const standard = config.export?.standard || 'web';\n const slug = slugify(config.title || 'tessera-course') || 'tessera-course';\n const version = config.version || '1.0.0';\n const zipName = `${slug}-${version}.zip`;\n const zipPath = resolve(projectRoot, zipName);\n\n switch (standard) {\n case 'web': {\n // Compute dist size\n const files = collectFiles(distDir);\n let totalSize = 0;\n for (const f of files) {\n totalSize += statSync(resolve(distDir, f)).size;\n }\n console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);\n break;\n }\n\n case 'scorm12': {\n const manifest = generateSCORM12Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'scorm2004': {\n const manifest = generateSCORM2004Manifest(config, distDir);\n writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(\n `✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`\n );\n break;\n }\n\n case 'cmi5': {\n const xml = generateCMI5Xml(config);\n writeFileSync(resolve(distDir, 'cmi5.xml'), xml, 'utf-8');\n cleanOldZips(projectRoot, slug);\n const zipSize = await createZip(distDir, zipPath);\n console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);\n break;\n }\n }\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\n\nconst VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';\nconst RESOLVED_LAYOUT_ID = '\\0' + VIRTUAL_LAYOUT_ID;\n\nexport function tesseraLayoutPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:layout',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_LAYOUT_ID) return null;\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n if (existsSync(layoutPath)) {\n // Register the file with Vite so edits trigger HMR / build --watch\n // re-runs. Only add when the file actually exists — calling\n // addWatchFile on a non-existent path makes Vite's importAnalysis\n // try to resolve it as a real import.\n this.addWatchFile(layoutPath);\n const normalized = layoutPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n return `export default null;`;\n },\n\n configureServer(server: ViteDevServer) {\n const layoutPath = resolve(projectRoot, 'layout.svelte');\n // Only react to add/unlink: those flip the virtual module's load() output\n // between `export default null` and `export { default } from '...'`. A\n // `change` event leaves that output identical and is handled by Svelte's\n // own HMR for the underlying file — full-reloading on every edit would\n // wipe in-page state for no reason.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== layoutPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { existsSync } from 'node:fs';\nimport { resolve, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';\nconst RESOLVED_QUIZ_ID = '\\0' + VIRTUAL_QUIZ_ID;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n/**\n * Resolve the project's quiz shell.\n * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,\n * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).\n */\nexport function tesseraQuizPlugin(): Plugin {\n let projectRoot: string;\n // Resolve the built-in Quiz.svelte once. The plugin lives in\n // `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —\n // both layouts put `Quiz.svelte` two levels up under `src/components/`.\n const packageRoot = resolve(__dirname, '..', '..');\n const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');\n\n return {\n name: 'tessera:quiz',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_QUIZ_ID) return null;\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n if (existsSync(userQuizPath)) {\n // Watch the user file so add/remove flips through HMR (see below).\n this.addWatchFile(userQuizPath);\n const normalized = userQuizPath.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n }\n const normalized = builtinQuiz.replace(/\\\\/g, '/');\n return `export { default } from '${normalized}';`;\n },\n\n configureServer(server: ViteDevServer) {\n const userQuizPath = resolve(projectRoot, 'quiz.svelte');\n // Only react to add/unlink — those flip the load() output between the\n // user quiz and the built-in. A `change` event leaves the resolved\n // module identical and is handled by Svelte's own HMR.\n server.watcher.on('all', (event, filePath) => {\n if (filePath !== userQuizPath) return;\n if (event !== 'add' && event !== 'unlink') return;\n const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);\n if (mod) server.moduleGraph.invalidateModule(mod);\n server.ws.send({ type: 'full-reload' });\n });\n },\n };\n}\n","import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nimport { fileURLToPath } from 'node:url';\nimport { dirname, resolve } from 'node:path';\nimport { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';\nimport { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';\nimport JSON5 from 'json5';\nimport type { Manifest } from './manifest.js';\nimport { validateProject } from './validation.js';\nimport { runExport } from './export.js';\nimport { tesseraLayoutPlugin } from './layout.js';\nimport { tesseraQuizPlugin } from './quiz.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// Resolve the runtime directory where App.svelte lives\nfunction resolveRuntimeDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'src', 'runtime');\n}\n\n// Resolve the framework styles directory\nfunction resolveStylesDir(): string {\n const packageRoot = resolve(__dirname, '..', '..');\n return resolve(packageRoot, 'styles');\n}\n\nexport function tesseraPlugin() {\n const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };\n return [\n svelte({\n compilerOptions: { css: 'external' },\n }),\n tesseraValidationPlugin(),\n tesseraEntryPlugin(),\n tesseraConfigPlugin(),\n tesseraPagesPlugin(),\n tesseraManifestPlugin(manifestRef),\n tesseraLayoutPlugin(),\n tesseraQuizPlugin(),\n tesseraAdapterPlugin(),\n tesseraXAPISetupPlugin(),\n tesseraFirstPagePreloadPlugin(manifestRef),\n tesseraExportPlugin(),\n ];\n}\n\n// ---------- Entry Plugin ----------\n\nconst VIRTUAL_ENTRY_ID = 'virtual:tessera-entry';\nconst RESOLVED_ENTRY_ID = '\\0' + VIRTUAL_ENTRY_ID;\nconst VIRTUAL_MAIN_ID = '/virtual:tessera-main';\nconst RESOLVED_MAIN_ID = '\\0virtual:tessera-main';\n\nfunction tesseraEntryPlugin(): Plugin {\n const runtimeDir = resolveRuntimeDir();\n const stylesDir = resolveStylesDir();\n const appSveltePath = resolve(runtimeDir, 'App.svelte');\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:entry',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n // For build mode: write index.html so Rollup can find it\n buildStart() {\n if (isBuild) {\n writeFileSync(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');\n }\n },\n\n // For build mode: clean up temporary index.html and copy assets\n closeBundle() {\n if (isBuild) {\n const htmlPath = resolve(projectRoot, 'index.html');\n if (existsSync(htmlPath)) {\n try { unlinkSync(htmlPath); } catch {}\n }\n\n // Copy assets/ directory to dist/assets/ so $assets/ references resolve\n const assetsDir = resolve(projectRoot, 'assets');\n const distAssetsDir = resolve(projectRoot, 'dist', 'assets');\n if (existsSync(assetsDir)) {\n mkdirSync(distAssetsDir, { recursive: true });\n cpSync(assetsDir, distAssetsDir, { recursive: true });\n }\n }\n },\n\n // Serve index.html for the dev server\n configureServer(server: ViteDevServer) {\n return () => {\n server.middlewares.use(async (req, res, next) => {\n if (req.url === '/' || req.url === '/index.html') {\n const html = generateIndexHtml();\n const transformed = await server.transformIndexHtml(req.url, html);\n res.setHeader('Content-Type', 'text/html');\n res.statusCode = 200;\n res.end(transformed);\n return;\n }\n next();\n });\n };\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;\n if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main') return RESOLVED_MAIN_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_ENTRY_ID || id === RESOLVED_MAIN_ID) {\n return generateEntryScript(appSveltePath, stylesDir, projectRoot);\n }\n return null;\n },\n };\n}\n\nfunction generateIndexHtml(): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Tessera Course</title>\n</head>\n<body>\n <div id=\"tessera-root\"></div>\n <script type=\"module\" src=\"/virtual:tessera-main\"></script>\n</body>\n</html>`;\n}\n\nfunction generateEntryScript(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {\n const normalizedPath = appSveltePath.replace(/\\\\/g, '/');\n\n // Framework CSS imports (theme → base → layout)\n const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];\n const frameworkImports = frameworkCssOrder\n .map(file => resolve(frameworkStylesDir, file).replace(/\\\\/g, '/'))\n .filter(path => existsSync(path))\n .map(path => `import '${path}';`)\n .join('\\n');\n\n // User CSS imports from project's styles/ directory\n const userStylesDir = resolve(projectRoot, 'styles');\n let userImports = '';\n if (existsSync(userStylesDir)) {\n const userCssFiles = readdirSync(userStylesDir)\n .filter(f => f.endsWith('.css'))\n .sort();\n userImports = userCssFiles\n .map(f => resolve(userStylesDir, f).replace(/\\\\/g, '/'))\n .map(path => `import '${path}';`)\n .join('\\n');\n }\n\n return `// Framework styles\n${frameworkImports}\n// User styles\n${userImports}\n\nimport { mount } from 'svelte';\nimport App from '${normalizedPath}';\n\nmount(App, {\n target: document.getElementById('tessera-root'),\n});\n`;\n}\n\n// ---------- Config Plugin ----------\n\nconst VIRTUAL_CONFIG_ID = 'virtual:tessera-config';\nconst RESOLVED_CONFIG_ID = '\\0' + VIRTUAL_CONFIG_ID;\n\nfunction completionDefaults(mode: string | undefined): {\n completion: Record<string, unknown>;\n passingScore: number;\n} {\n if (mode === 'manual') {\n return { completion: { mode: 'manual' }, passingScore: 0 };\n }\n return { completion: { mode: 'percentage', percentageThreshold: 100 }, passingScore: 70 };\n}\n\nfunction tesseraConfigPlugin(): Plugin {\n let projectRoot: string;\n\n return {\n name: 'tessera:config',\n enforce: 'pre',\n\n config(config) {\n const root = config.root || process.cwd();\n\n return {\n base: './',\n resolve: {\n alias: {\n '$assets': resolve(root, 'assets'),\n },\n },\n // tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer\n // doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.\n optimizeDeps: {\n exclude: ['tessera-learn'],\n },\n };\n },\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n },\n\n resolveId(id) {\n if (id === VIRTUAL_CONFIG_ID) return RESOLVED_CONFIG_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_CONFIG_ID) {\n const configPath = resolve(projectRoot, 'course.config.js');\n let userConfig: Record<string, any> = {};\n\n if (existsSync(configPath)) {\n this.addWatchFile(configPath);\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try { userConfig = JSON5.parse(objectStr); } catch {}\n }\n }\n\n const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);\n const merged = {\n title: userConfig.title || 'Untitled Course',\n ...userConfig,\n navigation: { mode: 'free', ...userConfig.navigation },\n completion: { ...completion, ...userConfig.completion },\n scoring: { passingScore, ...userConfig.scoring },\n export: { standard: 'web', ...userConfig.export },\n };\n\n return `export default ${JSON.stringify(merged)};`;\n }\n return null;\n },\n };\n}\n\n// ---------- Manifest Watch Helpers ----------\n\n/** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */\nfunction addWatchFiles(ctx: { addWatchFile(id: string): void }, dir: string): void {\n if (!existsSync(dir)) return;\n for (const entry of readdirSync(dir)) {\n const full = resolve(dir, entry);\n if (statSync(full).isDirectory()) {\n addWatchFiles(ctx, full);\n } else if (entry.endsWith('.svelte') || entry === '_meta.js') {\n ctx.addWatchFile(full);\n }\n }\n}\n\n// ---------- Pages Plugin ----------\n\nconst VIRTUAL_PAGES_ID = 'virtual:tessera-pages';\nconst RESOLVED_PAGES_ID = '\\0' + VIRTUAL_PAGES_ID;\n\n/**\n * Provides a virtual module that exports an import.meta.glob map for all .svelte\n * pages. This runs in the user's project context so the glob resolves against their\n * pages/ directory, and Vite can statically analyze it for code splitting.\n */\nfunction tesseraPagesPlugin(): Plugin {\n return {\n name: 'tessera:pages',\n enforce: 'pre',\n\n resolveId(id) {\n if (id === VIRTUAL_PAGES_ID) return RESOLVED_PAGES_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_PAGES_ID) {\n return `export default import.meta.glob('/pages/**/*.svelte');`;\n }\n return null;\n },\n };\n}\n\n// ---------- Validation Plugin ----------\n\nfunction tesseraValidationPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:validation',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n // Run validation during dev (configResolved fires before server starts)\n if (!isBuild) {\n runValidation(projectRoot);\n }\n },\n\n buildStart() {\n // Run validation during build (buildStart fires once before bundling)\n if (isBuild) {\n runValidation(projectRoot);\n }\n },\n };\n}\n\nfunction runValidation(projectRoot: string): void {\n const { errors, warnings } = validateProject(projectRoot);\n\n for (const warning of warnings) {\n console.warn(`\\x1b[33m[tessera warning]\\x1b[0m ${warning}`);\n }\n\n if (errors.length > 0) {\n for (const error of errors) {\n console.error(`\\x1b[31m[tessera error]\\x1b[0m ${error}`);\n }\n throw new Error(\n `Tessera validation failed with ${errors.length} error(s). Fix the errors above to continue.`\n );\n }\n}\n\n// ---------- Export Plugin ----------\n\nfunction tesseraExportPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:export',\n enforce: 'post',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n async closeBundle() {\n if (!isBuild) return;\n\n const configPath = resolve(projectRoot, 'course.config.js');\n if (!existsSync(configPath)) {\n // Validation already required course.config.js — getting here means\n // the file vanished mid-build. Surface that loudly rather than\n // shipping a bundle with no LMS export silently.\n throw new Error(\n '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'\n );\n }\n\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (!objectStr) {\n throw new Error(\n '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'\n );\n }\n\n let config: any;\n try {\n config = JSON5.parse(objectStr);\n } catch (err) {\n throw new Error(\n `[tessera:export] course.config.js: failed to parse export-default object literal — ${(err as Error).message}`\n );\n }\n\n await runExport(projectRoot, config);\n },\n };\n}\n\n// ---------- Manifest Plugin ----------\n\nconst VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';\nconst RESOLVED_MANIFEST_ID = '\\0' + VIRTUAL_MANIFEST_ID;\n\nfunction tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {\n let projectRoot: string;\n let pagesDir: string;\n let server: ViteDevServer | null = null;\n\n function buildManifest(): Manifest {\n const m = generateManifest(pagesDir);\n manifestRef.current = m;\n return m;\n }\n\n return {\n name: 'tessera:manifest',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n pagesDir = resolve(projectRoot, 'pages');\n manifestRef.root = projectRoot;\n },\n\n configureServer(devServer: ViteDevServer) {\n server = devServer;\n\n // Watch the pages directory for changes\n devServer.watcher.on('all', (event, filePath) => {\n if (!filePath.startsWith(pagesDir)) return;\n\n // Rebuild manifest on relevant file changes\n const isRelevant =\n filePath.endsWith('.svelte') ||\n filePath.endsWith('_meta.js') ||\n event === 'addDir' ||\n event === 'unlinkDir';\n\n if (isRelevant) {\n manifestRef.current = null; // invalidate cache\n\n // Invalidate the virtual module to trigger HMR\n const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);\n if (mod) {\n devServer.moduleGraph.invalidateModule(mod);\n devServer.ws.send({ type: 'full-reload' });\n }\n\n console.log(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`);\n }\n });\n },\n\n buildStart() {\n buildManifest();\n },\n\n resolveId(id) {\n if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;\n return null;\n },\n\n load(id) {\n if (id === RESOLVED_MANIFEST_ID) {\n if (!manifestRef.current) {\n buildManifest();\n }\n\n // Register watch files so Vite's built-in watcher (used in build --watch)\n // knows to re-trigger when pages/ content changes.\n addWatchFiles(this, pagesDir);\n\n // Encode as base64 to prevent Vite's import analysis from\n // scanning .svelte importPath strings as module imports.\n // Replace Infinity with 1e9 since JSON.stringify drops it.\n const json = JSON.stringify(manifestRef.current, (_key, value) =>\n value === Infinity ? 1e9 : value\n );\n const b64 = Buffer.from(json).toString('base64');\n return `export default JSON.parse(atob(\"${b64}\"));`;\n }\n return null;\n },\n };\n}\n\nconst VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';\nconst RESOLVED_ADAPTER_ID = '\\0' + VIRTUAL_ADAPTER_ID;\n\nfunction tesseraAdapterPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:adapter',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_ADAPTER_ID) return null;\n\n // In dev, defer to the runtime selector so its WebAdapter fallback\n // for unreachable LMS APIs keeps working.\n if (!isBuild) {\n return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;\n }\n\n let standard = 'web';\n const configPath = resolve(projectRoot, 'course.config.js');\n if (existsSync(configPath)) {\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try {\n const parsed = JSON5.parse(objectStr);\n if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;\n } catch {}\n }\n }\n\n switch (standard) {\n case 'scorm12':\n return `\nimport { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';\nimport { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';\nimport { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';\nexport function createAdapter() {\n const api = findSCORM12API();\n if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');\n return new SCORM12Adapter(api);\n}\n`;\n case 'scorm2004':\n return `\nimport { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';\nimport { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';\nimport { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';\nexport function createAdapter() {\n const api = findSCORM2004API();\n if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');\n return new SCORM2004Adapter(api);\n}\n`;\n case 'cmi5':\n return `\nimport { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';\nimport { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';\nimport { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';\nexport function createAdapter() {\n if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');\n return new CMI5Adapter();\n}\n`;\n default:\n return `\nimport { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';\nexport function createAdapter(config) {\n return new WebAdapter(config);\n}\n`;\n }\n },\n };\n}\n\nconst VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';\nconst RESOLVED_XAPI_SETUP_ID = '\\0' + VIRTUAL_XAPI_SETUP_ID;\n\nfunction tesseraXAPISetupPlugin(): Plugin {\n let projectRoot: string;\n let isBuild = false;\n\n return {\n name: 'tessera:xapi-setup',\n enforce: 'pre',\n\n configResolved(config: ResolvedConfig) {\n projectRoot = config.root;\n isBuild = config.command === 'build';\n },\n\n resolveId(id) {\n if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;\n return null;\n },\n\n load(id) {\n if (id !== RESOLVED_XAPI_SETUP_ID) return null;\n\n if (!isBuild) {\n return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;\n }\n\n let standard = 'web';\n let hasXapi = false;\n const configPath = resolve(projectRoot, 'course.config.js');\n if (existsSync(configPath)) {\n const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));\n if (objectStr) {\n try {\n const parsed = JSON5.parse(objectStr);\n if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;\n hasXapi = parsed?.xapi != null;\n } catch {}\n }\n }\n\n // cmi5 needs the publisher regardless of explicit xapi config (cmi5\n // adapter shares the publisher queue for its own LMS-required statements).\n if (hasXapi || standard === 'cmi5') {\n return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;\n }\n\n return `export async function buildXAPIClient() { return null; }`;\n },\n };\n}\n\nfunction tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {\n return {\n name: 'tessera:first-page-preload',\n apply: 'build',\n transformIndexHtml: {\n order: 'post',\n handler(_html, ctx) {\n const firstPagePath = manifestRef.current?.pages[0]?.importPath;\n if (!firstPagePath || !ctx.bundle) return;\n const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\\//, '')).replace(/\\\\/g, '/');\n const chunk = Object.values(ctx.bundle).find(\n (c): c is import('vite').Rollup.OutputChunk =>\n c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\\\/g, '/') === normalized\n );\n if (!chunk) return;\n return [{\n tag: 'link',\n attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },\n injectTo: 'head',\n }];\n },\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -521,7 +521,7 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
|
|
|
521
521
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
522
522
|
validateQuestionComponents(content, fileRel, errors);
|
|
523
523
|
validateContractBypass(content, fileRel, errors);
|
|
524
|
-
if (pageConfig?.quiz && !HAS_USE_QUESTION_RE.test(content) && !HAS_QUESTION_TAG_RE.test(content)) warnings.push(`${fileRel}: quiz page has no question components or useQuestion() calls — the quiz will have nothing to score`);
|
|
524
|
+
if (pageConfig?.quiz && !HAS_USE_QUESTION_RE.test(content) && !HAS_QUESTION_TAG_RE.test(content) && !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)) warnings.push(`${fileRel}: quiz page has no question components or useQuestion() calls — the quiz will have nothing to score`);
|
|
525
525
|
return {
|
|
526
526
|
page: {
|
|
527
527
|
fileRel,
|
|
@@ -676,11 +676,7 @@ function validateQuizConfig(quiz, fileRel, errors) {
|
|
|
676
676
|
const val = cfg.maxAttempts;
|
|
677
677
|
if (val !== Infinity && (typeof val !== "number" || val <= 0 || !Number.isFinite(val))) errors.push(`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`);
|
|
678
678
|
}
|
|
679
|
-
for (const field of [
|
|
680
|
-
"graded",
|
|
681
|
-
"gatesProgress",
|
|
682
|
-
"showFeedback"
|
|
683
|
-
]) if (cfg[field] !== void 0 && typeof cfg[field] !== "boolean") errors.push(`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`);
|
|
679
|
+
for (const field of ["graded", "gatesProgress"]) if (cfg[field] !== void 0 && typeof cfg[field] !== "boolean") errors.push(`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`);
|
|
684
680
|
}
|
|
685
681
|
const QUESTION_COMPONENT_REQUIRED = {
|
|
686
682
|
MultipleChoice: [
|
|
@@ -850,6 +846,7 @@ const QUIZ_COMPLETE_DISPATCH_RE = /(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-
|
|
|
850
846
|
const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
|
|
851
847
|
const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
852
848
|
const HAS_QUESTION_TAG_RE = new RegExp(`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join("|")})(?=[\\s/>])`);
|
|
849
|
+
const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
853
850
|
/**
|
|
854
851
|
* Detect ways an author file can bypass the LMS data contract. These check
|
|
855
852
|
* source text for known escape hatches — they never inspect course content,
|
|
@@ -908,4 +905,4 @@ function crossValidate(config, pageResults, errors, warnings) {
|
|
|
908
905
|
//#endregion
|
|
909
906
|
export { extractDefaultExportObjectLiteral as n, generateManifest as r, validateProject as t };
|
|
910
907
|
|
|
911
|
-
//# sourceMappingURL=validation-
|
|
908
|
+
//# sourceMappingURL=validation-BxWAMMnJ.js.map
|