tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,118 @@
1
+ <script>
2
+ /**
3
+ * @component Video
4
+ * Embeds YouTube/Vimeo via iframe or local video files.
5
+ * Lazy-loads via IntersectionObserver.
6
+ *
7
+ * @prop {string} src - Video URL (YouTube, Vimeo, or direct video file)
8
+ * @prop {string} [title] - Accessible label for the video
9
+ */
10
+ import { onMount } from 'svelte';
11
+
12
+ let { src, title = '' } = $props();
13
+ let containerRef = $state(null);
14
+ let visible = $state(false);
15
+
16
+ const youtubeRegex = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
17
+ const vimeoRegex = /vimeo\.com\/(?:video\/)?(\d+)/;
18
+
19
+ let embedUrl = $derived.by(() => {
20
+ const ytMatch = src.match(youtubeRegex);
21
+ if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`;
22
+
23
+ const vimeoMatch = src.match(vimeoRegex);
24
+ if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
25
+
26
+ return null;
27
+ });
28
+
29
+ let isEmbed = $derived(embedUrl !== null);
30
+
31
+ onMount(() => {
32
+ if (!containerRef) return;
33
+
34
+ const observer = new IntersectionObserver(
35
+ ([entry]) => {
36
+ if (entry.isIntersecting) {
37
+ visible = true;
38
+ observer.disconnect();
39
+ }
40
+ },
41
+ { rootMargin: '200px' }
42
+ );
43
+
44
+ observer.observe(containerRef);
45
+ return () => observer.disconnect();
46
+ });
47
+ </script>
48
+
49
+ <div class="tessera-video" bind:this={containerRef} aria-label={title || 'Video'}>
50
+ {#if visible}
51
+ {#if isEmbed}
52
+ <div class="tessera-video-embed">
53
+ <iframe
54
+ src={embedUrl}
55
+ {title}
56
+ frameborder="0"
57
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
58
+ allowfullscreen
59
+ ></iframe>
60
+ </div>
61
+ {:else}
62
+ <!-- svelte-ignore a11y_media_has_caption -->
63
+ <video controls class="tessera-video-native" aria-label={title}>
64
+ <source {src} />
65
+ Your browser does not support the video element.
66
+ </video>
67
+ {/if}
68
+ {:else}
69
+ <div class="tessera-video-placeholder">
70
+ <span class="tessera-video-placeholder-icon" aria-hidden="true">▶</span>
71
+ </div>
72
+ {/if}
73
+ </div>
74
+
75
+ <style>
76
+ .tessera-video {
77
+ margin-bottom: var(--tessera-spacing-lg);
78
+ }
79
+
80
+ .tessera-video-embed {
81
+ position: relative;
82
+ padding-bottom: 56.25%; /* 16:9 */
83
+ height: 0;
84
+ overflow: hidden;
85
+ border-radius: 8px;
86
+ }
87
+
88
+ .tessera-video-embed iframe {
89
+ position: absolute;
90
+ top: 0;
91
+ left: 0;
92
+ width: 100%;
93
+ height: 100%;
94
+ border: none;
95
+ border-radius: 8px;
96
+ }
97
+
98
+ .tessera-video-native {
99
+ width: 100%;
100
+ border-radius: 8px;
101
+ display: block;
102
+ }
103
+
104
+ .tessera-video-placeholder {
105
+ aspect-ratio: 16 / 9;
106
+ background-color: var(--tessera-bg-secondary);
107
+ border: 1px solid var(--tessera-border);
108
+ border-radius: 8px;
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ }
113
+
114
+ .tessera-video-placeholder-icon {
115
+ font-size: 2rem;
116
+ color: var(--tessera-text-light);
117
+ }
118
+ </style>
@@ -0,0 +1,15 @@
1
+ export { default as Callout } from './Callout.svelte';
2
+ export { default as Image } from './Image.svelte';
3
+ export { default as Accordion } from './Accordion.svelte';
4
+ export { default as AccordionItem } from './AccordionItem.svelte';
5
+ export { default as Carousel } from './Carousel.svelte';
6
+ export { default as CarouselSlide } from './CarouselSlide.svelte';
7
+ export { default as RevealModal } from './RevealModal.svelte';
8
+ export { default as Video } from './Video.svelte';
9
+ export { default as Audio } from './Audio.svelte';
10
+ export { default as Quiz } from './Quiz.svelte';
11
+ export { default as MultipleChoice } from './MultipleChoice.svelte';
12
+ export { default as FillInTheBlank } from './FillInTheBlank.svelte';
13
+ export { default as Matching } from './Matching.svelte';
14
+ export { default as Sorting } from './Sorting.svelte';
15
+ export { default as DefaultLayout } from './DefaultLayout.svelte';
@@ -0,0 +1,71 @@
1
+ import type { Interaction } from '../runtime/interaction.js';
2
+
3
+ /**
4
+ * Shape contributed by a question component when it registers with a `<Quiz>`.
5
+ * `useQuestion` always supplies both `id` and `interaction`; custom widgets
6
+ * may omit `interaction` (presentational steps that don't report to the LMS),
7
+ * in which case they're skipped by `buildQuizInteractions`.
8
+ */
9
+ export interface QuizQuestionApi {
10
+ id: string;
11
+ /** Optional weight for the score rollup. Default 1 — `Σ(w·correct)/Σ(w)*100`. */
12
+ weight?: number;
13
+ checkAnswer: (answer: unknown) => boolean;
14
+ reset?: () => void;
15
+ render?: unknown;
16
+ interaction?: () => Interaction;
17
+ }
18
+
19
+ /**
20
+ * Reactive context published by `<Quiz>` (and `useQuiz`) under the
21
+ * `'tessera-quiz'` Svelte context key. Question widgets read this through
22
+ * `getContext<QuizContext>('tessera-quiz')` to coordinate with their host.
23
+ *
24
+ * All accessors are getters so the consumer re-runs when the underlying rune
25
+ * state changes — destructuring the object will break reactivity.
26
+ */
27
+ export interface QuizContext {
28
+ registerQuestion(api: QuizQuestionApi): number;
29
+ setRender(index: number, render: unknown): void;
30
+ setAnswer(index: number, answer: unknown): void;
31
+ getAnswer(index: number): unknown;
32
+ readonly submitted: boolean;
33
+ readonly reviewing: boolean;
34
+ readonly showFeedback: boolean;
35
+ feedbackVisible(index: number): boolean;
36
+ isAnswerLocked(index: number): boolean;
37
+ isLockedCorrect(index: number): boolean;
38
+ }
39
+
40
+ export interface QuizInteractionEntry {
41
+ id: string;
42
+ interaction: Interaction;
43
+ correct: boolean;
44
+ }
45
+
46
+ /**
47
+ * Build the per-question payload included in `tessera-quiz-complete`.
48
+ *
49
+ * Skips questions whose `interaction` is missing or returns nullish — custom
50
+ * widgets may register without an interaction reporter (e.g. presentational
51
+ * "press to continue" steps), and those simply don't contribute to the
52
+ * `cmi.interactions` / xAPI Answered stream.
53
+ */
54
+ export function buildQuizInteractions(
55
+ questions: QuizQuestionApi[],
56
+ answers: Map<number, unknown>
57
+ ): QuizInteractionEntry[] {
58
+ const entries: QuizInteractionEntry[] = [];
59
+ for (let i = 0; i < questions.length; i++) {
60
+ const q = questions[i];
61
+ if (typeof q.interaction !== 'function') continue;
62
+ const interaction = q.interaction();
63
+ if (!interaction) continue;
64
+ entries.push({
65
+ id: q.id,
66
+ interaction,
67
+ correct: q.checkAnswer(answers.get(i)),
68
+ });
69
+ }
70
+ return entries;
71
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolve a `$assets/foo` URL to a document-relative path so the asset works
3
+ * under any deployment root (dev server, static hosts, LMS subpaths, file://).
4
+ * Pass-through for absolute or external URLs.
5
+ *
6
+ * Shared by Image / Audio / Video and any custom component that wants the
7
+ * same alias semantics.
8
+ */
9
+ export function resolveAsset(src: string): string {
10
+ return src.startsWith('$assets/') ? src.replace('$assets/', './assets/') : src;
11
+ }
12
+
13
+ /**
14
+ * Build a deterministic slug from a question prompt for use as a fallback
15
+ * `id` when the author hasn't supplied one. Stable across renders so SCORM /
16
+ * cmi5 interaction reporting addresses the same question consistently.
17
+ */
18
+ export function slugFromQuestion(text: unknown): string {
19
+ return String(text ?? '')
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, '-')
22
+ .replace(/^-|-$/g, '')
23
+ .slice(0, 40);
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ // ---- Components ----
2
+ // `DefaultLayout` is included via the wildcard re-export.
3
+ export * from './components/index.js';
4
+
5
+ // ---- Hooks ----
6
+ export {
7
+ useQuestion,
8
+ useQuiz,
9
+ useNavigation,
10
+ useProgress,
11
+ usePersistence,
12
+ } from './runtime/hooks.svelte.js';
13
+
14
+ // ---- Access ----
15
+ export {
16
+ freeAccess,
17
+ sequentialAccess,
18
+ resolveAccess,
19
+ } from './runtime/access.js';
20
+ export type {
21
+ AccessFn,
22
+ AccessContext,
23
+ } from './runtime/access.js';
24
+
25
+ // ---- xAPI ----
26
+ export { useXAPI } from './runtime/xapi/registry.js';
27
+ export type { XAPIClient } from './runtime/xapi/client.js';
28
+ export type {
29
+ XAPIAgent,
30
+ XAPIVerb,
31
+ XAPIObject,
32
+ XAPIContext,
33
+ XAPIResult,
34
+ PartialStatement,
35
+ Statement,
36
+ DestinationOutcome,
37
+ SendStatementResult,
38
+ SendStatementOptions,
39
+ } from './runtime/xapi/types.js';
40
+
41
+ // ---- Types ----
42
+ export type {
43
+ Interaction,
44
+ } from './runtime/interaction.js';
45
+ export { isCorrect } from './runtime/interaction.js';
46
+ export type {
47
+ UseQuestionOptions,
48
+ UseQuestionHandle,
49
+ UseQuizHandle,
50
+ } from './runtime/hooks.svelte.js';
51
+ export type {
52
+ XAPIConfig,
53
+ XAPIExplicitConfig,
54
+ XAPILMSConfig,
55
+ CourseConfig,
56
+ } from './runtime/types.js';
@@ -0,0 +1,264 @@
1
+ import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { createWriteStream } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { ZipArchive } from 'archiver';
6
+ import { slugify } from '../runtime/slugify.js';
7
+
8
+ // ---------- Types ----------
9
+
10
+ interface ExportConfig {
11
+ title: string;
12
+ description?: string;
13
+ version?: string;
14
+ scoring?: { passingScore?: number };
15
+ completion?: { mode?: 'quiz' | 'percentage' };
16
+ export?: { standard?: string };
17
+ }
18
+
19
+ // ---------- Helpers ----------
20
+
21
+ function escapeXml(str: string): string {
22
+ return str
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&apos;');
28
+ }
29
+
30
+ /**
31
+ * Recursively collect all file paths relative to a directory.
32
+ */
33
+ function collectFiles(dir: string, base: string = ''): string[] {
34
+ const files: string[] = [];
35
+ if (!existsSync(dir)) return files;
36
+
37
+ for (const entry of readdirSync(dir)) {
38
+ const fullPath = resolve(dir, entry);
39
+ const relPath = base ? `${base}/${entry}` : entry;
40
+ if (statSync(fullPath).isDirectory()) {
41
+ files.push(...collectFiles(fullPath, relPath));
42
+ } else {
43
+ files.push(relPath);
44
+ }
45
+ }
46
+ return files;
47
+ }
48
+
49
+ /**
50
+ * Derive a stable URN IRI from a seed string. cmi5 §13.1 / xs:anyURI
51
+ * require course / AU ids to be IRIs — bare hex or UUID-shaped strings
52
+ * (without correct version/variant bits) aren't conformant URNs and may
53
+ * be rejected by strict LMS importers.
54
+ *
55
+ * Hash the seed so the id survives rebuilds, then format as
56
+ * `urn:tessera:<kind>:<hex>`. The same seed always produces the same
57
+ * IRI, so existing LRS records are not orphaned by re-export.
58
+ */
59
+ function stableUrn(kind: 'course' | 'au', seed: string): string {
60
+ const h = createHash('sha256').update(seed).digest('hex');
61
+ // 32 hex chars (128 bits of entropy) is plenty; trim to keep ids short.
62
+ return `urn:tessera:${kind}:${h.slice(0, 32)}`;
63
+ }
64
+
65
+ function formatSize(bytes: number): string {
66
+ if (bytes < 1024) return `${bytes} B`;
67
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
68
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
69
+ }
70
+
71
+ // ---------- Manifest Generators ----------
72
+
73
+ export function generateSCORM12Manifest(
74
+ config: ExportConfig,
75
+ distDir: string
76
+ ): string {
77
+ const title = escapeXml(config.title || 'Tessera Course');
78
+ const files = collectFiles(distDir);
79
+ const fileElements = files
80
+ .map((f) => ` <file href="${escapeXml(f)}" />`)
81
+ .join('\n');
82
+
83
+ return `<?xml version="1.0" encoding="UTF-8"?>
84
+ <manifest identifier="tessera-course" version="1.0"
85
+ xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
86
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
87
+ <metadata>
88
+ <schema>ADL SCORM</schema>
89
+ <schemaversion>1.2</schemaversion>
90
+ </metadata>
91
+ <organizations default="org-1">
92
+ <organization identifier="org-1">
93
+ <title>${title}</title>
94
+ <item identifier="item-1" identifierref="res-1">
95
+ <title>${title}</title>
96
+ </item>
97
+ </organization>
98
+ </organizations>
99
+ <resources>
100
+ <resource identifier="res-1" type="webcontent" adlcp:scormtype="sco" href="index.html">
101
+ ${fileElements}
102
+ </resource>
103
+ </resources>
104
+ </manifest>`;
105
+ }
106
+
107
+ export function generateSCORM2004Manifest(
108
+ config: ExportConfig,
109
+ distDir: string
110
+ ): string {
111
+ const title = escapeXml(config.title || 'Tessera Course');
112
+ const files = collectFiles(distDir);
113
+ const fileElements = files
114
+ .map((f) => ` <file href="${escapeXml(f)}" />`)
115
+ .join('\n');
116
+
117
+ return `<?xml version="1.0" encoding="UTF-8"?>
118
+ <manifest identifier="tessera-course" version="1.0"
119
+ xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
120
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3">
121
+ <metadata>
122
+ <schema>ADL SCORM</schema>
123
+ <schemaversion>2004 4th Edition</schemaversion>
124
+ </metadata>
125
+ <organizations default="org-1">
126
+ <organization identifier="org-1">
127
+ <title>${title}</title>
128
+ <item identifier="item-1" identifierref="res-1">
129
+ <title>${title}</title>
130
+ </item>
131
+ </organization>
132
+ </organizations>
133
+ <resources>
134
+ <resource identifier="res-1" type="webcontent" adlcp:scormType="sco" href="index.html">
135
+ ${fileElements}
136
+ </resource>
137
+ </resources>
138
+ </manifest>`;
139
+ }
140
+
141
+ export function generateCMI5Xml(config: ExportConfig): string {
142
+ const title = escapeXml(config.title || 'Tessera Course');
143
+ const description = escapeXml(config.description || '');
144
+ // Derive stable IDs from the course title so they survive rebuilds without
145
+ // orphaning existing learner records in the LRS.
146
+ const courseId = stableUrn('course', `tessera-course:${config.title || ''}`);
147
+ const auId = stableUrn('au', `tessera-au:${config.title || ''}`);
148
+ const masteryScore = (config.scoring?.passingScore ?? 70) / 100;
149
+ // cmi5 §13.1.4 — `moveOn` decides which verb(s) the LMS treats as
150
+ // satisfying the AU. For graded courses (completion gated on a quiz)
151
+ // a learner who completes without passing should NOT receive credit, so
152
+ // the LMS needs both a Completed AND a Passed before satisfaction.
153
+ // Percentage-mode courses don't surface pass/fail, so completion alone
154
+ // is the right signal.
155
+ const moveOn =
156
+ config.completion?.mode === 'quiz' ? 'CompletedAndPassed' : 'Completed';
157
+
158
+ return `<?xml version="1.0" encoding="UTF-8"?>
159
+ <courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
160
+ <course id="${courseId}">
161
+ <title><langstring lang="en-US">${title}</langstring></title>
162
+ <description><langstring lang="en-US">${description}</langstring></description>
163
+ </course>
164
+ <au id="${auId}" url="index.html" moveOn="${moveOn}" masteryScore="${masteryScore}">
165
+ <title><langstring lang="en-US">${title}</langstring></title>
166
+ <description><langstring lang="en-US">${description}</langstring></description>
167
+ </au>
168
+ </courseStructure>`;
169
+ }
170
+
171
+ // ---------- ZIP Packaging ----------
172
+
173
+ export async function createZip(
174
+ distDir: string,
175
+ outputPath: string
176
+ ): Promise<number> {
177
+ return new Promise((res, reject) => {
178
+ const output = createWriteStream(outputPath);
179
+ const archive = new ZipArchive({ zlib: { level: 9 } });
180
+
181
+ output.on('close', () => {
182
+ res(archive.pointer());
183
+ });
184
+ output.on('error', reject);
185
+ archive.on('error', reject);
186
+
187
+ archive.pipe(output);
188
+ archive.directory(distDir, false);
189
+ archive.finalize();
190
+ });
191
+ }
192
+
193
+ // ---------- Main Export ----------
194
+
195
+ /**
196
+ * Run the export process after Vite build completes.
197
+ * Writes manifest XML into dist/, then packages into ZIP if needed.
198
+ */
199
+ /** Remove any previously built zips for this package to prevent accumulation. */
200
+ function cleanOldZips(projectRoot: string, slug: string): void {
201
+ try {
202
+ for (const f of readdirSync(projectRoot)) {
203
+ if (f.startsWith(`${slug}-`) && f.endsWith('.zip')) {
204
+ try { unlinkSync(resolve(projectRoot, f)); } catch {}
205
+ }
206
+ }
207
+ } catch {}
208
+ }
209
+
210
+ export async function runExport(
211
+ projectRoot: string,
212
+ config: ExportConfig
213
+ ): Promise<void> {
214
+ const distDir = resolve(projectRoot, 'dist');
215
+ const standard = config.export?.standard || 'web';
216
+ const slug = slugify(config.title || 'tessera-course') || 'tessera-course';
217
+ const version = config.version || '1.0.0';
218
+ const zipName = `${slug}-${version}.zip`;
219
+ const zipPath = resolve(projectRoot, zipName);
220
+
221
+ switch (standard) {
222
+ case 'web': {
223
+ // Compute dist size
224
+ const files = collectFiles(distDir);
225
+ let totalSize = 0;
226
+ for (const f of files) {
227
+ totalSize += statSync(resolve(distDir, f)).size;
228
+ }
229
+ console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);
230
+ break;
231
+ }
232
+
233
+ case 'scorm12': {
234
+ const manifest = generateSCORM12Manifest(config, distDir);
235
+ writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');
236
+ cleanOldZips(projectRoot, slug);
237
+ const zipSize = await createZip(distDir, zipPath);
238
+ console.log(
239
+ `✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`
240
+ );
241
+ break;
242
+ }
243
+
244
+ case 'scorm2004': {
245
+ const manifest = generateSCORM2004Manifest(config, distDir);
246
+ writeFileSync(resolve(distDir, 'imsmanifest.xml'), manifest, 'utf-8');
247
+ cleanOldZips(projectRoot, slug);
248
+ const zipSize = await createZip(distDir, zipPath);
249
+ console.log(
250
+ `✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`
251
+ );
252
+ break;
253
+ }
254
+
255
+ case 'cmi5': {
256
+ const xml = generateCMI5Xml(config);
257
+ writeFileSync(resolve(distDir, 'cmi5.xml'), xml, 'utf-8');
258
+ cleanOldZips(projectRoot, slug);
259
+ const zipSize = await createZip(distDir, zipPath);
260
+ console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);
261
+ break;
262
+ }
263
+ }
264
+ }