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/a11y/audit.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
import { generateManifest, readCourseConfig } from '../manifest.js';
|
|
@@ -8,8 +8,6 @@ import { normalizeA11y, type A11ySettings } from '../validation.js';
|
|
|
8
8
|
export interface AuditOptions {
|
|
9
9
|
/** Minimum violation impact that fails the run (CI gate). Default 'serious'. */
|
|
10
10
|
threshold?: ImpactLevel;
|
|
11
|
-
/** Force a fresh `vite build` even if dist/ exists. */
|
|
12
|
-
rebuild?: boolean;
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
export type ImpactLevel = 'minor' | 'moderate' | 'serious' | 'critical';
|
|
@@ -378,7 +376,6 @@ export async function runAudit(
|
|
|
378
376
|
|
|
379
377
|
// A throwaway web build, kept out of dist/ so a real LMS export is untouched.
|
|
380
378
|
const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
|
|
381
|
-
const distHtml = resolve(auditDist, 'index.html');
|
|
382
379
|
|
|
383
380
|
const prevEnv = process.env[AUDIT_ENV_FLAG];
|
|
384
381
|
process.env[AUDIT_ENV_FLAG] = '1';
|
|
@@ -386,15 +383,13 @@ export async function runAudit(
|
|
|
386
383
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
387
384
|
let server: any;
|
|
388
385
|
try {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
);
|
|
397
|
-
}
|
|
386
|
+
console.log('[tessera a11y] Building course…');
|
|
387
|
+
await vite.build(
|
|
388
|
+
vite.mergeConfig(auditBaseConfig, {
|
|
389
|
+
build: { outDir: auditDist, emptyOutDir: true },
|
|
390
|
+
logLevel: 'warn',
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
398
393
|
|
|
399
394
|
server = await vite.preview({
|
|
400
395
|
root: projectRoot,
|
package/src/plugin/a11y-cli.ts
CHANGED
|
@@ -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 = {
|
|
33
|
+
const args: AuditOptions = {};
|
|
37
34
|
if (threshold !== undefined) args.threshold = threshold;
|
|
38
35
|
return { ok: true, args };
|
|
39
36
|
}
|
package/src/plugin/ast.ts
CHANGED
|
@@ -41,10 +41,12 @@ interface CacheEntry {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const rootCache = new Map<string, CacheEntry>();
|
|
44
|
+
const jsModuleCache = new Map<string, Node | null>();
|
|
44
45
|
|
|
45
46
|
/** Drop every cached root. Call at the start of a run to scope the cache. */
|
|
46
47
|
export function clearParseCache(): void {
|
|
47
48
|
rootCache.clear();
|
|
49
|
+
jsModuleCache.clear();
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
function parseRoot(source: string): CacheEntry {
|
|
@@ -168,14 +170,19 @@ const TsParser = Parser.extend(
|
|
|
168
170
|
);
|
|
169
171
|
|
|
170
172
|
function parseJsModule(source: string): Node | null {
|
|
173
|
+
const cached = jsModuleCache.get(source);
|
|
174
|
+
if (cached !== undefined) return cached;
|
|
175
|
+
let result: Node | null;
|
|
171
176
|
try {
|
|
172
|
-
|
|
177
|
+
result = TsParser.parse(source, {
|
|
173
178
|
ecmaVersion: 'latest',
|
|
174
179
|
sourceType: 'module',
|
|
175
180
|
}) as unknown as Node;
|
|
176
181
|
} catch {
|
|
177
|
-
|
|
182
|
+
result = null;
|
|
178
183
|
}
|
|
184
|
+
jsModuleCache.set(source, result);
|
|
185
|
+
return result;
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
function unwrapTsCast(node: Node | null): Node | null {
|
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
|
|
@@ -35,6 +34,27 @@ export function splitCourseArg(rest: string[]): {
|
|
|
35
34
|
return { course: undefined, flags: rest };
|
|
36
35
|
}
|
|
37
36
|
|
|
37
|
+
type CourseCommand = (
|
|
38
|
+
courseRoot: string,
|
|
39
|
+
workspaceRoot: string,
|
|
40
|
+
flags: string[],
|
|
41
|
+
) => number | Promise<number>;
|
|
42
|
+
|
|
43
|
+
const COURSE_COMMANDS: Record<string, CourseCommand> = {
|
|
44
|
+
dev: async (courseRoot, workspaceRoot) =>
|
|
45
|
+
(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),
|
|
49
|
+
a11y: (courseRoot, workspaceRoot, flags) =>
|
|
50
|
+
runA11y(courseRoot, workspaceRoot, flags),
|
|
51
|
+
check: (courseRoot, workspaceRoot, flags) => {
|
|
52
|
+
const validateCode = runValidate(courseRoot, { showA11yTip: false });
|
|
53
|
+
if (validateCode !== 0) return validateCode;
|
|
54
|
+
return runA11y(courseRoot, workspaceRoot, flags);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
38
58
|
export async function main(
|
|
39
59
|
argv: string[],
|
|
40
60
|
cwd: string = process.cwd(),
|
|
@@ -44,56 +64,34 @@ export async function main(
|
|
|
44
64
|
if (sub === 'new') return runNew(rest[0], cwd);
|
|
45
65
|
if (sub === 'duplicate') return runDuplicate(rest[0], rest[1], cwd);
|
|
46
66
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
case 'export':
|
|
50
|
-
case 'validate':
|
|
51
|
-
case 'a11y':
|
|
52
|
-
case 'check': {
|
|
53
|
-
if (rest.includes('--help') || rest.includes('-h')) {
|
|
54
|
-
console.log(USAGE);
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
const { course, flags } = splitCourseArg(rest);
|
|
58
|
-
const resolved = resolveCourse(cwd, course);
|
|
59
|
-
if (!resolved.ok) {
|
|
60
|
-
console.error(`[tessera] ${resolved.error}`);
|
|
61
|
-
return 1;
|
|
62
|
-
}
|
|
63
|
-
const { courseRoot, workspaceRoot } = resolved;
|
|
64
|
-
|
|
65
|
-
switch (sub) {
|
|
66
|
-
case 'dev': {
|
|
67
|
-
const { runDev } = await import('./build-commands.js');
|
|
68
|
-
return runDev(courseRoot, workspaceRoot);
|
|
69
|
-
}
|
|
70
|
-
case 'export': {
|
|
71
|
-
const { runBuild } = await import('./build-commands.js');
|
|
72
|
-
return runBuild(courseRoot, workspaceRoot);
|
|
73
|
-
}
|
|
74
|
-
case 'validate':
|
|
75
|
-
return runValidate(courseRoot);
|
|
76
|
-
case 'check': {
|
|
77
|
-
const validateCode = runValidate(courseRoot);
|
|
78
|
-
if (validateCode !== 0) return validateCode;
|
|
79
|
-
return runA11y(courseRoot, workspaceRoot, flags);
|
|
80
|
-
}
|
|
81
|
-
case 'a11y':
|
|
82
|
-
return runA11y(courseRoot, workspaceRoot, flags);
|
|
83
|
-
}
|
|
84
|
-
return 0;
|
|
85
|
-
}
|
|
86
|
-
case '--help':
|
|
87
|
-
case '-h':
|
|
67
|
+
if (sub !== undefined && Object.hasOwn(COURSE_COMMANDS, sub)) {
|
|
68
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
88
69
|
console.log(USAGE);
|
|
89
70
|
return 0;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.error(`
|
|
71
|
+
}
|
|
72
|
+
const { course, flags } = splitCourseArg(rest);
|
|
73
|
+
const resolved = resolveCourse(cwd, course);
|
|
74
|
+
if (!resolved.ok) {
|
|
75
|
+
console.error(`[tessera] ${resolved.error}`);
|
|
95
76
|
return 1;
|
|
77
|
+
}
|
|
78
|
+
return COURSE_COMMANDS[sub](
|
|
79
|
+
resolved.courseRoot,
|
|
80
|
+
resolved.workspaceRoot,
|
|
81
|
+
flags,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (sub === '--help' || sub === '-h') {
|
|
86
|
+
console.log(USAGE);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (sub === undefined) {
|
|
90
|
+
console.error(`No command given.\n\n${USAGE}`);
|
|
91
|
+
return 1;
|
|
96
92
|
}
|
|
93
|
+
console.error(`Unknown command: ${sub}\n\n${USAGE}`);
|
|
94
|
+
return 1;
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
// import.meta.main is true only when this module is the program entry point,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Baseline Content-Security-Policy for web exports, as a per-directive object so
|
|
2
|
+
// course.config.js can extend individual directives (union, never replace).
|
|
3
|
+
// 'unsafe-inline' stays because Vite injects an inline modulepreload polyfill and
|
|
4
|
+
// Svelte ships scoped <style>. blob: on frame/worker-src needs prior script
|
|
5
|
+
// execution, so it adds no attacker capability; data: is intentionally absent
|
|
6
|
+
// from frame-src (a classic XSS-escalation vector).
|
|
7
|
+
export const WEB_CSP_BASELINE: Record<string, string[]> = {
|
|
8
|
+
'default-src': ["'self'"],
|
|
9
|
+
'img-src': ["'self'", 'data:', 'https:'],
|
|
10
|
+
'media-src': ["'self'", 'blob:', 'data:', 'https:'],
|
|
11
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
12
|
+
'script-src': ["'self'", "'unsafe-inline'"],
|
|
13
|
+
'font-src': ["'self'", 'data:'],
|
|
14
|
+
'connect-src': ["'self'", 'https:'],
|
|
15
|
+
'frame-src': ["'self'", 'blob:', 'https:'],
|
|
16
|
+
'worker-src': ["'self'", 'blob:'],
|
|
17
|
+
'object-src': ["'none'"],
|
|
18
|
+
'base-uri': ["'self'"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Reject the separators (whitespace ends a source, ';' ends a directive, ','
|
|
22
|
+
// ends a policy) so a stray char can't inject directives when sources are joined,
|
|
23
|
+
// plus " < > so a source can't break out of the content="..." meta attribute.
|
|
24
|
+
const CSP_DIRECTIVE = /^[a-zA-Z][a-zA-Z-]*$/;
|
|
25
|
+
const CSP_SOURCE = /^[^\s;,"<>]+$/;
|
|
26
|
+
|
|
27
|
+
export function isCspOverrides(v: unknown): v is Record<string, string[]> {
|
|
28
|
+
return (
|
|
29
|
+
typeof v === 'object' &&
|
|
30
|
+
v !== null &&
|
|
31
|
+
!Array.isArray(v) &&
|
|
32
|
+
Object.entries(v).every(
|
|
33
|
+
([directive, sources]) =>
|
|
34
|
+
CSP_DIRECTIVE.test(directive) &&
|
|
35
|
+
Array.isArray(sources) &&
|
|
36
|
+
sources.every((s) => typeof s === 'string' && CSP_SOURCE.test(s)),
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Malformed input falls back to the baseline unchanged — validation surfaces the
|
|
42
|
+
// warning separately.
|
|
43
|
+
export function buildCsp(overrides?: unknown): string {
|
|
44
|
+
const merged = new Map(
|
|
45
|
+
Object.entries(WEB_CSP_BASELINE).map(([k, v]) => [k, [...v]]),
|
|
46
|
+
);
|
|
47
|
+
if (isCspOverrides(overrides)) {
|
|
48
|
+
for (const [directive, sources] of Object.entries(overrides)) {
|
|
49
|
+
const existing = merged.get(directive) ?? [];
|
|
50
|
+
for (const src of sources) {
|
|
51
|
+
if (!existing.includes(src)) existing.push(src);
|
|
52
|
+
}
|
|
53
|
+
merged.set(directive, existing);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...merged]
|
|
57
|
+
.map(([directive, sources]) => `${directive} ${sources.join(' ')}`)
|
|
58
|
+
.join('; ');
|
|
59
|
+
}
|
|
@@ -1,8 +1,43 @@
|
|
|
1
|
-
import { cpSync, existsSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { basename, join, relative } from 'node:path';
|
|
3
4
|
import { resolveCourse } from './course-root.js';
|
|
4
5
|
import { validateProjectName } from './project-name.js';
|
|
5
6
|
|
|
7
|
+
// The top-level `id` property as scaffolders write it: the key (bare or quoted)
|
|
8
|
+
// first on its own line, then its value. Anchoring to line start keeps `id:`
|
|
9
|
+
// inside a comment or a string value from matching, and CourseConfig has no
|
|
10
|
+
// nested `id`, so the first match is always the course identity.
|
|
11
|
+
const ID_LINE =
|
|
12
|
+
/^([ \t]*'?id'?[ \t]*:[ \t]*)('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|[^\n,}]*)/m;
|
|
13
|
+
const DEFAULT_OBJECT = /export\s+default\s*\{/;
|
|
14
|
+
|
|
15
|
+
// A verbatim copy inherits the source's `id`; mint a fresh one so the duplicate
|
|
16
|
+
// is a distinct course (own storage key + LRS activity id).
|
|
17
|
+
function reidentifyCourse(courseRoot: string): void {
|
|
18
|
+
const configPath = join(courseRoot, 'course.config.js');
|
|
19
|
+
if (!existsSync(configPath)) return;
|
|
20
|
+
const text = readFileSync(configPath, 'utf-8');
|
|
21
|
+
const newId = `'urn:uuid:${randomUUID()}'`;
|
|
22
|
+
|
|
23
|
+
if (ID_LINE.test(text)) {
|
|
24
|
+
writeFileSync(configPath, text.replace(ID_LINE, `$1${newId}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const obj = DEFAULT_OBJECT.exec(text);
|
|
28
|
+
if (obj) {
|
|
29
|
+
const at = obj.index + obj[0].length;
|
|
30
|
+
writeFileSync(
|
|
31
|
+
configPath,
|
|
32
|
+
`${text.slice(0, at)}\n id: ${newId},${text.slice(at)}`,
|
|
33
|
+
);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.warn(
|
|
37
|
+
`[tessera duplicate] Could not set a unique id in ${configPath} — the copy shares the source's identity. Add a unique "id" manually.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
6
41
|
const HELP =
|
|
7
42
|
'Usage: tessera duplicate <source> <new>\n\n' +
|
|
8
43
|
'Copy courses/<source>/ to courses/<new>/ within the current workspace.';
|
|
@@ -62,6 +97,7 @@ export function runDuplicate(
|
|
|
62
97
|
recursive: true,
|
|
63
98
|
filter: (src) => src === srcDir || !skip(src),
|
|
64
99
|
});
|
|
100
|
+
reidentifyCourse(destDir);
|
|
65
101
|
|
|
66
102
|
const rel = relative(workspaceRoot, destDir);
|
|
67
103
|
const srcRel = relative(workspaceRoot, srcDir);
|
package/src/plugin/export.ts
CHANGED
|
@@ -10,11 +10,13 @@ import { createWriteStream } from 'node:fs';
|
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { ZipArchive } from 'archiver';
|
|
12
12
|
import { slugify } from '../runtime/slugify.js';
|
|
13
|
+
import { courseIdentity } from '../runtime/types.js';
|
|
13
14
|
|
|
14
15
|
// ---------- Types ----------
|
|
15
16
|
|
|
16
17
|
interface ExportConfig {
|
|
17
18
|
title: string;
|
|
19
|
+
id?: string;
|
|
18
20
|
description?: string;
|
|
19
21
|
version?: string;
|
|
20
22
|
scoring?: { passingScore?: number };
|
|
@@ -24,6 +26,8 @@ interface ExportConfig {
|
|
|
24
26
|
|
|
25
27
|
// ---------- Helpers ----------
|
|
26
28
|
|
|
29
|
+
const UNTITLED_TITLE = 'Untitled Course';
|
|
30
|
+
|
|
27
31
|
function escapeXml(str: string): string {
|
|
28
32
|
return str
|
|
29
33
|
.replace(/&/g, '&')
|
|
@@ -40,11 +44,14 @@ function collectFiles(dir: string, base: string = ''): string[] {
|
|
|
40
44
|
const files: string[] = [];
|
|
41
45
|
if (!existsSync(dir)) return files;
|
|
42
46
|
|
|
43
|
-
for (const entry of readdirSync(dir)) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
48
|
+
const relPath = base ? `${base}/${entry.name}` : entry.name;
|
|
49
|
+
// Dirent is lstat-based; stat symlinks so a symlinked dir still recurses.
|
|
50
|
+
const isDir = entry.isSymbolicLink()
|
|
51
|
+
? statSync(resolve(dir, entry.name)).isDirectory()
|
|
52
|
+
: entry.isDirectory();
|
|
53
|
+
if (isDir) {
|
|
54
|
+
files.push(...collectFiles(resolve(dir, entry.name), relPath));
|
|
48
55
|
} else {
|
|
49
56
|
files.push(relPath);
|
|
50
57
|
}
|
|
@@ -68,6 +75,13 @@ function stableUrn(kind: 'course' | 'au', seed: string): string {
|
|
|
68
75
|
return `urn:tessera:${kind}:${h.slice(0, 32)}`;
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
// AU activity id, derived from the course id so re-exports don't orphan LRS
|
|
79
|
+
// records. Shared by the cmi5 and tincan manifests.
|
|
80
|
+
function auIdFor(config: ExportConfig): string {
|
|
81
|
+
const id = courseIdentity(config);
|
|
82
|
+
return stableUrn('au', id ? `${id}#au` : 'tessera-au');
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
function formatSize(bytes: number): string {
|
|
72
86
|
if (bytes < 1024) return `${bytes} B`;
|
|
73
87
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -115,7 +129,7 @@ export function generateScormManifest(
|
|
|
115
129
|
distDir: string,
|
|
116
130
|
): string {
|
|
117
131
|
const dialect = SCORM_DIALECTS[version];
|
|
118
|
-
const title = escapeXml(config.title ||
|
|
132
|
+
const title = escapeXml(config.title || UNTITLED_TITLE);
|
|
119
133
|
const files = collectFiles(distDir);
|
|
120
134
|
const fileElements = files
|
|
121
135
|
.map((f) => ` <file href="${escapeXml(f)}" />`)
|
|
@@ -147,27 +161,16 @@ ${fileElements}
|
|
|
147
161
|
</manifest>`;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
export function generateSCORM12Manifest(
|
|
151
|
-
config: ExportConfig,
|
|
152
|
-
distDir: string,
|
|
153
|
-
): string {
|
|
154
|
-
return generateScormManifest('1.2', config, distDir);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function generateSCORM2004Manifest(
|
|
158
|
-
config: ExportConfig,
|
|
159
|
-
distDir: string,
|
|
160
|
-
): string {
|
|
161
|
-
return generateScormManifest('2004', config, distDir);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
164
|
export function generateCMI5Xml(config: ExportConfig): string {
|
|
165
|
-
const title = escapeXml(config.title ||
|
|
165
|
+
const title = escapeXml(config.title || UNTITLED_TITLE);
|
|
166
166
|
const description = escapeXml(config.description || '');
|
|
167
|
-
// Derive stable IDs from the course
|
|
167
|
+
// Derive stable IDs from the course id so they survive rebuilds without
|
|
168
168
|
// orphaning existing learner records in the LRS.
|
|
169
|
-
const courseId = stableUrn(
|
|
170
|
-
|
|
169
|
+
const courseId = stableUrn(
|
|
170
|
+
'course',
|
|
171
|
+
courseIdentity(config) || 'tessera-course',
|
|
172
|
+
);
|
|
173
|
+
const auId = auIdFor(config);
|
|
171
174
|
// cmi5 §10.2.4 caps masteryScore at 4 decimals; avoid float drift like 0.7000000000000001.
|
|
172
175
|
const masteryScore = Number(
|
|
173
176
|
((config.scoring?.passingScore ?? 70) / 100).toFixed(4),
|
|
@@ -195,6 +198,25 @@ export function generateCMI5Xml(config: ExportConfig): string {
|
|
|
195
198
|
</courseStructure>`;
|
|
196
199
|
}
|
|
197
200
|
|
|
201
|
+
export function generateTincanXml(config: ExportConfig): string {
|
|
202
|
+
const title = escapeXml(config.title || UNTITLED_TITLE);
|
|
203
|
+
const description = escapeXml(config.description || '');
|
|
204
|
+
// Reuse the cmi5/SCORM stable-id scheme so re-exports don't orphan LRS records.
|
|
205
|
+
const auId = auIdFor(config);
|
|
206
|
+
// tincan.xml carries NO xAPI version — the version is set at runtime by the
|
|
207
|
+
// adapter's X-Experience-API-Version header, not declared in the manifest.
|
|
208
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
209
|
+
<tincan xmlns="http://projecttincan.com/tincan.xsd">
|
|
210
|
+
<activities>
|
|
211
|
+
<activity id="${auId}" type="http://adlnet.gov/expapi/activities/course">
|
|
212
|
+
<name>${title}</name>
|
|
213
|
+
<description lang="en-US">${description}</description>
|
|
214
|
+
<launch lang="en-US">index.html</launch>
|
|
215
|
+
</activity>
|
|
216
|
+
</activities>
|
|
217
|
+
</tincan>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
// ---------- ZIP Packaging ----------
|
|
199
221
|
|
|
200
222
|
export async function createZip(
|
|
@@ -238,7 +260,7 @@ function cleanOldZips(projectRoot: string, slug: string): void {
|
|
|
238
260
|
|
|
239
261
|
/** Packaged (zipped) export targets: which manifest file to write and how. */
|
|
240
262
|
const PACKAGED_EXPORTS: Record<
|
|
241
|
-
'scorm12' | 'scorm2004' | 'cmi5',
|
|
263
|
+
'scorm12' | 'scorm2004' | 'cmi5' | 'xapi',
|
|
242
264
|
{
|
|
243
265
|
manifestFile: string;
|
|
244
266
|
label: string;
|
|
@@ -248,18 +270,25 @@ const PACKAGED_EXPORTS: Record<
|
|
|
248
270
|
scorm12: {
|
|
249
271
|
manifestFile: 'imsmanifest.xml',
|
|
250
272
|
label: 'SCORM 1.2',
|
|
251
|
-
generate:
|
|
273
|
+
generate: (config, distDir) =>
|
|
274
|
+
generateScormManifest('1.2', config, distDir),
|
|
252
275
|
},
|
|
253
276
|
scorm2004: {
|
|
254
277
|
manifestFile: 'imsmanifest.xml',
|
|
255
278
|
label: 'SCORM 2004',
|
|
256
|
-
generate:
|
|
279
|
+
generate: (config, distDir) =>
|
|
280
|
+
generateScormManifest('2004', config, distDir),
|
|
257
281
|
},
|
|
258
282
|
cmi5: {
|
|
259
283
|
manifestFile: 'cmi5.xml',
|
|
260
284
|
label: 'CMI5',
|
|
261
285
|
generate: (config) => generateCMI5Xml(config),
|
|
262
286
|
},
|
|
287
|
+
xapi: {
|
|
288
|
+
manifestFile: 'tincan.xml',
|
|
289
|
+
label: 'xAPI 1.0.3',
|
|
290
|
+
generate: (config) => generateTincanXml(config),
|
|
291
|
+
},
|
|
263
292
|
};
|
|
264
293
|
|
|
265
294
|
export async function runExport(
|