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.
- package/dist/plugin/cli.js +5 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +215 -124
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
- package/dist/validation-D9DXlqNP.js.map +1 -0
- package/package.json +9 -6
- package/src/components/Audio.svelte +5 -2
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +60 -98
- package/src/components/Image.svelte +3 -8
- package/src/components/LockedBanner.svelte +3 -4
- package/src/components/MultipleChoice.svelte +53 -94
- package/src/components/Quiz.svelte +2 -1
- package/src/components/Video.svelte +4 -2
- package/src/components/util.ts +1 -0
- package/src/plugin/cli.ts +2 -7
- package/src/plugin/export.ts +23 -41
- package/src/plugin/index.ts +197 -56
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +31 -5
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +38 -67
- package/src/runtime/App.svelte +48 -36
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +13 -83
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +28 -29
- package/src/runtime/adapters/retry.ts +0 -64
- package/src/runtime/adapters/scorm12.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +11 -16
- package/src/runtime/hooks.svelte.ts +14 -16
- package/src/runtime/navigation.svelte.ts +51 -45
- package/src/runtime/progress.svelte.ts +25 -10
- package/src/runtime/quiz-policy.ts +21 -178
- package/src/runtime/xapi/agent-rules.ts +7 -2
- package/src/runtime/xapi/publisher.ts +1 -11
- package/src/runtime/xapi/validation.ts +1 -1
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-BxWAMMnJ.js.map +0 -1
- 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 ===
|
|
62
|
+
if (optIndex === selectedOption && !isCorrectOption(optIndex)) return 'incorrect';
|
|
64
63
|
return '';
|
|
65
64
|
}
|
|
66
65
|
</script>
|
|
67
66
|
|
|
68
|
-
{#
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{#if
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
{
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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 {
|
|
66
|
+
<source src={resolvedSrc} />
|
|
65
67
|
Your browser does not support the video element.
|
|
66
68
|
</video>
|
|
67
69
|
{/if}
|
package/src/components/util.ts
CHANGED
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
|
-
|
|
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 =
|
package/src/plugin/export.ts
CHANGED
|
@@ -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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -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,
|
|
6
|
-
import { generateManifest,
|
|
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: '
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
365
|
-
if (!
|
|
366
|
-
// Validation already required course.config.js — getting
|
|
367
|
-
//
|
|
368
|
-
// shipping a bundle with no LMS export silently.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 — ${(
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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
|
+
}
|