tessera-learn 0.2.0 → 0.2.2

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.
@@ -32,29 +32,57 @@ export function findWorkspaceRoot(cwd: string): string | null {
32
32
  }
33
33
  }
34
34
 
35
- export function listCourses(workspaceRoot: string): string[] {
35
+ function scanCourses(workspaceRoot: string): {
36
+ courses: string[];
37
+ malformed: string[];
38
+ } {
36
39
  const coursesDir = join(workspaceRoot, 'courses');
40
+ const courses: string[] = [];
41
+ const malformed: string[] = [];
37
42
  try {
38
- return readdirSync(coursesDir, { withFileTypes: true })
39
- .filter((e) => e.isDirectory() && isCourse(join(coursesDir, e.name)))
40
- .map((e) => e.name)
41
- .sort();
43
+ for (const e of readdirSync(coursesDir, { withFileTypes: true })) {
44
+ if (!e.isDirectory()) continue;
45
+ const dir = join(coursesDir, e.name);
46
+ if (isCourse(dir)) courses.push(e.name);
47
+ else if (isDir(join(dir, 'pages'))) malformed.push(e.name);
48
+ }
42
49
  } catch {
43
- return [];
50
+ return { courses, malformed };
44
51
  }
52
+ return { courses: courses.sort(), malformed: malformed.sort() };
53
+ }
54
+
55
+ export function listCourses(workspaceRoot: string): string[] {
56
+ return scanCourses(workspaceRoot).courses;
57
+ }
58
+
59
+ export function listMalformedCourses(workspaceRoot: string): string[] {
60
+ return scanCourses(workspaceRoot).malformed;
45
61
  }
46
62
 
47
63
  const NOT_A_WORKSPACE =
48
64
  'Not inside a Tessera workspace — no `courses/` directory was found at or above the current directory.';
49
65
 
66
+ function malformedHint(malformed: string[]): string {
67
+ if (malformed.length === 0) return '';
68
+ return (
69
+ `\nSkipped (missing course.config.js):\n` +
70
+ malformed.map((c) => ` courses/${c}`).join('\n')
71
+ );
72
+ }
73
+
50
74
  function listHint(workspaceRoot: string): string {
51
- const courses = listCourses(workspaceRoot);
75
+ const { courses, malformed } = scanCourses(workspaceRoot);
52
76
  if (courses.length === 0) {
53
- return '\nNo courses found. Create one with `tessera new <name>`.';
77
+ return (
78
+ '\nNo courses found. Create one with `tessera new <name>`.' +
79
+ malformedHint(malformed)
80
+ );
54
81
  }
55
82
  return (
56
83
  `\nAvailable courses:\n${courses.map((c) => ` ${c}`).join('\n')}` +
57
- '\nName one (`tessera <command> <course>`) or cd into its folder.'
84
+ '\nName one (`tessera <command> <course>`) or cd into its folder.' +
85
+ malformedHint(malformed)
58
86
  );
59
87
  }
60
88
 
@@ -48,6 +48,13 @@
48
48
  );
49
49
  const nav = new NavigationState(manifest, progress, config, auditMode);
50
50
  nav.setPageModules(pageModules);
51
+
52
+ // Layout-independent navigation seam the Tier-2 auditor walks pages through.
53
+ if (auditMode) {
54
+ window.__tesseraAudit = {
55
+ goToIndex: (i) => nav.goToPage(i),
56
+ };
57
+ }
51
58
  let duration = $state(new DurationTracker(0));
52
59
 
53
60
  const onIdle =
@@ -64,6 +71,9 @@
64
71
  let pageLoading = $state(true);
65
72
  let pageError = $state(null);
66
73
  let retryKey = $state(0);
74
+ // Rendered page index, surfaced on #tessera-app so the auditor can wait for a
75
+ // requested navigation to settle before scanning.
76
+ let renderedPageIndex = $state(-1);
67
77
 
68
78
  // ---- Page context (reactive, read by Quiz in Step 8) ----
69
79
  let pageContext = $state({
@@ -130,6 +140,7 @@
130
140
  pageError = new Error(`Page not found: ${page.importPath}`);
131
141
  PageComponent = null;
132
142
  pageLoading = false;
143
+ renderedPageIndex = index;
133
144
  return;
134
145
  }
135
146
 
@@ -140,6 +151,7 @@
140
151
  pageContext.quiz = page.quiz;
141
152
  PageComponent = mod.default;
142
153
  pageLoading = false;
154
+ renderedPageIndex = index;
143
155
  progress.markVisited(index);
144
156
  if (
145
157
  manifest.pages[index].completesOn === 'view' &&
@@ -154,6 +166,7 @@
154
166
  console.error(`Tessera: Failed to load page ${index}`, err);
155
167
  pageError = err;
156
168
  pageLoading = false;
169
+ renderedPageIndex = index;
157
170
  });
