tessera-learn 0.0.9 → 0.0.11

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.
Files changed (45) hide show
  1. package/dist/plugin/cli.js +5 -3
  2. package/dist/plugin/cli.js.map +1 -1
  3. package/dist/plugin/index.d.ts.map +1 -1
  4. package/dist/plugin/index.js +215 -124
  5. package/dist/plugin/index.js.map +1 -1
  6. package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
  7. package/dist/validation-D9DXlqNP.js.map +1 -0
  8. package/package.json +9 -6
  9. package/src/components/Audio.svelte +5 -2
  10. package/src/components/DefaultLayout.svelte +2 -0
  11. package/src/components/FillInTheBlank.svelte +60 -98
  12. package/src/components/Image.svelte +3 -8
  13. package/src/components/LockedBanner.svelte +3 -4
  14. package/src/components/MultipleChoice.svelte +53 -94
  15. package/src/components/Quiz.svelte +2 -1
  16. package/src/components/Video.svelte +4 -2
  17. package/src/components/util.ts +1 -0
  18. package/src/plugin/cli.ts +2 -7
  19. package/src/plugin/export.ts +23 -41
  20. package/src/plugin/index.ts +197 -56
  21. package/src/plugin/layout.ts +6 -51
  22. package/src/plugin/manifest.ts +31 -5
  23. package/src/plugin/override-plugin.ts +68 -0
  24. package/src/plugin/quiz.ts +9 -54
  25. package/src/plugin/validation.ts +38 -67
  26. package/src/runtime/App.svelte +48 -36
  27. package/src/runtime/LoadingBar.svelte +47 -0
  28. package/src/runtime/Sidebar.svelte +2 -0
  29. package/src/runtime/adapters/cmi5.ts +13 -83
  30. package/src/runtime/adapters/format.ts +67 -0
  31. package/src/runtime/adapters/index.ts +28 -29
  32. package/src/runtime/adapters/retry.ts +0 -64
  33. package/src/runtime/adapters/scorm12.ts +1 -1
  34. package/src/runtime/adapters/scorm2004.ts +11 -16
  35. package/src/runtime/hooks.svelte.ts +14 -16
  36. package/src/runtime/navigation.svelte.ts +51 -45
  37. package/src/runtime/progress.svelte.ts +25 -10
  38. package/src/runtime/quiz-policy.ts +21 -178
  39. package/src/runtime/xapi/agent-rules.ts +7 -2
  40. package/src/runtime/xapi/publisher.ts +1 -11
  41. package/src/runtime/xapi/validation.ts +1 -1
  42. package/src/virtual.d.ts +13 -0
  43. package/styles/layout.css +34 -24
  44. package/dist/validation-BxWAMMnJ.js.map +0 -1
  45. package/src/runtime/LoadingSkeleton.svelte +0 -26
@@ -58,62 +58,65 @@
58
58
 
59
59
  function getOptionClass(optIndex) {
60
60
  if (!q.feedbackVisible) return '';
61
- const answer = inQuiz ? q.answer : selectedOption;
62
61
  if (isCorrectOption(optIndex)) return 'correct';
63
- if (optIndex === answer && !isCorrectOption(optIndex)) return 'incorrect';
62
+ if (optIndex === selectedOption && !isCorrectOption(optIndex)) return 'incorrect';
64
63
  return '';
65
64
  }
66
65
  </script>
67
66
 
