tessera-learn 0.3.0 → 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 (40) hide show
  1. package/AGENTS.md +17 -12
  2. package/README.md +1 -1
  3. package/dist/{audit-DkXqQTqn.js → audit-DsYqXbqm.js} +211 -183
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-CyzuCDXg.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-BEXyRqsJ.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 +38 -7
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +7 -1
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +2 -2
  16. package/dist/{plugin-CFUFgwHB.js → plugin-BuMiDTmU.js} +29 -38
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +1 -1
  19. package/src/components/MultipleChoice.svelte +1 -2
  20. package/src/plugin/build-commands.ts +7 -4
  21. package/src/plugin/cli.ts +54 -3
  22. package/src/plugin/index.ts +31 -42
  23. package/src/plugin/inline-config.ts +4 -2
  24. package/src/plugin/manifest.ts +21 -0
  25. package/src/plugin/validate-cli.ts +5 -2
  26. package/src/plugin/validation.ts +214 -233
  27. package/src/runtime/App.svelte +4 -1
  28. package/src/runtime/adapters/scorm-base.ts +15 -14
  29. package/src/runtime/adapters/scorm12.ts +6 -25
  30. package/src/runtime/adapters/scorm2004.ts +12 -55
  31. package/src/runtime/adapters/web.ts +5 -13
  32. package/src/runtime/fingerprint.ts +28 -0
  33. package/src/runtime/interaction-format.ts +0 -1
  34. package/src/runtime/persistence.ts +4 -0
  35. package/src/runtime/types.ts +3 -0
  36. package/src/runtime/xapi/publisher.ts +11 -14
  37. package/dist/audit-DkXqQTqn.js.map +0 -1
  38. package/dist/build-commands-CyzuCDXg.js.map +0 -1
  39. package/dist/inline-config-BEXyRqsJ.js.map +0 -1
  40. package/dist/plugin-CFUFgwHB.js.map +0 -1
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.
@@ -43,9 +77,26 @@ type CourseCommand = (
43
77
  const COURSE_COMMANDS: Record<string, CourseCommand> = {
44
78
  dev: async (courseRoot, workspaceRoot) =>
45
79
  (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),
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
+ },
49
100
  a11y: (courseRoot, workspaceRoot, flags) =>
50
101
  runA11y(courseRoot, workspaceRoot, flags),
51
102
  check: (courseRoot, workspaceRoot, flags) => {
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  generateManifest,
15
15
  readCourseConfig,
16
+ readResolvedConfig,
16
17
  type CourseConfigRead,
17
18
  } from './manifest.js';
18
19
  import type { Manifest } from './manifest.js';
@@ -119,7 +120,8 @@ function virtualModule(
119
120
  };
120
121
  }
121
122
 
122
- export function tesseraPlugin() {
123
+ export function tesseraPlugin(options: { standardOverride?: string } = {}) {
124
+ const { standardOverride } = options;
123
125
  const manifestRef: { current: Manifest | null; root: string } = {
124
126
  current: null,
125
127
  root: '',
@@ -150,18 +152,18 @@ export function tesseraPlugin() {
150
152
  },
151
153
  }),
152
154
  tesseraA11yCompilerPlugin(a11y),
153
- tesseraValidationPlugin(),
154
- tesseraEntryPlugin(),
155
+ tesseraValidationPlugin(standardOverride),
156
+ tesseraEntryPlugin(standardOverride),
155
157
  tesseraConfigDefaultsPlugin(),
156
- tesseraConfigPlugin(),
158
+ tesseraConfigPlugin(standardOverride),
157
159
  tesseraPagesPlugin(),
158
160
  tesseraManifestPlugin(manifestRef),
159
161
  tesseraLayoutPlugin(),
160
162
  tesseraQuizPlugin(),
161
- tesseraAdapterPlugin(),
162
- tesseraXAPISetupPlugin(),
163
+ tesseraAdapterPlugin(standardOverride),
164
+ tesseraXAPISetupPlugin(standardOverride),
163
165
  tesseraFirstPagePreloadPlugin(manifestRef),
164
- tesseraExportPlugin(),
166
+ tesseraExportPlugin(standardOverride),
165
167
  ];
