tessera-learn 0.2.3 → 0.4.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 (57) hide show
  1. package/AGENTS.md +50 -21
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
  8. package/dist/inline-config-DVvOCKht.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +91 -49
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +287 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +3 -3
  16. package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +7 -7
  19. package/src/components/DefaultLayout.svelte +2 -5
  20. package/src/components/MultipleChoice.svelte +1 -2
  21. package/src/components/Quiz.svelte +18 -26
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/build-commands.ts +7 -4
  24. package/src/plugin/cli.ts +96 -46
  25. package/src/plugin/csp.ts +59 -0
  26. package/src/plugin/duplicate-cli.ts +37 -1
  27. package/src/plugin/export.ts +56 -27
  28. package/src/plugin/index.ts +138 -93
  29. package/src/plugin/inline-config.ts +4 -2
  30. package/src/plugin/manifest.ts +24 -23
  31. package/src/plugin/new-cli.ts +2 -0
  32. package/src/plugin/validate-cli.ts +5 -2
  33. package/src/plugin/validation.ts +255 -238
  34. package/src/runtime/App.svelte +14 -9
  35. package/src/runtime/Sidebar.svelte +3 -1
  36. package/src/runtime/adapters/cmi5.ts +59 -402
  37. package/src/runtime/adapters/discovery.ts +11 -0
  38. package/src/runtime/adapters/index.ts +27 -60
  39. package/src/runtime/adapters/lms-error.ts +61 -0
  40. package/src/runtime/adapters/scorm-base.ts +15 -14
  41. package/src/runtime/adapters/scorm12.ts +6 -25
  42. package/src/runtime/adapters/scorm2004.ts +12 -54
  43. package/src/runtime/adapters/web.ts +11 -4
  44. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  45. package/src/runtime/adapters/xapi.ts +26 -0
  46. package/src/runtime/fingerprint.ts +28 -0
  47. package/src/runtime/interaction-format.ts +0 -1
  48. package/src/runtime/persistence.ts +4 -0
  49. package/src/runtime/types.ts +22 -1
  50. package/src/runtime/xapi/publisher.ts +16 -15
  51. package/src/runtime/xapi/setup.ts +24 -15
  52. package/src/virtual.d.ts +4 -1
  53. package/templates/course/course.config.js +1 -0
  54. package/dist/audit--fSWIOgK.js.map +0 -1
  55. package/dist/build-commands-Qyrlsp3n.js.map +0 -1
  56. package/dist/inline-config-DqAKsCNl.js.map +0 -1
  57. package/dist/plugin-B-aiL9-V.js.map +0 -1
@@ -62,8 +62,7 @@
62
62
  function getOptionClass(optIndex) {
63
63
  if (!q.feedbackVisible) return '';
64
64
  if (isCorrectOption(optIndex)) return 'correct';
65
- if (optIndex === selectedOption && !isCorrectOption(optIndex))
66
- return 'incorrect';
65
+ if (optIndex === selectedOption) return 'incorrect';
67
66
  return '';
68
67
  }
69
68
  </script>
@@ -81,6 +81,22 @@
81
81
  }
82
82
  </script>
83
83
 
