tessera-learn 0.2.1 → 0.2.3

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 (30) hide show
  1. package/AGENTS.md +280 -916
  2. package/README.md +3 -3
  3. package/dist/{audit-BNrvFHq_.js → audit--fSWIOgK.js} +156 -33
  4. package/dist/{audit-BNrvFHq_.js.map → audit--fSWIOgK.js.map} +1 -1
  5. package/dist/{build-commands-BWnATKat.js → build-commands-Qyrlsp3n.js} +2 -2
  6. package/dist/{build-commands-BWnATKat.js.map → build-commands-Qyrlsp3n.js.map} +1 -1
  7. package/dist/{inline-config-Dudu5r8w.js → inline-config-DqAKsCNl.js} +2 -2
  8. package/dist/{inline-config-Dudu5r8w.js.map → inline-config-DqAKsCNl.js.map} +1 -1
  9. package/dist/plugin/cli.d.ts.map +1 -1
  10. package/dist/plugin/cli.js +33 -18
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +0 -2
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -2
  15. package/dist/{plugin-diNZaDJK.js → plugin-B-aiL9-V.js} +2 -2
  16. package/dist/{plugin-diNZaDJK.js.map → plugin-B-aiL9-V.js.map} +1 -1
  17. package/package.json +11 -8
  18. package/src/components/FillInTheBlank.svelte +3 -27
  19. package/src/components/Matching.svelte +4 -26
  20. package/src/components/MultipleChoice.svelte +8 -27
  21. package/src/components/QuestionShell.svelte +35 -0
  22. package/src/components/Sorting.svelte +4 -26
  23. package/src/plugin/a11y/audit.ts +239 -39
  24. package/src/plugin/a11y-cli.ts +1 -4
  25. package/src/plugin/cli.ts +2 -3
  26. package/src/plugin/course-root.ts +37 -9
  27. package/src/plugin/validate-cli.ts +10 -4
  28. package/src/runtime/adapters/cmi5.ts +5 -14
  29. package/src/runtime/adapters/index.ts +41 -38
  30. package/src/runtime/adapters/scorm12.ts +1 -1
@@ -13,7 +13,6 @@ export type ParsedA11yArgs =
13
13
 
14
14
  export function parseA11yArgs(argv: string[]): ParsedA11yArgs {
15
15
  let threshold: ImpactLevel | undefined;
16
- let rebuild = false;
17
16
 
18
17
  for (let i = 0; i < argv.length; i++) {
19
18
  const arg = argv[i];
@@ -26,14 +25,12 @@ export function parseA11yArgs(argv: string[]): ParsedA11yArgs {
26
25
  };
27
26
  }
28
27
  threshold = value;
29
- } else if (arg === '--build') {
30
- rebuild = true;
31
28
  } else {
32
29
  return { ok: false, error: `Unknown argument: ${arg}` };
33
30
  }
34
31
  }
35
32
 
36
- const args: AuditOptions = { rebuild };
33
+ const args: AuditOptions = {};
37
34
  if (threshold !== undefined) args.threshold = threshold;
38
35
  return { ok: true, args };
39
36
  }
package/src/plugin/cli.ts CHANGED
@@ -19,8 +19,7 @@ Commands:
19
19
  Run a command from inside a course folder, or name the course explicitly.
20
20
 
21
21
  a11y/check options:
22
- --threshold <minor|moderate|serious|critical> Failing impact (default: serious)
23
- --build Force a fresh build first`;
22
+ --threshold <minor|moderate|serious|critical> Failing impact (default: serious)`;
24
23
 
25
24
  // The course is a leading positional: `tessera <cmd> [course] [flags]`. Only the
26
25
  // first token can be the course, and only when it isn't a flag — otherwise a flag
@@ -74,7 +73,7 @@ export async function main(
74
73
  case 'validate':
75
74
  return runValidate(courseRoot);
76
75
  case 'check': {
77
- const validateCode = runValidate(courseRoot);
76
+ const validateCode = runValidate(courseRoot, { showA11yTip: false });
78
77
  if (validateCode !== 0) return validateCode;
79
78
  return runA11y(courseRoot, workspaceRoot, flags);
80
79
  }
@@ -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
 
@@ -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
  }
@@ -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
  },