158
171
  }
159
172
 
@@ -446,6 +459,7 @@
446
459
  });
447
460
 
448
461
  onDestroy(() => {
462
+ if (auditMode) delete window.__tesseraAudit;
449
463
  window.removeEventListener('pagehide', handleExit);
450
464
  window.removeEventListener('beforeunload', handleExit);
451
465
  const appEl = document.getElementById('tessera-app');
@@ -474,7 +488,12 @@
474
488
  {/if}
475
489
  {/snippet}
476
490
 
477
- <div id="tessera-app" data-chrome={chromeMode}>
491
+ <div
492
+ id="tessera-app"
493
+ data-chrome={chromeMode}
494
+ data-tessera-page-index={auditMode ? renderedPageIndex : undefined}
495
+ data-tessera-page-error={auditMode && pageError ? 'true' : undefined}
496
+ >
478
497
  <LoadingBar active={pageLoading} />
479
498
  {#if UserLayout}
480
499
  <UserLayout {page} />
@@ -579,10 +579,7 @@ export class CMI5Adapter implements PersistenceAdapter {
579
579
  opts: { moveOn?: boolean; mastery?: boolean } = {},
580
580
  ): Record<string, unknown> {
581
581
  const tmpl = this.#launchData?.contextTemplate ?? {};
582
- const tmplActivities = (tmpl.contextActivities ?? {}) as Record<
583
- string,
584
- unknown
585
- >;
582
+ const tmplActivities = tmpl.contextActivities ?? {};
586
583
 
587
584
  // Concat-dedupe category to preserve any template-supplied entries
588
585
  // (§10.2.1 forbids overwriting them).
@@ -594,14 +591,8 @@ export class CMI5Adapter implements PersistenceAdapter {
594
591
  category.push({ id, objectType: 'Activity' });
595
592
  }
596
593
  };
597
- const templateCategory = Array.isArray(
598
- (tmplActivities as { category?: unknown }).category,
599
- )
600
- ? (
601
- tmplActivities as {
602
- category: Array<{ id: string; objectType?: string }>;
603
- }
604
- ).category
594
+ const templateCategory = Array.isArray(tmplActivities.category)
595
+ ? tmplActivities.category
605
596
  : [];
606
597
  for (const c of templateCategory) {
607
598
  if (c && typeof c.id === 'string') push(c.id);
@@ -615,13 +606,13 @@ export class CMI5Adapter implements PersistenceAdapter {
615
606
  };
616
607
 
617
608
  const ctx: Record<string, unknown> = {
618
- ...(tmpl as Record<string, unknown>),
609
+ ...tmpl,
619
610
  contextActivities,
620
611
  };
621
612
  // cmi5 §9.6.3.2 — masteryScore extension is scoped to Passed/Failed.
622
613
  if (opts.mastery && this.#masteryScore !== null) {
623
614
  ctx.extensions = {
624
- ...((tmpl.extensions ?? {}) as Record<string, unknown>),
615
+ ...(tmpl.extensions ?? {}),
625
616
  [CMI5_MASTERYSCORE_EXT]: this.#masteryScore,
626
617
  };
627
618
  }
@@ -19,27 +19,6 @@ export class LMSAdapterError extends Error {
19
19
  }
20
20
  }
21
21
 
22
- function missingApiError(
23
- standard: 'scorm12' | 'scorm2004' | 'cmi5',
24
- ): LMSAdapterError {
25
- const label =
26
- standard === 'scorm12'
27
- ? 'SCORM 1.2'
28
- : standard === 'scorm2004'
29
- ? 'SCORM 2004'
30
- : 'cmi5';
31
- const detail =
32
- standard === 'cmi5'
33
- ? 'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.'
34
- : `No ${label} API object found in the window.parent or window.opener chain.`;
35
- return new LMSAdapterError(
36
- standard,
37
- `Tessera: this course is configured for ${label} but ${detail} ` +
38
- `The course must be launched from an LMS that provides the ${label} runtime. ` +
39
- `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
40
- );
41
- }
42
-
43
22
  export interface CreateAdapterOptions {
44
23
  /**
45
24
  * When true, a missing LMS API falls back to `WebAdapter` with a console
@@ -49,44 +28,68 @@ export interface CreateAdapterOptions {
49
28
  allowFallback?: boolean;
50
29
  }
51
30
 
52
- /**
53
- * Select the appropriate persistence adapter based on course config.
54
- *
55
- * In production builds, an LMS-configured course (scorm12/scorm2004/cmi5)
56
- * will throw `LMSAdapterError` if the matching LMS API isn't reachable —
57
- * we fail loud so a misconfigured launch is visible immediately rather
58
- * than silently losing tracking to localStorage.
59
- *
60
- * In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
61
- * can still iterate locally.
62
- */
63
31
  type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
64
32
 
65
- /** Per-standard LMS detection. `detect` returns an adapter when the LMS runtime is reachable, else null. */
33
+ /** Per-standard LMS wiring: `detect` returns an adapter when the LMS runtime is reachable, else null. Labels are the single source for the dev warning and production error. */
66
34
  const LMS_ADAPTERS: Record<
67
35
  LMSStandard,
68
- { detect: () => PersistenceAdapter | null; label: string }
36
+ {
37
+ detect: () => PersistenceAdapter | null;
38
+ warnLabel: string;
39
+ name: string;
40
+ missingDetail: string;
41
+ }
69
42
  > = {
70
43
  scorm12: {
71
44
  detect: () => {
72
45
  const api = findSCORM12API();
73
46
  return api ? new SCORM12Adapter(api) : null;
74
47
  },
75
- label: 'SCORM 1.2 API',
48
+ warnLabel: 'SCORM 1.2 API',
49
+ name: 'SCORM 1.2',
50
+ missingDetail:
51
+ 'No SCORM 1.2 API object found in the window.parent or window.opener chain.',
76
52
  },
77
53
  scorm2004: {
78
54
  detect: () => {
79
55
  const api = findSCORM2004API();
80
56
  return api ? new SCORM2004Adapter(api) : null;
81
57
  },
82
- label: 'SCORM 2004 API',
58
+ warnLabel: 'SCORM 2004 API',
59
+ name: 'SCORM 2004',
60
+ missingDetail:
61
+ 'No SCORM 2004 API object found in the window.parent or window.opener chain.',
83
62
  },
84
63
  cmi5: {
85
64
  detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
86
- label: 'cmi5 launch parameters',
65
+ warnLabel: 'cmi5 launch parameters',
66
+ name: 'cmi5',
67
+ missingDetail:
68
+ 'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.',
87
69
  },
88
70
  };
89
71
 
72
+ function missingApiError(standard: LMSStandard): LMSAdapterError {
73
+ const { name, missingDetail } = LMS_ADAPTERS[standard];
74
+ return new LMSAdapterError(
75
+ standard,
76
+ `Tessera: this course is configured for ${name} but ${missingDetail} ` +
77
+ `The course must be launched from an LMS that provides the ${name} runtime. ` +
78
+ `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Select the appropriate persistence adapter based on course config.
84
+ *
85
+ * In production builds, an LMS-configured course (scorm12/scorm2004/cmi5)
86
+ * will throw `LMSAdapterError` if the matching LMS API isn't reachable —
87
+ * we fail loud so a misconfigured launch is visible immediately rather
88
+ * than silently losing tracking to localStorage.
89
+ *
90
+ * In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
91
+ * can still iterate locally.
92
+ */
90
93
  export function createAdapter(
91
94
  config: CourseConfig,
92
95
  options: CreateAdapterOptions = {},
@@ -103,7 +106,7 @@ export function createAdapter(
103
106
  if (adapter) return adapter;
104
107
  if (!allowFallback) throw missingApiError(standard);
105
108
  console.warn(
106
- `Tessera (dev): ${entry.label} not found — falling back to localStorage`,
109
+ `Tessera (dev): ${entry.warnLabel} not found — falling back to localStorage`,
107
110
  );
108
111
  }
109
112
  return new WebAdapter(config);
@@ -29,7 +29,7 @@ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
29
29
  responseField: 'student_response',
30
30
  timestampField: 'time',
31
31
  timestamp: () => new Date().toTimeString().slice(0, 8),
32
- typeValue: (t) => scorm12Type(t),
32
+ typeValue: scorm12Type,
33
33
  resultLabels: { correct: 'correct', incorrect: 'wrong' },
34
34
  format: SCORM12_INTERACTION_FORMAT,
35
35
  },
package/src/virtual.d.ts CHANGED
@@ -30,3 +30,9 @@ interface ImportMetaEnv {
30
30
  interface ImportMeta {
31
31
  readonly env: ImportMetaEnv;
32
32
  }
33
+
34
+ interface Window {
35
+ __tesseraAudit?: {
36
+ goToIndex(index: number): void;
37
+ };
38
+ }