166
168
  }
167
169
 
@@ -172,7 +174,7 @@ const RESOLVED_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID;
172
174
  const VIRTUAL_MAIN_ID = '/virtual:tessera-main';
173
175
  const RESOLVED_MAIN_ID = '\0virtual:tessera-main';
174
176
 
175
- function tesseraEntryPlugin(): Plugin {
177
+ function tesseraEntryPlugin(standardOverride?: string): Plugin {
176
178
  const runtimeDir = resolveRuntimeDir();
177
179
  const stylesDir = resolveStylesDir();
178
180
  const appSveltePath = resolve(runtimeDir, 'App.svelte');
@@ -193,7 +195,7 @@ function tesseraEntryPlugin(): Plugin {
193
195
  // For build mode: write index.html so Rollup can find it
194
196
  buildStart() {
195
197
  if (isBuild) {
196
- const read = readCourseConfig(projectRoot);
198
+ const read = readResolvedConfig(projectRoot, standardOverride);
197
199
  writeFileSync(
198
200
  resolve(projectRoot, 'index.html'),
199
201
  generateIndexHtml(readLanguage(read), cspMeta(read)),
@@ -265,19 +267,12 @@ function readLanguage(read: CourseConfigRead): string {
265
267
  return isPlausibleLanguageTag(lang) ? lang : 'en';
266
268
  }
267
269
 
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
270
  // Web export only — never on LMS packages (whose iframe JS bridges a meta CSP
276
271
  // could break) and never on the dev server (a meta connect-src would block
277
272
  // Vite's HMR websocket). `export.csp` extends the baseline per-directive, or
278
273
  // `false` drops the meta for deployments that set a CSP header themselves.
279
- function cspMeta(read: CourseConfigRead): string {
280
- if (readExportStandard(read) !== 'web') return '';
274
+ function cspMeta(read: CourseConfigRead & { standard: string }): string {
275
+ if (read.standard !== 'web') return '';
281
276
  const csp = read.ok ? read.config.export?.csp : undefined;
282
277
  if (csp === false) return '';
283
278
  return `\n <meta http-equiv="Content-Security-Policy" content="${buildCsp(csp)}" />`;
@@ -392,6 +387,7 @@ export function mergeCourseConfig(userConfig: Partial<CourseConfig>) {
392
387
  return {
393
388
  ...userConfig,
394
389
  title: userConfig.title || 'Untitled Course',
390
+ resume: userConfig.resume ?? 'auto',
395
391
  navigation: { mode: 'free', ...userConfig.navigation },
396
392
  completion: { ...completion, ...userConfig.completion },
397
393
  scoring: { passingScore, ...userConfig.scoring },
@@ -399,14 +395,16 @@ export function mergeCourseConfig(userConfig: Partial<CourseConfig>) {
399
395
  };
400
396
  }
401
397
 
402
- function tesseraConfigPlugin(): Plugin {
398
+ function tesseraConfigPlugin(standardOverride?: string): Plugin {
403
399
  return virtualModule(
404
400
  'tessera:config',
405
401
  VIRTUAL_CONFIG_ID,
406
402
  function ({ projectRoot }) {
407
403
  const configPath = resolve(projectRoot, 'course.config.js');
408
404
  if (existsSync(configPath)) this.addWatchFile(configPath);
409
- const read = readCourseConfig(projectRoot);
405
+ // The runtime reads export.standard too, so readResolvedConfig must apply
406
+ // the override here — the bundled config, not just the manifest/adapter.
407
+ const read = readResolvedConfig(projectRoot, standardOverride);
410
408
  const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
411
409
  return `export default ${JSON.stringify(mergeCourseConfig(userConfig))};`;
412
410
  },
@@ -448,7 +446,7 @@ function tesseraPagesPlugin(): Plugin {
448
446
 
449
447
  // ---------- Validation Plugin ----------
450
448
 
451
- function tesseraValidationPlugin(): Plugin {
449
+ function tesseraValidationPlugin(standardOverride?: string): Plugin {
452
450
  let projectRoot: string;
453
451
  let isBuild = false;
454
452
 
@@ -461,14 +459,14 @@ function tesseraValidationPlugin(): Plugin {
461
459
  isBuild = config.command === 'build';
462
460
  // Run validation during dev (configResolved fires before server starts)
463
461
  if (!isBuild) {
464
- runValidation(projectRoot);
462
+ runValidation(projectRoot, standardOverride);
465
463
  }
466
464
  },
467
465
 
468
466
  buildStart() {
469
467
  // Run validation during build (buildStart fires once before bundling)
470
468
  if (isBuild) {
471
- runValidation(projectRoot);
469
+ runValidation(projectRoot, standardOverride);
472
470
  }
473
471
  },
474
472
  };
@@ -506,8 +504,8 @@ function tesseraA11yCompilerPlugin(a11y: A11yCompilerState): Plugin {
506
504
  };
507
505
  }
508
506
 
509
- function runValidation(projectRoot: string): void {
510
- const result = validateProject(projectRoot);
507
+ function runValidation(projectRoot: string, standardOverride?: string): void {
508
+ const result = validateProject(projectRoot, standardOverride);
511
509
  reportValidationIssues(result);
512
510
  if (result.errors.length > 0) {
513
511
  throw new Error(
@@ -518,7 +516,7 @@ function runValidation(projectRoot: string): void {
518
516
 
519
517
  // ---------- Export Plugin ----------
520
518
 
521
- function tesseraExportPlugin(): Plugin {
519
+ function tesseraExportPlugin(standardOverride?: string): Plugin {
522
520
  let projectRoot: string;
523
521
  let isBuild = false;
524
522
 
@@ -535,7 +533,7 @@ function tesseraExportPlugin(): Plugin {
535
533
  if (!isBuild) return;
536
534
  if (isAuditBuild()) return;
537
535
 
538
- const read = readCourseConfig(projectRoot);
536
+ const read = readResolvedConfig(projectRoot, standardOverride);
539
537
  if (!read.ok) {
540
538
  // Validation already required a parseable course.config.js — getting
541
539
  // here means it vanished or broke mid-build. Surface that loudly
@@ -705,7 +703,7 @@ export function createAdapter() {
705
703
  `;
706
704
  }
707
705
 
708
- function tesseraAdapterPlugin(): Plugin {
706
+ function tesseraAdapterPlugin(standardOverride?: string): Plugin {
709
707
  return virtualModule(
710
708
  'tessera:adapter',
711
709
  VIRTUAL_ADAPTER_ID,
@@ -716,11 +714,7 @@ function tesseraAdapterPlugin(): Plugin {
716
714
  return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
717
715
  }
718
716
 
719
- let standard = 'web';
720
- const read = readCourseConfig(projectRoot);
721
- if (read.ok && typeof read.config.export?.standard === 'string') {
722
- standard = read.config.export.standard;
723
- }
717
+ let standard = readResolvedConfig(projectRoot, standardOverride).standard;
724
718
 
725
719
  // The audit renders headless with no LMS in the frame chain; the SCORM/
726
720
  // cmi5 adapters throw when their API is absent, so render with WebAdapter.
@@ -743,7 +737,7 @@ export function createAdapter(config, options) {
743
737
 
744
738
  const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
745
739
 
746
- function tesseraXAPISetupPlugin(): Plugin {
740
+ function tesseraXAPISetupPlugin(standardOverride?: string): Plugin {
747
741
  return virtualModule(
748
742
  'tessera:xapi-setup',
749
743
  VIRTUAL_XAPI_SETUP_ID,
@@ -757,14 +751,9 @@ function tesseraXAPISetupPlugin(): Plugin {
757
751
  return `export async function buildXAPIClient() { return null; }`;
758
752
  }
759
753
 
760
- let standard = 'web';
761
- let hasXapi = false;
762
- const read = readCourseConfig(projectRoot);
763
- if (read.ok) {
764
- if (typeof read.config.export?.standard === 'string')
765
- standard = read.config.export.standard;
766
- hasXapi = read.config.xapi != null;
767
- }
754
+ const read = readResolvedConfig(projectRoot, standardOverride);
755
+ const standard = read.standard;
756
+ const hasXapi = read.ok && read.config.xapi != null;
768
757
 
769
758
  // The launch standards (cmi5, plain xAPI) own a publisher the runtime
770
759
  // can share for `endpoint: 'lms'`, so wire the client regardless of
@@ -15,11 +15,12 @@ import { tesseraPlugin } from './index.js';
15
15
  export function buildInlineConfig(
16
16
  projectRoot: string,
17
17
  workspaceRoot: string,
18
+ standardOverride?: string,
18
19
  ): InlineConfig {
19
20
  return {
20
21
  root: projectRoot,
21
22
  configFile: false,
22
- plugins: [tesseraPlugin()],
23
+ plugins: [tesseraPlugin({ standardOverride })],
23
24
  resolve: { alias: { $shared: resolve(workspaceRoot, 'shared') } },
24
25
  server: { fs: { allow: [workspaceRoot] } },
25
26
  };
@@ -46,9 +47,10 @@ export async function resolveTesseraConfig(
46
47
  projectRoot: string,
47
48
  workspaceRoot: string,
48
49
  env: ConfigEnv,
50
+ standardOverride?: string,
49
51
  ): Promise<InlineConfig> {
50
52
  const vite = await import('vite');
51
- const base = buildInlineConfig(projectRoot, workspaceRoot);
53
+ const base = buildInlineConfig(projectRoot, workspaceRoot, standardOverride);
52
54
  const user = await loadUserConfig(projectRoot, env);
53
55
  return user ? vite.mergeConfig(base, user) : base;
54
56
  }
@@ -121,6 +121,27 @@ export function readCourseConfig(projectRoot: string): CourseConfigRead {
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * Resolve a project's effective export standard once: the CLI `--standard`
126
+ * override wins, else `export.standard`, else `'web'`. An unreadable config with
127
+ * no override fails closed with `'unknown'` so callers withhold standard-specific
128
+ * output rather than guess. The returned `config` already has the override
129
+ * applied, so consumers read it back directly. Exported for tests.
130
+ */
131
+ export function readResolvedConfig(
132
+ projectRoot: string,
133
+ standardOverride?: string,
134
+ ): CourseConfigRead & { standard: string } {
135
+ const read = readCourseConfig(projectRoot);
136
+ if (!read.ok) return { ...read, standard: standardOverride ?? 'unknown' };
137
+ // The CLI validates --standard against the allowed set before it reaches here.
138
+ const override = standardOverride as CourseConfig['export']['standard'];
139
+ const config: Partial<CourseConfig> = override
140
+ ? { ...read.config, export: { ...read.config.export, standard: override } }
141
+ : read.config;
142
+ return { ok: true, config, standard: config.export?.standard || 'web' };
143
+ }
144
+
124
145
  /**
125
146
  * Read a _meta.js file and extract its default export object.
126
147
  * Uses the same JSON5 approach as pageConfig extraction — find the object literal
@@ -3,9 +3,12 @@ import { validateProject, reportValidationIssues } from './validation.js';
3
3
 
4
4
  export function runValidate(
5
5
  projectRoot: string,
6
- { showA11yTip = true }: { showA11yTip?: boolean } = {},
6
+ {
7
+ showA11yTip = true,
8
+ standardOverride,
9
+ }: { showA11yTip?: boolean; standardOverride?: string } = {},
7
10
  ): number {
8
- const { errors, warnings } = validateProject(projectRoot);
11
+ const { errors, warnings } = validateProject(projectRoot, standardOverride);
9
12
 
10
13
  reportValidationIssues({ errors, warnings });
11
14