tessera-learn 0.2.3 → 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 (45) hide show
  1. package/AGENTS.md +44 -20
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DkXqQTqn.js} +84 -27
  4. package/dist/audit-DkXqQTqn.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-CyzuCDXg.js} +2 -2
  6. package/dist/{build-commands-Qyrlsp3n.js.map → build-commands-CyzuCDXg.js.map} +1 -1
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-BEXyRqsJ.js} +2 -2
  8. package/dist/{inline-config-DqAKsCNl.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 +57 -46
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +280 -1
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +3 -3
  15. package/dist/{plugin-B-aiL9-V.js → plugin-CFUFgwHB.js} +126 -83
  16. package/dist/plugin-CFUFgwHB.js.map +1 -0
  17. package/package.json +7 -7
  18. package/src/components/DefaultLayout.svelte +2 -5
  19. package/src/components/Quiz.svelte +18 -26
  20. package/src/plugin/ast.ts +9 -2
  21. package/src/plugin/cli.ts +45 -46
  22. package/src/plugin/csp.ts +59 -0
  23. package/src/plugin/duplicate-cli.ts +37 -1
  24. package/src/plugin/export.ts +56 -27
  25. package/src/plugin/index.ts +117 -61
  26. package/src/plugin/manifest.ts +3 -23
  27. package/src/plugin/new-cli.ts +2 -0
  28. package/src/plugin/validation.ts +48 -12
  29. package/src/runtime/App.svelte +10 -8
  30. package/src/runtime/Sidebar.svelte +3 -1
  31. package/src/runtime/adapters/cmi5.ts +59 -402
  32. package/src/runtime/adapters/discovery.ts +11 -0
  33. package/src/runtime/adapters/index.ts +27 -60
  34. package/src/runtime/adapters/lms-error.ts +61 -0
  35. package/src/runtime/adapters/scorm2004.ts +2 -1
  36. package/src/runtime/adapters/web.ts +19 -4
  37. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  38. package/src/runtime/adapters/xapi.ts +26 -0
  39. package/src/runtime/types.ts +19 -1
  40. package/src/runtime/xapi/publisher.ts +5 -1
  41. package/src/runtime/xapi/setup.ts +24 -15
  42. package/src/virtual.d.ts +4 -1
  43. package/templates/course/course.config.js +1 -0
  44. package/dist/audit--fSWIOgK.js.map +0 -1
  45. package/dist/plugin-B-aiL9-V.js.map +0 -1
package/src/plugin/cli.ts CHANGED
@@ -34,6 +34,27 @@ export function splitCourseArg(rest: string[]): {
34
34
  return { course: undefined, flags: rest };
35
35
  }
36
36
 
37
+ type CourseCommand = (
38
+ courseRoot: string,
39
+ workspaceRoot: string,
40
+ flags: string[],
41
+ ) => number | Promise<number>;
42
+
43
+ const COURSE_COMMANDS: Record<string, CourseCommand> = {
44
+ dev: async (courseRoot, workspaceRoot) =>
45
+ (await import('./build-commands.js')).runDev(courseRoot, workspaceRoot),
46
+ export: async (courseRoot, workspaceRoot) =>
47
+ (await import('./build-commands.js')).runBuild(courseRoot, workspaceRoot),
48
+ validate: (courseRoot) => runValidate(courseRoot),
49
+ a11y: (courseRoot, workspaceRoot, flags) =>
50
+ runA11y(courseRoot, workspaceRoot, flags),
51
+ check: (courseRoot, workspaceRoot, flags) => {
52
+ const validateCode = runValidate(courseRoot, { showA11yTip: false });
53
+ if (validateCode !== 0) return validateCode;
54
+ return runA11y(courseRoot, workspaceRoot, flags);
55
+ },
56
+ };
57
+
37
58
  export async function main(
38
59
  argv: string[],
39
60
  cwd: string = process.cwd(),
@@ -43,56 +64,34 @@ export async function main(
43
64
  if (sub === 'new') return runNew(rest[0], cwd);
44
65
  if (sub === 'duplicate') return runDuplicate(rest[0], rest[1], cwd);
45
66
 
46
- switch (sub) {
47
- case 'dev':
48
- case 'export':
49
- case 'validate':
50
- case 'a11y':
51
- case 'check': {
52
- if (rest.includes('--help') || rest.includes('-h')) {
53
- console.log(USAGE);
54
- return 0;
55
- }
56
- const { course, flags } = splitCourseArg(rest);
57
- const resolved = resolveCourse(cwd, course);
58
- if (!resolved.ok) {
59
- console.error(`[tessera] ${resolved.error}`);
60
- return 1;
61
- }
62
- const { courseRoot, workspaceRoot } = resolved;
63
-
64
- switch (sub) {
65
- case 'dev': {
66
- const { runDev } = await import('./build-commands.js');
67
- return runDev(courseRoot, workspaceRoot);
68
- }
69
- case 'export': {
70
- const { runBuild } = await import('./build-commands.js');
71
- return runBuild(courseRoot, workspaceRoot);
72
- }
73
- case 'validate':
74
- return runValidate(courseRoot);
75
- case 'check': {
76
- const validateCode = runValidate(courseRoot, { showA11yTip: false });
77
- if (validateCode !== 0) return validateCode;
78
- return runA11y(courseRoot, workspaceRoot, flags);
79
- }
80
- case 'a11y':
81
- return runA11y(courseRoot, workspaceRoot, flags);
82
- }
83
- return 0;
84
- }
85
- case '--help':
86
- case '-h':
67
+ if (sub !== undefined && Object.hasOwn(COURSE_COMMANDS, sub)) {
68
+ if (rest.includes('--help') || rest.includes('-h')) {
87
69
  console.log(USAGE);
88
70
  return 0;
89
- case undefined:
90
- console.error(`No command given.\n\n${USAGE}`);
91
- return 1;
92
- default:
93
- console.error(`Unknown command: ${sub}\n\n${USAGE}`);
71
+ }
72
+ const { course, flags } = splitCourseArg(rest);
73
+ const resolved = resolveCourse(cwd, course);
74
+ if (!resolved.ok) {
75
+ console.error(`[tessera] ${resolved.error}`);
94
76
  return 1;
77
+ }
78
+ return COURSE_COMMANDS[sub](
79
+ resolved.courseRoot,
80
+ resolved.workspaceRoot,
81
+ flags,
82
+ );
83
+ }
84
+
85
+ if (sub === '--help' || sub === '-h') {
86
+ console.log(USAGE);
87
+ return 0;
88
+ }
89
+ if (sub === undefined) {
90
+ console.error(`No command given.\n\n${USAGE}`);
91
+ return 1;
95
92
  }
93
+ console.error(`Unknown command: ${sub}\n\n${USAGE}`);
94
+ return 1;
96
95
  }
97
96
 
98
97
  // import.meta.main is true only when this module is the program entry point,
@@ -0,0 +1,59 @@
1
+ // Baseline Content-Security-Policy for web exports, as a per-directive object so
2
+ // course.config.js can extend individual directives (union, never replace).
3
+ // 'unsafe-inline' stays because Vite injects an inline modulepreload polyfill and
4
+ // Svelte ships scoped <style>. blob: on frame/worker-src needs prior script
5
+ // execution, so it adds no attacker capability; data: is intentionally absent
6
+ // from frame-src (a classic XSS-escalation vector).
7
+ export const WEB_CSP_BASELINE: Record<string, string[]> = {
8
+ 'default-src': ["'self'"],
9
+ 'img-src': ["'self'", 'data:', 'https:'],
10
+ 'media-src': ["'self'", 'blob:', 'data:', 'https:'],
11
+ 'style-src': ["'self'", "'unsafe-inline'"],
12
+ 'script-src': ["'self'", "'unsafe-inline'"],
13
+ 'font-src': ["'self'", 'data:'],
14
+ 'connect-src': ["'self'", 'https:'],
15
+ 'frame-src': ["'self'", 'blob:', 'https:'],
16
+ 'worker-src': ["'self'", 'blob:'],
17
+ 'object-src': ["'none'"],
18
+ 'base-uri': ["'self'"],
19
+ };
20
+
21
+ // Reject the separators (whitespace ends a source, ';' ends a directive, ','
22
+ // ends a policy) so a stray char can't inject directives when sources are joined,
23
+ // plus " < > so a source can't break out of the content="..." meta attribute.
24
+ const CSP_DIRECTIVE = /^[a-zA-Z][a-zA-Z-]*$/;
25
+ const CSP_SOURCE = /^[^\s;,"<>]+$/;
26
+
27
+ export function isCspOverrides(v: unknown): v is Record<string, string[]> {
28
+ return (
29
+ typeof v === 'object' &&
30
+ v !== null &&
31
+ !Array.isArray(v) &&
32
+ Object.entries(v).every(
33
+ ([directive, sources]) =>
34
+ CSP_DIRECTIVE.test(directive) &&
35
+ Array.isArray(sources) &&
36
+ sources.every((s) => typeof s === 'string' && CSP_SOURCE.test(s)),
37
+ )
38
+ );
39
+ }
40
+
41
+ // Malformed input falls back to the baseline unchanged — validation surfaces the
42
+ // warning separately.
43
+ export function buildCsp(overrides?: unknown): string {
44
+ const merged = new Map(
45
+ Object.entries(WEB_CSP_BASELINE).map(([k, v]) => [k, [...v]]),
46
+ );
47
+ if (isCspOverrides(overrides)) {
48
+ for (const [directive, sources] of Object.entries(overrides)) {
49
+ const existing = merged.get(directive) ?? [];
50
+ for (const src of sources) {
51
+ if (!existing.includes(src)) existing.push(src);
52
+ }
53
+ merged.set(directive, existing);
54
+ }
55
+ }
56
+ return [...merged]
57
+ .map(([directive, sources]) => `${directive} ${sources.join(' ')}`)
58
+ .join('; ');
59
+ }
@@ -1,8 +1,43 @@
1
- import { cpSync, existsSync } from 'node:fs';
1
+ import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { basename, join, relative } from 'node:path';
3
4
  import { resolveCourse } from './course-root.js';
4
5
  import { validateProjectName } from './project-name.js';
5
6
 
7
+ // The top-level `id` property as scaffolders write it: the key (bare or quoted)
8
+ // first on its own line, then its value. Anchoring to line start keeps `id:`
9
+ // inside a comment or a string value from matching, and CourseConfig has no
10
+ // nested `id`, so the first match is always the course identity.
11
+ const ID_LINE =
12
+ /^([ \t]*'?id'?[ \t]*:[ \t]*)('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|[^\n,}]*)/m;
13
+ const DEFAULT_OBJECT = /export\s+default\s*\{/;
14
+
15
+ // A verbatim copy inherits the source's `id`; mint a fresh one so the duplicate
16
+ // is a distinct course (own storage key + LRS activity id).
17
+ function reidentifyCourse(courseRoot: string): void {
18
+ const configPath = join(courseRoot, 'course.config.js');
19
+ if (!existsSync(configPath)) return;
20
+ const text = readFileSync(configPath, 'utf-8');
21
+ const newId = `'urn:uuid:${randomUUID()}'`;
22
+
23
+ if (ID_LINE.test(text)) {
24
+ writeFileSync(configPath, text.replace(ID_LINE, `$1${newId}`));
25
+ return;
26
+ }
27
+ const obj = DEFAULT_OBJECT.exec(text);
28
+ if (obj) {
29
+ const at = obj.index + obj[0].length;
30
+ writeFileSync(
31
+ configPath,
32
+ `${text.slice(0, at)}\n id: ${newId},${text.slice(at)}`,
33
+ );
34
+ return;
35
+ }
36
+ console.warn(
37
+ `[tessera duplicate] Could not set a unique id in ${configPath} — the copy shares the source's identity. Add a unique "id" manually.`,
38
+ );
39
+ }
40
+
6
41
  const HELP =
7
42
  'Usage: tessera duplicate <source> <new>\n\n' +
8
43
  'Copy courses/<source>/ to courses/<new>/ within the current workspace.';
@@ -62,6 +97,7 @@ export function runDuplicate(
62
97
  recursive: true,
63
98
  filter: (src) => src === srcDir || !skip(src),
64
99
  });
100
+ reidentifyCourse(destDir);
65
101
 
66
102
  const rel = relative(workspaceRoot, destDir);
67
103
  const srcRel = relative(workspaceRoot, srcDir);
@@ -10,11 +10,13 @@ import { createWriteStream } from 'node:fs';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { ZipArchive } from 'archiver';
12
12
  import { slugify } from '../runtime/slugify.js';
13
+ import { courseIdentity } from '../runtime/types.js';
13
14
 
14
15
  // ---------- Types ----------
15
16
 
16
17
  interface ExportConfig {
17
18
  title: string;
19
+ id?: string;
18
20
  description?: string;
19
21
  version?: string;
20
22
  scoring?: { passingScore?: number };
@@ -24,6 +26,8 @@ interface ExportConfig {
24
26
 
25
27
  // ---------- Helpers ----------
26
28
 
29
+ const UNTITLED_TITLE = 'Untitled Course';
30
+
27
31
  function escapeXml(str: string): string {
28
32
  return str
29
33
  .replace(/&/g, '&amp;')
@@ -40,11 +44,14 @@ function collectFiles(dir: string, base: string = ''): string[] {
40
44
  const files: string[] = [];
41
45
  if (!existsSync(dir)) return files;
42
46
 
43
- for (const entry of readdirSync(dir)) {
44
- const fullPath = resolve(dir, entry);
45
- const relPath = base ? `${base}/${entry}` : entry;
46
- if (statSync(fullPath).isDirectory()) {
47
- files.push(...collectFiles(fullPath, relPath));
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ const relPath = base ? `${base}/${entry.name}` : entry.name;
49
+ // Dirent is lstat-based; stat symlinks so a symlinked dir still recurses.
50
+ const isDir = entry.isSymbolicLink()
51
+ ? statSync(resolve(dir, entry.name)).isDirectory()
52
+ : entry.isDirectory();
53
+ if (isDir) {
54
+ files.push(...collectFiles(resolve(dir, entry.name), relPath));
48
55
  } else {
49
56
  files.push(relPath);
50
57
  }
@@ -68,6 +75,13 @@ function stableUrn(kind: 'course' | 'au', seed: string): string {
68
75
  return `urn:tessera:${kind}:${h.slice(0, 32)}`;
69
76
  }
70
77
 
78
+ // AU activity id, derived from the course id so re-exports don't orphan LRS
79
+ // records. Shared by the cmi5 and tincan manifests.
80
+ function auIdFor(config: ExportConfig): string {
81
+ const id = courseIdentity(config);
82
+ return stableUrn('au', id ? `${id}#au` : 'tessera-au');
83
+ }
84
+
71
85
  function formatSize(bytes: number): string {
72
86
  if (bytes < 1024) return `${bytes} B`;
73
87
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -115,7 +129,7 @@ export function generateScormManifest(
115
129
  distDir: string,
116
130
  ): string {
117
131
  const dialect = SCORM_DIALECTS[version];
118
- const title = escapeXml(config.title || 'Tessera Course');
132
+ const title = escapeXml(config.title || UNTITLED_TITLE);
119
133
  const files = collectFiles(distDir);
120
134
  const fileElements = files
121
135
  .map((f) => ` <file href="${escapeXml(f)}" />`)
@@ -147,27 +161,16 @@ ${fileElements}
147
161
  </manifest>`;
148
162
  }
149
163
 
150
- export function generateSCORM12Manifest(
151
- config: ExportConfig,
152
- distDir: string,
153
- ): string {
154
- return generateScormManifest('1.2', config, distDir);
155
- }
156
-
157
- export function generateSCORM2004Manifest(
158
- config: ExportConfig,
159
- distDir: string,
160
- ): string {
161
- return generateScormManifest('2004', config, distDir);
162
- }
163
-
164
164
  export function generateCMI5Xml(config: ExportConfig): string {
165
- const title = escapeXml(config.title || 'Tessera Course');
165
+ const title = escapeXml(config.title || UNTITLED_TITLE);
166
166
  const description = escapeXml(config.description || '');
167
- // Derive stable IDs from the course title so they survive rebuilds without
167
+ // Derive stable IDs from the course id so they survive rebuilds without
168
168
  // orphaning existing learner records in the LRS.
169
- const courseId = stableUrn('course', `tessera-course:${config.title || ''}`);
170
- const auId = stableUrn('au', `tessera-au:${config.title || ''}`);
169
+ const courseId = stableUrn(
170
+ 'course',
171
+ courseIdentity(config) || 'tessera-course',
172
+ );
173
+ const auId = auIdFor(config);
171
174
  // cmi5 §10.2.4 caps masteryScore at 4 decimals; avoid float drift like 0.7000000000000001.
172
175
  const masteryScore = Number(
173
176
  ((config.scoring?.passingScore ?? 70) / 100).toFixed(4),
@@ -195,6 +198,25 @@ export function generateCMI5Xml(config: ExportConfig): string {
195
198
  </courseStructure>`;
196
199
  }
197
200
 
201
+ export function generateTincanXml(config: ExportConfig): string {
202
+ const title = escapeXml(config.title || UNTITLED_TITLE);
203
+ const description = escapeXml(config.description || '');
204
+ // Reuse the cmi5/SCORM stable-id scheme so re-exports don't orphan LRS records.
205
+ const auId = auIdFor(config);
206
+ // tincan.xml carries NO xAPI version — the version is set at runtime by the
207
+ // adapter's X-Experience-API-Version header, not declared in the manifest.
208
+ return `<?xml version="1.0" encoding="UTF-8"?>
209
+ <tincan xmlns="http://projecttincan.com/tincan.xsd">
210
+ <activities>
211
+ <activity id="${auId}" type="http://adlnet.gov/expapi/activities/course">
212
+ <name>${title}</name>
213
+ <description lang="en-US">${description}</description>
214
+ <launch lang="en-US">index.html</launch>
215
+ </activity>
216
+ </activities>
217
+ </tincan>`;
218
+ }
219
+
198
220
  // ---------- ZIP Packaging ----------
199
221
 
200
222
  export async function createZip(
@@ -238,7 +260,7 @@ function cleanOldZips(projectRoot: string, slug: string): void {
238
260
 
239
261
  /** Packaged (zipped) export targets: which manifest file to write and how. */
240
262
  const PACKAGED_EXPORTS: Record<
241
- 'scorm12' | 'scorm2004' | 'cmi5',
263
+ 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi',
242
264
  {
243
265
  manifestFile: string;
244
266
  label: string;
@@ -248,18 +270,25 @@ const PACKAGED_EXPORTS: Record<
248
270
  scorm12: {
249
271
  manifestFile: 'imsmanifest.xml',
250
272
  label: 'SCORM 1.2',
251
- generate: generateSCORM12Manifest,
273
+ generate: (config, distDir) =>
274
+ generateScormManifest('1.2', config, distDir),
252
275
  },
253
276
  scorm2004: {
254
277
  manifestFile: 'imsmanifest.xml',
255
278
  label: 'SCORM 2004',
256
- generate: generateSCORM2004Manifest,
279
+ generate: (config, distDir) =>
280
+ generateScormManifest('2004', config, distDir),
257
281
  },
258
282
  cmi5: {
259
283
  manifestFile: 'cmi5.xml',
260
284
  label: 'CMI5',
261
285
  generate: (config) => generateCMI5Xml(config),
262
286
  },
287
+ xapi: {
288
+ manifestFile: 'tincan.xml',
289
+ label: 'xAPI 1.0.3',
290
+ generate: (config) => generateTincanXml(config),
291
+ },
263
292
  };
264
293
 
265
294
  export async function runExport(
@@ -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