tessera-learn 0.0.10 → 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.
Files changed (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.js.map +0 -1
@@ -1,16 +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 { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';
6
- import { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';
7
- import JSON5 from 'json5';
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';
14
+ import { generateManifest, readCourseConfig } from './manifest.js';
8
15
  import type { Manifest } from './manifest.js';
9
- import { validateProject } from './validation.js';
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';
10
25
  import { runExport } from './export.js';
11
26
  import { tesseraLayoutPlugin } from './layout.js';
12
27
  import { tesseraQuizPlugin } from './quiz.js';
13
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
+
14
38
  const __filename = fileURLToPath(import.meta.url);
15
39
  const __dirname = dirname(__filename);
16
40
 
@@ -26,12 +50,71 @@ function resolveStylesDir(): string {
26
50
  return resolve(packageRoot, 'styles');
27
51
  }
28
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
+
29
87
  export function tesseraPlugin() {
30
- const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };
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
+ };
31
98
  return [
32
99
  svelte({
33
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
+ },
34
116
  }),
117
+ tesseraA11yCompilerPlugin(a11y),
35
118
  tesseraValidationPlugin(),
36
119
  tesseraEntryPlugin(),
37
120
  tesseraConfigPlugin(),
@@ -58,6 +141,7 @@ function tesseraEntryPlugin(): Plugin {
58
141
  const stylesDir = resolveStylesDir();
59
142
  const appSveltePath = resolve(runtimeDir, 'App.svelte');
60
143
  let projectRoot: string;
144
+ let outDir: string;
61
145
  let isBuild = false;
62
146
 
63
147
  return {
@@ -66,13 +150,18 @@ function tesseraEntryPlugin(): Plugin {
66
150
 
67
151
  configResolved(config: ResolvedConfig) {
68
152
  projectRoot = config.root;
153
+ outDir = resolve(config.root, config.build.outDir);
69
154
  isBuild = config.command === 'build';
70
155
  },
71
156
 
72
157
  // For build mode: write index.html so Rollup can find it
73
158
  buildStart() {
74
159
  if (isBuild) {
75
- writeFileSync(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');
160
+ writeFileSync(
161
+ resolve(projectRoot, 'index.html'),
162
+ generateIndexHtml(readLanguage(projectRoot)),
163
+ 'utf-8',
164
+ );
76
165
  }
77
166
  },
78
167
 
@@ -81,12 +170,14 @@ function tesseraEntryPlugin(): Plugin {
81
170
  if (isBuild) {
82
171
  const htmlPath = resolve(projectRoot, 'index.html');
83
172
  if (existsSync(htmlPath)) {
84
- try { unlinkSync(htmlPath); } catch {}
173
+ try {
174
+ unlinkSync(htmlPath);
175
+ } catch {}
85
176
  }
86
177
 
87
- // Copy assets/ directory to dist/assets/ so $assets/ references resolve
178
+ // Copy assets/ into the build's assets/ so $assets/ references resolve
88
179
  const assetsDir = resolve(projectRoot, 'assets');
89
- const distAssetsDir = resolve(projectRoot, 'dist', 'assets');
180
+ const distAssetsDir = resolve(outDir, 'assets');
90
181
  if (existsSync(assetsDir)) {
91
182
  mkdirSync(distAssetsDir, { recursive: true });
92
183
  cpSync(assetsDir, distAssetsDir, { recursive: true });
@@ -99,7 +190,7 @@ function tesseraEntryPlugin(): Plugin {
99
190
  return () => {
100
191
  server.middlewares.use(async (req, res, next) => {
101
192
  if (req.url === '/' || req.url === '/index.html') {
102
- const html = generateIndexHtml();
193
+ const html = generateIndexHtml(readLanguage(projectRoot));
103
194
  const transformed = await server.transformIndexHtml(req.url, html);
104
195
  res.setHeader('Content-Type', 'text/html');
105
196
  res.statusCode = 200;
@@ -113,7 +204,8 @@ function tesseraEntryPlugin(): Plugin {
113
204
 
114
205
  resolveId(id) {
115
206
  if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;
116
- if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main') return RESOLVED_MAIN_ID;
207
+ if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main')
208
+ return RESOLVED_MAIN_ID;
117
209
  return null;
118
210
  },
119
211
 
@@ -126,9 +218,18 @@ function tesseraEntryPlugin(): Plugin {
126
218
  };
127
219
  }
128
220
 
129
- function generateIndexHtml(): string {
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 {
130
231
  return `<!DOCTYPE html>
131
- <html lang="en">
232
+ <html lang="${lang}">
132
233
  <head>
133
234
  <meta charset="UTF-8" />
134
235
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -141,15 +242,19 @@ function generateIndexHtml(): string {
141
242
  </html>`;
142
243
  }
143
244
 
144
- function generateEntryScript(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {
245
+ function generateEntryScript(
246
+ appSveltePath: string,
247
+ frameworkStylesDir: string,
248
+ projectRoot: string,
249
+ ): string {
145
250
  const normalizedPath = appSveltePath.replace(/\\/g, '/');
146
251
 
147
252
  // Framework CSS imports (theme → base → layout)
148
253
  const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];
149
254
  const frameworkImports = frameworkCssOrder
150
- .map(file => resolve(frameworkStylesDir, file).replace(/\\/g, '/'))
151
- .filter(path => existsSync(path))
152
- .map(path => `import '${path}';`)
255
+ .map((file) => resolve(frameworkStylesDir, file).replace(/\\/g, '/'))
256
+ .filter((path) => existsSync(path))
257
+ .map((path) => `import '${path}';`)
153
258
  .join('\n');
154
259
 
155
260
  // User CSS imports from project's styles/ directory
@@ -157,11 +262,11 @@ function generateEntryScript(appSveltePath: string, frameworkStylesDir: string,
157
262
  let userImports = '';
158
263
  if (existsSync(userStylesDir)) {
159
264
  const userCssFiles = readdirSync(userStylesDir)
160
- .filter(f => f.endsWith('.css'))
265
+ .filter((f) => f.endsWith('.css'))
161
266
  .sort();
162
267
  userImports = userCssFiles
163
- .map(f => resolve(userStylesDir, f).replace(/\\/g, '/'))
164
- .map(path => `import '${path}';`)
268
+ .map((f) => resolve(userStylesDir, f).replace(/\\/g, '/'))
269
+ .map((path) => `import '${path}';`)
165
270
  .join('\n');
166
271
  }
167
272
 
@@ -191,7 +296,10 @@ function completionDefaults(mode: string | undefined): {
191
296
  if (mode === 'manual') {
192
297
  return { completion: { mode: 'manual' }, passingScore: 0 };
193
298
  }
194
- return { completion: { mode: 'percentage', percentageThreshold: 100 }, passingScore: 70 };
299
+ return {
300
+ completion: { mode: 'percentage', percentageThreshold: 100 },
301
+ passingScore: 70,
302
+ };
195
303
  }
196
304
 
197
305
  function tesseraConfigPlugin(): Plugin {
@@ -206,9 +314,12 @@ function tesseraConfigPlugin(): Plugin {
206
314
 
207
315
  return {
208
316
  base: './',
317
+ build: {
318
+ assetsDir: 'tessera',
319
+ },
209
320
  resolve: {
210
321
  alias: {
211
- '$assets': resolve(root, 'assets'),
322
+ $assets: resolve(root, 'assets'),
212
323
  },
213
324
  },
214
325
  // tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer
@@ -231,17 +342,13 @@ function tesseraConfigPlugin(): Plugin {
231
342
  load(id) {
232
343
  if (id === RESOLVED_CONFIG_ID) {
233
344
  const configPath = resolve(projectRoot, 'course.config.js');
234
- let userConfig: Record<string, any> = {};
235
-
236
- if (existsSync(configPath)) {
237
- this.addWatchFile(configPath);
238
- const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
239
- if (objectStr) {
240
- try { userConfig = JSON5.parse(objectStr); } catch {}
241
- }
242
- }
345
+ if (existsSync(configPath)) this.addWatchFile(configPath);
346
+ const read = readCourseConfig(projectRoot);
347
+ const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
243
348
 
244
- const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
349
+ const { completion, passingScore } = completionDefaults(
350
+ userConfig.completion?.mode,
351
+ );
245
352
  const merged = {
246
353
  title: userConfig.title || 'Untitled Course',
247
354
  ...userConfig,
@@ -261,7 +368,10 @@ function tesseraConfigPlugin(): Plugin {
261
368
  // ---------- Manifest Watch Helpers ----------
262
369
 
263
370
  /** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */
264
- function addWatchFiles(ctx: { addWatchFile(id: string): void }, dir: string): void {
371
+ function addWatchFiles(
372
+ ctx: { addWatchFile(id: string): void },
373
+ dir: string,
374
+ ): void {
265
375
  if (!existsSync(dir)) return;
266
376
  for (const entry of readdirSync(dir)) {
267
377
  const full = resolve(dir, entry);
@@ -330,19 +440,44 @@ function tesseraValidationPlugin(): Plugin {
330
440
  };
331
441
  }
332
442
 
333
- function runValidation(projectRoot: string): void {
334
- const { errors, warnings } = validateProject(projectRoot);
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',
335
450
 
336
- for (const warning of warnings) {
337
- console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
338
- }
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
+ },
339
457
 
340
- if (errors.length > 0) {
341
- for (const error of errors) {
342
- console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
343
- }
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
+
475
+ function runValidation(projectRoot: string): void {
476
+ const result = validateProject(projectRoot);
477
+ reportValidationIssues(result);
478
+ if (result.errors.length > 0) {
344
479
  throw new Error(
345
- `Tessera validation failed with ${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.`,
346
481
  );
347
482
  }
348
483
  }
@@ -364,34 +499,32 @@ function tesseraExportPlugin(): Plugin {
364
499
 
365
500
  async closeBundle() {
366
501
  if (!isBuild) return;
367
-
368
- const configPath = resolve(projectRoot, 'course.config.js');
369
- if (!existsSync(configPath)) {
370
- // Validation already required course.config.js — getting here means
371
- // the file vanished mid-build. Surface that loudly rather than
372
- // shipping a bundle with no LMS export silently.
373
- throw new Error(
374
- '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'
375
- );
376
- }
377
-
378
- const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
379
- if (!objectStr) {
380
- throw new Error(
381
- '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'
382
- );
383
- }
384
-
385
- let config: any;
386
- try {
387
- config = JSON5.parse(objectStr);
388
- } catch (err) {
502
+ if (isAuditBuild()) return;
503
+
504
+ const read = readCourseConfig(projectRoot);
505
+ if (!read.ok) {
506
+ // Validation already required a parseable course.config.js getting
507
+ // here means it vanished or broke mid-build. Surface that loudly
508
+ // rather than shipping a bundle with no LMS export silently.
509
+ if (read.reason === 'missing') {
510
+ throw new Error(
511
+ '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.',
512
+ );
513
+ }
514
+ if (read.reason === 'no-export') {
515
+ throw new Error(
516
+ '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.',
517
+ );
518
+ }
389
519
  throw new Error(
390
- `[tessera:export] course.config.js: failed to parse export-default object literal — ${(err as Error).message}`
520
+ `[tessera:export] course.config.js: failed to parse export-default object literal — ${(read.error as Error).message}`,
391
521
  );
392
522
  }
393
523
 
394
- await runExport(projectRoot, config);
524
+ await runExport(
525
+ projectRoot,
526
+ read.config as Parameters<typeof runExport>[1],
527
+ );
395
528
  },
396
529
  };
397
530
  }
@@ -401,10 +534,12 @@ function tesseraExportPlugin(): Plugin {
401
534
  const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
402
535
  const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
403
536
 
404
- function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
537
+ function tesseraManifestPlugin(manifestRef: {
538
+ current: Manifest | null;
539
+ root: string;
540
+ }): Plugin {
405
541
  let projectRoot: string;
406
542
  let pagesDir: string;
407
- let server: ViteDevServer | null = null;
408
543
 
409
544
  function buildManifest(): Manifest {
410
545
  const m = generateManifest(pagesDir);
@@ -423,8 +558,6 @@ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: st
423
558
  },
424
559
 
425
560
  configureServer(devServer: ViteDevServer) {
426
- server = devServer;
427
-
428
561
  // Watch the pages directory for changes
429
562
  devServer.watcher.on('all', (event, filePath) => {
430
563
  if (!filePath.startsWith(pagesDir)) return;
@@ -446,7 +579,9 @@ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: st
446
579
  devServer.ws.send({ type: 'full-reload' });
447
580
  }
448
581
 
449
- console.log(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`);
582
+ console.log(
583
+ `[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`,
584
+ );
450
585
  }
451
586
  });
452
587
  },
@@ -474,7 +609,7 @@ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: st
474
609
  // scanning .svelte importPath strings as module imports.
475
610
  // Replace Infinity with 1e9 since JSON.stringify drops it.
476
611
  const json = JSON.stringify(manifestRef.current, (_key, value) =>
477
- value === Infinity ? 1e9 : value
612
+ value === Infinity ? 1e9 : value,
478
613
  );
479
614
  const b64 = Buffer.from(json).toString('base64');
480
615
  return `export default JSON.parse(atob("${b64}"));`;
@@ -515,17 +650,15 @@ function tesseraAdapterPlugin(): Plugin {
515
650
  }
516
651
 
517
652
  let standard = 'web';
518
- const configPath = resolve(projectRoot, 'course.config.js');
519
- if (existsSync(configPath)) {
520
- const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
521
- if (objectStr) {
522
- try {
523
- const parsed = JSON5.parse(objectStr);
524
- if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
525
- } catch {}
526
- }
653
+ const read = readCourseConfig(projectRoot);
654
+ if (read.ok && typeof read.config.export?.standard === 'string') {
655
+ standard = read.config.export.standard;
527
656
  }
528
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
+
529
662
  switch (standard) {
530
663
  case 'scorm12':
531
664
  return `
@@ -599,18 +732,18 @@ function tesseraXAPISetupPlugin(): Plugin {
599
732
  return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
600
733
  }
601
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
+
602
740
  let standard = 'web';
603
741
  let hasXapi = false;
604
- const configPath = resolve(projectRoot, 'course.config.js');
605
- if (existsSync(configPath)) {
606
- const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
607
- if (objectStr) {
608
- try {
609
- const parsed = JSON5.parse(objectStr);
610
- if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
611
- hasXapi = parsed?.xapi != null;
612
- } catch {}
613
- }
742
+ const read = readCourseConfig(projectRoot);
743
+ if (read.ok) {
744
+ if (typeof read.config.export?.standard === 'string')
745
+ standard = read.config.export.standard;
746
+ hasXapi = read.config.xapi != null;
614
747
  }
615
748
 
616
749
  // cmi5 needs the publisher regardless of explicit xapi config (cmi5
@@ -624,7 +757,10 @@ function tesseraXAPISetupPlugin(): Plugin {
624
757
  };
625
758
  }
626
759
 
627
- function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
760
+ function tesseraFirstPagePreloadPlugin(manifestRef: {
761
+ current: Manifest | null;
762
+ root: string;
763
+ }): Plugin {
628
764
  return {
629
765
  name: 'tessera:first-page-preload',
630
766
  apply: 'build',
@@ -633,17 +769,24 @@ function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null;
633
769
  handler(_html, ctx) {
634
770
  const firstPagePath = manifestRef.current?.pages[0]?.importPath;
635
771
  if (!firstPagePath || !ctx.bundle) return;
636
- const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\//, '')).replace(/\\/g, '/');
772
+ const normalized = resolve(
773
+ manifestRef.root,
774
+ firstPagePath.replace(/^\//, ''),
775
+ ).replace(/\\/g, '/');
637
776
  const chunk = Object.values(ctx.bundle).find(
638
777
  (c): c is import('vite').Rollup.OutputChunk =>
639
- c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, '/') === normalized
778
+ c.type === 'chunk' &&
779
+ !!c.facadeModuleId &&
780
+ c.facadeModuleId.replace(/\\/g, '/') === normalized,
640
781
  );
641
782
  if (!chunk) return;
642
- return [{
643
- tag: 'link',
644
- attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
645
- injectTo: 'head',
646
- }];
783
+ return [
784
+ {
785
+ tag: 'link',
786
+ attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
787
+ injectTo: 'head',
788
+ },
789
+ ];
647
790
  },
648
791
  },
649
792
  };
@@ -1,55 +1,10 @@
1
- import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
- import { existsSync } from 'node:fs';
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
- let projectRoot: string;
10
-
11
- return {
5
+ return createOverridePlugin({
12
6
  name: 'tessera:layout',
13
- enforce: 'pre',
14
-
15
- configResolved(config: ResolvedConfig) {
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
  }