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
@@ -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);
@@ -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)) {