84
+ {#snippet questionList(activeIndex)}
85
+ <div class="tessera-quiz-questions">
86
+ {#each handle.questions as q, i (q.id)}
87
+ <div
88
+ class="tessera-quiz-question-wrapper"
89
+ class:active={i === activeIndex}
90
+ aria-hidden={i !== activeIndex}
91
+ >
92
+ {#if q.render}
93
+ {@render q.render()}
94
+ {/if}
95
+ </div>
96
+ {/each}
97
+ </div>
98
+ {/snippet}
99
+
84
100
  <div
85
101
  class="tessera-quiz"
86
102
  bind:this={quizElement}
@@ -108,19 +124,7 @@
108
124
  </div>
109
125
  </div>
110
126
 
111
- <div class="tessera-quiz-questions">
112
- {#each handle.questions as q, i (q.id)}
113
- <div
114
- class="tessera-quiz-question-wrapper"
115
- class:active={i === currentQuestionIndex}
116
- aria-hidden={i !== currentQuestionIndex}
117
- >
118
- {#if q.render}
119
- {@render q.render()}
120
- {/if}
121
- </div>
122
- {/each}
123
- </div>
127
+ {@render questionList(currentQuestionIndex)}
124
128
 
125
129
  <div class="tessera-quiz-nav">
126
130
  <button
@@ -170,19 +174,7 @@
170
174
  </span>
171
175
  </div>
172
176
 
173
- <div class="tessera-quiz-questions">
174
- {#each handle.questions as q, i (q.id)}
175
- <div
176
- class="tessera-quiz-question-wrapper"
177
- class:active={i === reviewIndex}
178
- aria-hidden={i !== reviewIndex}
179
- >
180
- {#if q.render}
181
- {@render q.render()}
182
- {/if}
183
- </div>
184
- {/each}
185
- </div>
177
+ {@render questionList(reviewIndex)}
186
178
 
187
179
  <div class="tessera-quiz-nav">
188
180
  <button
package/src/plugin/ast.ts CHANGED
@@ -41,10 +41,12 @@ interface CacheEntry {
41
41
  }
42
42
 
43
43
  const rootCache = new Map<string, CacheEntry>();
44
+ const jsModuleCache = new Map<string, Node | null>();
44
45
 
45
46
  /** Drop every cached root. Call at the start of a run to scope the cache. */
46
47
  export function clearParseCache(): void {
47
48
  rootCache.clear();
49
+ jsModuleCache.clear();
48
50
  }
49
51
 
50
52
  function parseRoot(source: string): CacheEntry {
@@ -168,14 +170,19 @@ const TsParser = Parser.extend(
168
170
  );
169
171
 
170
172
  function parseJsModule(source: string): Node | null {
173
+ const cached = jsModuleCache.get(source);
174
+ if (cached !== undefined) return cached;
175
+ let result: Node | null;
171
176
  try {
172
- return TsParser.parse(source, {
177
+ result = TsParser.parse(source, {
173
178
  ecmaVersion: 'latest',
174
179
  sourceType: 'module',
175
180
  }) as unknown as Node;
176
181
  } catch {
177
- return null;
182
+ result = null;
178
183
  }
184
+ jsModuleCache.set(source, result);
185
+ return result;
179
186
  }
180
187
 
181
188
  function unwrapTsCast(node: Node | null): Node | null {
@@ -20,12 +20,15 @@ export async function runDev(
20
20
  export async function runBuild(
21
21
  projectRoot: string,
22
22
  workspaceRoot: string,
23
+ standardOverride?: string,
23
24
  ): Promise<number> {
24
25
  const vite = await import('vite');
25
- const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
26
- command: 'build',
27
- mode: 'production',
28
- });
26
+ const config = await resolveTesseraConfig(
27
+ projectRoot,
28
+ workspaceRoot,
29
+ { command: 'build', mode: 'production' },
30
+ standardOverride,
31
+ );
29
32
  await vite.build(config);
30
33
  return 0;
31
34
  }
package/src/plugin/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { runA11y } from './a11y-cli.js';
4
4
  import { runNew } from './new-cli.js';
5
5
  import { runDuplicate } from './duplicate-cli.js';
6
6
  import { resolveCourse } from './course-root.js';
7
+ import { VALID_EXPORT_STANDARDS } from './validation.js';
7
8
 
8
9
  const USAGE = `Usage: tessera <command> [course] [options]
9
10
 
@@ -18,9 +19,42 @@ Commands:
18
19
 
19
20
  Run a command from inside a course folder, or name the course explicitly.
20
21
 
22
+ export/validate options:
23
+ --standard <web|scorm12|scorm2004|cmi5|xapi> Override course.config.js export.standard
24
+
21
25
  a11y/check options:
22
26
  --threshold <minor|moderate|serious|critical> Failing impact (default: serious)`;
23
27
 
28
+ // Validate here, against the config validator's list, so an unknown standard
29
+ // fails before Vite spins up.
30
+ export function parseExportFlags(flags: string[]): {
31
+ standardOverride?: string;
32
+ error?: string;
33
+ } {
34
+ let standardOverride: string | undefined;
35
+ for (let i = 0; i < flags.length; i++) {
36
+ const arg = flags[i];
37
+ let value: string | undefined;
38
+ if (arg === '--standard') {
39
+ value = flags[++i];
40
+ } else if (arg.startsWith('--standard=')) {
41
+ value = arg.slice('--standard='.length);
42
+ } else {
43
+ return { error: `Unknown argument: ${arg}` };
44
+ }
45
+ if (value === undefined || value.startsWith('-')) {
46
+ return { error: '--standard requires a value' };
47
+ }
48
+ if (!VALID_EXPORT_STANDARDS.includes(value)) {
49
+ return {
50
+ error: `--standard must be one of ${VALID_EXPORT_STANDARDS.join(', ')}, got "${value}"`,
51
+ };
52
+ }
53
+ standardOverride = value;
54
+ }
55
+ return standardOverride ? { standardOverride } : {};
56
+ }
57
+
24
58
  // The course is a leading positional: `tessera <cmd> [course] [flags]`. Only the
25
59
  // first token can be the course, and only when it isn't a flag — otherwise a flag
26
60
  // value (e.g. the `serious` in `--threshold serious`) would be misread as a name.
@@ -34,6 +68,44 @@ export function splitCourseArg(rest: string[]): {
34
68
  return { course: undefined, flags: rest };
35
69
  }
36
70
 
71
+ type CourseCommand = (
72
+ courseRoot: string,
73
+ workspaceRoot: string,
74
+ flags: string[],
75
+ ) => number | Promise<number>;
76
+
77
+ const COURSE_COMMANDS: Record<string, CourseCommand> = {
78
+ dev: async (courseRoot, workspaceRoot) =>
79
+ (await import('./build-commands.js')).runDev(courseRoot, workspaceRoot),
80
+ export: async (courseRoot, workspaceRoot, flags) => {
81
+ const { standardOverride, error } = parseExportFlags(flags);
82
+ if (error) {
83
+ console.error(`[tessera] ${error}`);
84
+ return 1;
85
+ }
86
+ return (await import('./build-commands.js')).runBuild(
87
+ courseRoot,
88
+ workspaceRoot,
89
+ standardOverride,
90
+ );
91
+ },
92
+ validate: (courseRoot, _workspaceRoot, flags) => {
93
+ const { standardOverride, error } = parseExportFlags(flags);
94
+ if (error) {
95
+ console.error(`[tessera] ${error}`);
96
+ return 1;
97
+ }
98
+ return runValidate(courseRoot, { standardOverride });
99
+ },
100
+ a11y: (courseRoot, workspaceRoot, flags) =>
101
+ runA11y(courseRoot, workspaceRoot, flags),
102
+ check: (courseRoot, workspaceRoot, flags) => {
103
+ const validateCode = runValidate(courseRoot, { showA11yTip: false });
104
+ if (validateCode !== 0) return validateCode;
105
+ return runA11y(courseRoot, workspaceRoot, flags);
106
+ },
107
+ };
108
+
37
109
  export async function main(
38
110
  argv: string[],
39
111
  cwd: string = process.cwd(),
@@ -43,56 +115,34 @@ export async function main(
43
115
  if (sub === 'new') return runNew(rest[0], cwd);
44
116
  if (sub === 'duplicate') return runDuplicate(rest[0], rest[1], cwd);
45
117
 
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':
118
+ if (sub !== undefined && Object.hasOwn(COURSE_COMMANDS, sub)) {
119
+ if (rest.includes('--help') || rest.includes('-h')) {
87
120
  console.log(USAGE);
88
121
  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}`);
122
+ }
123
+ const { course, flags } = splitCourseArg(rest);
124
+ const resolved = resolveCourse(cwd, course);
125
+ if (!resolved.ok) {
126
+ console.error(`[tessera] ${resolved.error}`);
94
127
  return 1;
128
+ }
129
+ return COURSE_COMMANDS[sub](
130
+ resolved.courseRoot,
131
+ resolved.workspaceRoot,
132
+ flags,
133
+ );
134
+ }
135
+
136
+ if (sub === '--help' || sub === '-h') {
137
+ console.log(USAGE);
138
+ return 0;
139
+ }
140
+ if (sub === undefined) {
141
+ console.error(`No command given.\n\n${USAGE}`);
142
+ return 1;
95
143
  }
144
+ console.error(`Unknown command: ${sub}\n\n${USAGE}`);
145
+ return 1;
96
146
  }
97
147
 
98
148
  // 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(