tessera-learn 0.2.2 → 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.
- package/AGENTS.md +161 -535
- package/README.md +2 -2
- package/dist/{audit-B9VHgVjk.js → audit-DkXqQTqn.js} +92 -38
- package/dist/audit-DkXqQTqn.js.map +1 -0
- package/dist/{build-commands-D127jw0J.js → build-commands-CyzuCDXg.js} +2 -2
- package/dist/{build-commands-D127jw0J.js.map → build-commands-CyzuCDXg.js.map} +1 -1
- package/dist/{inline-config-eHjv9XuA.js → inline-config-BEXyRqsJ.js} +2 -2
- package/dist/{inline-config-eHjv9XuA.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +62 -54
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +280 -3
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin--8H9xQIl.js → plugin-CFUFgwHB.js} +126 -83
- package/dist/plugin-CFUFgwHB.js.map +1 -0
- package/package.json +12 -9
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/a11y/audit.ts +8 -13
- package/src/plugin/a11y-cli.ts +1 -4
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/cli.ts +46 -48
- 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 +117 -61
- package/src/plugin/manifest.ts +3 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +10 -4
- package/src/plugin/validation.ts +48 -12
- package/src/runtime/App.svelte +10 -8
- 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/scorm2004.ts +2 -1
- package/src/runtime/adapters/web.ts +19 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/types.ts +19 -1
- package/src/runtime/xapi/publisher.ts +5 -1
- 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-B9VHgVjk.js.map +0 -1
- package/dist/plugin--8H9xQIl.js.map +0 -1
package/src/plugin/index.ts
CHANGED
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
cpSync,
|
|
11
11
|
mkdirSync,
|
|
12
12
|
} from 'node:fs';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
generateManifest,
|
|
15
|
+
readCourseConfig,
|
|
16
|
+
type CourseConfigRead,
|
|
17
|
+
} from './manifest.js';
|
|
14
18
|
import type { Manifest } from './manifest.js';
|
|
15
19
|
import type { CourseConfig } from '../runtime/types.js';
|
|
16
20
|
import {
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
isIgnored,
|
|
26
30
|
type A11ySettings,
|
|
27
31
|
} from './validation.js';
|
|
32
|
+
import { buildCsp } from './csp.js';
|
|
28
33
|
import { runExport } from './export.js';
|
|
29
34
|
import { tesseraLayoutPlugin } from './layout.js';
|
|
30
35
|
import { tesseraQuizPlugin } from './quiz.js';
|
|
@@ -188,9 +193,10 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
188
193
|
// For build mode: write index.html so Rollup can find it
|
|
189
194
|
buildStart() {
|
|
190
195
|
if (isBuild) {
|
|
196
|
+
const read = readCourseConfig(projectRoot);
|
|
191
197
|
writeFileSync(
|
|
192
198
|
resolve(projectRoot, 'index.html'),
|
|
193
|
-
generateIndexHtml(readLanguage(
|
|
199
|
+
generateIndexHtml(readLanguage(read), cspMeta(read)),
|
|
194
200
|
'utf-8',
|
|
195
201
|
);
|
|
196
202
|
}
|
|
@@ -221,7 +227,9 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
221
227
|
return () => {
|
|
222
228
|
server.middlewares.use(async (req, res, next) => {
|
|
223
229
|
if (req.url === '/' || req.url === '/index.html') {
|
|
224
|
-
const html = generateIndexHtml(
|
|
230
|
+
const html = generateIndexHtml(
|
|
231
|
+
readLanguage(readCourseConfig(projectRoot)),
|
|
232
|
+
);
|
|
225
233
|
const transformed = await server.transformIndexHtml(req.url, html);
|
|
226
234
|
res.setHeader('Content-Type', 'text/html');
|
|
227
235
|
res.statusCode = 200;
|
|
@@ -252,18 +260,35 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
252
260
|
// 'en' fallback applied here: the config default-merge runs later than buildStart.
|
|
253
261
|
// Only a validated BCP-47 tag is interpolated into <html lang>, so a malformed
|
|
254
262
|
// value (caught separately as a warning) can't ship a broken attribute.
|
|
255
|
-
function readLanguage(
|
|
256
|
-
const read = readCourseConfig(projectRoot);
|
|
263
|
+
function readLanguage(read: CourseConfigRead): string {
|
|
257
264
|
const lang = read.ok ? read.config.language : undefined;
|
|
258
265
|
return isPlausibleLanguageTag(lang) ? lang : 'en';
|
|
259
266
|
}
|
|
260
267
|
|
|
261
|
-
|
|
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
|
+
// Web export only — never on LMS packages (whose iframe JS bridges a meta CSP
|
|
276
|
+
// could break) and never on the dev server (a meta connect-src would block
|
|
277
|
+
// Vite's HMR websocket). `export.csp` extends the baseline per-directive, or
|
|
278
|
+
// `false` drops the meta for deployments that set a CSP header themselves.
|
|
279
|
+
function cspMeta(read: CourseConfigRead): string {
|
|
280
|
+
if (readExportStandard(read) !== 'web') return '';
|
|
281
|
+
const csp = read.ok ? read.config.export?.csp : undefined;
|
|
282
|
+
if (csp === false) return '';
|
|
283
|
+
return `\n <meta http-equiv="Content-Security-Policy" content="${buildCsp(csp)}" />`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function generateIndexHtml(lang: string, csp = ''): string {
|
|
262
287
|
return `<!DOCTYPE html>
|
|
263
288
|
<html lang="${lang}">
|
|
264
289
|
<head>
|
|
265
290
|
<meta charset="UTF-8" />
|
|
266
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0"
|
|
291
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />${csp}
|
|
267
292
|
<title>Tessera Course</title>
|
|
268
293
|
</head>
|
|
269
294
|
<body>
|
|
@@ -326,6 +351,12 @@ function completionDefaults(mode: string | undefined): {
|
|
|
326
351
|
if (mode === 'manual') {
|
|
327
352
|
return { completion: { mode: 'manual' }, passingScore: 0 };
|
|
328
353
|
}
|
|
354
|
+
if (mode === 'quiz') {
|
|
355
|
+
return {
|
|
356
|
+
completion: { mode: 'quiz' },
|
|
357
|
+
passingScore: DEFAULT_PASSING_SCORE,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
329
360
|
return {
|
|
330
361
|
completion: {
|
|
331
362
|
mode: 'percentage',
|
|
@@ -353,6 +384,21 @@ function tesseraConfigDefaultsPlugin(): Plugin {
|
|
|
353
384
|
};
|
|
354
385
|
}
|
|
355
386
|
|
|
387
|
+
/** Fill runtime defaults into a parsed course.config.js. Exported for tests. */
|
|
388
|
+
export function mergeCourseConfig(userConfig: Partial<CourseConfig>) {
|
|
389
|
+
const { completion, passingScore } = completionDefaults(
|
|
390
|
+
userConfig.completion?.mode,
|
|
391
|
+
);
|
|
392
|
+
return {
|
|
393
|
+
...userConfig,
|
|
394
|
+
title: userConfig.title || 'Untitled Course',
|
|
395
|
+
navigation: { mode: 'free', ...userConfig.navigation },
|
|
396
|
+
completion: { ...completion, ...userConfig.completion },
|
|
397
|
+
scoring: { passingScore, ...userConfig.scoring },
|
|
398
|
+
export: { standard: 'web', ...userConfig.export },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
356
402
|
function tesseraConfigPlugin(): Plugin {
|
|
357
403
|
return virtualModule(
|
|
358
404
|
'tessera:config',
|
|
@@ -362,18 +408,7 @@ function tesseraConfigPlugin(): Plugin {
|
|
|
362
408
|
if (existsSync(configPath)) this.addWatchFile(configPath);
|
|
363
409
|
const read = readCourseConfig(projectRoot);
|
|
364
410
|
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)};`;
|
|
411
|
+
return `export default ${JSON.stringify(mergeCourseConfig(userConfig))};`;
|
|
377
412
|
},
|
|
378
413
|
);
|
|
379
414
|
}
|
|
@@ -611,7 +646,8 @@ function tesseraManifestPlugin(manifestRef: {
|
|
|
611
646
|
value === Infinity ? 1e9 : value,
|
|
612
647
|
);
|
|
613
648
|
const b64 = Buffer.from(json).toString('base64');
|
|
614
|
-
|
|
649
|
+
// atob yields Latin1 bytes; decode through UTF-8 or non-ASCII titles ship as mojibake.
|
|
650
|
+
return `export default JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${b64}"),(c)=>c.charCodeAt(0))));`;
|
|
615
651
|
}
|
|
616
652
|
return null;
|
|
617
653
|
},
|
|
@@ -620,6 +656,55 @@ function tesseraManifestPlugin(manifestRef: {
|
|
|
620
656
|
|
|
621
657
|
const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
|
|
622
658
|
|
|
659
|
+
// `takesApi`: SCORM detectors return the API object the constructor needs;
|
|
660
|
+
// cmi5/xAPI ones return a boolean.
|
|
661
|
+
const LMS_ADAPTER_GEN: Record<
|
|
662
|
+
'scorm12' | 'scorm2004' | 'cmi5' | 'xapi',
|
|
663
|
+
{ adapter: string; module: string; detect: string; takesApi: boolean }
|
|
664
|
+
> = {
|
|
665
|
+
scorm12: {
|
|
666
|
+
adapter: 'SCORM12Adapter',
|
|
667
|
+
module: 'scorm12',
|
|
668
|
+
detect: 'findSCORM12API',
|
|
669
|
+
takesApi: true,
|
|
670
|
+
},
|
|
671
|
+
scorm2004: {
|
|
672
|
+
adapter: 'SCORM2004Adapter',
|
|
673
|
+
module: 'scorm2004',
|
|
674
|
+
detect: 'findSCORM2004API',
|
|
675
|
+
takesApi: true,
|
|
676
|
+
},
|
|
677
|
+
cmi5: {
|
|
678
|
+
adapter: 'CMI5Adapter',
|
|
679
|
+
module: 'cmi5',
|
|
680
|
+
detect: 'hasCMI5LaunchParams',
|
|
681
|
+
takesApi: false,
|
|
682
|
+
},
|
|
683
|
+
xapi: {
|
|
684
|
+
adapter: 'XAPIAdapter',
|
|
685
|
+
module: 'xapi',
|
|
686
|
+
detect: 'hasXAPILaunchParams',
|
|
687
|
+
takesApi: false,
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
function generateLmsAdapterModule(
|
|
692
|
+
standard: keyof typeof LMS_ADAPTER_GEN,
|
|
693
|
+
): string {
|
|
694
|
+
const { adapter, module, detect, takesApi } = LMS_ADAPTER_GEN[standard];
|
|
695
|
+
const guard = takesApi
|
|
696
|
+
? `const api = ${detect}();\n if (!api) throw missingApiError('${standard}');\n return new ${adapter}(api);`
|
|
697
|
+
: `if (!${detect}()) throw missingApiError('${standard}');\n return new ${adapter}();`;
|
|
698
|
+
return `
|
|
699
|
+
import { ${adapter} } from 'tessera-learn/runtime/adapters/${module}.js';
|
|
700
|
+
import { ${detect} } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
701
|
+
import { missingApiError } from 'tessera-learn/runtime/adapters/lms-error.js';
|
|
702
|
+
export function createAdapter() {
|
|
703
|
+
${guard}
|
|
704
|
+
}
|
|
705
|
+
`;
|
|
706
|
+
}
|
|
707
|
+
|
|
623
708
|
function tesseraAdapterPlugin(): Plugin {
|
|
624
709
|
return virtualModule(
|
|
625
710
|
'tessera:adapter',
|
|
@@ -641,47 +726,17 @@ function tesseraAdapterPlugin(): Plugin {
|
|
|
641
726
|
// cmi5 adapters throw when their API is absent, so render with WebAdapter.
|
|
642
727
|
if (isAuditBuild()) standard = 'web';
|
|
643
728
|
|
|
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 `
|
|
729
|
+
if (standard in LMS_ADAPTER_GEN) {
|
|
730
|
+
return generateLmsAdapterModule(
|
|
731
|
+
standard as keyof typeof LMS_ADAPTER_GEN,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
return `
|
|
679
735
|
import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
|
|
680
|
-
export function createAdapter(config) {
|
|
681
|
-
return new WebAdapter(config);
|
|
736
|
+
export function createAdapter(config, options) {
|
|
737
|
+
return new WebAdapter(config, options && options.manifest);
|
|
682
738
|
}
|
|
683
739
|
`;
|
|
684
|
-
}
|
|
685
740
|
},
|
|
686
741
|
);
|
|
687
742
|
}
|
|
@@ -711,9 +766,10 @@ function tesseraXAPISetupPlugin(): Plugin {
|
|
|
711
766
|
hasXapi = read.config.xapi != null;
|
|
712
767
|
}
|
|
713
768
|
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
769
|
+
// The launch standards (cmi5, plain xAPI) own a publisher the runtime
|
|
770
|
+
// can share for `endpoint: 'lms'`, so wire the client regardless of
|
|
771
|
+
// explicit xapi config.
|
|
772
|
+
if (hasXapi || standard === 'cmi5' || standard === 'xapi') {
|
|
717
773
|
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
718
774
|
}
|
|
719
775
|
|
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' };
|
|
@@ -150,9 +132,7 @@ export function readMetaFile(metaPath: string): {
|
|
|
150
132
|
} {
|
|
151
133
|
if (!existsSync(metaPath)) return {};
|
|
152
134
|
|
|
153
|
-
const result =
|
|
154
|
-
readSourceFileCached(metaPath),
|
|
155
|
-
);
|
|
135
|
+
const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
156
136
|
if (result.kind !== 'literal') return {};
|
|
157
137
|
|
|
158
138
|
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);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
1
2
|
import { validateProject, reportValidationIssues } from './validation.js';
|
|
2
3
|
|
|
3
|
-
export function runValidate(
|
|
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
|
-
|
|
27
|
-
|
|
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
|
}
|
package/src/plugin/validation.ts
CHANGED
|
@@ -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 {
|
|
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 "
|
|
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
|
|
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
|
-
//
|
|
634
|
-
|
|
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
|
|
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
|
|
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
|
);
|
package/src/runtime/App.svelte
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
8
|
+
const collapsedSections = new SvelteSet();
|
|
7
9
|
|
|
8
10
|
function toggleSection(slug) {
|
|
9
11
|
if (collapsedSections.has(slug)) {
|