tessera-learn 0.0.11 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +2 -1
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +17 -3
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +21 -18
- package/src/components/util.ts +3 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-D9DXlqNP.js.map +0 -1
package/src/plugin/index.ts
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
2
2
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { dirname, resolve } from 'node:path';
|
|
5
|
-
import {
|
|
4
|
+
import { dirname, resolve, relative, isAbsolute } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
cpSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
} from 'node:fs';
|
|
6
14
|
import { generateManifest, readCourseConfig } from './manifest.js';
|
|
7
15
|
import type { Manifest } from './manifest.js';
|
|
8
|
-
import {
|
|
16
|
+
import type { CourseConfig } from '../runtime/types.js';
|
|
17
|
+
import {
|
|
18
|
+
validateProject,
|
|
19
|
+
reportValidationIssues,
|
|
20
|
+
normalizeA11y,
|
|
21
|
+
isPlausibleLanguageTag,
|
|
22
|
+
isIgnored,
|
|
23
|
+
type A11ySettings,
|
|
24
|
+
} from './validation.js';
|
|
9
25
|
import { runExport } from './export.js';
|
|
10
26
|
import { tesseraLayoutPlugin } from './layout.js';
|
|
11
27
|
import { tesseraQuizPlugin } from './quiz.js';
|
|
12
28
|
|
|
29
|
+
import { AUDIT_ENV_FLAG } from './a11y/audit.js';
|
|
30
|
+
|
|
31
|
+
export { runAudit } from './a11y/audit.js';
|
|
32
|
+
export type { AuditOptions, ImpactLevel } from './a11y/audit.js';
|
|
33
|
+
|
|
34
|
+
function isAuditBuild(): boolean {
|
|
35
|
+
return process.env[AUDIT_ENV_FLAG] === '1';
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
39
|
const __dirname = dirname(__filename);
|
|
15
40
|
|
|
@@ -25,12 +50,71 @@ function resolveStylesDir(): string {
|
|
|
25
50
|
return resolve(packageRoot, 'styles');
|
|
26
51
|
}
|
|
27
52
|
|
|
53
|
+
// Tier-1a state shared between the svelte() onwarn handler and the sibling
|
|
54
|
+
// gate plugin. onwarn fires during transform (after the Tier-1b buildStart
|
|
55
|
+
// gate), so a11y warnings are collected here and flushed/gated at buildEnd.
|
|
56
|
+
interface A11yCompilerState {
|
|
57
|
+
warnings: string[];
|
|
58
|
+
projectRoot: string;
|
|
59
|
+
isBuild: boolean;
|
|
60
|
+
settings: A11ySettings;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Svelte's onwarn filename is relative to the vite root (e.g. `pages/x.svelte`)
|
|
64
|
+
// in build and may be absolute or a virtual id elsewhere. Return the
|
|
65
|
+
// project-relative path for a real author file, or null to skip framework /
|
|
66
|
+
// node_modules / virtual modules — Tier 0 owns the framework's own warnings.
|
|
67
|
+
function projectFileRel(
|
|
68
|
+
filename: string | undefined,
|
|
69
|
+
projectRoot: string,
|
|
70
|
+
): string | null {
|
|
71
|
+
if (!filename || !projectRoot) return null;
|
|
72
|
+
if (
|
|
73
|
+
filename.startsWith('\0') ||
|
|
74
|
+
filename.includes('virtual:') ||
|
|
75
|
+
filename.includes('node_modules')
|
|
76
|
+
) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const abs = isAbsolute(filename) ? filename : resolve(projectRoot, filename);
|
|
80
|
+
const rel = relative(projectRoot, abs);
|
|
81
|
+
if (rel.startsWith('..') || isAbsolute(rel) || rel.includes('node_modules')) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return rel;
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
export function tesseraPlugin() {
|
|
29
|
-
const manifestRef: { current: Manifest | null; root: string } = {
|
|
88
|
+
const manifestRef: { current: Manifest | null; root: string } = {
|
|
89
|
+
current: null,
|
|
90
|
+
root: '',
|
|
91
|
+
};
|
|
92
|
+
const a11y: A11yCompilerState = {
|
|
93
|
+
warnings: [],
|
|
94
|
+
projectRoot: '',
|
|
95
|
+
isBuild: false,
|
|
96
|
+
settings: normalizeA11y(undefined),
|
|
97
|
+
};
|
|
30
98
|
return [
|
|
31
99
|
svelte({
|
|
32
100
|
compilerOptions: { css: 'external' },
|
|
101
|
+
onwarn(warning, defaultHandler) {
|
|
102
|
+
if (warning.code?.startsWith('a11y')) {
|
|
103
|
+
const rel = projectFileRel(warning.filename, a11y.projectRoot);
|
|
104
|
+
if (rel !== null) {
|
|
105
|
+
const msg = `[${warning.code}] ${rel}: ${warning.message}`;
|
|
106
|
+
if (a11y.isBuild) {
|
|
107
|
+
a11y.warnings.push(msg);
|
|
108
|
+
} else if (!a11y.settings.ignore.includes(warning.code)) {
|
|
109
|
+
reportValidationIssues({ errors: [], warnings: [msg] });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return; // suppress the raw Vite print; we re-emit via the reporter
|
|
113
|
+
}
|
|
114
|
+
defaultHandler?.(warning);
|
|
115
|
+
},
|
|
33
116
|
}),
|
|
117
|
+
tesseraA11yCompilerPlugin(a11y),
|
|
34
118
|
tesseraValidationPlugin(),
|
|
35
119
|
tesseraEntryPlugin(),
|
|
36
120
|
tesseraConfigPlugin(),
|
|
@@ -57,6 +141,7 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
57
141
|
const stylesDir = resolveStylesDir();
|
|
58
142
|
const appSveltePath = resolve(runtimeDir, 'App.svelte');
|
|
59
143
|
let projectRoot: string;
|
|
144
|
+
let outDir: string;
|
|
60
145
|
let isBuild = false;
|
|
61
146
|
|
|
62
147
|
return {
|
|
@@ -65,13 +150,18 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
65
150
|
|
|
66
151
|
configResolved(config: ResolvedConfig) {
|
|
67
152
|
projectRoot = config.root;
|
|
153
|
+
outDir = resolve(config.root, config.build.outDir);
|
|
68
154
|
isBuild = config.command === 'build';
|
|
69
155
|
},
|
|
70
156
|
|
|
71
157
|
// For build mode: write index.html so Rollup can find it
|
|
72
158
|
buildStart() {
|
|
73
159
|
if (isBuild) {
|
|
74
|
-
writeFileSync(
|
|
160
|
+
writeFileSync(
|
|
161
|
+
resolve(projectRoot, 'index.html'),
|
|
162
|
+
generateIndexHtml(readLanguage(projectRoot)),
|
|
163
|
+
'utf-8',
|
|
164
|
+
);
|
|
75
165
|
}
|
|
76
166
|
},
|
|
77
167
|
|
|
@@ -80,12 +170,14 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
80
170
|
if (isBuild) {
|
|
81
171
|
const htmlPath = resolve(projectRoot, 'index.html');
|
|
82
172
|
if (existsSync(htmlPath)) {
|
|
83
|
-
try {
|
|
173
|
+
try {
|
|
174
|
+
unlinkSync(htmlPath);
|
|
175
|
+
} catch {}
|
|
84
176
|
}
|
|
85
177
|
|
|
86
|
-
// Copy assets/
|
|
178
|
+
// Copy assets/ into the build's assets/ so $assets/ references resolve
|
|
87
179
|
const assetsDir = resolve(projectRoot, 'assets');
|
|
88
|
-
const distAssetsDir = resolve(
|
|
180
|
+
const distAssetsDir = resolve(outDir, 'assets');
|
|
89
181
|
if (existsSync(assetsDir)) {
|
|
90
182
|
mkdirSync(distAssetsDir, { recursive: true });
|
|
91
183
|
cpSync(assetsDir, distAssetsDir, { recursive: true });
|
|
@@ -98,7 +190,7 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
98
190
|
return () => {
|
|
99
191
|
server.middlewares.use(async (req, res, next) => {
|
|
100
192
|
if (req.url === '/' || req.url === '/index.html') {
|
|
101
|
-
const html = generateIndexHtml();
|
|
193
|
+
const html = generateIndexHtml(readLanguage(projectRoot));
|
|
102
194
|
const transformed = await server.transformIndexHtml(req.url, html);
|
|
103
195
|
res.setHeader('Content-Type', 'text/html');
|
|
104
196
|
res.statusCode = 200;
|
|
@@ -112,7 +204,8 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
112
204
|
|
|
113
205
|
resolveId(id) {
|
|
114
206
|
if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;
|
|
115
|
-
if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main')
|
|
207
|
+
if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main')
|
|
208
|
+
return RESOLVED_MAIN_ID;
|
|
116
209
|
return null;
|
|
117
210
|
},
|
|
118
211
|
|
|
@@ -125,9 +218,18 @@ function tesseraEntryPlugin(): Plugin {
|
|
|
125
218
|
};
|
|
126
219
|
}
|
|
127
220
|
|
|
128
|
-
|
|
221
|
+
// 'en' fallback applied here: the config default-merge runs later than buildStart.
|
|
222
|
+
// Only a validated BCP-47 tag is interpolated into <html lang>, so a malformed
|
|
223
|
+
// value (caught separately as a warning) can't ship a broken attribute.
|
|
224
|
+
function readLanguage(projectRoot: string): string {
|
|
225
|
+
const read = readCourseConfig(projectRoot);
|
|
226
|
+
const lang = read.ok ? read.config.language : undefined;
|
|
227
|
+
return isPlausibleLanguageTag(lang) ? lang : 'en';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function generateIndexHtml(lang: string): string {
|
|
129
231
|
return `<!DOCTYPE html>
|
|
130
|
-
<html lang="
|
|
232
|
+
<html lang="${lang}">
|
|
131
233
|
<head>
|
|
132
234
|
<meta charset="UTF-8" />
|
|
133
235
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -140,15 +242,19 @@ function generateIndexHtml(): string {
|
|
|
140
242
|
</html>`;
|
|
141
243
|
}
|
|
142
244
|
|
|
143
|
-
function generateEntryScript(
|
|
245
|
+
function generateEntryScript(
|
|
246
|
+
appSveltePath: string,
|
|
247
|
+
frameworkStylesDir: string,
|
|
248
|
+
projectRoot: string,
|
|
249
|
+
): string {
|
|
144
250
|
const normalizedPath = appSveltePath.replace(/\\/g, '/');
|
|
145
251
|
|
|
146
252
|
// Framework CSS imports (theme → base → layout)
|
|
147
253
|
const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];
|
|
148
254
|
const frameworkImports = frameworkCssOrder
|
|
149
|
-
.map(file => resolve(frameworkStylesDir, file).replace(/\\/g, '/'))
|
|
150
|
-
.filter(path => existsSync(path))
|
|
151
|
-
.map(path => `import '${path}';`)
|
|
255
|
+
.map((file) => resolve(frameworkStylesDir, file).replace(/\\/g, '/'))
|
|
256
|
+
.filter((path) => existsSync(path))
|
|
257
|
+
.map((path) => `import '${path}';`)
|
|
152
258
|
.join('\n');
|
|
153
259
|
|
|
154
260
|
// User CSS imports from project's styles/ directory
|
|
@@ -156,11 +262,11 @@ function generateEntryScript(appSveltePath: string, frameworkStylesDir: string,
|
|
|
156
262
|
let userImports = '';
|
|
157
263
|
if (existsSync(userStylesDir)) {
|
|
158
264
|
const userCssFiles = readdirSync(userStylesDir)
|
|
159
|
-
.filter(f => f.endsWith('.css'))
|
|
265
|
+
.filter((f) => f.endsWith('.css'))
|
|
160
266
|
.sort();
|
|
161
267
|
userImports = userCssFiles
|
|
162
|
-
.map(f => resolve(userStylesDir, f).replace(/\\/g, '/'))
|
|
163
|
-
.map(path => `import '${path}';`)
|
|
268
|
+
.map((f) => resolve(userStylesDir, f).replace(/\\/g, '/'))
|
|
269
|
+
.map((path) => `import '${path}';`)
|
|
164
270
|
.join('\n');
|
|
165
271
|
}
|
|
166
272
|
|
|
@@ -190,7 +296,10 @@ function completionDefaults(mode: string | undefined): {
|
|
|
190
296
|
if (mode === 'manual') {
|
|
191
297
|
return { completion: { mode: 'manual' }, passingScore: 0 };
|
|
192
298
|
}
|
|
193
|
-
return {
|
|
299
|
+
return {
|
|
300
|
+
completion: { mode: 'percentage', percentageThreshold: 100 },
|
|
301
|
+
passingScore: 70,
|
|
302
|
+
};
|
|
194
303
|
}
|
|
195
304
|
|
|
196
305
|
function tesseraConfigPlugin(): Plugin {
|
|
@@ -210,7 +319,7 @@ function tesseraConfigPlugin(): Plugin {
|
|
|
210
319
|
},
|
|
211
320
|
resolve: {
|
|
212
321
|
alias: {
|
|
213
|
-
|
|
322
|
+
$assets: resolve(root, 'assets'),
|
|
214
323
|
},
|
|
215
324
|
},
|
|
216
325
|
// tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer
|
|
@@ -235,9 +344,11 @@ function tesseraConfigPlugin(): Plugin {
|
|
|
235
344
|
const configPath = resolve(projectRoot, 'course.config.js');
|
|
236
345
|
if (existsSync(configPath)) this.addWatchFile(configPath);
|
|
237
346
|
const read = readCourseConfig(projectRoot);
|
|
238
|
-
const userConfig:
|
|
347
|
+
const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
|
|
239
348
|
|
|
240
|
-
const { completion, passingScore } = completionDefaults(
|
|
349
|
+
const { completion, passingScore } = completionDefaults(
|
|
350
|
+
userConfig.completion?.mode,
|
|
351
|
+
);
|
|
241
352
|
const merged = {
|
|
242
353
|
title: userConfig.title || 'Untitled Course',
|
|
243
354
|
...userConfig,
|
|
@@ -257,7 +368,10 @@ function tesseraConfigPlugin(): Plugin {
|
|
|
257
368
|
// ---------- Manifest Watch Helpers ----------
|
|
258
369
|
|
|
259
370
|
/** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */
|
|
260
|
-
function addWatchFiles(
|
|
371
|
+
function addWatchFiles(
|
|
372
|
+
ctx: { addWatchFile(id: string): void },
|
|
373
|
+
dir: string,
|
|
374
|
+
): void {
|
|
261
375
|
if (!existsSync(dir)) return;
|
|
262
376
|
for (const entry of readdirSync(dir)) {
|
|
263
377
|
const full = resolve(dir, entry);
|
|
@@ -326,12 +440,44 @@ function tesseraValidationPlugin(): Plugin {
|
|
|
326
440
|
};
|
|
327
441
|
}
|
|
328
442
|
|
|
443
|
+
// Tier 1a: flush + gate the Svelte compiler's a11y warnings at buildEnd, after
|
|
444
|
+
// every module is transformed. svelte() accepts `onwarn` but not arbitrary
|
|
445
|
+
// Rollup hooks, so the gate lives here and shares the onwarn closure.
|
|
446
|
+
function tesseraA11yCompilerPlugin(a11y: A11yCompilerState): Plugin {
|
|
447
|
+
return {
|
|
448
|
+
name: 'tessera:a11y-compiler',
|
|
449
|
+
enforce: 'pre',
|
|
450
|
+
|
|
451
|
+
configResolved(config: ResolvedConfig) {
|
|
452
|
+
a11y.projectRoot = config.root;
|
|
453
|
+
a11y.isBuild = config.command === 'build';
|
|
454
|
+
const read = readCourseConfig(config.root);
|
|
455
|
+
a11y.settings = normalizeA11y(read.ok ? read.config.a11y : undefined);
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
buildEnd() {
|
|
459
|
+
if (!a11y.isBuild || a11y.warnings.length === 0) return;
|
|
460
|
+
const ignored = new Set(a11y.settings.ignore);
|
|
461
|
+
const warnings = a11y.warnings.filter((msg) => !isIgnored(msg, ignored));
|
|
462
|
+
a11y.warnings = [];
|
|
463
|
+
if (warnings.length === 0) return;
|
|
464
|
+
if (a11y.settings.level === 'error') {
|
|
465
|
+
reportValidationIssues({ errors: warnings, warnings: [] });
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Tessera: ${warnings.length} a11y issue(s) with a11y.level: 'error'. Fix the errors above to continue.`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
reportValidationIssues({ errors: [], warnings });
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
329
475
|
function runValidation(projectRoot: string): void {
|
|
330
476
|
const result = validateProject(projectRoot);
|
|
331
477
|
reportValidationIssues(result);
|
|
332
478
|
if (result.errors.length > 0) {
|
|
333
479
|
throw new Error(
|
|
334
|
-
`Tessera validation failed with ${result.errors.length} error(s). Fix the errors above to continue
|
|
480
|
+
`Tessera validation failed with ${result.errors.length} error(s). Fix the errors above to continue.`,
|
|
335
481
|
);
|
|
336
482
|
}
|
|
337
483
|
}
|
|
@@ -353,6 +499,7 @@ function tesseraExportPlugin(): Plugin {
|
|
|
353
499
|
|
|
354
500
|
async closeBundle() {
|
|
355
501
|
if (!isBuild) return;
|
|
502
|
+
if (isAuditBuild()) return;
|
|
356
503
|
|
|
357
504
|
const read = readCourseConfig(projectRoot);
|
|
358
505
|
if (!read.ok) {
|
|
@@ -361,20 +508,23 @@ function tesseraExportPlugin(): Plugin {
|
|
|
361
508
|
// rather than shipping a bundle with no LMS export silently.
|
|
362
509
|
if (read.reason === 'missing') {
|
|
363
510
|
throw new Error(
|
|
364
|
-
'[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'
|
|
511
|
+
'[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.',
|
|
365
512
|
);
|
|
366
513
|
}
|
|
367
514
|
if (read.reason === 'no-export') {
|
|
368
515
|
throw new Error(
|
|
369
|
-
'[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'
|
|
516
|
+
'[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.',
|
|
370
517
|
);
|
|
371
518
|
}
|
|
372
519
|
throw new Error(
|
|
373
|
-
`[tessera:export] course.config.js: failed to parse export-default object literal — ${(read.error as Error).message}
|
|
520
|
+
`[tessera:export] course.config.js: failed to parse export-default object literal — ${(read.error as Error).message}`,
|
|
374
521
|
);
|
|
375
522
|
}
|
|
376
523
|
|
|
377
|
-
await runExport(
|
|
524
|
+
await runExport(
|
|
525
|
+
projectRoot,
|
|
526
|
+
read.config as Parameters<typeof runExport>[1],
|
|
527
|
+
);
|
|
378
528
|
},
|
|
379
529
|
};
|
|
380
530
|
}
|
|
@@ -384,10 +534,12 @@ function tesseraExportPlugin(): Plugin {
|
|
|
384
534
|
const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
|
|
385
535
|
const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
|
|
386
536
|
|
|
387
|
-
function tesseraManifestPlugin(manifestRef: {
|
|
537
|
+
function tesseraManifestPlugin(manifestRef: {
|
|
538
|
+
current: Manifest | null;
|
|
539
|
+
root: string;
|
|
540
|
+
}): Plugin {
|
|
388
541
|
let projectRoot: string;
|
|
389
542
|
let pagesDir: string;
|
|
390
|
-
let server: ViteDevServer | null = null;
|
|
391
543
|
|
|
392
544
|
function buildManifest(): Manifest {
|
|
393
545
|
const m = generateManifest(pagesDir);
|
|
@@ -406,8 +558,6 @@ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: st
|
|
|
406
558
|
},
|
|
407
559
|
|
|
408
560
|
configureServer(devServer: ViteDevServer) {
|
|
409
|
-
server = devServer;
|
|
410
|
-
|
|
411
561
|
// Watch the pages directory for changes
|
|
412
562
|
devServer.watcher.on('all', (event, filePath) => {
|
|
413
563
|
if (!filePath.startsWith(pagesDir)) return;
|
|
@@ -429,7 +579,9 @@ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: st
|
|
|
429
579
|
devServer.ws.send({ type: 'full-reload' });
|
|
430
580
|
}
|
|
431
581
|
|
|
432
|
-
console.log(
|
|
582
|
+
console.log(
|
|
583
|
+
`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`,
|
|
584
|
+
);
|
|
433
585
|
}
|
|
434
586
|
});
|
|
435
587
|
},
|
|
@@ -457,7 +609,7 @@ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: st
|
|
|
457
609
|
// scanning .svelte importPath strings as module imports.
|
|
458
610
|
// Replace Infinity with 1e9 since JSON.stringify drops it.
|
|
459
611
|
const json = JSON.stringify(manifestRef.current, (_key, value) =>
|
|
460
|
-
value === Infinity ? 1e9 : value
|
|
612
|
+
value === Infinity ? 1e9 : value,
|
|
461
613
|
);
|
|
462
614
|
const b64 = Buffer.from(json).toString('base64');
|
|
463
615
|
return `export default JSON.parse(atob("${b64}"));`;
|
|
@@ -503,6 +655,10 @@ function tesseraAdapterPlugin(): Plugin {
|
|
|
503
655
|
standard = read.config.export.standard;
|
|
504
656
|
}
|
|
505
657
|
|
|
658
|
+
// The audit renders headless with no LMS in the frame chain; the SCORM/
|
|
659
|
+
// cmi5 adapters throw when their API is absent, so render with WebAdapter.
|
|
660
|
+
if (isAuditBuild()) standard = 'web';
|
|
661
|
+
|
|
506
662
|
switch (standard) {
|
|
507
663
|
case 'scorm12':
|
|
508
664
|
return `
|
|
@@ -576,11 +732,17 @@ function tesseraXAPISetupPlugin(): Plugin {
|
|
|
576
732
|
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
577
733
|
}
|
|
578
734
|
|
|
735
|
+
// The audit runs offline — don't wire real LRS destinations into it.
|
|
736
|
+
if (isAuditBuild()) {
|
|
737
|
+
return `export async function buildXAPIClient() { return null; }`;
|
|
738
|
+
}
|
|
739
|
+
|
|
579
740
|
let standard = 'web';
|
|
580
741
|
let hasXapi = false;
|
|
581
742
|
const read = readCourseConfig(projectRoot);
|
|
582
743
|
if (read.ok) {
|
|
583
|
-
if (typeof read.config.export?.standard === 'string')
|
|
744
|
+
if (typeof read.config.export?.standard === 'string')
|
|
745
|
+
standard = read.config.export.standard;
|
|
584
746
|
hasXapi = read.config.xapi != null;
|
|
585
747
|
}
|
|
586
748
|
|
|
@@ -595,7 +757,10 @@ function tesseraXAPISetupPlugin(): Plugin {
|
|
|
595
757
|
};
|
|
596
758
|
}
|
|
597
759
|
|
|
598
|
-
function tesseraFirstPagePreloadPlugin(manifestRef: {
|
|
760
|
+
function tesseraFirstPagePreloadPlugin(manifestRef: {
|
|
761
|
+
current: Manifest | null;
|
|
762
|
+
root: string;
|
|
763
|
+
}): Plugin {
|
|
599
764
|
return {
|
|
600
765
|
name: 'tessera:first-page-preload',
|
|
601
766
|
apply: 'build',
|
|
@@ -604,17 +769,24 @@ function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null;
|
|
|
604
769
|
handler(_html, ctx) {
|
|
605
770
|
const firstPagePath = manifestRef.current?.pages[0]?.importPath;
|
|
606
771
|
if (!firstPagePath || !ctx.bundle) return;
|
|
607
|
-
const normalized = resolve(
|
|
772
|
+
const normalized = resolve(
|
|
773
|
+
manifestRef.root,
|
|
774
|
+
firstPagePath.replace(/^\//, ''),
|
|
775
|
+
).replace(/\\/g, '/');
|
|
608
776
|
const chunk = Object.values(ctx.bundle).find(
|
|
609
777
|
(c): c is import('vite').Rollup.OutputChunk =>
|
|
610
|
-
c.type === 'chunk' &&
|
|
778
|
+
c.type === 'chunk' &&
|
|
779
|
+
!!c.facadeModuleId &&
|
|
780
|
+
c.facadeModuleId.replace(/\\/g, '/') === normalized,
|
|
611
781
|
);
|
|
612
782
|
if (!chunk) return;
|
|
613
|
-
return [
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
783
|
+
return [
|
|
784
|
+
{
|
|
785
|
+
tag: 'link',
|
|
786
|
+
attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
|
|
787
|
+
injectTo: 'head',
|
|
788
|
+
},
|
|
789
|
+
];
|
|
618
790
|
},
|
|
619
791
|
},
|
|
620
792
|
};
|
package/src/plugin/manifest.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { resolve, basename, extname } from 'node:path';
|
|
3
3
|
import JSON5 from 'json5';
|
|
4
|
-
import type { QuizConfig } from '../runtime/types.js';
|
|
4
|
+
import type { CourseConfig, QuizConfig } from '../runtime/types.js';
|
|
5
5
|
|
|
6
6
|
// ---------- Types ----------
|
|
7
7
|
|
|
@@ -48,7 +48,10 @@ export function ensureSvelteSuffix(name: string): string {
|
|
|
48
48
|
* sharing the read avoids the second disk hit (and matters most on cold-cache
|
|
49
49
|
* CI runs and large courses).
|
|
50
50
|
*/
|
|
51
|
-
const fileContentCache = new Map<
|
|
51
|
+
const fileContentCache = new Map<
|
|
52
|
+
string,
|
|
53
|
+
{ mtimeMs: number; content: string }
|
|
54
|
+
>();
|
|
52
55
|
|
|
53
56
|
export function readSourceFileCached(filePath: string): string {
|
|
54
57
|
const stat = statSync(filePath);
|
|
@@ -70,7 +73,7 @@ export function stripPrefix(name: string): string {
|
|
|
70
73
|
export function titleCase(slug: string): string {
|
|
71
74
|
return slug
|
|
72
75
|
.split('-')
|
|
73
|
-
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
76
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
74
77
|
.join(' ');
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -97,7 +100,9 @@ const DEFAULT_EXPORT_RE = /export\s+default\s*/;
|
|
|
97
100
|
* or null if no balanced object literal follows the `export default` keyword.
|
|
98
101
|
* Used by both manifest extraction and project validation.
|
|
99
102
|
*/
|
|
100
|
-
export function extractDefaultExportObjectLiteral(
|
|
103
|
+
export function extractDefaultExportObjectLiteral(
|
|
104
|
+
source: string,
|
|
105
|
+
): string | null {
|
|
101
106
|
const match = source.match(DEFAULT_EXPORT_RE);
|
|
102
107
|
if (!match || match.index === undefined) return null;
|
|
103
108
|
const startIndex = source.indexOf('{', match.index);
|
|
@@ -106,8 +111,12 @@ export function extractDefaultExportObjectLiteral(source: string): string | null
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
export type CourseConfigRead =
|
|
109
|
-
| { ok: true; config:
|
|
110
|
-
| {
|
|
114
|
+
| { ok: true; config: Partial<CourseConfig> }
|
|
115
|
+
| {
|
|
116
|
+
ok: false;
|
|
117
|
+
reason: 'missing' | 'no-export' | 'parse-error';
|
|
118
|
+
error?: unknown;
|
|
119
|
+
};
|
|
111
120
|
|
|
112
121
|
/**
|
|
113
122
|
* Read and JSON5-parse the `export default { ... }` literal from a project's
|
|
@@ -119,7 +128,9 @@ export type CourseConfigRead =
|
|
|
119
128
|
export function readCourseConfig(projectRoot: string): CourseConfigRead {
|
|
120
129
|
const configPath = resolve(projectRoot, 'course.config.js');
|
|
121
130
|
if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
|
|
122
|
-
const objectStr = extractDefaultExportObjectLiteral(
|
|
131
|
+
const objectStr = extractDefaultExportObjectLiteral(
|
|
132
|
+
readSourceFileCached(configPath),
|
|
133
|
+
);
|
|
123
134
|
if (!objectStr) return { ok: false, reason: 'no-export' };
|
|
124
135
|
try {
|
|
125
136
|
return { ok: true, config: JSON5.parse(objectStr) };
|
|
@@ -133,10 +144,15 @@ export function readCourseConfig(projectRoot: string): CourseConfigRead {
|
|
|
133
144
|
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
|
|
134
145
|
* after `export default` and parse it.
|
|
135
146
|
*/
|
|
136
|
-
export function readMetaFile(metaPath: string): {
|
|
147
|
+
export function readMetaFile(metaPath: string): {
|
|
148
|
+
title?: string;
|
|
149
|
+
pages?: string[];
|
|
150
|
+
} {
|
|
137
151
|
if (!existsSync(metaPath)) return {};
|
|
138
152
|
|
|
139
|
-
const objectStr = extractDefaultExportObjectLiteral(
|
|
153
|
+
const objectStr = extractDefaultExportObjectLiteral(
|
|
154
|
+
readSourceFileCached(metaPath),
|
|
155
|
+
);
|
|
140
156
|
if (!objectStr) return {};
|
|
141
157
|
|
|
142
158
|
try {
|
|
@@ -151,12 +167,17 @@ export type PageConfigParseResult =
|
|
|
151
167
|
/** No module script, or no `pageConfig =` export. Treat as "no config". */
|
|
152
168
|
| { kind: 'none' }
|
|
153
169
|
/** Found and successfully parsed. */
|
|
154
|
-
| {
|
|
170
|
+
| {
|
|
171
|
+
kind: 'ok';
|
|
172
|
+
value: { title?: string; quiz?: QuizConfig; completesOn?: 'view' };
|
|
173
|
+
}
|
|
155
174
|
/** Found but couldn't parse as a static object literal — non-literal RHS or JSON5 failure. */
|
|
156
175
|
| { kind: 'invalid' };
|
|
157
176
|
|
|
158
177
|
/** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
|
|
159
|
-
export function parsePageConfigFromSource(
|
|
178
|
+
export function parsePageConfigFromSource(
|
|
179
|
+
content: string,
|
|
180
|
+
): PageConfigParseResult {
|
|
160
181
|
const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
|
|
161
182
|
if (!moduleScriptMatch) return { kind: 'none' };
|
|
162
183
|
|
|
@@ -171,7 +192,10 @@ export function parsePageConfigFromSource(content: string): PageConfigParseResul
|
|
|
171
192
|
// pageConfig assigned to something other than an object literal — flag as invalid.
|
|
172
193
|
if (!afterExport.startsWith('{')) return { kind: 'invalid' };
|
|
173
194
|
|
|
174
|
-
const startIndex = scriptContent.indexOf(
|
|
195
|
+
const startIndex = scriptContent.indexOf(
|
|
196
|
+
'{',
|
|
197
|
+
configMatch.index + configMatch[0].length,
|
|
198
|
+
);
|
|
175
199
|
if (startIndex < 0) return { kind: 'invalid' };
|
|
176
200
|
const objectStr = extractObjectLiteral(scriptContent, startIndex);
|
|
177
201
|
if (!objectStr) return { kind: 'invalid' };
|
|
@@ -184,12 +208,16 @@ export function parsePageConfigFromSource(content: string): PageConfigParseResul
|
|
|
184
208
|
}
|
|
185
209
|
|
|
186
210
|
/** Extract pageConfig from a .svelte file. Throws on parse failure. */
|
|
187
|
-
export function extractPageConfig(filePath: string): {
|
|
211
|
+
export function extractPageConfig(filePath: string): {
|
|
212
|
+
title?: string;
|
|
213
|
+
quiz?: QuizConfig;
|
|
214
|
+
completesOn?: 'view';
|
|
215
|
+
} {
|
|
188
216
|
const result = parsePageConfigFromSource(readSourceFileCached(filePath));
|
|
189
217
|
if (result.kind === 'ok') return result.value;
|
|
190
218
|
if (result.kind === 'invalid') {
|
|
191
219
|
throw new Error(
|
|
192
|
-
`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)
|
|
220
|
+
`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`,
|
|
193
221
|
);
|
|
194
222
|
}
|
|
195
223
|
return {};
|
|
@@ -201,7 +229,10 @@ export function extractPageConfig(filePath: string): { title?: string; quiz?: Qu
|
|
|
201
229
|
* the open char is wrong or no matching close is found. Shared by manifest
|
|
202
230
|
* extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
|
|
203
231
|
*/
|
|
204
|
-
export function extractObjectLiteral(
|
|
232
|
+
export function extractObjectLiteral(
|
|
233
|
+
source: string,
|
|
234
|
+
startIndex: number,
|
|
235
|
+
): string | null {
|
|
205
236
|
const open = source[startIndex];
|
|
206
237
|
if (open !== '{' && open !== '[') return null;
|
|
207
238
|
|
|
@@ -266,7 +297,7 @@ export function extractObjectLiteral(source: string, startIndex: number): string
|
|
|
266
297
|
function getSortedDirs(dirPath: string): string[] {
|
|
267
298
|
if (!existsSync(dirPath)) return [];
|
|
268
299
|
return readdirSync(dirPath)
|
|
269
|
-
.filter(name => {
|
|
300
|
+
.filter((name) => {
|
|
270
301
|
const full = resolve(dirPath, name);
|
|
271
302
|
return statSync(full).isDirectory() && !name.startsWith('.');
|
|
272
303
|
})
|
|
@@ -279,7 +310,7 @@ function getSortedDirs(dirPath: string): string[] {
|
|
|
279
310
|
function getSvelteFiles(dirPath: string): string[] {
|
|
280
311
|
if (!existsSync(dirPath)) return [];
|
|
281
312
|
return readdirSync(dirPath)
|
|
282
|
-
.filter(name => name.endsWith('.svelte'))
|
|
313
|
+
.filter((name) => name.endsWith('.svelte'))
|
|
283
314
|
.sort();
|
|
284
315
|
}
|
|
285
316
|
|
|
@@ -327,7 +358,11 @@ export function generateManifest(pagesDir: string): Manifest {
|
|
|
327
358
|
const filePath = resolve(lessonPath, fileName);
|
|
328
359
|
const pageSlug = deriveSlug(fileName, true);
|
|
329
360
|
|
|
330
|
-
let pageConfig: {
|
|
361
|
+
let pageConfig: {
|
|
362
|
+
title?: string;
|
|
363
|
+
quiz?: QuizConfig;
|
|
364
|
+
completesOn?: 'view';
|
|
365
|
+
} = {};
|
|
331
366
|
try {
|
|
332
367
|
pageConfig = extractPageConfig(filePath);
|
|
333
368
|
} catch (e) {
|
|
@@ -344,7 +379,9 @@ export function generateManifest(pagesDir: string): Manifest {
|
|
|
344
379
|
slug: pageSlug,
|
|
345
380
|
importPath: relativePath,
|
|
346
381
|
quiz: pageConfig.quiz || null,
|
|
347
|
-
...(pageConfig.completesOn === 'view'
|
|
382
|
+
...(pageConfig.completesOn === 'view'
|
|
383
|
+
? { completesOn: 'view' as const }
|
|
384
|
+
: {}),
|
|
348
385
|
};
|
|
349
386
|
|
|
350
387
|
lesson.pages.push(page);
|
|
@@ -368,17 +405,20 @@ export function generateManifest(pagesDir: string): Manifest {
|
|
|
368
405
|
/**
|
|
369
406
|
* Order .svelte files: listed in `pages` array first (in order), then unlisted appended alphabetically.
|
|
370
407
|
*/
|
|
371
|
-
export function orderPageFiles(
|
|
408
|
+
export function orderPageFiles(
|
|
409
|
+
allFiles: string[],
|
|
410
|
+
pagesArray?: string[],
|
|
411
|
+
): string[] {
|
|
372
412
|
if (!pagesArray || pagesArray.length === 0) {
|
|
373
413
|
return allFiles;
|
|
374
414
|
}
|
|
375
415
|
|
|
376
416
|
const listed = pagesArray.map(ensureSvelteSuffix);
|
|
377
417
|
const listedSet = new Set(listed);
|
|
378
|
-
const unlisted = allFiles.filter(f => !listedSet.has(f)).sort();
|
|
418
|
+
const unlisted = allFiles.filter((f) => !listedSet.has(f)).sort();
|
|
379
419
|
|
|
380
420
|
// Only include listed files that actually exist
|
|
381
|
-
const validListed = listed.filter(f => allFiles.includes(f));
|
|
421
|
+
const validListed = listed.filter((f) => allFiles.includes(f));
|
|
382
422
|
|
|
383
423
|
return [...validListed, ...unlisted];
|
|
384
424
|
}
|