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.
- package/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- 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, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|