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.
- package/AGENTS.md +50 -21
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
- package/dist/inline-config-DVvOCKht.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +91 -49
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +287 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +96 -46
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +138 -93
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +24 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +255 -238
- package/src/runtime/App.svelte +14 -9
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -54
- package/src/runtime/adapters/web.ts +11 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/fingerprint.ts +28 -0
- package/src/runtime/interaction-format.ts +0 -1
- package/src/runtime/persistence.ts +4 -0
- package/src/runtime/types.ts +22 -1
- package/src/runtime/xapi/publisher.ts +16 -15
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit--fSWIOgK.js.map +0 -1
- package/dist/build-commands-Qyrlsp3n.js.map +0 -1
- package/dist/inline-config-DqAKsCNl.js.map +0 -1
- package/dist/plugin-B-aiL9-V.js.map +0 -1
package/src/plugin/index.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
cpSync,
|
|
11
11
|
mkdirSync,
|
|
12
12
|
} from 'node:fs';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
generateManifest,
|
|
15
|
+
readCourseConfig,
|
|
16
|
+
readResolvedConfig,
|
|
17
|
+
type CourseConfigRead,
|
|
18
|
+
} from './manifest.js';
|
|
14
19
|
import type { Manifest } from './manifest.js';
|
|
15
20
|
import type { CourseConfig } from '../runtime/types.js';
|
|
16
21
|
import {
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
isIgnored,
|
|
26
31
|
type A11ySettings,
|
|
27
32
|
} from './validation.js';
|
|
33
|
+
import { buildCsp } from './csp.js';
|
|
28
34
|
import { runExport } from './export.js';
|
|
29
35
|
import { tesseraLayoutPlugin } from './layout.js';
|
|
30
36
|
import { tesseraQuizPlugin } from './quiz.js';
|
|
@@ -114,7 +120,8 @@ function virtualModule(
|
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
export function tesseraPlugin() {
|
|
123
|
+
export function tesseraPlugin(options: { standardOverride?: string } = {}) {
|
|
124
|
+
const { standardOverride } = options;
|
|
118
125
|
const manifestRef: { current: Manifest | null; root: string } = {
|
|
119
126
|
current: null,
|
|
120
127
|
root: '',
|
|
@@ -145,18 +152,18 @@ export function tesseraPlugin() {
|
|
|
145
152
|
},
|
|
146
153
|
}),
|
|
147
154
|
tesseraA11yCompilerPlugin(a11y),
|
|
148
|
-
tesseraValidationPlugin(),
|
|
149
|
-
tesseraEntryPlugin(),
|
|
155
|
+
tesseraValidationPlugin(standardOverride),
|
|
156
|
+
tesseraEntryPlugin(standardOverride),
|
|
150
157
|
tesseraConfigDefaultsPlugin(),
|
|
151
|
-
tesseraConfigPlugin(),
|
|
158
|
+
tesseraConfigPlugin(standardOverride),
|
|
152
159
|
tesseraPagesPlugin(),
|
|
153
160
|
tesseraManifestPlugin(manifestRef),
|
|
154
161
|
tesseraLayoutPlugin(),
|
|
155
162
|
tesseraQuizPlugin(),
|
|
156
|
-
tesseraAdapterPlugin(),
|
|
157
|
-
tesseraXAPISetupPlugin(),
|
|
163
|
+
tesseraAdapterPlugin(standardOverride),
|
|
164
|
+
tesseraXAPISetupPlugin(standardOverride),
|
|
158
165
|
tesseraFirstPagePreloadPlugin(manifestRef),
|
|
159
|
-
tesseraExportPlugin(),
|
|
166
|
+
tesseraExportPlugin(standardOverride),
|
|
160
167
|
];
|
|
161
168
|
}
|
|
162
169
|
|
|
@@ -167,7 +174,7 @@ const RESOLVED_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID;
|
|
|
167
174
|
const VIRTUAL_MAIN_ID = '/virtual:tessera-main';
|
|
168
175
|
const RESOLVED_MAIN_ID = '\0virtual:tessera-main';
|
|
169
176
|
|
|
170
|
-
function tesseraEntryPlugin(): Plugin {
|
|
177
|
+
function tesseraEntryPlugin(standardOverride?: string): Plugin {
|
|
171
178
|
const runtimeDir = resolveRuntimeDir();
|
|
172
179
|
const stylesDir = resolveStylesDir();
|
|
173
180
|
const appSveltePath = resolve(runtimeDir, 'App.svelte');
|
|
@@ -188,9 +195,10 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
188
195
|
// For build mode: write index.html so Rollup can find it
|
|
189
196
|
buildStart() {
|
|
190
197
|
if (isBuild) {
|
|
198
|
+
const read = readResolvedConfig(projectRoot, standardOverride);
|
|
191
199
|
writeFileSync(
|
|
192
200
|
resolve(projectRoot, 'index.html'),
|
|
193
|
-
generateIndexHtml(readLanguage(
|
|
201
|
+
generateIndexHtml(readLanguage(read), cspMeta(read)),
|
|
194
202
|
'utf-8',
|
|
195
203
|
);
|
|
196
204
|
}
|
|
@@ -221,7 +229,9 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
221
229
|
return () => {
|
|
222
230
|
server.middlewares.use(async (req, res, next) => {
|
|
223
231
|
if (req.url === '/' || req.url === '/index.html') {
|
|
224
|
-
const html = generateIndexHtml(
|
|
232
|
+
const html = generateIndexHtml(
|
|
233
|
+
readLanguage(readCourseConfig(projectRoot)),
|
|
234
|
+
);
|
|
225
235
|
const transformed = await server.transformIndexHtml(req.url, html);
|
|
226
236
|
res.setHeader('Content-Type', 'text/html');
|
|
227
237
|
res.statusCode = 200;
|
|
@@ -252,18 +262,28 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
252
262
|
// 'en' fallback applied here: the config default-merge runs later than buildStart.
|
|
253
263
|
// Only a validated BCP-47 tag is interpolated into <html lang>, so a malformed
|
|
254
264
|
// value (caught separately as a warning) can't ship a broken attribute.
|
|
255
|
-
function readLanguage(
|
|
256
|
-
const read = readCourseConfig(projectRoot);
|
|
265
|
+
function readLanguage(read: CourseConfigRead): string {
|
|
257
266
|
const lang = read.ok ? read.config.language : undefined;
|
|
258
267
|
return isPlausibleLanguageTag(lang) ? lang : 'en';
|
|
259
268
|
}
|
|
260
269
|
|
|
261
|
-
|
|
270
|
+
// Web export only — never on LMS packages (whose iframe JS bridges a meta CSP
|
|
271
|
+
// could break) and never on the dev server (a meta connect-src would block
|
|
272
|
+
// Vite's HMR websocket). `export.csp` extends the baseline per-directive, or
|
|
273
|
+
// `false` drops the meta for deployments that set a CSP header themselves.
|
|
274
|
+
function cspMeta(read: CourseConfigRead & { standard: string }): string {
|
|
275
|
+
if (read.standard !== 'web') return '';
|
|
276
|
+
const csp = read.ok ? read.config.export?.csp : undefined;
|
|
277
|
+
if (csp === false) return '';
|
|
278
|
+
return `\n <meta http-equiv="Content-Security-Policy" content="${buildCsp(csp)}" />`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function generateIndexHtml(lang: string, csp = ''): string {
|
|
262
282
|
return `<!DOCTYPE html>
|
|
263
283
|
<html lang="${lang}">
|
|
264
284
|
<head>
|
|
265
285
|
<meta charset="UTF-8" />
|
|
266
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0"
|
|
286
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />${csp}
|
|
267
287
|
<title>Tessera Course</title>
|
|
268
288
|
</head>
|
|
269
289
|
<body>
|
|
@@ -326,6 +346,12 @@ function completionDefaults(mode: string | undefined): {
|
|
|
326
346
|
if (mode === 'manual') {
|
|
327
347
|
return { completion: { mode: 'manual' }, passingScore: 0 };
|
|
328
348
|
}
|
|
349
|
+
if (mode === 'quiz') {
|
|
350
|
+
return {
|
|
351
|
+
completion: { mode: 'quiz' },
|
|
352
|
+
passingScore: DEFAULT_PASSING_SCORE,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
329
355
|
return {
|
|
330
356
|
completion: {
|
|
331
357
|
mode: 'percentage',
|
|
@@ -353,27 +379,34 @@ function tesseraConfigDefaultsPlugin(): Plugin {
|
|
|
353
379
|
};
|
|
354
380
|
}
|
|
355
381
|
|
|
356
|
-
|
|
382
|
+
/** Fill runtime defaults into a parsed course.config.js. Exported for tests. */
|
|
383
|
+
export function mergeCourseConfig(userConfig: Partial<CourseConfig>) {
|
|
384
|
+
const { completion, passingScore } = completionDefaults(
|
|
385
|
+
userConfig.completion?.mode,
|
|
386
|
+
);
|
|
387
|
+
return {
|
|
388
|
+
...userConfig,
|
|
389
|
+
title: userConfig.title || 'Untitled Course',
|
|
390
|
+
resume: userConfig.resume ?? 'auto',
|
|
391
|
+
navigation: { mode: 'free', ...userConfig.navigation },
|
|
392
|
+
completion: { ...completion, ...userConfig.completion },
|
|
393
|
+
scoring: { passingScore, ...userConfig.scoring },
|
|
394
|
+
export: { standard: 'web', ...userConfig.export },
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function tesseraConfigPlugin(standardOverride?: string): Plugin {
|
|
357
399
|
return virtualModule(
|
|
358
400
|
'tessera:config',
|
|
359
401
|
VIRTUAL_CONFIG_ID,
|
|
360
402
|
function ({ projectRoot }) {
|
|
361
403
|
const configPath = resolve(projectRoot, 'course.config.js');
|
|
362
404
|
if (existsSync(configPath)) this.addWatchFile(configPath);
|
|
363
|
-
|
|
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);
|
|
364
408
|
const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
|
|
365
|
-
|
|
366
|
-
userConfig.completion?.mode,
|
|
367
|
-
);
|
|
368
|
-
const merged = {
|
|
369
|
-
title: userConfig.title || 'Untitled Course',
|
|
370
|
-
...userConfig,
|
|
371
|
-
navigation: { mode: 'free', ...userConfig.navigation },
|
|
372
|
-
completion: { ...completion, ...userConfig.completion },
|
|
373
|
-
scoring: { passingScore, ...userConfig.scoring },
|
|
374
|
-
export: { standard: 'web', ...userConfig.export },
|
|
375
|
-
};
|
|
376
|
-
return `export default ${JSON.stringify(merged)};`;
|
|
409
|
+
return `export default ${JSON.stringify(mergeCourseConfig(userConfig))};`;
|
|
377
410
|
},
|
|
378
411
|
);
|
|
379
412
|
}
|
|
@@ -413,7 +446,7 @@ function tesseraPagesPlugin(): Plugin {
|
|
|
413
446
|
|
|
414
447
|
// ---------- Validation Plugin ----------
|
|
415
448
|
|
|
416
|
-
function tesseraValidationPlugin(): Plugin {
|
|
449
|
+
function tesseraValidationPlugin(standardOverride?: string): Plugin {
|
|
417
450
|
let projectRoot: string;
|
|
418
451
|
let isBuild = false;
|
|
419
452
|
|
|
@@ -426,14 +459,14 @@ function tesseraValidationPlugin(): Plugin {
|
|
|
426
459
|
isBuild = config.command === 'build';
|
|
427
460
|
// Run validation during dev (configResolved fires before server starts)
|
|
428
461
|
if (!isBuild) {
|
|
429
|
-
runValidation(projectRoot);
|
|
462
|
+
runValidation(projectRoot, standardOverride);
|
|
430
463
|
}
|
|
431
464
|
},
|
|
432
465
|
|
|
433
466
|
buildStart() {
|
|
434
467
|
// Run validation during build (buildStart fires once before bundling)
|
|
435
468
|
if (isBuild) {
|
|
436
|
-
runValidation(projectRoot);
|
|
469
|
+
runValidation(projectRoot, standardOverride);
|
|
437
470
|
}
|
|
438
471
|
},
|
|
439
472
|
};
|
|
@@ -471,8 +504,8 @@ function tesseraA11yCompilerPlugin(a11y: A11yCompilerState): Plugin {
|
|
|
471
504
|
};
|
|
472
505
|
}
|
|
473
506
|
|
|
474
|
-
function runValidation(projectRoot: string): void {
|
|
475
|
-
const result = validateProject(projectRoot);
|
|
507
|
+
function runValidation(projectRoot: string, standardOverride?: string): void {
|
|
508
|
+
const result = validateProject(projectRoot, standardOverride);
|
|
476
509
|
reportValidationIssues(result);
|
|
477
510
|
if (result.errors.length > 0) {
|
|
478
511
|
throw new Error(
|
|
@@ -483,7 +516,7 @@ function runValidation(projectRoot: string): void {
|
|
|
483
516
|
|
|
484
517
|
// ---------- Export Plugin ----------
|
|
485
518
|
|
|
486
|
-
function tesseraExportPlugin(): Plugin {
|
|
519
|
+
function tesseraExportPlugin(standardOverride?: string): Plugin {
|
|
487
520
|
let projectRoot: string;
|
|
488
521
|
let isBuild = false;
|
|
489
522
|
|
|
@@ -500,7 +533,7 @@ function tesseraExportPlugin(): Plugin {
|
|
|
500
533
|
if (!isBuild) return;
|
|
501
534
|
if (isAuditBuild()) return;
|
|
502
535
|
|
|
503
|
-
const read =
|
|
536
|
+
const read = readResolvedConfig(projectRoot, standardOverride);
|
|
504
537
|
if (!read.ok) {
|
|
505
538
|
// Validation already required a parseable course.config.js — getting
|
|
506
539
|
// here means it vanished or broke mid-build. Surface that loudly
|
|
@@ -611,7 +644,8 @@ function tesseraManifestPlugin(manifestRef: {
|
|
|
611
644
|
value === Infinity ? 1e9 : value,
|
|
612
645
|
);
|
|
613
646
|
const b64 = Buffer.from(json).toString('base64');
|
|
614
|
-
|
|
647
|
+
// atob yields Latin1 bytes; decode through UTF-8 or non-ASCII titles ship as mojibake.
|
|
648
|
+
return `export default JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${b64}"),(c)=>c.charCodeAt(0))));`;
|
|
615
649
|
}
|
|
616
650
|
return null;
|
|
617
651
|
},
|
|
@@ -620,7 +654,56 @@ function tesseraManifestPlugin(manifestRef: {
|
|
|
620
654
|
|
|
621
655
|
const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
|
|
622
656
|
|
|
623
|
-
|
|
657
|
+
// `takesApi`: SCORM detectors return the API object the constructor needs;
|
|
658
|
+
// cmi5/xAPI ones return a boolean.
|
|
659
|
+
const LMS_ADAPTER_GEN: Record<
|
|
660
|
+
'scorm12' | 'scorm2004' | 'cmi5' | 'xapi',
|
|
661
|
+
{ adapter: string; module: string; detect: string; takesApi: boolean }
|
|
662
|
+
> = {
|
|
663
|
+
scorm12: {
|
|
664
|
+
adapter: 'SCORM12Adapter',
|
|
665
|
+
module: 'scorm12',
|
|
666
|
+
detect: 'findSCORM12API',
|
|
667
|
+
takesApi: true,
|
|
668
|
+
},
|
|
669
|
+
scorm2004: {
|
|
670
|
+
adapter: 'SCORM2004Adapter',
|
|
671
|
+
module: 'scorm2004',
|
|
672
|
+
detect: 'findSCORM2004API',
|
|
673
|
+
takesApi: true,
|
|
674
|
+
},
|
|
675
|
+
cmi5: {
|
|
676
|
+
adapter: 'CMI5Adapter',
|
|
677
|
+
module: 'cmi5',
|
|
678
|
+
detect: 'hasCMI5LaunchParams',
|
|
679
|
+
takesApi: false,
|
|
680
|
+
},
|
|
681
|
+
xapi: {
|
|
682
|
+
adapter: 'XAPIAdapter',
|
|
683
|
+
module: 'xapi',
|
|
684
|
+
detect: 'hasXAPILaunchParams',
|
|
685
|
+
takesApi: false,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
function generateLmsAdapterModule(
|
|
690
|
+
standard: keyof typeof LMS_ADAPTER_GEN,
|
|
691
|
+
): string {
|
|
692
|
+
const { adapter, module, detect, takesApi } = LMS_ADAPTER_GEN[standard];
|
|
693
|
+
const guard = takesApi
|
|
694
|
+
? `const api = ${detect}();\n if (!api) throw missingApiError('${standard}');\n return new ${adapter}(api);`
|
|
695
|
+
: `if (!${detect}()) throw missingApiError('${standard}');\n return new ${adapter}();`;
|
|
696
|
+
return `
|
|
697
|
+
import { ${adapter} } from 'tessera-learn/runtime/adapters/${module}.js';
|
|
698
|
+
import { ${detect} } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
699
|
+
import { missingApiError } from 'tessera-learn/runtime/adapters/lms-error.js';
|
|
700
|
+
export function createAdapter() {
|
|
701
|
+
${guard}
|
|
702
|
+
}
|
|
703
|
+
`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function tesseraAdapterPlugin(standardOverride?: string): Plugin {
|
|
624
707
|
return virtualModule(
|
|
625
708
|
'tessera:adapter',
|
|
626
709
|
VIRTUAL_ADAPTER_ID,
|
|
@@ -631,64 +714,30 @@ function tesseraAdapterPlugin(): Plugin {
|
|
|
631
714
|
return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
|
|
632
715
|
}
|
|
633
716
|
|
|
634
|
-
let standard =
|
|
635
|
-
const read = readCourseConfig(projectRoot);
|
|
636
|
-
if (read.ok && typeof read.config.export?.standard === 'string') {
|
|
637
|
-
standard = read.config.export.standard;
|
|
638
|
-
}
|
|
717
|
+
let standard = readResolvedConfig(projectRoot, standardOverride).standard;
|
|
639
718
|
|
|
640
719
|
// The audit renders headless with no LMS in the frame chain; the SCORM/
|
|
641
720
|
// cmi5 adapters throw when their API is absent, so render with WebAdapter.
|
|
642
721
|
if (isAuditBuild()) standard = 'web';
|
|
643
722
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
export function createAdapter() {
|
|
651
|
-
const api = findSCORM12API();
|
|
652
|
-
if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');
|
|
653
|
-
return new SCORM12Adapter(api);
|
|
654
|
-
}
|
|
655
|
-
`;
|
|
656
|
-
case 'scorm2004':
|
|
657
|
-
return `
|
|
658
|
-
import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
|
|
659
|
-
import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
660
|
-
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
661
|
-
export function createAdapter() {
|
|
662
|
-
const api = findSCORM2004API();
|
|
663
|
-
if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');
|
|
664
|
-
return new SCORM2004Adapter(api);
|
|
665
|
-
}
|
|
666
|
-
`;
|
|
667
|
-
case 'cmi5':
|
|
668
|
-
return `
|
|
669
|
-
import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
|
|
670
|
-
import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
671
|
-
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
672
|
-
export function createAdapter() {
|
|
673
|
-
if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
|
|
674
|
-
return new CMI5Adapter();
|
|
675
|
-
}
|
|
676
|
-
`;
|
|
677
|
-
default:
|
|
678
|
-
return `
|
|
723
|
+
if (standard in LMS_ADAPTER_GEN) {
|
|
724
|
+
return generateLmsAdapterModule(
|
|
725
|
+
standard as keyof typeof LMS_ADAPTER_GEN,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
return `
|
|
679
729
|
import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
|
|
680
|
-
export function createAdapter(config) {
|
|
681
|
-
return new WebAdapter(config);
|
|
730
|
+
export function createAdapter(config, options) {
|
|
731
|
+
return new WebAdapter(config, options && options.manifest);
|
|
682
732
|
}
|
|
683
733
|
`;
|
|
684
|
-
}
|
|
685
734
|
},
|
|
686
735
|
);
|
|
687
736
|
}
|
|
688
737
|
|
|
689
738
|
const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
|
|
690
739
|
|
|
691
|
-
function tesseraXAPISetupPlugin(): Plugin {
|
|
740
|
+
function tesseraXAPISetupPlugin(standardOverride?: string): Plugin {
|
|
692
741
|
return virtualModule(
|
|
693
742
|
'tessera:xapi-setup',
|
|
694
743
|
VIRTUAL_XAPI_SETUP_ID,
|
|
@@ -702,18 +751,14 @@ function tesseraXAPISetupPlugin(): Plugin {
|
|
|
702
751
|
return `export async function buildXAPIClient() { return null; }`;
|
|
703
752
|
}
|
|
704
753
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
if (read.ok) {
|
|
709
|
-
if (typeof read.config.export?.standard === 'string')
|
|
710
|
-
standard = read.config.export.standard;
|
|
711
|
-
hasXapi = read.config.xapi != null;
|
|
712
|
-
}
|
|
754
|
+
const read = readResolvedConfig(projectRoot, standardOverride);
|
|
755
|
+
const standard = read.standard;
|
|
756
|
+
const hasXapi = read.ok && read.config.xapi != null;
|
|
713
757
|
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
758
|
+
// The launch standards (cmi5, plain xAPI) own a publisher the runtime
|
|
759
|
+
// can share for `endpoint: 'lms'`, so wire the client regardless of
|
|
760
|
+
// explicit xapi config.
|
|
761
|
+
if (hasXapi || standard === 'cmi5' || standard === 'xapi') {
|
|
717
762
|
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
718
763
|
}
|
|
719
764
|
|
|
@@ -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
|
}
|
package/src/plugin/manifest.ts
CHANGED
|
@@ -90,23 +90,7 @@ export function deriveSlug(name: string, isFile = false): string {
|
|
|
90
90
|
return stripPrefix(name);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export
|
|
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 =
|
|
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' };
|
|
@@ -139,6 +121,27 @@ export function readCourseConfig(projectRoot: string): CourseConfigRead {
|
|
|
139
121
|
}
|
|
140
122
|
}
|
|
141
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
|
+
|
|
142
145
|
/**
|
|
143
146
|
* Read a _meta.js file and extract its default export object.
|
|
144
147
|
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
|
|
@@ -150,9 +153,7 @@ export function readMetaFile(metaPath: string): {
|
|
|
150
153
|
} {
|
|
151
154
|
if (!existsSync(metaPath)) return {};
|
|
152
155
|
|
|
153
|
-
const result =
|
|
154
|
-
readSourceFileCached(metaPath),
|
|
155
|
-
);
|
|
156
|
+
const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
156
157
|
if (result.kind !== 'literal') return {};
|
|
157
158
|
|
|
158
159
|
try {
|
package/src/plugin/new-cli.ts
CHANGED
|
@@ -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);
|
|
@@ -3,9 +3,12 @@ import { validateProject, reportValidationIssues } from './validation.js';
|
|
|
3
3
|
|
|
4
4
|
export function runValidate(
|
|
5
5
|
projectRoot: string,
|
|
6
|
-
{
|
|
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
|
|