tessera-learn 0.2.0 → 0.2.2
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 +297 -535
- package/README.md +3 -3
- package/dist/{audit-BA5o0ick.js → audit-B9VHgVjk.js} +195 -42
- package/dist/{audit-BA5o0ick.js.map → audit-B9VHgVjk.js.map} +1 -1
- package/dist/{build-commands-C0OnV-Vg.js → build-commands-D127jw0J.js} +2 -2
- package/dist/{build-commands-C0OnV-Vg.js.map → build-commands-D127jw0J.js.map} +1 -1
- package/dist/{inline-config-CroQ-_2Y.js → inline-config-eHjv9XuA.js} +2 -2
- package/dist/{inline-config-CroQ-_2Y.js.map → inline-config-eHjv9XuA.js.map} +1 -1
- package/dist/plugin/cli.js +27 -9
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/{plugin-W_rk3Pit.js → plugin--8H9xQIl.js} +2 -2
- package/dist/{plugin-W_rk3Pit.js.map → plugin--8H9xQIl.js.map} +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +3 -27
- package/src/components/Matching.svelte +4 -26
- package/src/components/MultipleChoice.svelte +8 -27
- package/src/components/QuestionShell.svelte +35 -0
- package/src/components/Sorting.svelte +4 -26
- package/src/plugin/a11y/audit.ts +306 -45
- package/src/plugin/course-root.ts +37 -9
- package/src/runtime/App.svelte +20 -1
- package/src/runtime/adapters/cmi5.ts +5 -14
- package/src/runtime/adapters/index.ts +41 -38
- package/src/runtime/adapters/scorm12.ts +1 -1
- package/src/virtual.d.ts +6 -0
|
@@ -32,29 +32,57 @@ export function findWorkspaceRoot(cwd: string): string | null {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
function scanCourses(workspaceRoot: string): {
|
|
36
|
+
courses: string[];
|
|
37
|
+
malformed: string[];
|
|
38
|
+
} {
|
|
36
39
|
const coursesDir = join(workspaceRoot, 'courses');
|
|
40
|
+
const courses: string[] = [];
|
|
41
|
+
const malformed: string[] = [];
|
|
37
42
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.
|
|
43
|
+
for (const e of readdirSync(coursesDir, { withFileTypes: true })) {
|
|
44
|
+
if (!e.isDirectory()) continue;
|
|
45
|
+
const dir = join(coursesDir, e.name);
|
|
46
|
+
if (isCourse(dir)) courses.push(e.name);
|
|
47
|
+
else if (isDir(join(dir, 'pages'))) malformed.push(e.name);
|
|
48
|
+
}
|
|
42
49
|
} catch {
|
|
43
|
-
return
|
|
50
|
+
return { courses, malformed };
|
|
44
51
|
}
|
|
52
|
+
return { courses: courses.sort(), malformed: malformed.sort() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listCourses(workspaceRoot: string): string[] {
|
|
56
|
+
return scanCourses(workspaceRoot).courses;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listMalformedCourses(workspaceRoot: string): string[] {
|
|
60
|
+
return scanCourses(workspaceRoot).malformed;
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
const NOT_A_WORKSPACE =
|
|
48
64
|
'Not inside a Tessera workspace — no `courses/` directory was found at or above the current directory.';
|
|
49
65
|
|
|
66
|
+
function malformedHint(malformed: string[]): string {
|
|
67
|
+
if (malformed.length === 0) return '';
|
|
68
|
+
return (
|
|
69
|
+
`\nSkipped (missing course.config.js):\n` +
|
|
70
|
+
malformed.map((c) => ` courses/${c}`).join('\n')
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
function listHint(workspaceRoot: string): string {
|
|
51
|
-
const courses =
|
|
75
|
+
const { courses, malformed } = scanCourses(workspaceRoot);
|
|
52
76
|
if (courses.length === 0) {
|
|
53
|
-
return
|
|
77
|
+
return (
|
|
78
|
+
'\nNo courses found. Create one with `tessera new <name>`.' +
|
|
79
|
+
malformedHint(malformed)
|
|
80
|
+
);
|
|
54
81
|
}
|
|
55
82
|
return (
|
|
56
83
|
`\nAvailable courses:\n${courses.map((c) => ` ${c}`).join('\n')}` +
|
|
57
|
-
'\nName one (`tessera <command> <course>`) or cd into its folder.'
|
|
84
|
+
'\nName one (`tessera <command> <course>`) or cd into its folder.' +
|
|
85
|
+
malformedHint(malformed)
|
|
58
86
|
);
|
|
59
87
|
}
|
|
60
88
|
|
package/src/runtime/App.svelte
CHANGED
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
);
|
|
49
49
|
const nav = new NavigationState(manifest, progress, config, auditMode);
|
|
50
50
|
nav.setPageModules(pageModules);
|
|
51
|
+
|
|
52
|
+
// Layout-independent navigation seam the Tier-2 auditor walks pages through.
|
|
53
|
+
if (auditMode) {
|
|
54
|
+
window.__tesseraAudit = {
|
|
55
|
+
goToIndex: (i) => nav.goToPage(i),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
51
58
|
let duration = $state(new DurationTracker(0));
|
|
52
59
|
|
|
53
60
|
const onIdle =
|
|
@@ -64,6 +71,9 @@
|
|
|
64
71
|
let pageLoading = $state(true);
|
|
65
72
|
let pageError = $state(null);
|
|
66
73
|
let retryKey = $state(0);
|
|
74
|
+
// Rendered page index, surfaced on #tessera-app so the auditor can wait for a
|
|
75
|
+
// requested navigation to settle before scanning.
|
|
76
|
+
let renderedPageIndex = $state(-1);
|
|
67
77
|
|
|
68
78
|
// ---- Page context (reactive, read by Quiz in Step 8) ----
|
|
69
79
|
let pageContext = $state({
|
|
@@ -130,6 +140,7 @@
|
|
|
130
140
|
pageError = new Error(`Page not found: ${page.importPath}`);
|
|
131
141
|
PageComponent = null;
|
|
132
142
|
pageLoading = false;
|
|
143
|
+
renderedPageIndex = index;
|
|
133
144
|
return;
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -140,6 +151,7 @@
|
|
|
140
151
|
pageContext.quiz = page.quiz;
|
|
141
152
|
PageComponent = mod.default;
|
|
142
153
|
pageLoading = false;
|
|
154
|
+
renderedPageIndex = index;
|
|
143
155
|
progress.markVisited(index);
|
|
144
156
|
if (
|
|
145
157
|
manifest.pages[index].completesOn === 'view' &&
|
|
@@ -154,6 +166,7 @@
|
|
|
154
166
|
console.error(`Tessera: Failed to load page ${index}`, err);
|
|
155
167
|
pageError = err;
|
|
156
168
|
pageLoading = false;
|
|
169
|
+
renderedPageIndex = index;
|
|
157
170
|
});
|
|
158
171
|
}
|
|
159
172
|
|
|
@@ -446,6 +459,7 @@
|
|
|
446
459
|
});
|
|
447
460
|
|
|
448
461
|
onDestroy(() => {
|
|
462
|
+
if (auditMode) delete window.__tesseraAudit;
|
|
449
463
|
window.removeEventListener('pagehide', handleExit);
|
|
450
464
|
window.removeEventListener('beforeunload', handleExit);
|
|
451
465
|
const appEl = document.getElementById('tessera-app');
|
|
@@ -474,7 +488,12 @@
|
|
|
474
488
|
{/if}
|
|
475
489
|
{/snippet}
|
|
476
490
|
|
|
477
|
-
<div
|
|
491
|
+
<div
|
|
492
|
+
id="tessera-app"
|
|
493
|
+
data-chrome={chromeMode}
|
|
494
|
+
data-tessera-page-index={auditMode ? renderedPageIndex : undefined}
|
|
495
|
+
data-tessera-page-error={auditMode && pageError ? 'true' : undefined}
|
|
496
|
+
>
|
|
478
497
|
<LoadingBar active={pageLoading} />
|
|
479
498
|
{#if UserLayout}
|
|
480
499
|
<UserLayout {page} />
|
|
@@ -579,10 +579,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
579
579
|
opts: { moveOn?: boolean; mastery?: boolean } = {},
|
|
580
580
|
): Record<string, unknown> {
|
|
581
581
|
const tmpl = this.#launchData?.contextTemplate ?? {};
|
|
582
|
-
const tmplActivities =
|
|
583
|
-
string,
|
|
584
|
-
unknown
|
|
585
|
-
>;
|
|
582
|
+
const tmplActivities = tmpl.contextActivities ?? {};
|
|
586
583
|
|
|
587
584
|
// Concat-dedupe category to preserve any template-supplied entries
|
|
588
585
|
// (§10.2.1 forbids overwriting them).
|
|
@@ -594,14 +591,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
594
591
|
category.push({ id, objectType: 'Activity' });
|
|
595
592
|
}
|
|
596
593
|
};
|
|
597
|
-
const templateCategory = Array.isArray(
|
|
598
|
-
|
|
599
|
-
)
|
|
600
|
-
? (
|
|
601
|
-
tmplActivities as {
|
|
602
|
-
category: Array<{ id: string; objectType?: string }>;
|
|
603
|
-
}
|
|
604
|
-
).category
|
|
594
|
+
const templateCategory = Array.isArray(tmplActivities.category)
|
|
595
|
+
? tmplActivities.category
|
|
605
596
|
: [];
|
|
606
597
|
for (const c of templateCategory) {
|
|
607
598
|
if (c && typeof c.id === 'string') push(c.id);
|
|
@@ -615,13 +606,13 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
615
606
|
};
|
|
616
607
|
|
|
617
608
|
const ctx: Record<string, unknown> = {
|
|
618
|
-
...
|
|
609
|
+
...tmpl,
|
|
619
610
|
contextActivities,
|
|
620
611
|
};
|
|
621
612
|
// cmi5 §9.6.3.2 — masteryScore extension is scoped to Passed/Failed.
|
|
622
613
|
if (opts.mastery && this.#masteryScore !== null) {
|
|
623
614
|
ctx.extensions = {
|
|
624
|
-
...(
|
|
615
|
+
...(tmpl.extensions ?? {}),
|
|
625
616
|
[CMI5_MASTERYSCORE_EXT]: this.#masteryScore,
|
|
626
617
|
};
|
|
627
618
|
}
|
|
@@ -19,27 +19,6 @@ export class LMSAdapterError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function missingApiError(
|
|
23
|
-
standard: 'scorm12' | 'scorm2004' | 'cmi5',
|
|
24
|
-
): LMSAdapterError {
|
|
25
|
-
const label =
|
|
26
|
-
standard === 'scorm12'
|
|
27
|
-
? 'SCORM 1.2'
|
|
28
|
-
: standard === 'scorm2004'
|
|
29
|
-
? 'SCORM 2004'
|
|
30
|
-
: 'cmi5';
|
|
31
|
-
const detail =
|
|
32
|
-
standard === 'cmi5'
|
|
33
|
-
? 'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.'
|
|
34
|
-
: `No ${label} API object found in the window.parent or window.opener chain.`;
|
|
35
|
-
return new LMSAdapterError(
|
|
36
|
-
standard,
|
|
37
|
-
`Tessera: this course is configured for ${label} but ${detail} ` +
|
|
38
|
-
`The course must be launched from an LMS that provides the ${label} runtime. ` +
|
|
39
|
-
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
22
|
export interface CreateAdapterOptions {
|
|
44
23
|
/**
|
|
45
24
|
* When true, a missing LMS API falls back to `WebAdapter` with a console
|
|
@@ -49,44 +28,68 @@ export interface CreateAdapterOptions {
|
|
|
49
28
|
allowFallback?: boolean;
|
|
50
29
|
}
|
|
51
30
|
|
|
52
|
-
/**
|
|
53
|
-
* Select the appropriate persistence adapter based on course config.
|
|
54
|
-
*
|
|
55
|
-
* In production builds, an LMS-configured course (scorm12/scorm2004/cmi5)
|
|
56
|
-
* will throw `LMSAdapterError` if the matching LMS API isn't reachable —
|
|
57
|
-
* we fail loud so a misconfigured launch is visible immediately rather
|
|
58
|
-
* than silently losing tracking to localStorage.
|
|
59
|
-
*
|
|
60
|
-
* In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
|
|
61
|
-
* can still iterate locally.
|
|
62
|
-
*/
|
|
63
31
|
type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
|
|
64
32
|
|
|
65
|
-
/** Per-standard LMS
|
|
33
|
+
/** Per-standard LMS wiring: `detect` returns an adapter when the LMS runtime is reachable, else null. Labels are the single source for the dev warning and production error. */
|
|
66
34
|
const LMS_ADAPTERS: Record<
|
|
67
35
|
LMSStandard,
|
|
68
|
-
{
|
|
36
|
+
{
|
|
37
|
+
detect: () => PersistenceAdapter | null;
|
|
38
|
+
warnLabel: string;
|
|
39
|
+
name: string;
|
|
40
|
+
missingDetail: string;
|
|
41
|
+
}
|
|
69
42
|
> = {
|
|
70
43
|
scorm12: {
|
|
71
44
|
detect: () => {
|
|
72
45
|
const api = findSCORM12API();
|
|
73
46
|
return api ? new SCORM12Adapter(api) : null;
|
|
74
47
|
},
|
|
75
|
-
|
|
48
|
+
warnLabel: 'SCORM 1.2 API',
|
|
49
|
+
name: 'SCORM 1.2',
|
|
50
|
+
missingDetail:
|
|
51
|
+
'No SCORM 1.2 API object found in the window.parent or window.opener chain.',
|
|
76
52
|
},
|
|
77
53
|
scorm2004: {
|
|
78
54
|
detect: () => {
|
|
79
55
|
const api = findSCORM2004API();
|
|
80
56
|
return api ? new SCORM2004Adapter(api) : null;
|
|
81
57
|
},
|
|
82
|
-
|
|
58
|
+
warnLabel: 'SCORM 2004 API',
|
|
59
|
+
name: 'SCORM 2004',
|
|
60
|
+
missingDetail:
|
|
61
|
+
'No SCORM 2004 API object found in the window.parent or window.opener chain.',
|
|
83
62
|
},
|
|
84
63
|
cmi5: {
|
|
85
64
|
detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
|
|
86
|
-
|
|
65
|
+
warnLabel: 'cmi5 launch parameters',
|
|
66
|
+
name: 'cmi5',
|
|
67
|
+
missingDetail:
|
|
68
|
+
'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.',
|
|
87
69
|
},
|
|
88
70
|
};
|
|
89
71
|
|
|
72
|
+
function missingApiError(standard: LMSStandard): LMSAdapterError {
|
|
73
|
+
const { name, missingDetail } = LMS_ADAPTERS[standard];
|
|
74
|
+
return new LMSAdapterError(
|
|
75
|
+
standard,
|
|
76
|
+
`Tessera: this course is configured for ${name} but ${missingDetail} ` +
|
|
77
|
+
`The course must be launched from an LMS that provides the ${name} runtime. ` +
|
|
78
|
+
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Select the appropriate persistence adapter based on course config.
|
|
84
|
+
*
|
|
85
|
+
* In production builds, an LMS-configured course (scorm12/scorm2004/cmi5)
|
|
86
|
+
* will throw `LMSAdapterError` if the matching LMS API isn't reachable —
|
|
87
|
+
* we fail loud so a misconfigured launch is visible immediately rather
|
|
88
|
+
* than silently losing tracking to localStorage.
|
|
89
|
+
*
|
|
90
|
+
* In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
|
|
91
|
+
* can still iterate locally.
|
|
92
|
+
*/
|
|
90
93
|
export function createAdapter(
|
|
91
94
|
config: CourseConfig,
|
|
92
95
|
options: CreateAdapterOptions = {},
|
|
@@ -103,7 +106,7 @@ export function createAdapter(
|
|
|
103
106
|
if (adapter) return adapter;
|
|
104
107
|
if (!allowFallback) throw missingApiError(standard);
|
|
105
108
|
console.warn(
|
|
106
|
-
`Tessera (dev): ${entry.
|
|
109
|
+
`Tessera (dev): ${entry.warnLabel} not found — falling back to localStorage`,
|
|
107
110
|
);
|
|
108
111
|
}
|
|
109
112
|
return new WebAdapter(config);
|
|
@@ -29,7 +29,7 @@ const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
|
|
|
29
29
|
responseField: 'student_response',
|
|
30
30
|
timestampField: 'time',
|
|
31
31
|
timestamp: () => new Date().toTimeString().slice(0, 8),
|
|
32
|
-
typeValue:
|
|
32
|
+
typeValue: scorm12Type,
|
|
33
33
|
resultLabels: { correct: 'correct', incorrect: 'wrong' },
|
|
34
34
|
format: SCORM12_INTERACTION_FORMAT,
|
|
35
35
|
},
|