68
- {#if !inQuiz}
69
- <div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
70
- <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
71
-
72
- <div class="tessera-mc-options">
73
- {#each options as option, i}
74
- {@const optionId = `${groupId}-opt-${i}`}
75
- {@const isSelected = selectedOption === i}
76
- {@const stateClass = getOptionClass(i)}
77
- <label
78
- class="tessera-mc-option {stateClass}"
79
- class:selected={isSelected}
80
- for={optionId}
81
- >
82
- <input
83
- type="radio"
84
- id={optionId}
85
- name={groupId}
86
- value={i}
87
- checked={isSelected}
88
- disabled={q.submitted}
89
- onchange={() => handleSelect(i)}
90
- />
91
- <span class="tessera-mc-radio-custom"></span>
92
- <span class="tessera-mc-option-text">{option}</span>
93
-
94
- {#if q.submitted}
95
- {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
96
- <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
97
- {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
98
- <span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
99
- {:else if optionFeedback[i]}
100
- <span class="tessera-mc-feedback">{optionFeedback[i]}</span>
101
- {/if}
67
+ {#snippet mcContent()}
68
+ <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
69
+
70
+ <div class="tessera-mc-options">
71
+ {#each options as option, i}
72
+ {@const optionId = `${groupId}-opt-${i}`}
73
+ {@const isSelected = selectedOption === i}
74
+ {@const stateClass = getOptionClass(i)}
75
+ <label
76
+ class="tessera-mc-option {stateClass}"
77
+ class:selected={isSelected}
78
+ for={optionId}
79
+ >
80
+ <input
81
+ type="radio"
82
+ id={optionId}
83
+ name={groupId}
84
+ value={i}
85
+ checked={isSelected}
86
+ disabled={q.locked}
87
+ onchange={() => handleSelect(i)}
88
+ />
89
+ <span class="tessera-mc-radio-custom"></span>
90
+ <span class="tessera-mc-option-text">{option}</span>
91
+
92
+ {#if q.feedbackVisible}
93
+ {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
94
+ <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
95
+ {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
96
+ <span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
97
+ {:else if optionFeedback[i]}
98
+ <span class="tessera-mc-feedback">{optionFeedback[i]}</span>
102
99
  {/if}
103
- </label>
104
- {/each}
105
- </div>
106
-
107
- {#if q.submitted}
108
- {#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
109
- <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
110
- {:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
111
- <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
112
- {/if}
113
- {#if q.canRetry}
114
- <RetryButton onclick={() => q.retry()} />
115
- {/if}
100
+ {/if}
101
+ </label>
102
+ {/each}
103
+ </div>
104
+
105
+ {#if q.feedbackVisible}
106
+ {#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
107
+ <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
108
+ {:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
109
+ <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
110
+ {/if}
111
+ {#if !inQuiz && q.canRetry}
112
+ <RetryButton onclick={() => q.retry()} />
116
113
  {/if}
114
+ {/if}
115
+ {/snippet}
116
+
117
+ {#if !inQuiz}
118
+ <div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
119
+ {@render mcContent()}
117
120
  </div>
118
121
  {/if}
119
122
 
@@ -122,51 +125,7 @@
122
125
  {#if q.isLockedCorrect}
123
126
  <LockedBanner />
124
127
  {/if}
125
- <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
126
-
127
- <div class="tessera-mc-options">
128
- {#each options as option, i}
129
- {@const optionId = `${groupId}-opt-${i}`}
130
- {@const isSelected = (q.locked ? q.answer : selectedOption) === i}
131
- {@const stateClass = getOptionClass(i)}
132
- <label
133
- class="tessera-mc-option {stateClass}"
134
- class:selected={isSelected}
135
- for={optionId}
136
- >
137
- <input
138
- type="radio"
139
- id={optionId}
140
- name={groupId}
141
- value={i}
142
- checked={isSelected}
143
- disabled={q.locked}
144
- onchange={() => handleSelect(i)}
145
- />
146
- <span class="tessera-mc-radio-custom"></span>
147
- <span class="tessera-mc-option-text">{option}</span>
148
-
149
- {#if q.feedbackVisible}
150
- {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
151
- <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
152
- {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
153
- <span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
154
- {:else if optionFeedback[i]}
155
- <span class="tessera-mc-feedback">{optionFeedback[i]}</span>
156
- {/if}
157
- {/if}
158
- </label>
159
- {/each}
160
- </div>
161
-
162
- {#if q.feedbackVisible}
163
- {@const answer = q.answer}
164
- {#if answer === correct && correctFeedback && !optionFeedback[answer]}
165
- <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
166
- {:else if answer !== correct && incorrectFeedback && !optionFeedback[answer]}
167
- <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
168
- {/if}
169
- {/if}
128
+ {@render mcContent()}
170
129
  </div>
171
130
  {/snippet}
172
131
 
@@ -1,13 +1,14 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
3
  import { useQuiz } from '../runtime/hooks.svelte.js';
4
+ import { TESSERA_PAGE } from '../runtime/contexts.js';
4
5
 
5
6
  let { children } = $props();
6
7
  let quizElement = $state(null);
7
8
 
8
9
  const handle = useQuiz({ element: () => quizElement });
9
10
 
10
- const pageCtx = getContext('tessera-page');
11
+ const pageCtx = getContext(TESSERA_PAGE);
11
12
  let quizConfig = $derived(pageCtx?.quiz ?? {});
12
13
  let feedbackDisabled = $derived(quizConfig.feedbackMode === 'never');
13
14
  let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
@@ -4,12 +4,14 @@
4
4
  * Embeds YouTube/Vimeo via iframe or local video files.
5
5
  * Lazy-loads via IntersectionObserver.
6
6
  *
7
- * @prop {string} src - Video URL (YouTube, Vimeo, or direct video file)
7
+ * @prop {string} src - Video URL (YouTube, Vimeo, direct video file, or $assets/ path)
8
8
  * @prop {string} [title] - Accessible label for the video
9
9
  */
10
10
  import { onMount } from 'svelte';
11
+ import { resolveAsset } from './util.js';
11
12
 
12
13
  let { src, title = '' } = $props();
14
+ let resolvedSrc = $derived(resolveAsset(src));
13
15
  let containerRef = $state(null);
14
16
  let visible = $state(false);
15
17
 
@@ -61,7 +63,7 @@
61
63
  {:else}
62
64
  <!-- svelte-ignore a11y_media_has_caption -->
63
65
  <video controls class="tessera-video-native" aria-label={title}>
64
- <source {src} />
66
+ <source src={resolvedSrc} />
65
67
  Your browser does not support the video element.
66
68
  </video>
67
69
  {/if}
@@ -7,6 +7,7 @@
7
7
  * same alias semantics.
8
8
  */
9
9
  export function resolveAsset(src: string): string {
10
+ if (!src) return src;
10
11
  return src.startsWith('$assets/') ? src.replace('$assets/', './assets/') : src;
11
12
  }
12
13
 
package/src/plugin/cli.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { validateProject } from './validation.js';
2
+ import { validateProject, reportValidationIssues } from './validation.js';
3
3
 
4
4
  const projectRoot = process.cwd();
5
5
  const { errors, warnings } = validateProject(projectRoot);
6
6
 
7
- for (const warning of warnings) {
8
- console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
9
- }
10
- for (const error of errors) {
11
- console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
12
- }
7
+ reportValidationIssues({ errors, warnings });
13
8
 
14
9
  if (errors.length > 0) {
15
10
  const summary =
@@ -228,6 +228,16 @@ function cleanOldZips(projectRoot: string, slug: string): void {
228
228
  } catch {}
229
229
  }
230
230
 
231
+ /** Packaged (zipped) export targets: which manifest file to write and how. */
232
+ const PACKAGED_EXPORTS: Record<
233
+ 'scorm12' | 'scorm2004' | 'cmi5',
234
+ { manifestFile: string; label: string; generate: (config: ExportConfig, distDir: string) => string }
235
+ > = {
236
+ scorm12: { manifestFile: 'imsmanifest.xml', label: 'SCORM 1.2', generate: generateSCORM12Manifest },
237
+ scorm2004: { manifestFile: 'imsmanifest.xml', label: 'SCORM 2004', generate: generateSCORM2004Manifest },
238
+ cmi5: { manifestFile: 'cmi5.xml', label: 'CMI5', generate: (config) => generateCMI5Xml(config) },
239
+ };
240
+
231
241
  export async function runExport(
232
242
  projectRoot: string,
233
243
  config: ExportConfig
@@ -239,47 +249,19 @@ export async function runExport(
239
249
  const zipName = `${slug}-${version}.zip`;
240
250
  const zipPath = resolve(projectRoot, zipName);
241
251
 
242
- switch (standard) {
243
- case 'web': {
244
- // Compute dist size
245
- const files = collectFiles(distDir);
246
- let totalSize = 0;
247
- for (const f of files) {
248
- totalSize += statSync(resolve(distDir, f)).size;
249
- }
250
- console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);
251
- break;
252
- }
253
-
254
- case 'scorm12': {
255
- const manifest = generateSCORM12Manifest(config, distDir);
256
- writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');
257
- cleanOldZips(projectRoot, slug);
258
- const zipSize = await createZip(distDir, zipPath);
259
- console.log(
260
- `✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`
261
- );
262
- break;
263
- }
252
+ if (standard === 'web') {
253
+ const files = collectFiles(distDir);
254
+ let totalSize = 0;
255
+ for (const f of files) totalSize += statSync(resolve(distDir, f)).size;
256
+ console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);
257
+ return;
258
+ }
264
259
 
265
- case 'scorm2004': {
266
- const manifest = generateSCORM2004Manifest(config, distDir);
267
- writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');
268
- cleanOldZips(projectRoot, slug);
269
- const zipSize = await createZip(distDir, zipPath);
270
- console.log(
271
- `✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`
272
- );
273
- break;
274
- }
260
+ const spec = PACKAGED_EXPORTS[standard as keyof typeof PACKAGED_EXPORTS];
261
+ if (!spec) return; // unknown standard — the validator rejects these upstream
275
262
 
276
- case 'cmi5': {
277
- const xml = generateCMI5Xml(config);
278
- writeFileSync(resolve(distDir, 'cmi5.xml'), xml, 'utf-8');
279
- cleanOldZips(projectRoot, slug);
280
- const zipSize = await createZip(distDir, zipPath);
281
- console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);
282
- break;
283
- }
284
- }
263
+ writeFileSync(resolve(distDir, spec.manifestFile), spec.generate(config, distDir), 'utf-8');
264
+ cleanOldZips(projectRoot, slug);
265
+ const zipSize = await createZip(distDir, zipPath);
266
+ console.log(`✓ ${spec.label} export: ${zipName} (${formatSize(zipSize)})`);
285
267
  }
@@ -2,11 +2,10 @@ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
2
  import { svelte } from '@sveltejs/vite-plugin-svelte';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, resolve } from 'node:path';
5
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';
6
- import { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';
7
- import JSON5 from 'json5';
5
+ import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';
6
+ import { generateManifest, readCourseConfig } from './manifest.js';
8
7
  import type { Manifest } from './manifest.js';
9
- import { validateProject } from './validation.js';
8
+ import { validateProject, reportValidationIssues } from './validation.js';
10
9
  import { runExport } from './export.js';
11
10
  import { tesseraLayoutPlugin } from './layout.js';
12
11
  import { tesseraQuizPlugin } from './quiz.js';
@@ -27,17 +26,21 @@ function resolveStylesDir(): string {
27
26
  }
28
27
 
29
28
  export function tesseraPlugin() {
29
+ const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };
30
30
  return [
31
31
  svelte({
32
- compilerOptions: { css: 'injected' },
32
+ compilerOptions: { css: 'external' },
33
33
  }),
34
34
  tesseraValidationPlugin(),
35
35
  tesseraEntryPlugin(),
36
36
  tesseraConfigPlugin(),
37
37
  tesseraPagesPlugin(),
38
- tesseraManifestPlugin(),
38
+ tesseraManifestPlugin(manifestRef),
39
39
  tesseraLayoutPlugin(),
40
40
  tesseraQuizPlugin(),
41
+ tesseraAdapterPlugin(),
42
+ tesseraXAPISetupPlugin(),
43
+ tesseraFirstPagePreloadPlugin(manifestRef),
41
44
  tesseraExportPlugin(),
42
45
  ];
43
46
  }
@@ -202,6 +205,9 @@ function tesseraConfigPlugin(): Plugin {
202
205
 
203
206
  return {
204
207
  base: './',
208
+ build: {
209
+ assetsDir: 'tessera',
210
+ },
205
211
  resolve: {
206
212
  alias: {
207
213
  '$assets': resolve(root, 'assets'),
@@ -227,15 +233,9 @@ function tesseraConfigPlugin(): Plugin {
227
233
  load(id) {
228
234
  if (id === RESOLVED_CONFIG_ID) {
229
235
  const configPath = resolve(projectRoot, 'course.config.js');
230
- let userConfig: Record<string, any> = {};
231
-
232
- if (existsSync(configPath)) {
233
- this.addWatchFile(configPath);
234
- const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
235
- if (objectStr) {
236
- try { userConfig = JSON5.parse(objectStr); } catch {}
237
- }
238
- }
236
+ if (existsSync(configPath)) this.addWatchFile(configPath);
237
+ const read = readCourseConfig(projectRoot);
238
+ const userConfig: Record<string, any> = read.ok ? read.config : {};
239
239
 
240
240
  const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
241
241
  const merged = {
@@ -327,18 +327,11 @@ function tesseraValidationPlugin(): Plugin {
327
327
  }
328
328
 
329
329
  function runValidation(projectRoot: string): void {
330
- const { errors, warnings } = validateProject(projectRoot);
331
-
332
- for (const warning of warnings) {
333
- console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
334
- }
335
-
336
- if (errors.length > 0) {
337
- for (const error of errors) {
338
- console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
339
- }
330
+ const result = validateProject(projectRoot);
331
+ reportValidationIssues(result);
332
+ if (result.errors.length > 0) {
340
333
  throw new Error(
341
- `Tessera validation failed with ${errors.length} error(s). Fix the errors above to continue.`
334
+ `Tessera validation failed with ${result.errors.length} error(s). Fix the errors above to continue.`
342
335
  );
343
336
  }
344
337
  }
@@ -361,33 +354,27 @@ function tesseraExportPlugin(): Plugin {
361
354
  async closeBundle() {
362
355
  if (!isBuild) return;
363
356
 
364
- const configPath = resolve(projectRoot, 'course.config.js');
365
- if (!existsSync(configPath)) {
366
- // Validation already required course.config.js — getting here means
367
- // the file vanished mid-build. Surface that loudly rather than
368
- // shipping a bundle with no LMS export silently.
369
- throw new Error(
370
- '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'
371
- );
372
- }
373
-
374
- const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
375
- if (!objectStr) {
376
- throw new Error(
377
- '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'
378
- );
379
- }
380
-
381
- let config: any;
382
- try {
383
- config = JSON5.parse(objectStr);
384
- } catch (err) {
357
+ const read = readCourseConfig(projectRoot);
358
+ if (!read.ok) {
359
+ // Validation already required a parseable course.config.js — getting
360
+ // here means it vanished or broke mid-build. Surface that loudly
361
+ // rather than shipping a bundle with no LMS export silently.
362
+ if (read.reason === 'missing') {
363
+ throw new Error(
364
+ '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'
365
+ );
366
+ }
367
+ if (read.reason === 'no-export') {
368
+ throw new Error(
369
+ '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'
370
+ );
371
+ }
385
372
  throw new Error(
386
- `[tessera:export] course.config.js: failed to parse export-default object literal — ${(err as Error).message}`
373
+ `[tessera:export] course.config.js: failed to parse export-default object literal — ${(read.error as Error).message}`
387
374
  );
388
375
  }
389
376
 
390
- await runExport(projectRoot, config);
377
+ await runExport(projectRoot, read.config as Parameters<typeof runExport>[1]);
391
378
  },
392
379
  };
393
380
  }
@@ -397,15 +384,15 @@ function tesseraExportPlugin(): Plugin {
397
384
  const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
398
385
  const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
399
386
 
400
- function tesseraManifestPlugin(): Plugin {
387
+ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
401
388
  let projectRoot: string;
402
389
  let pagesDir: string;
403
- let currentManifest: Manifest | null = null;
404
390
  let server: ViteDevServer | null = null;
405
391
 
406
392
  function buildManifest(): Manifest {
407
- currentManifest = generateManifest(pagesDir);
408
- return currentManifest;
393
+ const m = generateManifest(pagesDir);
394
+ manifestRef.current = m;
395
+ return m;
409
396
  }
410
397
 
411
398
  return {
@@ -415,6 +402,7 @@ function tesseraManifestPlugin(): Plugin {
415
402
  configResolved(config: ResolvedConfig) {
416
403
  projectRoot = config.root;
417
404
  pagesDir = resolve(projectRoot, 'pages');
405
+ manifestRef.root = projectRoot;
418
406
  },
419
407
 
420
408
  configureServer(devServer: ViteDevServer) {
@@ -432,7 +420,7 @@ function tesseraManifestPlugin(): Plugin {
432
420
  event === 'unlinkDir';
433
421
 
434
422
  if (isRelevant) {
435
- currentManifest = null; // invalidate cache
423
+ manifestRef.current = null; // invalidate cache
436
424
 
437
425
  // Invalidate the virtual module to trigger HMR
438
426
  const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
@@ -457,7 +445,7 @@ function tesseraManifestPlugin(): Plugin {
457
445
 
458
446
  load(id) {
459
447
  if (id === RESOLVED_MANIFEST_ID) {
460
- if (!currentManifest) {
448
+ if (!manifestRef.current) {
461
449
  buildManifest();
462
450
  }
463
451
 
@@ -468,7 +456,7 @@ function tesseraManifestPlugin(): Plugin {
468
456
  // Encode as base64 to prevent Vite's import analysis from
469
457
  // scanning .svelte importPath strings as module imports.
470
458
  // Replace Infinity with 1e9 since JSON.stringify drops it.
471
- const json = JSON.stringify(currentManifest, (_key, value) =>
459
+ const json = JSON.stringify(manifestRef.current, (_key, value) =>
472
460
  value === Infinity ? 1e9 : value
473
461
  );
474
462
  const b64 = Buffer.from(json).toString('base64');
@@ -478,3 +466,156 @@ function tesseraManifestPlugin(): Plugin {
478
466
  },
479
467
  };
480
468
  }
469
+
470
+ const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
471
+ const RESOLVED_ADAPTER_ID = '\0' + VIRTUAL_ADAPTER_ID;
472
+
473
+ function tesseraAdapterPlugin(): Plugin {
474
+ let projectRoot: string;
475
+ let isBuild = false;
476
+
477
+ return {
478
+ name: 'tessera:adapter',
479
+ enforce: 'pre',
480
+
481
+ configResolved(config: ResolvedConfig) {
482
+ projectRoot = config.root;
483
+ isBuild = config.command === 'build';
484
+ },
485
+
486
+ resolveId(id) {
487
+ if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;
488
+ return null;
489
+ },
490
+
491
+ load(id) {
492
+ if (id !== RESOLVED_ADAPTER_ID) return null;
493
+
494
+ // In dev, defer to the runtime selector so its WebAdapter fallback
495
+ // for unreachable LMS APIs keeps working.
496
+ if (!isBuild) {
497
+ return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
498
+ }
499
+
500
+ let standard = 'web';
501
+ const read = readCourseConfig(projectRoot);
502
+ if (read.ok && typeof read.config.export?.standard === 'string') {
503
+ standard = read.config.export.standard;
504
+ }
505
+
506
+ switch (standard) {
507
+ case 'scorm12':
508
+ return `
509
+ import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
510
+ import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
511
+ import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
512
+ export function createAdapter() {
513
+ const api = findSCORM12API();
514
+ 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.');
515
+ return new SCORM12Adapter(api);
516
+ }
517
+ `;
518
+ case 'scorm2004':
519
+ return `
520
+ import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
521
+ import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
522
+ import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
523
+ export function createAdapter() {
524
+ const api = findSCORM2004API();
525
+ 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.');
526
+ return new SCORM2004Adapter(api);
527
+ }
528
+ `;
529
+ case 'cmi5':
530
+ return `
531
+ import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
532
+ import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
533
+ import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
534
+ export function createAdapter() {
535
+ if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
536
+ return new CMI5Adapter();
537
+ }
538
+ `;
539
+ default:
540
+ return `
541
+ import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
542
+ export function createAdapter(config) {
543
+ return new WebAdapter(config);
544
+ }
545
+ `;
546
+ }
547
+ },
548
+ };
549
+ }
550
+
551
+ const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
552
+ const RESOLVED_XAPI_SETUP_ID = '\0' + VIRTUAL_XAPI_SETUP_ID;
553
+
554
+ function tesseraXAPISetupPlugin(): Plugin {
555
+ let projectRoot: string;
556
+ let isBuild = false;
557
+
558
+ return {
559
+ name: 'tessera:xapi-setup',
560
+ enforce: 'pre',
561
+
562
+ configResolved(config: ResolvedConfig) {
563
+ projectRoot = config.root;
564
+ isBuild = config.command === 'build';
565
+ },
566
+
567
+ resolveId(id) {
568
+ if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;
569
+ return null;
570
+ },
571
+
572
+ load(id) {
573
+ if (id !== RESOLVED_XAPI_SETUP_ID) return null;
574
+
575
+ if (!isBuild) {
576
+ return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
577
+ }
578
+
579
+ let standard = 'web';
580
+ let hasXapi = false;
581
+ const read = readCourseConfig(projectRoot);
582
+ if (read.ok) {
583
+ if (typeof read.config.export?.standard === 'string') standard = read.config.export.standard;
584
+ hasXapi = read.config.xapi != null;
585
+ }
586
+
587
+ // cmi5 needs the publisher regardless of explicit xapi config (cmi5
588
+ // adapter shares the publisher queue for its own LMS-required statements).
589
+ if (hasXapi || standard === 'cmi5') {
590
+ return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
591
+ }
592
+
593
+ return `export async function buildXAPIClient() { return null; }`;
594
+ },
595
+ };
596
+ }
597
+
598
+ function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
599
+ return {
600
+ name: 'tessera:first-page-preload',
601
+ apply: 'build',
602
+ transformIndexHtml: {
603
+ order: 'post',
604
+ handler(_html, ctx) {
605
+ const firstPagePath = manifestRef.current?.pages[0]?.importPath;
606
+ if (!firstPagePath || !ctx.bundle) return;
607
+ const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\//, '')).replace(/\\/g, '/');
608
+ const chunk = Object.values(ctx.bundle).find(
609
+ (c): c is import('vite').Rollup.OutputChunk =>
610
+ c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, '/') === normalized
611
+ );
612
+ if (!chunk) return;
613
+ return [{
614
+ tag: 'link',
615
+ attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
616
+ injectTo: 'head',
617
+ }];
618
+ },
619
+ },
620
+ };
621
+ }