tessera-learn 0.2.2 → 0.3.0

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 (48) hide show
  1. package/AGENTS.md +161 -535
  2. package/README.md +2 -2
  3. package/dist/{audit-B9VHgVjk.js → audit-DkXqQTqn.js} +92 -38
  4. package/dist/audit-DkXqQTqn.js.map +1 -0
  5. package/dist/{build-commands-D127jw0J.js → build-commands-CyzuCDXg.js} +2 -2
  6. package/dist/{build-commands-D127jw0J.js.map → build-commands-CyzuCDXg.js.map} +1 -1
  7. package/dist/{inline-config-eHjv9XuA.js → inline-config-BEXyRqsJ.js} +2 -2
  8. package/dist/{inline-config-eHjv9XuA.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
  9. package/dist/plugin/cli.d.ts.map +1 -1
  10. package/dist/plugin/cli.js +62 -54
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +280 -3
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +3 -3
  15. package/dist/{plugin--8H9xQIl.js → plugin-CFUFgwHB.js} +126 -83
  16. package/dist/plugin-CFUFgwHB.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/DefaultLayout.svelte +2 -5
  19. package/src/components/Quiz.svelte +18 -26
  20. package/src/plugin/a11y/audit.ts +8 -13
  21. package/src/plugin/a11y-cli.ts +1 -4
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/cli.ts +46 -48
  24. package/src/plugin/csp.ts +59 -0
  25. package/src/plugin/duplicate-cli.ts +37 -1
  26. package/src/plugin/export.ts +56 -27
  27. package/src/plugin/index.ts +117 -61
  28. package/src/plugin/manifest.ts +3 -23
  29. package/src/plugin/new-cli.ts +2 -0
  30. package/src/plugin/validate-cli.ts +10 -4
  31. package/src/plugin/validation.ts +48 -12
  32. package/src/runtime/App.svelte +10 -8
  33. package/src/runtime/Sidebar.svelte +3 -1
  34. package/src/runtime/adapters/cmi5.ts +59 -402
  35. package/src/runtime/adapters/discovery.ts +11 -0
  36. package/src/runtime/adapters/index.ts +27 -60
  37. package/src/runtime/adapters/lms-error.ts +61 -0
  38. package/src/runtime/adapters/scorm2004.ts +2 -1
  39. package/src/runtime/adapters/web.ts +19 -4
  40. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  41. package/src/runtime/adapters/xapi.ts +26 -0
  42. package/src/runtime/types.ts +19 -1
  43. package/src/runtime/xapi/publisher.ts +5 -1
  44. package/src/runtime/xapi/setup.ts +24 -15
  45. package/src/virtual.d.ts +4 -1
  46. package/templates/course/course.config.js +1 -0
  47. package/dist/audit-B9VHgVjk.js.map +0 -1
  48. package/dist/plugin--8H9xQIl.js.map +0 -1
@@ -10,7 +10,11 @@ import {
10
10
  cpSync,
11
11
  mkdirSync,
12
12
  } from 'node:fs';
13
- import { generateManifest, readCourseConfig } from './manifest.js';
13
+ import {
14
+ generateManifest,
15
+ readCourseConfig,
16
+ type CourseConfigRead,
17
+ } from './manifest.js';
14
18
  import type { Manifest } from './manifest.js';
15
19
  import type { CourseConfig } from '../runtime/types.js';
16
20
  import {
@@ -25,6 +29,7 @@ import {
25
29
  isIgnored,
26
30
  type A11ySettings,
27
31
  } from './validation.js';
32
+ import { buildCsp } from './csp.js';
28
33
  import { runExport } from './export.js';
29
34
  import { tesseraLayoutPlugin } from './layout.js';
30
35
  import { tesseraQuizPlugin } from './quiz.js';
@@ -188,9 +193,10 @@ function tesseraEntryPlugin(): Plugin {
188
193
  // For build mode: write index.html so Rollup can find it
189
194
  buildStart() {
190
195
  if (isBuild) {
196
+ const read = readCourseConfig(projectRoot);
191
197
  writeFileSync(
192
198
  resolve(projectRoot, 'index.html'),
193
- generateIndexHtml(readLanguage(projectRoot)),
199
+ generateIndexHtml(readLanguage(read), cspMeta(read)),
194
200
  'utf-8',
195
201
  );
196
202
  }
@@ -221,7 +227,9 @@ function tesseraEntryPlugin(): Plugin {
221
227
  return () => {
222
228
  server.middlewares.use(async (req, res, next) => {
223
229
  if (req.url === '/' || req.url === '/index.html') {
224
- const html = generateIndexHtml(readLanguage(projectRoot));
230
+ const html = generateIndexHtml(
231
+ readLanguage(readCourseConfig(projectRoot)),
232
+ );
225
233
  const transformed = await server.transformIndexHtml(req.url, html);
226
234
  res.setHeader('Content-Type', 'text/html');
227
235
  res.statusCode = 200;
@@ -252,18 +260,35 @@ function tesseraEntryPlugin(): Plugin {
252
260
  // 'en' fallback applied here: the config default-merge runs later than buildStart.
253
261
  // Only a validated BCP-47 tag is interpolated into <html lang>, so a malformed
254
262
  // value (caught separately as a warning) can't ship a broken attribute.
255
- function readLanguage(projectRoot: string): string {
256
- const read = readCourseConfig(projectRoot);
263
+ function readLanguage(read: CourseConfigRead): string {
257
264
  const lang = read.ok ? read.config.language : undefined;
258
265
  return isPlausibleLanguageTag(lang) ? lang : 'en';
259
266
  }
260
267
 
261
- function generateIndexHtml(lang: string): string {
268
+ // Fail closed on an unreadable config: cspMeta only emits for exactly 'web', so
269
+ // an unknown standard withholds the CSP rather than guess the one mode it breaks.
270
+ function readExportStandard(read: CourseConfigRead): string {
271
+ if (!read.ok) return 'unknown';
272
+ return read.config.export?.standard || 'web';
273
+ }
274
+
275
+ // Web export only — never on LMS packages (whose iframe JS bridges a meta CSP
276
+ // could break) and never on the dev server (a meta connect-src would block
277
+ // Vite's HMR websocket). `export.csp` extends the baseline per-directive, or
278
+ // `false` drops the meta for deployments that set a CSP header themselves.
279
+ function cspMeta(read: CourseConfigRead): string {
280
+ if (readExportStandard(read) !== 'web') return '';
281
+ const csp = read.ok ? read.config.export?.csp : undefined;
282
+ if (csp === false) return '';
283
+ return `\n <meta http-equiv="Content-Security-Policy" content="${buildCsp(csp)}" />`;
284
+ }
285
+
286
+ function generateIndexHtml(lang: string, csp = ''): string {
262
287
  return `<!DOCTYPE html>
263
288
  <html lang="${lang}">
264
289
  <head>
265
290
  <meta charset="UTF-8" />
266
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
291
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />${csp}
267
292
  <title>Tessera Course</title>
268
293
  </head>
269
294
  <body>
@@ -326,6 +351,12 @@ function completionDefaults(mode: string | undefined): {
326
351
  if (mode === 'manual') {
327
352
  return { completion: { mode: 'manual' }, passingScore: 0 };
328
353
  }
354
+ if (mode === 'quiz') {
355
+ return {
356
+ completion: { mode: 'quiz' },
357
+ passingScore: DEFAULT_PASSING_SCORE,
358
+ };
359
+ }
329
360
  return {
330
361
  completion: {
331
362
  mode: 'percentage',
@@ -353,6 +384,21 @@ function tesseraConfigDefaultsPlugin(): Plugin {
353
384
  };
354
385
  }
355
386
 
387
+ /** Fill runtime defaults into a parsed course.config.js. Exported for tests. */
388
+ export function mergeCourseConfig(userConfig: Partial<CourseConfig>) {
389
+ const { completion, passingScore } = completionDefaults(
390
+ userConfig.completion?.mode,
391
+ );
392
+ return {
393
+ ...userConfig,
394
+ title: userConfig.title || 'Untitled Course',
395
+ navigation: { mode: 'free', ...userConfig.navigation },
396
+ completion: { ...completion, ...userConfig.completion },
397
+ scoring: { passingScore, ...userConfig.scoring },
398
+ export: { standard: 'web', ...userConfig.export },
399
+ };
400
+ }
401
+
356
402
  function tesseraConfigPlugin(): Plugin {
357
403
  return virtualModule(
358
404
  'tessera:config',
@@ -362,18 +408,7 @@ function tesseraConfigPlugin(): Plugin {
362
408
  if (existsSync(configPath)) this.addWatchFile(configPath);
363
409
  const read = readCourseConfig(projectRoot);
364
410
  const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
365
- const { completion, passingScore } = completionDefaults(
366
- userConfig.completion?.mode,
367
- );
368
- const merged = {
369
- title: userConfig.title || 'Untitled Course',
370
- ...userConfig,
371
- navigation: { mode: 'free', ...userConfig.navigation },
372
- completion: { ...completion, ...userConfig.completion },
373
- scoring: { passingScore, ...userConfig.scoring },
374
- export: { standard: 'web', ...userConfig.export },
375
- };
376
- return `export default ${JSON.stringify(merged)};`;
411
+ return `export default ${JSON.stringify(mergeCourseConfig(userConfig))};`;
377
412
  },
378
413
  );
379
414
  }
@@ -611,7 +646,8 @@ function tesseraManifestPlugin(manifestRef: {
611
646
  value === Infinity ? 1e9 : value,
612
647
  );
613
648
  const b64 = Buffer.from(json).toString('base64');
614
- return `export default JSON.parse(atob("${b64}"));`;
649
+ // atob yields Latin1 bytes; decode through UTF-8 or non-ASCII titles ship as mojibake.
650
+ return `export default JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${b64}"),(c)=>c.charCodeAt(0))));`;
615
651
  }
616
652
  return null;
617
653
  },
@@ -620,6 +656,55 @@ function tesseraManifestPlugin(manifestRef: {
620
656
 
621
657
  const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
622
658
 
659
+ // `takesApi`: SCORM detectors return the API object the constructor needs;
660
+ // cmi5/xAPI ones return a boolean.
661
+ const LMS_ADAPTER_GEN: Record<
662
+ 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi',
663
+ { adapter: string; module: string; detect: string; takesApi: boolean }
664
+ > = {
665
+ scorm12: {
666
+ adapter: 'SCORM12Adapter',
667
+ module: 'scorm12',
668
+ detect: 'findSCORM12API',
669
+ takesApi: true,
670
+ },
671
+ scorm2004: {
672
+ adapter: 'SCORM2004Adapter',
673
+ module: 'scorm2004',
674
+ detect: 'findSCORM2004API',
675
+ takesApi: true,
676
+ },
677
+ cmi5: {
678
+ adapter: 'CMI5Adapter',
679
+ module: 'cmi5',
680
+ detect: 'hasCMI5LaunchParams',
681
+ takesApi: false,
682
+ },
683
+ xapi: {
684
+ adapter: 'XAPIAdapter',
685
+ module: 'xapi',
686
+ detect: 'hasXAPILaunchParams',
687
+ takesApi: false,
688
+ },
689
+ };
690
+
691
+ function generateLmsAdapterModule(
692
+ standard: keyof typeof LMS_ADAPTER_GEN,
693
+ ): string {
694
+ const { adapter, module, detect, takesApi } = LMS_ADAPTER_GEN[standard];
695
+ const guard = takesApi
696
+ ? `const api = ${detect}();\n if (!api) throw missingApiError('${standard}');\n return new ${adapter}(api);`
697
+ : `if (!${detect}()) throw missingApiError('${standard}');\n return new ${adapter}();`;
698
+ return `
699
+ import { ${adapter} } from 'tessera-learn/runtime/adapters/${module}.js';
700
+ import { ${detect} } from 'tessera-learn/runtime/adapters/discovery.js';
701
+ import { missingApiError } from 'tessera-learn/runtime/adapters/lms-error.js';
702
+ export function createAdapter() {
703
+ ${guard}
704
+ }
705
+ `;
706
+ }
707
+
623
708
  function tesseraAdapterPlugin(): Plugin {
624
709
  return virtualModule(
625
710
  'tessera:adapter',
@@ -641,47 +726,17 @@ function tesseraAdapterPlugin(): Plugin {
641
726
  // cmi5 adapters throw when their API is absent, so render with WebAdapter.
642
727
  if (isAuditBuild()) standard = 'web';
643
728
 
644
- switch (standard) {
645
- case 'scorm12':
646
- return `
647
- import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
648
- import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
649
- import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
650
- export function createAdapter() {
651
- const api = findSCORM12API();
652
- 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.');
653
- return new SCORM12Adapter(api);
654
- }
655
- `;
656
- case 'scorm2004':
657
- return `
658
- import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
659
- import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
660
- import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
661
- export function createAdapter() {
662
- const api = findSCORM2004API();
663
- 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.');
664
- return new SCORM2004Adapter(api);
665
- }
666
- `;
667
- case 'cmi5':
668
- return `
669
- import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
670
- import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
671
- import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
672
- export function createAdapter() {
673
- if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
674
- return new CMI5Adapter();
675
- }
676
- `;
677
- default:
678
- return `
729
+ if (standard in LMS_ADAPTER_GEN) {
730
+ return generateLmsAdapterModule(
731
+ standard as keyof typeof LMS_ADAPTER_GEN,
732
+ );
733
+ }
734
+ return `
679
735
  import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
680
- export function createAdapter(config) {
681
- return new WebAdapter(config);
736
+ export function createAdapter(config, options) {
737
+ return new WebAdapter(config, options && options.manifest);
682
738
  }
683
739
  `;
684
- }
685
740
  },
686
741
  );
687
742
  }
@@ -711,9 +766,10 @@ function tesseraXAPISetupPlugin(): Plugin {
711
766
  hasXapi = read.config.xapi != null;
712
767
  }
713
768
 
714
- // cmi5 needs the publisher regardless of explicit xapi config (cmi5
715
- // adapter shares the publisher queue for its own LMS-required statements).
716
- if (hasXapi || standard === 'cmi5') {
769
+ // The launch standards (cmi5, plain xAPI) own a publisher the runtime
770
+ // can share for `endpoint: 'lms'`, so wire the client regardless of
771
+ // explicit xapi config.
772
+ if (hasXapi || standard === 'cmi5' || standard === 'xapi') {
717
773
  return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
718
774
  }
719
775
 
@@ -90,23 +90,7 @@ export function deriveSlug(name: string, isFile = false): string {
90
90
  return stripPrefix(name);
91
91
  }
92
92
 
93
- export type DefaultExportLiteralResult =
94
- | { kind: 'literal'; text: string }
95
- | { kind: 'none' }
96
- | { kind: 'invalid' }
97
- | { kind: 'parse-error' };
98
-
99
- /**
100
- * Locate `export default { ... }` and return its object-literal text. Returns
101
- * a discriminated result so callers can tell parse failure from a missing or
102
- * non-literal default export. Used by both manifest extraction and project
103
- * validation.
104
- */
105
- export function extractDefaultExportObjectLiteral(
106
- source: string,
107
- ): DefaultExportLiteralResult {
108
- return defaultExportObjectLiteral(source);
109
- }
93
+ export { defaultExportObjectLiteral as extractDefaultExportObjectLiteral } from './ast.js';
110
94
 
111
95
  export type CourseConfigRead =
112
96
  | { ok: true; config: Partial<CourseConfig> }
@@ -126,9 +110,7 @@ export type CourseConfigRead =
126
110
  export function readCourseConfig(projectRoot: string): CourseConfigRead {
127
111
  const configPath = resolve(projectRoot, 'course.config.js');
128
112
  if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
129
- const result = extractDefaultExportObjectLiteral(
130
- readSourceFileCached(configPath),
131
- );
113
+ const result = defaultExportObjectLiteral(readSourceFileCached(configPath));
132
114
  if (result.kind === 'parse-error')
133
115
  return { ok: false, reason: 'parse-error' };
134
116
  if (result.kind !== 'literal') return { ok: false, reason: 'no-export' };
@@ -150,9 +132,7 @@ export function readMetaFile(metaPath: string): {
150
132
  } {
151
133
  if (!existsSync(metaPath)) return {};
152
134
 
153
- const result = extractDefaultExportObjectLiteral(
154
- readSourceFileCached(metaPath),
155
- );
135
+ const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
156
136
  if (result.kind !== 'literal') return {};
157
137
 
158
138
  try {
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { join, relative, resolve } from 'node:path';
3
4
  import { findWorkspaceRoot } from './course-root.js';
4
5
  import { validateProjectName, toTitleCase } from './project-name.js';
@@ -43,6 +44,7 @@ export function runNew(name: string | undefined, cwd: string): number {
43
44
  const templateDir = join(resolvePackageRoot(), 'templates', 'course');
44
45
  copyTemplate(templateDir, courseDir, {
45
46
  PROJECT_TITLE: toTitleCase(name),
47
+ COURSE_ID: `urn:uuid:${randomUUID()}`,
46
48
  });
47
49
 
48
50
  const rel = relative(workspaceRoot, courseDir);
@@ -1,6 +1,10 @@
1
+ import { basename } from 'node:path';
1
2
  import { validateProject, reportValidationIssues } from './validation.js';
2
3
 
3
- export function runValidate(projectRoot: string): number {
4
+ export function runValidate(
5
+ projectRoot: string,
6
+ { showA11yTip = true }: { showA11yTip?: boolean } = {},
7
+ ): number {
4
8
  const { errors, warnings } = validateProject(projectRoot);
5
9
 
6
10
  reportValidationIssues({ errors, warnings });
@@ -23,8 +27,10 @@ export function runValidate(projectRoot: string): number {
23
27
  '\x1b[32m[tessera]\x1b[0m Validation passed — no issues found.',
24
28
  );
25
29
  }
26
- console.log(
27
- '\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: pnpm exec tessera a11y\x1b[0m',
28
- );
30
+ if (showA11yTip) {
31
+ console.log(
32
+ `\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: pnpm a11y ${basename(projectRoot)}\x1b[0m`,
33
+ );
34
+ }
29
35
  return 0;
30
36
  }
@@ -24,8 +24,13 @@ import {
24
24
  } from '../runtime/xapi/agent-rules.js';
25
25
  import { httpOrigin } from '../runtime/xapi/derive-actor.js';
26
26
  import { shortIdentifier } from '../runtime/interaction-format.js';
27
- import { FEEDBACK_MODES, RETRY_MODES } from '../runtime/types.js';
27
+ import {
28
+ FEEDBACK_MODES,
29
+ RETRY_MODES,
30
+ courseIdentity,
31
+ } from '../runtime/types.js';
28
32
  import { contrastRatio } from './a11y/contrast.js';
33
+ import { isCspOverrides } from './csp.js';
29
34
  import { isVideoEmbed } from '../components/video-embed.js';
30
35
 
31
36
  // ---------- Types ----------
@@ -143,6 +148,7 @@ export function reportValidationIssues({
143
148
  // Known top-level config fields
144
149
  const KNOWN_CONFIG_FIELDS = new Set([
145
150
  'title',
151
+ 'id',
146
152
  'description',
147
153
  'author',
148
154
  'version',
@@ -168,7 +174,7 @@ export function isPlausibleLanguageTag(value: unknown): value is string {
168
174
 
169
175
  const VALID_NAV_MODES = ['free', 'sequential'];
170
176
  const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
171
- const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
177
+ const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5', 'xapi'];
172
178
  const VALID_MANUAL_TRIGGERS = ['page'];
173
179
  const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
174
180
  // Derived from the runtime types (single source of truth) — widened to
@@ -235,6 +241,7 @@ export function validateProject(projectRoot: string): ValidationResult {
235
241
 
236
242
  interface ParsedConfig {
237
243
  title?: string;
244
+ id?: string;
238
245
  navigation?: { mode?: string };
239
246
  completion?: {
240
247
  mode?: string;
@@ -243,7 +250,7 @@ interface ParsedConfig {
243
250
  requireSuccessStatus?: string;
244
251
  };
245
252
  scoring?: { passingScore?: number };
246
- export?: { standard?: string };
253
+ export?: { standard?: string; csp?: unknown };
247
254
  [key: string]: unknown;
248
255
  }
249
256
 
@@ -316,6 +323,20 @@ function parseConfig(
316
323
  );
317
324
  }
318
325
 
326
+ // Identity matters for web (storage key) and cmi5/xAPI (LRS activity id);
327
+ // SCORM identity is owned by the LMS, so only nudge for the others.
328
+ const standard = config.export?.standard;
329
+ const identityStandard =
330
+ standard === undefined ||
331
+ standard === 'web' ||
332
+ standard === 'cmi5' ||
333
+ standard === 'xapi';
334
+ if (identityStandard && !courseIdentity(config)) {
335
+ warnings.push(
336
+ `course.config.js: no "id" set — the web storage key and cmi5/xAPI activity id then share a fixed fallback that collides across courses. Add a unique id (e.g. "urn:uuid:…"); scaffolded courses include one.`,
337
+ );
338
+ }
339
+
319
340
  // Validate a11y config block
320
341
  if (config.a11y !== undefined) {
321
342
  validateA11yConfig(config.a11y, errors);
@@ -371,7 +392,21 @@ function parseConfig(
371
392
  if (config.export?.standard !== undefined) {
372
393
  if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
373
394
  errors.push(
374
- `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`,
395
+ `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", "cmi5", or "xapi", got "${config.export.standard}"`,
396
+ );
397
+ }
398
+ }
399
+
400
+ // Validate export.csp (web-only CSP extension)
401
+ if (config.export?.csp !== undefined) {
402
+ const csp = config.export.csp;
403
+ if (csp !== false && !isCspOverrides(csp)) {
404
+ warnings.push(
405
+ 'course.config.js: "export.csp" must be false or an object of directive → string[]; ignoring it and using the baseline CSP',
406
+ );
407
+ } else if ((config.export.standard ?? 'web') !== 'web') {
408
+ warnings.push(
409
+ `course.config.js: "export.csp" is ignored when "export.standard" is "${config.export.standard}" (the CSP meta is web-export only)`,
375
410
  );
376
411
  }
377
412
  }
@@ -567,7 +602,7 @@ function validateXAPIConfig(
567
602
  ).length;
568
603
  if (lmsCount > 1) {
569
604
  errors.push(
570
- "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed",
605
+ "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one launch-inherited destination is allowed",
571
606
  );
572
607
  }
573
608
  // Warn on duplicate explicit endpoints.
@@ -630,14 +665,15 @@ function validateSingleXAPIEntry(
630
665
  }
631
666
 
632
667
  if (endpoint === 'lms') {
633
- // Forbid under non-cmi5 export.
634
- if (standard !== 'cmi5') {
668
+ // 'lms' inherits the LRS from the launch — only the launch-based
669
+ // standards (cmi5, plain xAPI) carry one.
670
+ if (standard !== 'cmi5' && standard !== 'xapi') {
635
671
  errors.push(
636
- `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
672
+ `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' or 'xapi' (you have "${standard}"). ` +
637
673
  'Either change the export standard or specify an explicit LRS endpoint.',
638
674
  );
639
675
  }
640
- // Forbid extra fields — everything is inherited from the cmi5 launch.
676
+ // Forbid extra fields — everything is inherited from the launch.
641
677
  const forbidden = [
642
678
  'auth',
643
679
  'actor',
@@ -648,7 +684,7 @@ function validateSingleXAPIEntry(
648
684
  for (const f of forbidden) {
649
685
  if (entry[f] !== undefined) {
650
686
  errors.push(
651
- `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`,
687
+ `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the launch.`,
652
688
  );
653
689
  }
654
690
  }
@@ -764,7 +800,7 @@ function validateSingleXAPIEntry(
764
800
  `course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`,
765
801
  );
766
802
  }
767
- if (standard === 'cmi5' || standard === 'web') {
803
+ if (standard === 'cmi5' || standard === 'xapi' || standard === 'web') {
768
804
  warnings.push(
769
805
  `course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`,
770
806
  );
@@ -794,7 +830,7 @@ function validateSingleXAPIEntry(
794
830
  `course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`,
795
831
  );
796
832
  }
797
- if (standard !== 'cmi5') {
833
+ if (standard !== 'cmi5' && standard !== 'xapi') {
798
834
  warnings.push(
799
835
  `course.config.js: ${label}.registration is a cmi5 concept; the LRS will accept it under "${standard}" but most analytics tools won't know what to do with it.`,
800
836
  );
@@ -24,7 +24,7 @@
24
24
  } from './contexts.js';
25
25
 
26
26
  // ---- Persistence ----
27
- const adapter = createAdapter(config);
27
+ const adapter = createAdapter(config, { manifest });
28
28
  let persistenceReady = $state(false);
29
29
  // Holds the resolved xAPI client for unload-time markUnloading. Set
30
30
  // after adapter.init() resolves and registered globally so useXAPI()
@@ -209,10 +209,12 @@
209
209
  v: [...progress.visitedPages],
210
210
  q,
211
211
  d: duration.totalSeconds,
212
- c,
213
- s,
214
- gs: [...progress.gradedStandalonePages],
215
- u: { ...userState },
212
+ ...(progress.chunkProgress.size > 0 ? { c } : {}),
213
+ ...(progress.standaloneQuestionScores.size > 0 ? { s } : {}),
214
+ ...(progress.gradedStandalonePages.size > 0
215
+ ? { gs: [...progress.gradedStandalonePages] }
216
+ : {}),
217
+ ...(Object.keys(userState).length > 0 ? { u: { ...userState } } : {}),
216
218
  ...(progress.manuallyCompleted ? { m: 1 } : {}),
217
219
  };
218
220
  }
@@ -297,7 +299,7 @@
297
299
 
298
300
  // ---- Persistence: report score/completion/success to adapter ----
299
301
  // These are no-ops for WebAdapter but used by LMS adapters (Step 10)
300
- let prevReportedScore = $state(null);
302
+ let prevReportedScore = null;
301
303
  $effect(() => {
302
304
  void progress.version;
303
305
  if (!persistenceReady) return;
@@ -322,7 +324,7 @@
322
324
  });
323
325
  });
324
326
 
325
- let prevCompletionStatus = $state('incomplete');
327
+ let prevCompletionStatus = 'incomplete';
326
328
  $effect(() => {
327
329
  const status = progress.completionStatus;
328
330
  if (!persistenceReady) return;
@@ -335,7 +337,7 @@
335
337
  });
336
338
  });
337
339
 
338
- let prevSuccessStatus = $state('unknown');
340
+ let prevSuccessStatus = 'unknown';
339
341
  $effect(() => {
340
342
  const status = progress.successStatus;
341
343
  if (!persistenceReady) return;
@@ -1,9 +1,11 @@
1
1
  <script>
2
+ import { SvelteSet } from 'svelte/reactivity';
3
+
2
4
  let { manifest, config, currentPageIndex, nav, onnavigate, onclose } =
3
5
  $props();
4
6
 
5
7
  // Track which sections are collapsed. All expanded by default.
6
- let collapsedSections = $state(new Set());
8
+ const collapsedSections = new SvelteSet();
7
9
 
8
10
  function toggleSection(slug) {
9
11
  if (collapsedSections.has(slug)) {