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,250 @@
|
|
|
1
|
+
import type { CourseConfig, XAPIConfig, XAPIExplicitConfig } from '../types.js';
|
|
2
|
+
import type { PersistenceAdapter } from '../persistence.js';
|
|
3
|
+
import type { XAPIAgent } from './types.js';
|
|
4
|
+
import { XAPIPublisher } from './publisher.js';
|
|
5
|
+
import { XAPIClient } from './client.js';
|
|
6
|
+
import {
|
|
7
|
+
synthesizeSCORM12Actor,
|
|
8
|
+
synthesizeSCORM2004Actor,
|
|
9
|
+
} from './derive-actor.js';
|
|
10
|
+
import { CMI5Adapter } from '../adapters/cmi5.js';
|
|
11
|
+
import { SCORM12Adapter } from '../adapters/scorm12.js';
|
|
12
|
+
import { SCORM2004Adapter } from '../adapters/scorm2004.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wraps a value that the runtime knows how to materialize into an
|
|
16
|
+
* `XAPIPublisher`. Either a fresh publisher constructed for an explicit
|
|
17
|
+
* destination, or a reference to the cmi5 adapter's existing publisher
|
|
18
|
+
* (for the `endpoint: 'lms'` sentinel — same instance, shared queue).
|
|
19
|
+
*/
|
|
20
|
+
type DestinationSource =
|
|
21
|
+
| { kind: 'lms-shared'; adapter: CMI5Adapter }
|
|
22
|
+
| { kind: 'explicit'; publisher: XAPIPublisher };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Throws synchronously when `endpoint: 'lms'` appears under cmi5 export
|
|
26
|
+
* but the runtime was constructed without cmi5 launch parameters (i.e.,
|
|
27
|
+
* running locally outside an LMS). Surfaced through every
|
|
28
|
+
* `sendStatement` call rather than silently no-oping — the alternative
|
|
29
|
+
* produces the "works in dev, silently broken in prod" footgun.
|
|
30
|
+
*/
|
|
31
|
+
class XAPIDevFallbackError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super(
|
|
34
|
+
"Tessera xAPI: xapi.endpoint is 'lms' but no cmi5 launch parameters " +
|
|
35
|
+
'(fetch / endpoint / activityId / actor) were present on the URL. ' +
|
|
36
|
+
'Either launch this course from a real LMS / SCORM Cloud, or ' +
|
|
37
|
+
"temporarily change xapi.endpoint to an explicit URL pointed at a " +
|
|
38
|
+
'local LRS (e.g. http://localhost:8080/data/xAPI/) for dev work.'
|
|
39
|
+
);
|
|
40
|
+
this.name = 'XAPIDevFallbackError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a stub publisher whose sends reject with `error`. Used for both
|
|
46
|
+
* dev-fallback paths: cmi5 `endpoint: 'lms'` with no launch params, and
|
|
47
|
+
* SCORM explicit endpoints that depend on a learner identity the dev
|
|
48
|
+
* fallback can't synthesize. The placeholder publisher carries a static
|
|
49
|
+
* actor so its constructor invariants hold and `XAPIClient.buildStatement`
|
|
50
|
+
* can run without throwing — only the network-bound methods reject.
|
|
51
|
+
*/
|
|
52
|
+
function makeRejectingPublisher(error: () => Error): XAPIPublisher {
|
|
53
|
+
const pub = new XAPIPublisher({
|
|
54
|
+
endpoint: 'http://localhost/__tessera_dev_fallback__/',
|
|
55
|
+
auth: '',
|
|
56
|
+
actor: { mbox: 'mailto:nobody@example.invalid', objectType: 'Agent' },
|
|
57
|
+
activityId: 'http://localhost/__tessera_dev_fallback__',
|
|
58
|
+
});
|
|
59
|
+
// The static actor is cached at construction so getActor()/buildStatement
|
|
60
|
+
// work without a separate init() call. We only override the methods that
|
|
61
|
+
// would otherwise hit the network so author code surfaces the explicit
|
|
62
|
+
// error rather than silently no-oping.
|
|
63
|
+
const reject = (): Promise<never> => Promise.reject(error());
|
|
64
|
+
(pub as any).sendStatement = reject;
|
|
65
|
+
(pub as any).enqueueBuilt = reject;
|
|
66
|
+
return pub;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeDevFallbackPublisher(): XAPIPublisher {
|
|
70
|
+
return makeRejectingPublisher(() => new XAPIDevFallbackError());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class XAPISCORMDevFallbackError extends Error {
|
|
74
|
+
constructor(standard: 'scorm12' | 'scorm2004') {
|
|
75
|
+
const label = standard === 'scorm12' ? 'SCORM 1.2' : 'SCORM 2004';
|
|
76
|
+
super(
|
|
77
|
+
`Tessera xAPI: ${label} learner identity is unavailable in dev (no LMS API found, ` +
|
|
78
|
+
'falling back to localStorage). The runtime cannot synthesize an actor for this xapi ' +
|
|
79
|
+
'destination. Either supply xapi.actor explicitly in course.config.js, or launch from ' +
|
|
80
|
+
'a real LMS / SCORM Cloud where ' +
|
|
81
|
+
(standard === 'scorm12' ? 'cmi.core.student_id' : 'cmi.learner_id') +
|
|
82
|
+
' is populated.'
|
|
83
|
+
);
|
|
84
|
+
this.name = 'XAPISCORMDevFallbackError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeSCORMDevFallbackPublisher(
|
|
89
|
+
standard: 'scorm12' | 'scorm2004'
|
|
90
|
+
): XAPIPublisher {
|
|
91
|
+
return makeRejectingPublisher(() => new XAPISCORMDevFallbackError(standard));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a single `XAPIConfig` entry into a destination source. Returns
|
|
96
|
+
* null when the entry can't materialize (e.g., 'lms' with no cmi5
|
|
97
|
+
* adapter present in non-cmi5 export modes — the validator should have
|
|
98
|
+
* caught this at build time).
|
|
99
|
+
*/
|
|
100
|
+
function resolveDestination(
|
|
101
|
+
entry: XAPIConfig,
|
|
102
|
+
config: CourseConfig,
|
|
103
|
+
adapter: PersistenceAdapter | null
|
|
104
|
+
): DestinationSource | null {
|
|
105
|
+
if (entry.endpoint === 'lms') {
|
|
106
|
+
if (config.export?.standard !== 'cmi5') {
|
|
107
|
+
// Build-time validator should reject this; defense in depth at runtime.
|
|
108
|
+
console.warn(
|
|
109
|
+
"Tessera xAPI: ignoring xapi entry with endpoint: 'lms' under non-cmi5 export."
|
|
110
|
+
);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (adapter instanceof CMI5Adapter) {
|
|
114
|
+
return { kind: 'lms-shared', adapter };
|
|
115
|
+
}
|
|
116
|
+
// Dev fallback — cmi5 launch params absent, adapter is the WebAdapter
|
|
117
|
+
// fallback. Materialize a publisher whose sends reject with an
|
|
118
|
+
// explicit error so author code surfaces the dev/prod gap.
|
|
119
|
+
return { kind: 'explicit', publisher: makeDevFallbackPublisher() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Explicit endpoint.
|
|
123
|
+
const explicit = entry as XAPIExplicitConfig;
|
|
124
|
+
const actorOrResolver = resolveExplicitActor(explicit, config, adapter);
|
|
125
|
+
if (actorOrResolver === null) return null;
|
|
126
|
+
if (
|
|
127
|
+
typeof actorOrResolver === 'object' &&
|
|
128
|
+
(actorOrResolver as { __scormDevFallback?: 'scorm12' | 'scorm2004' })
|
|
129
|
+
.__scormDevFallback
|
|
130
|
+
) {
|
|
131
|
+
const std = (actorOrResolver as { __scormDevFallback: 'scorm12' | 'scorm2004' })
|
|
132
|
+
.__scormDevFallback;
|
|
133
|
+
return { kind: 'explicit', publisher: makeSCORMDevFallbackPublisher(std) };
|
|
134
|
+
}
|
|
135
|
+
const publisher = new XAPIPublisher({
|
|
136
|
+
endpoint: explicit.endpoint,
|
|
137
|
+
auth: explicit.auth,
|
|
138
|
+
actor: actorOrResolver as XAPIAgent | (() => XAPIAgent | Promise<XAPIAgent>),
|
|
139
|
+
activityId: explicit.activityId,
|
|
140
|
+
registration: explicit.registration,
|
|
141
|
+
});
|
|
142
|
+
return { kind: 'explicit', publisher };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pick an actor (object or resolver function) for an explicit destination,
|
|
147
|
+
* applying the priority order: author-supplied > cmi5 launch actor >
|
|
148
|
+
* SCORM-derived actor > error. Returns null if no actor can be resolved
|
|
149
|
+
* (web export with no `xapi.actor` — build-time validator should have
|
|
150
|
+
* caught this; runtime returns null and the publisher is skipped).
|
|
151
|
+
*/
|
|
152
|
+
function resolveExplicitActor(
|
|
153
|
+
explicit: XAPIExplicitConfig,
|
|
154
|
+
config: CourseConfig,
|
|
155
|
+
adapter: PersistenceAdapter | null
|
|
156
|
+
):
|
|
157
|
+
| XAPIAgent
|
|
158
|
+
| (() => XAPIAgent | Promise<XAPIAgent>)
|
|
159
|
+
| { __scormDevFallback: 'scorm12' | 'scorm2004' }
|
|
160
|
+
| null {
|
|
161
|
+
if (explicit.actor !== undefined) return explicit.actor;
|
|
162
|
+
// No author-supplied actor — try mode-specific derivation.
|
|
163
|
+
if (config.export?.standard === 'cmi5' && adapter instanceof CMI5Adapter) {
|
|
164
|
+
const inner = adapter.getPublisher();
|
|
165
|
+
if (inner) {
|
|
166
|
+
// The cmi5 adapter's publisher has the launch actor cached.
|
|
167
|
+
try {
|
|
168
|
+
return inner.getActor();
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (config.export?.standard === 'scorm12') {
|
|
176
|
+
if (adapter instanceof SCORM12Adapter) {
|
|
177
|
+
return synthesizeSCORM12Actor(
|
|
178
|
+
adapter.getAPI(),
|
|
179
|
+
explicit.activityId,
|
|
180
|
+
explicit.actorAccountHomePage
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
// Adapter is the WebAdapter dev fallback. Mirror the cmi5 'lms'
|
|
184
|
+
// dev-fallback path: install a stub publisher that surfaces an
|
|
185
|
+
// explicit error rather than silently no-oping. Authors get the
|
|
186
|
+
// same dev/prod parity in SCORM that they get in cmi5.
|
|
187
|
+
return { __scormDevFallback: 'scorm12' };
|
|
188
|
+
}
|
|
189
|
+
if (config.export?.standard === 'scorm2004') {
|
|
190
|
+
if (adapter instanceof SCORM2004Adapter) {
|
|
191
|
+
return synthesizeSCORM2004Actor(
|
|
192
|
+
adapter.getAPI(),
|
|
193
|
+
explicit.activityId,
|
|
194
|
+
explicit.actorAccountHomePage
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return { __scormDevFallback: 'scorm2004' };
|
|
198
|
+
}
|
|
199
|
+
// Web export with no actor — build-time validator should have errored.
|
|
200
|
+
console.warn(
|
|
201
|
+
'Tessera xAPI: explicit destination has no actor and no derivation source — skipping.'
|
|
202
|
+
);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Construct an `XAPIClient` from a course's `config.xapi`. Returns null
|
|
208
|
+
* when xapi is unset, or when no destinations could be resolved.
|
|
209
|
+
*
|
|
210
|
+
* The returned client must have `init()` awaited before being registered
|
|
211
|
+
* so author code calling `useXAPI()` sees a fully initialized client
|
|
212
|
+
* (in particular, `getActor()` is safe to call sync).
|
|
213
|
+
*/
|
|
214
|
+
export async function buildXAPIClient(
|
|
215
|
+
config: CourseConfig,
|
|
216
|
+
adapter: PersistenceAdapter | null
|
|
217
|
+
): Promise<XAPIClient | null> {
|
|
218
|
+
const raw = config.xapi;
|
|
219
|
+
if (raw === undefined || raw === null) return null;
|
|
220
|
+
const entries: XAPIConfig[] = Array.isArray(raw) ? raw : [raw];
|
|
221
|
+
const sources: DestinationSource[] = [];
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const src = resolveDestination(entry, config, adapter);
|
|
224
|
+
if (src) sources.push(src);
|
|
225
|
+
}
|
|
226
|
+
if (sources.length === 0) return null;
|
|
227
|
+
|
|
228
|
+
// For each destination, get the publisher (either freshly constructed
|
|
229
|
+
// for an explicit entry, or the cmi5 adapter's existing instance for
|
|
230
|
+
// 'lms') and ensure it's initialized.
|
|
231
|
+
const publishers: XAPIPublisher[] = [];
|
|
232
|
+
for (const src of sources) {
|
|
233
|
+
if (src.kind === 'lms-shared') {
|
|
234
|
+
const inner = src.adapter.getPublisher();
|
|
235
|
+
if (inner) publishers.push(inner);
|
|
236
|
+
} else {
|
|
237
|
+
try {
|
|
238
|
+
await src.publisher.init();
|
|
239
|
+
publishers.push(src.publisher);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.warn(
|
|
242
|
+
'Tessera xAPI: failed to initialize an explicit destination — skipping.',
|
|
243
|
+
err
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (publishers.length === 0) return null;
|
|
249
|
+
return new XAPIClient(publishers);
|
|
250
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xAPI types used by the publisher and registry. These mirror the relevant
|
|
3
|
+
* subset of the xAPI 1.0.3 spec — the publisher only models Agents (not
|
|
4
|
+
* Groups) for v1, and only the statement fields actually exercised by
|
|
5
|
+
* Tessera or surfaced to authors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Identified xAPI Agent. Exactly one of `mbox` / `mbox_sha1sum` / `openid` /
|
|
10
|
+
* `account` must be present (the IFI rule). The publisher validates this on
|
|
11
|
+
* any actor it resolves; values that fail produce a runtime error rather
|
|
12
|
+
* than a silent LRS 400.
|
|
13
|
+
*/
|
|
14
|
+
export interface XAPIAgent {
|
|
15
|
+
name?: string;
|
|
16
|
+
mbox?: string;
|
|
17
|
+
mbox_sha1sum?: string;
|
|
18
|
+
openid?: string;
|
|
19
|
+
account?: { homePage: string; name: string };
|
|
20
|
+
objectType?: 'Agent';
|
|
21
|
+
// Group support is non-goal for v1; field exists so a Group passed by an
|
|
22
|
+
// author surfaces a friendly validation error instead of a TS mismatch.
|
|
23
|
+
member?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface XAPIVerb {
|
|
27
|
+
id: string;
|
|
28
|
+
display?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface XAPIObject {
|
|
32
|
+
id: string;
|
|
33
|
+
objectType?: string;
|
|
34
|
+
definition?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface XAPIContext {
|
|
38
|
+
registration?: string;
|
|
39
|
+
contextActivities?: {
|
|
40
|
+
parent?: Array<{ id: string }>;
|
|
41
|
+
grouping?: Array<{ id: string }>;
|
|
42
|
+
category?: Array<{ id: string }>;
|
|
43
|
+
other?: Array<{ id: string }>;
|
|
44
|
+
};
|
|
45
|
+
extensions?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface XAPIResult {
|
|
49
|
+
success?: boolean;
|
|
50
|
+
completion?: boolean;
|
|
51
|
+
duration?: string;
|
|
52
|
+
score?: { scaled?: number; raw?: number; min?: number; max?: number };
|
|
53
|
+
response?: string;
|
|
54
|
+
extensions?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimal partial-statement shape authors pass to `sendStatement`. The
|
|
59
|
+
* publisher fills in actor, timestamp, registration, grouping, sessionid
|
|
60
|
+
* extension, and statement id.
|
|
61
|
+
*/
|
|
62
|
+
export interface PartialStatement {
|
|
63
|
+
verb: XAPIVerb;
|
|
64
|
+
object?: XAPIObject;
|
|
65
|
+
result?: XAPIResult;
|
|
66
|
+
context?: XAPIContext;
|
|
67
|
+
attachments?: unknown[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fully-formed statement after the publisher has filled in its automatic
|
|
72
|
+
* fields. Returned in `sendStatement`'s resolved value so authors can log
|
|
73
|
+
* or assert on what was actually sent.
|
|
74
|
+
*/
|
|
75
|
+
export interface Statement {
|
|
76
|
+
id: string;
|
|
77
|
+
actor: XAPIAgent;
|
|
78
|
+
verb: XAPIVerb;
|
|
79
|
+
object: XAPIObject;
|
|
80
|
+
result?: XAPIResult;
|
|
81
|
+
context?: XAPIContext;
|
|
82
|
+
timestamp: string;
|
|
83
|
+
attachments?: unknown[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DestinationOutcome {
|
|
87
|
+
endpoint: string;
|
|
88
|
+
ok: boolean;
|
|
89
|
+
status?: number;
|
|
90
|
+
error?: Error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SendStatementResult {
|
|
94
|
+
statementId: string;
|
|
95
|
+
statement: Statement;
|
|
96
|
+
destinations: DestinationOutcome[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SendStatementOptions {
|
|
100
|
+
/**
|
|
101
|
+
* When false, the publisher sends one attempt and reports the outcome
|
|
102
|
+
* regardless of failure. Useful for high-volume telemetry where a missing
|
|
103
|
+
* statement is harmless. Default: true (retry on 5xx/network).
|
|
104
|
+
*/
|
|
105
|
+
retry?: boolean;
|
|
106
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4122 v4 UUID. Prefer `crypto.randomUUID` (modern browsers, Node 14.17+);
|
|
3
|
+
* fall back to a getRandomValues-based generator for legacy LMS shells.
|
|
4
|
+
*/
|
|
5
|
+
export function uuidv4(): string {
|
|
6
|
+
const c: Crypto | undefined = globalThis.crypto;
|
|
7
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
8
|
+
const bytes = new Uint8Array(16);
|
|
9
|
+
if (c?.getRandomValues) c.getRandomValues(bytes);
|
|
10
|
+
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
11
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
12
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
13
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0'));
|
|
14
|
+
return (
|
|
15
|
+
`${hex.slice(0, 4).join('')}-` +
|
|
16
|
+
`${hex.slice(4, 6).join('')}-` +
|
|
17
|
+
`${hex.slice(6, 8).join('')}-` +
|
|
18
|
+
`${hex.slice(8, 10).join('')}-` +
|
|
19
|
+
`${hex.slice(10, 16).join('')}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { PartialStatement } from './types.js';
|
|
2
|
+
export { validateAgent, validateAuthCredential } from './agent-rules.js';
|
|
3
|
+
|
|
4
|
+
/** Thrown for runtime-validation failures (auth/actor resolver misuse). */
|
|
5
|
+
export class XAPIConfigError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'XAPIConfigError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Thrown synchronously by `sendStatement` for partial-statement misuse. */
|
|
13
|
+
export class XAPIStatementError extends Error {
|
|
14
|
+
statement: PartialStatement;
|
|
15
|
+
constructor(message: string, statement: PartialStatement) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'XAPIStatementError';
|
|
18
|
+
this.statement = statement;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a partial statement at the boundary. Three checks —
|
|
24
|
+
* verb.id, object.id when supplied, score.scaled when supplied. Anything
|
|
25
|
+
* else passes through; the LRS gives clearer errors than we can.
|
|
26
|
+
*
|
|
27
|
+
* Called from both the client (so a fan-out send fails once before any
|
|
28
|
+
* destination's `buildStatement` runs) and the publisher (so a single-
|
|
29
|
+
* destination caller bypassing the client is still validated).
|
|
30
|
+
*/
|
|
31
|
+
export function validatePartialStatement(partial: PartialStatement): void {
|
|
32
|
+
if (!partial || typeof partial !== 'object') {
|
|
33
|
+
throw new XAPIStatementError(
|
|
34
|
+
'sendStatement: partial statement must be an object',
|
|
35
|
+
partial
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
!partial.verb ||
|
|
40
|
+
typeof partial.verb !== 'object' ||
|
|
41
|
+
typeof partial.verb.id !== 'string' ||
|
|
42
|
+
!partial.verb.id
|
|
43
|
+
) {
|
|
44
|
+
throw new XAPIStatementError(
|
|
45
|
+
'sendStatement: verb.id is required and must be a non-empty string',
|
|
46
|
+
partial
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (partial.object !== undefined) {
|
|
50
|
+
if (
|
|
51
|
+
!partial.object ||
|
|
52
|
+
typeof partial.object !== 'object' ||
|
|
53
|
+
typeof partial.object.id !== 'string' ||
|
|
54
|
+
!partial.object.id
|
|
55
|
+
) {
|
|
56
|
+
throw new XAPIStatementError(
|
|
57
|
+
'sendStatement: object.id must be a non-empty string when object is supplied',
|
|
58
|
+
partial
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const scaled = partial.result?.score?.scaled;
|
|
63
|
+
if (scaled !== undefined) {
|
|
64
|
+
if (typeof scaled !== 'number' || !Number.isFinite(scaled) || scaled < -1 || scaled > 1) {
|
|
65
|
+
throw new XAPIStatementError(
|
|
66
|
+
`sendStatement: result.score.scaled must be a number in [-1, 1], got ${scaled}`,
|
|
67
|
+
partial
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xAPI version negotiated by Tessera. Sent as the `X-Experience-API-Version`
|
|
3
|
+
* header on every Statement / State API request.
|
|
4
|
+
*
|
|
5
|
+
* Pinned to ADL xAPI 1.0.3 — NOT IEEE 9274.1.1 (xAPI 2.0) — because:
|
|
6
|
+
*
|
|
7
|
+
* 1. cmi5 v1.0 (the LMS-launch profile we implement) is normatively bound
|
|
8
|
+
* to xAPI 1.0.x: §3 / §11 require LRS communication at 1.0.x and the
|
|
9
|
+
* `X-Experience-API-Version: 1.0.x` header. A conformant cmi5 LMS will
|
|
10
|
+
* reject a `2.0.0` header on its launch endpoint.
|
|
11
|
+
* 2. ADL has not yet published a cmi5 revision rebased on IEEE 9274. Until
|
|
12
|
+
* they do, every cmi5 launch in the wild expects 1.0.x.
|
|
13
|
+
* 3. None of the 2.0 additions (typed extensions, attachments-by-reference,
|
|
14
|
+
* profile registry) are features the runtime exercises — the wire format
|
|
15
|
+
* for the statement / state / registration / sessionid extension we use
|
|
16
|
+
* is unchanged.
|
|
17
|
+
*
|
|
18
|
+
* The right time to bump is when ADL releases cmi5 v2; that work will need
|
|
19
|
+
* proper version negotiation (the LRS announces its supported versions and
|
|
20
|
+
* the AU picks the highest mutually-supported one), not just a constant
|
|
21
|
+
* change here.
|
|
22
|
+
*/
|
|
23
|
+
export const X_API_VERSION = '1.0.3';
|
package/src/virtual.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare module 'virtual:tessera-layout' {
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
const layout: Component<{ page: import('svelte').Snippet }> | null;
|
|
4
|
+
export default layout;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface ImportMetaEnv {
|
|
8
|
+
readonly DEV: boolean;
|
|
9
|
+
readonly PROD: boolean;
|
|
10
|
+
readonly MODE: string;
|
|
11
|
+
readonly SSR: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ImportMeta {
|
|
15
|
+
readonly env: ImportMetaEnv;
|
|
16
|
+
}
|
package/styles/base.css
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/* ---- Reset ---- */
|
|
2
|
+
*,
|
|
3
|
+
*::before,
|
|
4
|
+
*::after {
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
margin: 0;
|
|
7
|
+
padding: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
html {
|
|
11
|
+
-webkit-text-size-adjust: 100%;
|
|
12
|
+
-moz-tab-size: 4;
|
|
13
|
+
tab-size: 4;
|
|
14
|
+
line-height: 1.15;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
font-family: var(--tessera-font-family);
|
|
19
|
+
font-size: var(--tessera-font-size-base);
|
|
20
|
+
line-height: var(--tessera-line-height);
|
|
21
|
+
color: var(--tessera-text);
|
|
22
|
+
background-color: var(--tessera-bg);
|
|
23
|
+
-webkit-font-smoothing: antialiased;
|
|
24
|
+
-moz-osx-font-smoothing: grayscale;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
button,
|
|
28
|
+
input,
|
|
29
|
+
select,
|
|
30
|
+
textarea {
|
|
31
|
+
font-family: inherit;
|
|
32
|
+
font-size: inherit;
|
|
33
|
+
line-height: inherit;
|
|
34
|
+
color: inherit;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
img,
|
|
38
|
+
svg,
|
|
39
|
+
video,
|
|
40
|
+
canvas,
|
|
41
|
+
audio,
|
|
42
|
+
iframe,
|
|
43
|
+
embed,
|
|
44
|
+
object {
|
|
45
|
+
display: block;
|
|
46
|
+
max-width: 100%;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ---- Typography ---- */
|
|
50
|
+
h1, h2, h3, h4, h5, h6 {
|
|
51
|
+
font-weight: 700;
|
|
52
|
+
line-height: 1.25;
|
|
53
|
+
color: var(--tessera-text);
|
|
54
|
+
margin-top: var(--tessera-spacing-xl);
|
|
55
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
h1 { font-size: 2rem; }
|
|
59
|
+
h2 { font-size: 1.625rem; }
|
|
60
|
+
h3 { font-size: 1.375rem; }
|
|
61
|
+
h4 { font-size: 1.125rem; }
|
|
62
|
+
h5 { font-size: 1rem; }
|
|
63
|
+
h6 { font-size: 0.875rem; }
|
|
64
|
+
|
|
65
|
+
h1:first-child,
|
|
66
|
+
h2:first-child,
|
|
67
|
+
h3:first-child {
|
|
68
|
+
margin-top: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
p {
|
|
72
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
a {
|
|
76
|
+
color: var(--tessera-primary);
|
|
77
|
+
text-decoration: underline;
|
|
78
|
+
text-decoration-thickness: 1px;
|
|
79
|
+
text-underline-offset: 2px;
|
|
80
|
+
transition: color var(--tessera-transition-fast);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
a:hover {
|
|
84
|
+
color: var(--tessera-primary-dark);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
a:focus-visible {
|
|
88
|
+
outline: none;
|
|
89
|
+
box-shadow: var(--tessera-focus-ring);
|
|
90
|
+
border-radius: 2px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
strong, b {
|
|
94
|
+
font-weight: 700;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
em, i {
|
|
98
|
+
font-style: italic;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ---- Lists ---- */
|
|
102
|
+
ul, ol {
|
|
103
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
104
|
+
padding-left: var(--tessera-spacing-xl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
li {
|
|
108
|
+
margin-bottom: var(--tessera-spacing-sm);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
li > ul,
|
|
112
|
+
li > ol {
|
|
113
|
+
margin-top: var(--tessera-spacing-sm);
|
|
114
|
+
margin-bottom: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ---- Code ---- */
|
|
118
|
+
code {
|
|
119
|
+
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
|
|
120
|
+
font-size: 0.875em;
|
|
121
|
+
background-color: var(--tessera-bg-secondary);
|
|
122
|
+
border: 1px solid var(--tessera-border);
|
|
123
|
+
border-radius: 4px;
|
|
124
|
+
padding: 0.15em 0.4em;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pre {
|
|
128
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
129
|
+
padding: var(--tessera-spacing-md);
|
|
130
|
+
background-color: var(--tessera-bg-secondary);
|
|
131
|
+
border: 1px solid var(--tessera-border);
|
|
132
|
+
border-radius: 8px;
|
|
133
|
+
overflow-x: auto;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pre code {
|
|
137
|
+
background: none;
|
|
138
|
+
border: none;
|
|
139
|
+
padding: 0;
|
|
140
|
+
font-size: 0.875rem;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ---- Blockquote ---- */
|
|
144
|
+
blockquote {
|
|
145
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
146
|
+
padding: var(--tessera-spacing-md) var(--tessera-spacing-lg);
|
|
147
|
+
border-left: 4px solid var(--tessera-primary);
|
|
148
|
+
background-color: var(--tessera-bg-secondary);
|
|
149
|
+
border-radius: 0 8px 8px 0;
|
|
150
|
+
color: var(--tessera-text-light);
|
|
151
|
+
font-style: italic;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
blockquote p:last-child {
|
|
155
|
+
margin-bottom: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---- Horizontal Rule ---- */
|
|
159
|
+
hr {
|
|
160
|
+
border: none;
|
|
161
|
+
border-top: 1px solid var(--tessera-border);
|
|
162
|
+
margin: var(--tessera-spacing-xl) 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ---- Table ---- */
|
|
166
|
+
table {
|
|
167
|
+
width: 100%;
|
|
168
|
+
border-collapse: collapse;
|
|
169
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
170
|
+
font-size: 0.9375rem;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
th, td {
|
|
174
|
+
padding: var(--tessera-spacing-sm) var(--tessera-spacing-md);
|
|
175
|
+
border: 1px solid var(--tessera-border);
|
|
176
|
+
text-align: left;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
th {
|
|
180
|
+
background-color: var(--tessera-bg-secondary);
|
|
181
|
+
font-weight: 600;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ---- Focus Visible ---- */
|
|
185
|
+
:focus-visible {
|
|
186
|
+
outline: none;
|
|
187
|
+
box-shadow: var(--tessera-focus-ring);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ---- Selection ---- */
|
|
191
|
+
::selection {
|
|
192
|
+
background-color: var(--tessera-primary-light);
|
|
193
|
+
color: var(--tessera-text);
|
|
194
|
+
}
|