tessera-learn 0.0.9 → 0.0.11
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/dist/plugin/cli.js +5 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +215 -124
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
- package/dist/validation-D9DXlqNP.js.map +1 -0
- package/package.json +9 -6
- package/src/components/Audio.svelte +5 -2
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +60 -98
- package/src/components/Image.svelte +3 -8
- package/src/components/LockedBanner.svelte +3 -4
- package/src/components/MultipleChoice.svelte +53 -94
- package/src/components/Quiz.svelte +2 -1
- package/src/components/Video.svelte +4 -2
- package/src/components/util.ts +1 -0
- package/src/plugin/cli.ts +2 -7
- package/src/plugin/export.ts +23 -41
- package/src/plugin/index.ts +197 -56
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +31 -5
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +38 -67
- package/src/runtime/App.svelte +48 -36
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +13 -83
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +28 -29
- package/src/runtime/adapters/retry.ts +0 -64
- package/src/runtime/adapters/scorm12.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +11 -16
- package/src/runtime/hooks.svelte.ts +14 -16
- package/src/runtime/navigation.svelte.ts +51 -45
- package/src/runtime/progress.svelte.ts +25 -10
- package/src/runtime/quiz-policy.ts +21 -178
- package/src/runtime/xapi/agent-rules.ts +7 -2
- package/src/runtime/xapi/publisher.ts +1 -11
- package/src/runtime/xapi/validation.ts +1 -1
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-BxWAMMnJ.js.map +0 -1
- package/src/runtime/LoadingSkeleton.svelte +0 -26
package/src/plugin/layout.ts
CHANGED
|
@@ -1,55 +1,10 @@
|
|
|
1
|
-
import type { Plugin
|
|
2
|
-
import {
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
|
|
5
|
-
const VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';
|
|
6
|
-
const RESOLVED_LAYOUT_ID = '\0' + VIRTUAL_LAYOUT_ID;
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { createOverridePlugin } from './override-plugin.js';
|
|
7
3
|
|
|
8
4
|
export function tesseraLayoutPlugin(): Plugin {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return {
|
|
5
|
+
return createOverridePlugin({
|
|
12
6
|
name: 'tessera:layout',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
projectRoot = config.root;
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
resolveId(id) {
|
|
20
|
-
if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;
|
|
21
|
-
return null;
|
|
22
|
-
},
|
|
23
|
-
|
|
24
|
-
load(id) {
|
|
25
|
-
if (id !== RESOLVED_LAYOUT_ID) return null;
|
|
26
|
-
const layoutPath = resolve(projectRoot, 'layout.svelte');
|
|
27
|
-
if (existsSync(layoutPath)) {
|
|
28
|
-
// Register the file with Vite so edits trigger HMR / build --watch
|
|
29
|
-
// re-runs. Only add when the file actually exists — calling
|
|
30
|
-
// addWatchFile on a non-existent path makes Vite's importAnalysis
|
|
31
|
-
// try to resolve it as a real import.
|
|
32
|
-
this.addWatchFile(layoutPath);
|
|
33
|
-
const normalized = layoutPath.replace(/\\/g, '/');
|
|
34
|
-
return `export { default } from '${normalized}';`;
|
|
35
|
-
}
|
|
36
|
-
return `export default null;`;
|
|
37
|
-
},
|
|
38
|
-
|
|
39
|
-
configureServer(server: ViteDevServer) {
|
|
40
|
-
const layoutPath = resolve(projectRoot, 'layout.svelte');
|
|
41
|
-
// Only react to add/unlink: those flip the virtual module's load() output
|
|
42
|
-
// between `export default null` and `export { default } from '...'`. A
|
|
43
|
-
// `change` event leaves that output identical and is handled by Svelte's
|
|
44
|
-
// own HMR for the underlying file — full-reloading on every edit would
|
|
45
|
-
// wipe in-page state for no reason.
|
|
46
|
-
server.watcher.on('all', (event, filePath) => {
|
|
47
|
-
if (filePath !== layoutPath) return;
|
|
48
|
-
if (event !== 'add' && event !== 'unlink') return;
|
|
49
|
-
const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);
|
|
50
|
-
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
51
|
-
server.ws.send({ type: 'full-reload' });
|
|
52
|
-
});
|
|
53
|
-
},
|
|
54
|
-
};
|
|
7
|
+
virtualId: 'virtual:tessera-layout',
|
|
8
|
+
projectFile: 'layout.svelte',
|
|
9
|
+
});
|
|
55
10
|
}
|
package/src/plugin/manifest.ts
CHANGED
|
@@ -105,6 +105,29 @@ export function extractDefaultExportObjectLiteral(source: string): string | null
|
|
|
105
105
|
return extractObjectLiteral(source, startIndex);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export type CourseConfigRead =
|
|
109
|
+
| { ok: true; config: Record<string, any> }
|
|
110
|
+
| { ok: false; reason: 'missing' | 'no-export' | 'parse-error'; error?: unknown };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read and JSON5-parse the `export default { ... }` literal from a project's
|
|
114
|
+
* course.config.js. Shared by the build plugin and the validator so the read,
|
|
115
|
+
* cache, and parse rules live in one place. The discriminated `reason` lets
|
|
116
|
+
* callers that care (export, validation) emit precise errors while callers
|
|
117
|
+
* that just need a value can fall back on `!ok`.
|
|
118
|
+
*/
|
|
119
|
+
export function readCourseConfig(projectRoot: string): CourseConfigRead {
|
|
120
|
+
const configPath = resolve(projectRoot, 'course.config.js');
|
|
121
|
+
if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
|
|
122
|
+
const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
|
|
123
|
+
if (!objectStr) return { ok: false, reason: 'no-export' };
|
|
124
|
+
try {
|
|
125
|
+
return { ok: true, config: JSON5.parse(objectStr) };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, reason: 'parse-error', error };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
/**
|
|
109
132
|
* Read a _meta.js file and extract its default export object.
|
|
110
133
|
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
|
|
@@ -173,11 +196,14 @@ export function extractPageConfig(filePath: string): { title?: string; quiz?: Qu
|
|
|
173
196
|
}
|
|
174
197
|
|
|
175
198
|
/**
|
|
176
|
-
* Extract
|
|
177
|
-
*
|
|
199
|
+
* Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
|
|
200
|
+
* skipping strings and comments. Returns the substring (inclusive) or null if
|
|
201
|
+
* the open char is wrong or no matching close is found. Shared by manifest
|
|
202
|
+
* extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
|
|
178
203
|
*/
|
|
179
204
|
export function extractObjectLiteral(source: string, startIndex: number): string | null {
|
|
180
|
-
|
|
205
|
+
const open = source[startIndex];
|
|
206
|
+
if (open !== '{' && open !== '[') return null;
|
|
181
207
|
|
|
182
208
|
let depth = 0;
|
|
183
209
|
let inString: string | null = null;
|
|
@@ -222,8 +248,8 @@ export function extractObjectLiteral(source: string, startIndex: number): string
|
|
|
222
248
|
continue;
|
|
223
249
|
}
|
|
224
250
|
|
|
225
|
-
if (char === '{') depth++;
|
|
226
|
-
if (char === '}') {
|
|
251
|
+
if (char === '{' || char === '[') depth++;
|
|
252
|
+
if (char === '}' || char === ']') {
|
|
227
253
|
depth--;
|
|
228
254
|
if (depth === 0) {
|
|
229
255
|
return source.slice(startIndex, i + 1);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
2
|
+
import { normalizePath } from 'vite';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export interface OverridePluginOptions {
|
|
7
|
+
name: string;
|
|
8
|
+
virtualId: string;
|
|
9
|
+
projectFile: string;
|
|
10
|
+
/** Built-in re-exported when the project file is absent; null export otherwise. */
|
|
11
|
+
builtinFile?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A virtual module that resolves to a project-root override file when present,
|
|
16
|
+
* and to the built-in (or a null export) otherwise. Shared by the layout and
|
|
17
|
+
* quiz plugins — they differ only in the virtual id, file name, and built-in.
|
|
18
|
+
*/
|
|
19
|
+
export function createOverridePlugin({
|
|
20
|
+
name,
|
|
21
|
+
virtualId,
|
|
22
|
+
projectFile,
|
|
23
|
+
builtinFile,
|
|
24
|
+
}: OverridePluginOptions): Plugin {
|
|
25
|
+
const resolvedId = '\0' + virtualId;
|
|
26
|
+
const fallback = builtinFile
|
|
27
|
+
? `export { default } from '${normalizePath(builtinFile)}';`
|
|
28
|
+
: 'export default null;';
|
|
29
|
+
let filePath: string;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
enforce: 'pre',
|
|
34
|
+
|
|
35
|
+
configResolved(config: ResolvedConfig) {
|
|
36
|
+
filePath = resolve(config.root, projectFile);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
resolveId(id) {
|
|
40
|
+
if (id === virtualId) return resolvedId;
|
|
41
|
+
return null;
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
load(id) {
|
|
45
|
+
if (id !== resolvedId) return null;
|
|
46
|
+
if (existsSync(filePath)) {
|
|
47
|
+
// Only watch when it exists — addWatchFile on a missing path makes
|
|
48
|
+
// Vite's importAnalysis try to resolve it as a real import.
|
|
49
|
+
this.addWatchFile(filePath);
|
|
50
|
+
return `export { default } from '${normalizePath(filePath)}';`;
|
|
51
|
+
}
|
|
52
|
+
return fallback;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
configureServer(server: ViteDevServer) {
|
|
56
|
+
// Only add/unlink flips load()'s output between the override and the
|
|
57
|
+
// fallback; a `change` leaves it identical and Svelte's own HMR handles
|
|
58
|
+
// the underlying file.
|
|
59
|
+
server.watcher.on('all', (event, changed) => {
|
|
60
|
+
if (changed !== filePath) return;
|
|
61
|
+
if (event !== 'add' && event !== 'unlink') return;
|
|
62
|
+
const mod = server.moduleGraph.getModuleById(resolvedId);
|
|
63
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
64
|
+
server.ws.send({ type: 'full-reload' });
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/plugin/quiz.ts
CHANGED
|
@@ -1,65 +1,20 @@
|
|
|
1
|
-
import type { Plugin
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
3
2
|
import { resolve, dirname } from 'node:path';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
|
|
6
|
-
const VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';
|
|
7
|
-
const RESOLVED_QUIZ_ID = '\0' + VIRTUAL_QUIZ_ID;
|
|
4
|
+
import { createOverridePlugin } from './override-plugin.js';
|
|
8
5
|
|
|
9
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
7
|
const __dirname = dirname(__filename);
|
|
11
8
|
|
|
12
|
-
/**
|
|
13
|
-
* Resolve the project's quiz shell.
|
|
14
|
-
* `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,
|
|
15
|
-
* otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).
|
|
16
|
-
*/
|
|
17
9
|
export function tesseraQuizPlugin(): Plugin {
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
// `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —
|
|
21
|
-
// both layouts put `Quiz.svelte` two levels up under `src/components/`.
|
|
10
|
+
// The plugin lives in `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts`
|
|
11
|
+
// in source — both put `Quiz.svelte` two levels up under `src/components/`.
|
|
22
12
|
const packageRoot = resolve(__dirname, '..', '..');
|
|
23
13
|
const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');
|
|
24
|
-
|
|
25
|
-
return {
|
|
14
|
+
return createOverridePlugin({
|
|
26
15
|
name: 'tessera:quiz',
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
resolveId(id) {
|
|
34
|
-
if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;
|
|
35
|
-
return null;
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
load(id) {
|
|
39
|
-
if (id !== RESOLVED_QUIZ_ID) return null;
|
|
40
|
-
const userQuizPath = resolve(projectRoot, 'quiz.svelte');
|
|
41
|
-
if (existsSync(userQuizPath)) {
|
|
42
|
-
// Watch the user file so add/remove flips through HMR (see below).
|
|
43
|
-
this.addWatchFile(userQuizPath);
|
|
44
|
-
const normalized = userQuizPath.replace(/\\/g, '/');
|
|
45
|
-
return `export { default } from '${normalized}';`;
|
|
46
|
-
}
|
|
47
|
-
const normalized = builtinQuiz.replace(/\\/g, '/');
|
|
48
|
-
return `export { default } from '${normalized}';`;
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
configureServer(server: ViteDevServer) {
|
|
52
|
-
const userQuizPath = resolve(projectRoot, 'quiz.svelte');
|
|
53
|
-
// Only react to add/unlink — those flip the load() output between the
|
|
54
|
-
// user quiz and the built-in. A `change` event leaves the resolved
|
|
55
|
-
// module identical and is handled by Svelte's own HMR.
|
|
56
|
-
server.watcher.on('all', (event, filePath) => {
|
|
57
|
-
if (filePath !== userQuizPath) return;
|
|
58
|
-
if (event !== 'add' && event !== 'unlink') return;
|
|
59
|
-
const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);
|
|
60
|
-
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
61
|
-
server.ws.send({ type: 'full-reload' });
|
|
62
|
-
});
|
|
63
|
-
},
|
|
64
|
-
};
|
|
16
|
+
virtualId: 'virtual:tessera-quiz',
|
|
17
|
+
projectFile: 'quiz.svelte',
|
|
18
|
+
builtinFile: builtinQuiz,
|
|
19
|
+
});
|
|
65
20
|
}
|
package/src/plugin/validation.ts
CHANGED
|
@@ -3,11 +3,17 @@ import { resolve, relative } from 'node:path';
|
|
|
3
3
|
import JSON5 from 'json5';
|
|
4
4
|
import {
|
|
5
5
|
extractDefaultExportObjectLiteral,
|
|
6
|
+
extractObjectLiteral,
|
|
6
7
|
parsePageConfigFromSource,
|
|
7
8
|
readSourceFileCached,
|
|
8
9
|
ensureSvelteSuffix,
|
|
10
|
+
readCourseConfig,
|
|
9
11
|
} from './manifest.js';
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
validateAgent,
|
|
14
|
+
validateAuthCredential,
|
|
15
|
+
joinFieldError,
|
|
16
|
+
} from '../runtime/xapi/agent-rules.js';
|
|
11
17
|
|
|
12
18
|
// ---------- Types ----------
|
|
13
19
|
|
|
@@ -16,6 +22,16 @@ export interface ValidationResult {
|
|
|
16
22
|
warnings: string[];
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
/** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
|
|
26
|
+
export function reportValidationIssues({ errors, warnings }: ValidationResult): void {
|
|
27
|
+
for (const warning of warnings) {
|
|
28
|
+
console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
|
|
29
|
+
}
|
|
30
|
+
for (const error of errors) {
|
|
31
|
+
console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
// Known top-level config fields
|
|
20
36
|
const KNOWN_CONFIG_FIELDS = new Set([
|
|
21
37
|
'title',
|
|
@@ -55,7 +71,7 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
// 2. Parse and validate config
|
|
58
|
-
const config = parseConfig(
|
|
74
|
+
const config = parseConfig(projectRoot, errors, warnings);
|
|
59
75
|
|
|
60
76
|
// 3. Validate pages directory
|
|
61
77
|
const pagesDir = resolve(projectRoot, 'pages');
|
|
@@ -97,27 +113,25 @@ interface ParsedConfig {
|
|
|
97
113
|
}
|
|
98
114
|
|
|
99
115
|
function parseConfig(
|
|
100
|
-
|
|
116
|
+
projectRoot: string,
|
|
101
117
|
errors: string[],
|
|
102
118
|
warnings: string[]
|
|
103
119
|
): ParsedConfig | null {
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
errors.push(
|
|
117
|
-
'course.config.js: syntax error — must export a static object literal'
|
|
118
|
-
);
|
|
120
|
+
const read = readCourseConfig(projectRoot);
|
|
121
|
+
if (!read.ok) {
|
|
122
|
+
// 'missing' can't occur — validateProject checks existsSync first.
|
|
123
|
+
if (read.reason === 'no-export') {
|
|
124
|
+
errors.push(
|
|
125
|
+
'course.config.js: could not parse — must use `export default { ... }` syntax'
|
|
126
|
+
);
|
|
127
|
+
} else if (read.reason === 'parse-error') {
|
|
128
|
+
errors.push(
|
|
129
|
+
'course.config.js: syntax error — must export a static object literal'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
119
132
|
return null;
|
|
120
133
|
}
|
|
134
|
+
const config = read.config as ParsedConfig;
|
|
121
135
|
|
|
122
136
|
// Check for unknown fields
|
|
123
137
|
for (const key of Object.keys(config)) {
|
|
@@ -360,16 +374,9 @@ function validateSingleXAPIEntry(
|
|
|
360
374
|
if (auth === undefined) {
|
|
361
375
|
errors.push(`course.config.js: ${label}.auth is required`);
|
|
362
376
|
} else if (typeof auth === 'string') {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
errors.push(
|
|
367
|
-
`course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`
|
|
368
|
-
);
|
|
369
|
-
} else if (/^bearer\s/i.test(auth)) {
|
|
370
|
-
errors.push(
|
|
371
|
-
`course.config.js: ${label}.auth: Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.`
|
|
372
|
-
);
|
|
377
|
+
const authErr = validateAuthCredential(auth);
|
|
378
|
+
if (authErr) {
|
|
379
|
+
errors.push(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
|
|
373
380
|
} else {
|
|
374
381
|
warnings.push(
|
|
375
382
|
`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
|
|
@@ -411,10 +418,7 @@ function validateSingleXAPIEntry(
|
|
|
411
418
|
} else if (typeof actor === 'object' && actor !== null) {
|
|
412
419
|
const err = validateAgent(actor);
|
|
413
420
|
if (err) {
|
|
414
|
-
|
|
415
|
-
? `${label}.actor${err}`
|
|
416
|
-
: `${label}.actor ${err}`;
|
|
417
|
-
errors.push(`course.config.js: ${joined}`);
|
|
421
|
+
errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
|
|
418
422
|
}
|
|
419
423
|
} else if (typeof actor !== 'function') {
|
|
420
424
|
errors.push(
|
|
@@ -834,39 +838,6 @@ type PropValue =
|
|
|
834
838
|
| { kind: 'expr'; raw: string }
|
|
835
839
|
| { kind: 'bool' };
|
|
836
840
|
|
|
837
|
-
/** Extract a balanced {...} or [...] span starting at startIndex, or null. */
|
|
838
|
-
function extractBalanced(source: string, startIndex: number): string | null {
|
|
839
|
-
const open = source[startIndex];
|
|
840
|
-
if (open !== '{' && open !== '[') return null;
|
|
841
|
-
let depth = 0;
|
|
842
|
-
let inString: string | null = null;
|
|
843
|
-
let escaped = false;
|
|
844
|
-
for (let i = startIndex; i < source.length; i++) {
|
|
845
|
-
const char = source[i];
|
|
846
|
-
if (escaped) {
|
|
847
|
-
escaped = false;
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
if (char === '\\' && inString) {
|
|
851
|
-
escaped = true;
|
|
852
|
-
continue;
|
|
853
|
-
}
|
|
854
|
-
if (inString) {
|
|
855
|
-
if (char === inString) inString = null;
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
if (char === '"' || char === "'" || char === '`') {
|
|
859
|
-
inString = char;
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
if (char === '{' || char === '[') depth++;
|
|
863
|
-
if (char === '}' || char === ']') {
|
|
864
|
-
depth--;
|
|
865
|
-
if (depth === 0) return source.slice(startIndex, i + 1);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
return null;
|
|
869
|
-
}
|
|
870
841
|
|
|
871
842
|
/**
|
|
872
843
|
* Parse the props of an opening tag starting just after the component name.
|
|
@@ -884,7 +855,7 @@ function parseTagProps(content: string, start: number): Map<string, PropValue> |
|
|
|
884
855
|
if (c === '/' && content[i + 1] === '>') return props;
|
|
885
856
|
// Spread / shorthand expression — skip the whole {...} block.
|
|
886
857
|
if (c === '{') {
|
|
887
|
-
const block =
|
|
858
|
+
const block = extractObjectLiteral(content, i);
|
|
888
859
|
if (!block) return null;
|
|
889
860
|
i += block.length;
|
|
890
861
|
continue;
|
|
@@ -907,7 +878,7 @@ function parseTagProps(content: string, start: number): Map<string, PropValue> |
|
|
|
907
878
|
props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
|
|
908
879
|
i = end + 1;
|
|
909
880
|
} else if (v === '{') {
|
|
910
|
-
const block =
|
|
881
|
+
const block = extractObjectLiteral(content, i);
|
|
911
882
|
if (!block) return null;
|
|
912
883
|
props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
|
|
913
884
|
i += block.length;
|
package/src/runtime/App.svelte
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
import UserLayout from 'virtual:tessera-layout';
|
|
6
6
|
import Quiz from 'virtual:tessera-quiz';
|
|
7
7
|
import { onMount, onDestroy, setContext, untrack } from 'svelte';
|
|
8
|
-
import
|
|
8
|
+
import LoadingBar from './LoadingBar.svelte';
|
|
9
9
|
import ErrorPage from './ErrorPage.svelte';
|
|
10
10
|
import DefaultLayout from '../components/DefaultLayout.svelte';
|
|
11
11
|
import { NavigationState } from './navigation.svelte.js';
|
|
12
12
|
import { ProgressState } from './progress.svelte.js';
|
|
13
13
|
import { DurationTracker } from './duration.js';
|
|
14
|
-
import { createAdapter } from '
|
|
15
|
-
import { buildXAPIClient } from '
|
|
14
|
+
import { createAdapter } from 'virtual:tessera-adapter';
|
|
15
|
+
import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
|
|
16
16
|
import { registerXAPIClient } from './xapi/registry.js';
|
|
17
17
|
import { TESSERA_PAGE, TESSERA_NAV, TESSERA_ADAPTER, TESSERA_USER_STATE } from './contexts.js';
|
|
18
18
|
|
|
@@ -24,12 +24,19 @@
|
|
|
24
24
|
// can reach it.
|
|
25
25
|
let xapiClient = null;
|
|
26
26
|
|
|
27
|
+
const gradedQuizIndices = new Set(
|
|
28
|
+
manifest.pages.filter(p => p.quiz?.graded).map(p => p.index)
|
|
29
|
+
);
|
|
30
|
+
|
|
27
31
|
// ---- State classes ----
|
|
28
|
-
const progress = new ProgressState();
|
|
32
|
+
const progress = new ProgressState(gradedQuizIndices);
|
|
29
33
|
const nav = new NavigationState(manifest, progress, config);
|
|
34
|
+
nav.setPageModules(pageModules);
|
|
30
35
|
let duration = $state(new DurationTracker(0));
|
|
31
36
|
|
|
32
|
-
const
|
|
37
|
+
const onIdle = typeof window !== 'undefined' && window.requestIdleCallback
|
|
38
|
+
? window.requestIdleCallback.bind(window)
|
|
39
|
+
: (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1);
|
|
33
40
|
|
|
34
41
|
// Page loading state
|
|
35
42
|
let PageComponent = $state(null);
|
|
@@ -84,22 +91,20 @@
|
|
|
84
91
|
|
|
85
92
|
const gen = ++loadGeneration;
|
|
86
93
|
pageLoading = true;
|
|
87
|
-
pageError = null;
|
|
88
|
-
PageComponent = null;
|
|
89
|
-
|
|
90
|
-
// Update context for the new page
|
|
91
|
-
pageContext.quiz = page.quiz;
|
|
92
94
|
|
|
93
95
|
const loader = pageModules[page.importPath];
|
|
94
96
|
if (!loader) {
|
|
95
97
|
console.error(`Tessera: No loader for page ${index} at ${page.importPath}`);
|
|
96
98
|
pageError = new Error(`Page not found: ${page.importPath}`);
|
|
99
|
+
PageComponent = null;
|
|
97
100
|
pageLoading = false;
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
loader().then(mod => {
|
|
102
105
|
if (gen !== loadGeneration) return; // stale
|
|
106
|
+
pageError = null;
|
|
107
|
+
pageContext.quiz = page.quiz;
|
|
103
108
|
PageComponent = mod.default;
|
|
104
109
|
pageLoading = false;
|
|
105
110
|
progress.markVisited(index);
|
|
@@ -109,8 +114,9 @@
|
|
|
109
114
|
) {
|
|
110
115
|
progress.markCompleteManually();
|
|
111
116
|
}
|
|
112
|
-
progress.recalculateCompletion(manifest, config);
|
|
113
|
-
progress.recalculateSuccess(
|
|
117
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
118
|
+
progress.recalculateSuccess(config);
|
|
119
|
+
onIdle(() => nav.prefetch(index + 1));
|
|
114
120
|
}).catch(err => {
|
|
115
121
|
if (gen !== loadGeneration) return; // stale
|
|
116
122
|
console.error(`Tessera: Failed to load page ${index}`, err);
|
|
@@ -132,18 +138,25 @@
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
// ---- Branding ----
|
|
141
|
+
// Two sentinels so the validity check doesn't false-positive when the
|
|
142
|
+
// input happens to normalize to the initial fillStyle ("#000000").
|
|
135
143
|
function parseColor(color) {
|
|
136
144
|
if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
|
|
137
145
|
return null;
|
|
138
146
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
148
|
+
if (!ctx) return null;
|
|
149
|
+
ctx.fillStyle = '#000';
|
|
150
|
+
ctx.fillStyle = color;
|
|
151
|
+
const onBlack = ctx.fillStyle;
|
|
152
|
+
ctx.fillStyle = '#fff';
|
|
153
|
+
ctx.fillStyle = color;
|
|
154
|
+
const onWhite = ctx.fillStyle;
|
|
155
|
+
if (onBlack !== onWhite) return null;
|
|
156
|
+
const hex = String(onBlack).match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
157
|
+
if (hex) return { r: parseInt(hex[1], 16), g: parseInt(hex[2], 16), b: parseInt(hex[3], 16) };
|
|
158
|
+
const rgba = String(onBlack).match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
159
|
+
return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
function rgbToHsl(r, g, b) {
|
|
@@ -181,8 +194,8 @@
|
|
|
181
194
|
const { score } = e.detail;
|
|
182
195
|
const pageIndex = nav.currentPageIndex;
|
|
183
196
|
progress.quizCompleted(pageIndex, score);
|
|
184
|
-
progress.recalculateCompletion(manifest, config);
|
|
185
|
-
progress.recalculateSuccess(
|
|
197
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
198
|
+
progress.recalculateSuccess(config);
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
// ---- Persistence: serialize / restore ----
|
|
@@ -251,8 +264,8 @@
|
|
|
251
264
|
progress.markCompleteManually();
|
|
252
265
|
}
|
|
253
266
|
// Recalculate derived state
|
|
254
|
-
progress.recalculateCompletion(manifest, config);
|
|
255
|
-
progress.recalculateSuccess(
|
|
267
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
268
|
+
progress.recalculateSuccess(config);
|
|
256
269
|
// Navigate to bookmark (after state is restored so locking is correct)
|
|
257
270
|
if (saved.b > 0 && saved.b < manifest.totalPages) {
|
|
258
271
|
nav.goToPage(saved.b);
|
|
@@ -293,20 +306,20 @@
|
|
|
293
306
|
|
|
294
307
|
// ---- Persistence: report score/completion/success to adapter ----
|
|
295
308
|
// These are no-ops for WebAdapter but used by LMS adapters (Step 10)
|
|
309
|
+
let prevReportedScore = $state(null);
|
|
296
310
|
$effect(() => {
|
|
297
|
-
|
|
298
|
-
if (!persistenceReady
|
|
299
|
-
if (gradedQuizIndices.length === 0) return;
|
|
311
|
+
void progress.version;
|
|
312
|
+
if (!persistenceReady) return;
|
|
300
313
|
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
314
|
+
const { average, attempted } = progress.gradedScore();
|
|
315
|
+
if (!attempted) return;
|
|
303
316
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
const rounded = Math.round(average);
|
|
318
|
+
if (rounded === prevReportedScore) return;
|
|
319
|
+
prevReportedScore = rounded;
|
|
307
320
|
|
|
308
321
|
untrack(() => {
|
|
309
|
-
adapter.setScore(
|
|
322
|
+
adapter.setScore(rounded);
|
|
310
323
|
// Under manual mode, success is owned by requireSuccessStatus.
|
|
311
324
|
if (config.completion.mode !== 'manual') {
|
|
312
325
|
adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
|
|
@@ -463,9 +476,7 @@
|
|
|
463
476
|
</script>
|
|
464
477
|
|
|
465
478
|
{#snippet page()}
|
|
466
|
-
{#if
|
|
467
|
-
<LoadingSkeleton />
|
|
468
|
-
{:else if pageError}
|
|
479
|
+
{#if pageError}
|
|
469
480
|
<ErrorPage error={pageError} onretry={retryPage} />
|
|
470
481
|
{:else if PageComponent}
|
|
471
482
|
{#if pageContext.quiz}
|
|
@@ -479,6 +490,7 @@
|
|
|
479
490
|
{/snippet}
|
|
480
491
|
|
|
481
492
|
<div id="tessera-app" data-chrome={chromeMode}>
|
|
493
|
+
<LoadingBar active={pageLoading} />
|
|
482
494
|
{#if UserLayout}
|
|
483
495
|
<UserLayout {page} />
|
|
484
496
|
{:else if chromeMode === 'custom'}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let { active = false } = $props();
|
|
5
|
+
|
|
6
|
+
let visible = $state(false);
|
|
7
|
+
let appeared = $state(false);
|
|
8
|
+
let complete = $state(false);
|
|
9
|
+
let showSlowMessage = $state(false);
|
|
10
|
+
|
|
11
|
+
$effect(() => {
|
|
12
|
+
if (active) {
|
|
13
|
+
// Defer the bar so sub-100ms loads never flash. Add `.appear` on the
|
|
14
|
+
// next frame so the CSS transition from width:0 → 90% actually fires.
|
|
15
|
+
const appearTimer = setTimeout(() => {
|
|
16
|
+
visible = true;
|
|
17
|
+
requestAnimationFrame(() => { appeared = true; });
|
|
18
|
+
}, 100);
|
|
19
|
+
const slowTimer = setTimeout(() => { showSlowMessage = true; }, 5000);
|
|
20
|
+
return () => {
|
|
21
|
+
clearTimeout(appearTimer);
|
|
22
|
+
clearTimeout(slowTimer);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Completing. If the bar never appeared we have nothing to finish.
|
|
27
|
+
// untrack so flipping `visible` doesn't re-trigger this effect.
|
|
28
|
+
if (!untrack(() => visible)) return;
|
|
29
|
+
complete = true;
|
|
30
|
+
const hideTimer = setTimeout(() => {
|
|
31
|
+
visible = false;
|
|
32
|
+
appeared = false;
|
|
33
|
+
complete = false;
|
|
34
|
+
showSlowMessage = false;
|
|
35
|
+
}, 220);
|
|
36
|
+
return () => clearTimeout(hideTimer);
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
{#if visible}
|
|
41
|
+
<div class="tessera-loading-bar" class:appear={appeared} class:complete aria-hidden="true">
|
|
42
|
+
<div class="tessera-loading-bar-fill"></div>
|
|
43
|
+
</div>
|
|
44
|
+
{#if showSlowMessage}
|
|
45
|
+
<p class="tessera-loading-bar-message" role="status">Still loading…</p>
|
|
46
|
+
{/if}
|
|
47
|
+
{/if}
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
aria-current={page.index === currentPageIndex ? 'page' : undefined}
|
|
61
61
|
aria-disabled={locked ? 'true' : undefined}
|
|
62
62
|
onclick={() => handlePageClick(page.index)}
|
|
63
|
+
onpointerenter={() => !locked && nav.prefetch(page.index)}
|
|
64
|
+
onfocusin={() => !locked && nav.prefetch(page.index)}
|
|
63
65
|
>
|
|
64
66
|
{#if locked}
|
|
65
67
|
<svg class="tessera-nav-lock-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" width="12" height="12">
|