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.
Files changed (75) 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 +2 -1
  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 +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  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 +17 -3
  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 +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  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 +21 -18
  34. package/src/components/util.ts +3 -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 +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. package/dist/validation-D9DXlqNP.js.map +0 -1
@@ -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 { existsSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';
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 { validateProject, reportValidationIssues } 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';
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 } = { 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
+ };
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(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');
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 { unlinkSync(htmlPath); } catch {}
173
+ try {
174
+ unlinkSync(htmlPath);
175
+ } catch {}
84
176
  }
85
177
 
86
- // Copy assets/ directory to dist/assets/ so $assets/ references resolve
178
+ // Copy assets/ into the build's assets/ so $assets/ references resolve
87
179
  const assetsDir = resolve(projectRoot, 'assets');
88
- const distAssetsDir = resolve(projectRoot, 'dist', 'assets');
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') return RESOLVED_MAIN_ID;
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
- 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 {
129
231
  return `<!DOCTYPE html>
130
- <html lang="en">
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(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {
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 { completion: { mode: 'percentage', percentageThreshold: 100 }, passingScore: 70 };
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
- '$assets': resolve(root, 'assets'),
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: Record<string, any> = read.ok ? read.config : {};
347
+ const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
239
348
 
240
- const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
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(ctx: { addWatchFile(id: string): void }, dir: string): void {
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(projectRoot, read.config as Parameters<typeof runExport>[1]);
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: { current: Manifest | null; root: string }): Plugin {
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(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`);
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') standard = read.config.export.standard;
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: { current: Manifest | null; root: string }): Plugin {
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(manifestRef.root, firstPagePath.replace(/^\//, '')).replace(/\\/g, '/');
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' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, '/') === normalized
778
+ c.type === 'chunk' &&
779
+ !!c.facadeModuleId &&
780
+ c.facadeModuleId.replace(/\\/g, '/') === normalized,
611
781
  );
612
782
  if (!chunk) return;
613
- return [{
614
- tag: 'link',
615
- attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
616
- injectTo: 'head',
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
  };
@@ -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<string, { mtimeMs: number; content: string }>();
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(source: string): string | null {
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: Record<string, any> }
110
- | { ok: false; reason: 'missing' | 'no-export' | 'parse-error'; error?: unknown };
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(readSourceFileCached(configPath));
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): { title?: string; pages?: 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(readSourceFileCached(metaPath));
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
- | { kind: 'ok'; value: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } }
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(content: string): PageConfigParseResult {
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('{', configMatch.index + configMatch[0].length);
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): { title?: string; quiz?: QuizConfig; completesOn?: 'view' } {
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(source: string, startIndex: number): string | null {
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: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } = {};
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' ? { completesOn: 'view' as const } : {}),
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(allFiles: string[], pagesArray?: string[]): string[] {
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
  }