tessera-learn 0.2.3 → 0.4.0

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 (57) hide show
  1. package/AGENTS.md +50 -21
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
  8. package/dist/inline-config-DVvOCKht.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +91 -49
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +287 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +3 -3
  16. package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +7 -7
  19. package/src/components/DefaultLayout.svelte +2 -5
  20. package/src/components/MultipleChoice.svelte +1 -2
  21. package/src/components/Quiz.svelte +18 -26
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/build-commands.ts +7 -4
  24. package/src/plugin/cli.ts +96 -46
  25. package/src/plugin/csp.ts +59 -0
  26. package/src/plugin/duplicate-cli.ts +37 -1
  27. package/src/plugin/export.ts +56 -27
  28. package/src/plugin/index.ts +138 -93
  29. package/src/plugin/inline-config.ts +4 -2
  30. package/src/plugin/manifest.ts +24 -23
  31. package/src/plugin/new-cli.ts +2 -0
  32. package/src/plugin/validate-cli.ts +5 -2
  33. package/src/plugin/validation.ts +255 -238
  34. package/src/runtime/App.svelte +14 -9
  35. package/src/runtime/Sidebar.svelte +3 -1
  36. package/src/runtime/adapters/cmi5.ts +59 -402
  37. package/src/runtime/adapters/discovery.ts +11 -0
  38. package/src/runtime/adapters/index.ts +27 -60
  39. package/src/runtime/adapters/lms-error.ts +61 -0
  40. package/src/runtime/adapters/scorm-base.ts +15 -14
  41. package/src/runtime/adapters/scorm12.ts +6 -25
  42. package/src/runtime/adapters/scorm2004.ts +12 -54
  43. package/src/runtime/adapters/web.ts +11 -4
  44. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  45. package/src/runtime/adapters/xapi.ts +26 -0
  46. package/src/runtime/fingerprint.ts +28 -0
  47. package/src/runtime/interaction-format.ts +0 -1
  48. package/src/runtime/persistence.ts +4 -0
  49. package/src/runtime/types.ts +22 -1
  50. package/src/runtime/xapi/publisher.ts +16 -15
  51. package/src/runtime/xapi/setup.ts +24 -15
  52. package/src/virtual.d.ts +4 -1
  53. package/templates/course/course.config.js +1 -0
  54. package/dist/audit--fSWIOgK.js.map +0 -1
  55. package/dist/build-commands-Qyrlsp3n.js.map +0 -1
  56. package/dist/inline-config-DqAKsCNl.js.map +0 -1
  57. package/dist/plugin-B-aiL9-V.js.map +0 -1
@@ -24,8 +24,13 @@ import {
24
24
  } from '../runtime/xapi/agent-rules.js';
25
25
  import { httpOrigin } from '../runtime/xapi/derive-actor.js';
26
26
  import { shortIdentifier } from '../runtime/interaction-format.js';
27
- import { FEEDBACK_MODES, RETRY_MODES } from '../runtime/types.js';
27
+ import {
28
+ FEEDBACK_MODES,
29
+ RETRY_MODES,
30
+ courseIdentity,
31
+ } from '../runtime/types.js';
28
32
  import { contrastRatio } from './a11y/contrast.js';
33
+ import { isCspOverrides } from './csp.js';
29
34
  import { isVideoEmbed } from '../components/video-embed.js';
30
35
 
31
36
  // ---------- Types ----------
@@ -35,6 +40,18 @@ export interface ValidationResult {
35
40
  warnings: string[];
36
41
  }
37
42
 
43
+ /** Collects errors and warnings so checkers thread one argument, not a pair. */
44
+ export class Diagnostics implements ValidationResult {
45
+ errors: string[] = [];
46
+ warnings: string[] = [];
47
+ error(message: string): void {
48
+ this.errors.push(message);
49
+ }
50
+ warn(message: string): void {
51
+ this.warnings.push(message);
52
+ }
53
+ }
54
+
38
55
  // ---------- A11y rule IDs ----------
39
56
 
40
57
  /** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
@@ -106,24 +123,21 @@ export function normalizeA11y(raw: unknown): A11ySettings {
106
123
  * promotable a11y warnings to errors) to a result in place. `ignore` suppresses
107
124
  * at any severity, including hard contract errors; `level` only re-rates.
108
125
  */
109
- function applyA11ySettings(
110
- result: ValidationResult,
111
- settings: A11ySettings,
112
- ): void {
126
+ function applyA11ySettings(d: Diagnostics, settings: A11ySettings): void {
113
127
  if (settings.ignore.length > 0) {
114
128
  const ignored = new Set(settings.ignore);
115
129
  const keep = (msg: string) => !isIgnored(msg, ignored);
116
- result.errors = result.errors.filter(keep);
117
- result.warnings = result.warnings.filter(keep);
130
+ d.errors = d.errors.filter(keep);
131
+ d.warnings = d.warnings.filter(keep);
118
132
  }
119
133
  if (settings.level === 'error') {
120
134
  const remaining: string[] = [];
121
- for (const msg of result.warnings) {
135
+ for (const msg of d.warnings) {
122
136
  const id = diagnosticId(msg);
123
- if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) result.errors.push(msg);
137
+ if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) d.error(msg);
124
138
  else remaining.push(msg);
125
139
  }
126
- result.warnings = remaining;
140
+ d.warnings = remaining;
127
141
  }
128
142
  }
129
143
 
@@ -143,9 +157,11 @@ export function reportValidationIssues({
143
157
  // Known top-level config fields
144
158
  const KNOWN_CONFIG_FIELDS = new Set([
145
159
  'title',
160
+ 'id',
146
161
  'description',
147
162
  'author',
148
163
  'version',
164
+ 'resume',
149
165
  'language',
150
166
  'branding',
151
167
  'navigation',
@@ -168,7 +184,13 @@ export function isPlausibleLanguageTag(value: unknown): value is string {
168
184
 
169
185
  const VALID_NAV_MODES = ['free', 'sequential'];
170
186
  const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
171
- const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
187
+ export const VALID_EXPORT_STANDARDS = [
188
+ 'web',
189
+ 'scorm12',
190
+ 'scorm2004',
191
+ 'cmi5',
192
+ 'xapi',
193
+ ];
172
194
  const VALID_MANUAL_TRIGGERS = ['page'];
173
195
  const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
174
196
  // Derived from the runtime types (single source of truth) — widened to
@@ -182,20 +204,22 @@ const VALID_RETRY_MODES: readonly string[] = RETRY_MODES;
182
204
  * Validate a Tessera project at the given root.
183
205
  * Returns errors (block build) and warnings (informational).
184
206
  */
185
- export function validateProject(projectRoot: string): ValidationResult {
207
+ export function validateProject(
208
+ projectRoot: string,
209
+ standardOverride?: string,
210
+ ): ValidationResult {
186
211
  clearParseCache();
187
- const errors: string[] = [];
188
- const warnings: string[] = [];
212
+ const d = new Diagnostics();
189
213
 
190
214
  // 1. Check course.config.js exists
191
215
  const configPath = resolve(projectRoot, 'course.config.js');
192
216
  if (!existsSync(configPath)) {
193
- errors.push('course.config.js not found in project root');
194
- return { errors, warnings };
217
+ d.error('course.config.js not found in project root');
218
+ return d;
195
219
  }
196
220
 
197
221
  // 2. Parse and validate config
198
- const config = parseConfig(projectRoot, errors, warnings);
222
+ const config = parseConfig(projectRoot, d, standardOverride);
199
223
 
200
224
  // 3. Validate pages directory
201
225
  const pagesDir = resolve(projectRoot, 'pages');
@@ -204,37 +228,33 @@ export function validateProject(projectRoot: string): ValidationResult {
204
228
  pagesDir,
205
229
  assetsDir,
206
230
  projectRoot,
231
+ d,
207
232
  config?.export?.standard,
208
233
  );
209
- errors.push(...pageResults.errors);
210
- warnings.push(...pageResults.warnings);
211
234
 
212
235
  // 4. Contract-bypass checks on project-root shell files
213
236
  for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
214
237
  const shellPath = resolve(projectRoot, shellFile);
215
238
  if (existsSync(shellPath)) {
216
- validateContractBypass(
217
- readSourceFileCached(shellPath),
218
- shellFile,
219
- errors,
220
- );
239
+ validateContractBypass(readSourceFileCached(shellPath), shellFile, d);
221
240
  }
222
241
  }
223
242
 
224
243
  // 5. Cross-cutting validations
225
244
  if (config) {
226
- crossValidate(config, pageResults, errors, warnings);
245
+ crossValidate(config, pageResults, d);
227
246
  }
228
247
 
229
- const result: ValidationResult = { errors, warnings };
230
- applyA11ySettings(result, normalizeA11y(config?.a11y));
231
- return result;
248
+ applyA11ySettings(d, normalizeA11y(config?.a11y));
249
+ return d;
232
250
  }
233
251
 
234
252
  // ---------- Config Validation ----------
235
253
 
236
254
  interface ParsedConfig {
237
255
  title?: string;
256
+ id?: string;
257
+ resume?: string;
238
258
  navigation?: { mode?: string };
239
259
  completion?: {
240
260
  mode?: string;
@@ -243,24 +263,22 @@ interface ParsedConfig {
243
263
  requireSuccessStatus?: string;
244
264
  };
245
265
  scoring?: { passingScore?: number };
246
- export?: { standard?: string };
266
+ export?: { standard?: string; csp?: unknown };
247
267
  [key: string]: unknown;
248
268
  }
249
269
 
250
270
  function parseConfig(
251
271
  projectRoot: string,
252
- errors: string[],
253
- warnings: string[],
272
+ d: Diagnostics,
273
+ standardOverride?: string,
254
274
  ): ParsedConfig | null {
255
275
  const read = readCourseConfig(projectRoot);
256
276
  if (!read.ok) {
257
277
  // 'missing' can't occur — validateProject checks existsSync first.
258
278
  if (read.reason === 'no-export') {
259
- errors.push('course.config.js: must use `export default { ... }` syntax');
279
+ d.error('course.config.js: must use `export default { ... }` syntax');
260
280
  } else if (read.reason === 'parse-error') {
261
- errors.push(
262
- 'course.config.js: could not parse — JavaScript syntax error',
263
- );
281
+ d.error('course.config.js: could not parse — JavaScript syntax error');
264
282
  }
265
283
  return null;
266
284
  }
@@ -269,9 +287,7 @@ function parseConfig(
269
287
  // Check for unknown fields
270
288
  for (const key of Object.keys(config)) {
271
289
  if (!KNOWN_CONFIG_FIELDS.has(key)) {
272
- warnings.push(
273
- `course.config.js: unknown field "${key}" — will be ignored`,
274
- );
290
+ d.warn(`course.config.js: unknown field "${key}" — will be ignored`);
275
291
  }
276
292
  }
277
293
 
@@ -281,34 +297,34 @@ function parseConfig(
281
297
  // non-string is a misconfiguration — a truthy one ships as-is, a falsy one
282
298
  // falls back, but either way the author should fix it (error).
283
299
  if (config.title !== undefined && typeof config.title !== 'string') {
284
- errors.push(
300
+ d.error(
285
301
  `course.config.js: "title" must be a string, got ${typeof config.title}`,
286
302
  );
287
303
  } else if (config.title === undefined || config.title === '') {
288
- warnings.push(
304
+ d.warn(
289
305
  'course.config.js: "title" is missing or empty — the course will ship as "Untitled Course"',
290
306
  );
291
307
  } else if (config.title.trim() === '') {
292
- warnings.push(
308
+ d.warn(
293
309
  'course.config.js: "title" is only whitespace — it ships verbatim and will not fall back to "Untitled Course"',
294
310
  );
295
311
  }
296
312
 
297
313
  // Validate branding
298
314
  if (config.branding !== undefined) {
299
- validateBranding(config.branding, warnings);
315
+ validateBranding(config.branding, d);
300
316
  }
301
317
 
302
318
  // Rule 1.8: language present and well-formed (BCP-47)
303
319
  if (config.language === undefined) {
304
- warnings.push(
320
+ d.warn(
305
321
  tag(
306
322
  A11Y_IDS.lang,
307
323
  `course.config.js: "language" is not set — defaulting <html lang> to "en". Set it to the course's language (BCP-47, e.g. "en", "fr-CA") for WCAG 3.1.1.`,
308
324
  ),
309
325
  );
310
326
  } else if (!isPlausibleLanguageTag(config.language)) {
311
- warnings.push(
327
+ d.warn(
312
328
  tag(
313
329
  A11Y_IDS.lang,
314
330
  `course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`,
@@ -316,15 +332,45 @@ function parseConfig(
316
332
  );
317
333
  }
318
334
 
335
+ // Validate export.standard
336
+ if (config.export?.standard !== undefined) {
337
+ if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
338
+ d.error(
339
+ `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", "cmi5", or "xapi", got "${config.export.standard}"`,
340
+ );
341
+ }
342
+ }
343
+
344
+ // Apply the override after validating the file value above, so every
345
+ // standard-dependent check below (identity, csp, xapi, crossValidate) sees
346
+ // what actually ships.
347
+ if (standardOverride) {
348
+ config.export = { ...config.export, standard: standardOverride };
349
+ }
350
+
351
+ // Identity matters for web (storage key) and cmi5/xAPI (LRS activity id);
352
+ // SCORM identity is owned by the LMS, so only nudge for the others.
353
+ const standard = config.export?.standard;
354
+ const identityStandard =
355
+ standard === undefined ||
356
+ standard === 'web' ||
357
+ standard === 'cmi5' ||
358
+ standard === 'xapi';
359
+ if (identityStandard && !courseIdentity(config)) {
360
+ d.warn(
361
+ `course.config.js: no "id" set — the web storage key and cmi5/xAPI activity id then share a fixed fallback that collides across courses. Add a unique id (e.g. "urn:uuid:…"); scaffolded courses include one.`,
362
+ );
363
+ }
364
+
319
365
  // Validate a11y config block
320
366
  if (config.a11y !== undefined) {
321
- validateA11yConfig(config.a11y, errors);
367
+ validateA11yConfig(config.a11y, d);
322
368
  }
323
369
 
324
370
  // Validate navigation.mode
325
371
  if (config.navigation?.mode !== undefined) {
326
372
  if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
327
- errors.push(
373
+ d.error(
328
374
  `course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`,
329
375
  );
330
376
  }
@@ -333,7 +379,7 @@ function parseConfig(
333
379
  // Validate completion.mode
334
380
  if (config.completion?.mode !== undefined) {
335
381
  if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
336
- errors.push(
382
+ d.error(
337
383
  `course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`,
338
384
  );
339
385
  }
@@ -341,11 +387,11 @@ function parseConfig(
341
387
 
342
388
  if (config.completion?.trigger !== undefined) {
343
389
  if (config.completion.mode !== 'manual') {
344
- warnings.push(
390
+ d.warn(
345
391
  `course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`,
346
392
  );
347
393
  } else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
348
- errors.push(
394
+ d.error(
349
395
  `course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`,
350
396
  );
351
397
  }
@@ -353,7 +399,7 @@ function parseConfig(
353
399
 
354
400
  if (config.completion?.requireSuccessStatus !== undefined) {
355
401
  if (config.completion.mode !== 'manual') {
356
- warnings.push(
402
+ d.warn(
357
403
  `course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`,
358
404
  );
359
405
  } else if (
@@ -361,17 +407,33 @@ function parseConfig(
361
407
  config.completion.requireSuccessStatus,
362
408
  )
363
409
  ) {
364
- errors.push(
410
+ d.error(
365
411
  `course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`,
366
412
  );
367
413
  }
368
414
  }
369
415
 
370
- // Validate export.standard
371
- if (config.export?.standard !== undefined) {
372
- if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
373
- errors.push(
374
- `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`,
416
+ // Validate resume policy
417
+ if (
418
+ config.resume !== undefined &&
419
+ config.resume !== 'auto' &&
420
+ config.resume !== 'never'
421
+ ) {
422
+ d.error(
423
+ `course.config.js: "resume" must be "auto" or "never", got "${config.resume}"`,
424
+ );
425
+ }
426
+
427
+ // Validate export.csp (web-only CSP extension)
428
+ if (config.export?.csp !== undefined) {
429
+ const csp = config.export.csp;
430
+ if (csp !== false && !isCspOverrides(csp)) {
431
+ d.warn(
432
+ 'course.config.js: "export.csp" must be false or an object of directive → string[]; ignoring it and using the baseline CSP',
433
+ );
434
+ } else if ((config.export.standard ?? 'web') !== 'web') {
435
+ d.warn(
436
+ `course.config.js: "export.csp" is ignored when "export.standard" is "${config.export.standard}" (the CSP meta is web-export only)`,
375
437
  );
376
438
  }
377
439
  }
@@ -380,7 +442,7 @@ function parseConfig(
380
442
  if (config.scoring?.passingScore !== undefined) {
381
443
  const score = config.scoring.passingScore;
382
444
  if (typeof score !== 'number' || score < 0 || score > 100) {
383
- errors.push(
445
+ d.error(
384
446
  `course.config.js: "scoring.passingScore" must be 0–100, got ${score}`,
385
447
  );
386
448
  }
@@ -390,7 +452,7 @@ function parseConfig(
390
452
  if (config.completion?.percentageThreshold !== undefined) {
391
453
  const threshold = config.completion.percentageThreshold;
392
454
  if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
393
- errors.push(
455
+ d.error(
394
456
  `course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`,
395
457
  );
396
458
  }
@@ -398,12 +460,7 @@ function parseConfig(
398
460
 
399
461
  // Validate xapi (publisher destinations)
400
462
  if (config.xapi !== undefined) {
401
- validateXAPIConfig(
402
- config.xapi,
403
- config.export?.standard ?? 'web',
404
- errors,
405
- warnings,
406
- );
463
+ validateXAPIConfig(config.xapi, config.export?.standard ?? 'web', d);
407
464
  }
408
465
 
409
466
  return config;
@@ -438,9 +495,9 @@ function describeType(raw: unknown): string {
438
495
  return raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw;
439
496
  }
440
497
 
441
- function validateBranding(raw: unknown, warnings: string[]): void {
498
+ function validateBranding(raw: unknown, d: Diagnostics): void {
442
499
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
443
- warnings.push(
500
+ d.warn(
444
501
  `course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`,
445
502
  );
446
503
  return;
@@ -450,11 +507,11 @@ function validateBranding(raw: unknown, warnings: string[]): void {
450
507
  const logo = branding.logo;
451
508
  if (logo !== undefined) {
452
509
  if (typeof logo !== 'string') {
453
- warnings.push(
510
+ d.warn(
454
511
  `course.config.js: "branding.logo" must be a string, got ${typeof logo}`,
455
512
  );
456
513
  } else if (logo.startsWith('$assets/')) {
457
- warnings.push(
514
+ d.warn(
458
515
  'course.config.js: "branding.logo" starts with "$assets/", but branding paths are not asset-resolved — it will ship as a literal, broken src. Use a URL or a path relative to the deployed root.',
459
516
  );
460
517
  }
@@ -463,11 +520,11 @@ function validateBranding(raw: unknown, warnings: string[]): void {
463
520
  const primaryColor = branding.primaryColor;
464
521
  if (primaryColor !== undefined) {
465
522
  if (typeof primaryColor !== 'string') {
466
- warnings.push(
523
+ d.warn(
467
524
  `course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`,
468
525
  );
469
526
  } else if (!isPlausibleColor(primaryColor)) {
470
- warnings.push(
527
+ d.warn(
471
528
  `course.config.js: "branding.primaryColor" "${primaryColor}" does not look like a valid CSS color — the theme will fall back to its default shades if the browser can't parse it`,
472
529
  );
473
530
  } else {
@@ -476,7 +533,7 @@ function validateBranding(raw: unknown, warnings: string[]): void {
476
533
  // ratio covers both. Non-#hex valid colors return null and defer to Tier 2.
477
534
  const ratio = contrastRatio(primaryColor, '#ffffff');
478
535
  if (ratio !== null && ratio < 4.5) {
479
- warnings.push(
536
+ d.warn(
480
537
  tag(
481
538
  A11Y_IDS.primaryContrast,
482
539
  `course.config.js: branding.primaryColor (${primaryColor}) is ${ratio.toFixed(2)}:1 against white — it's used both for links on the page background and as a button fill behind white text, and WCAG AA needs 4.5:1 for each`,
@@ -488,7 +545,7 @@ function validateBranding(raw: unknown, warnings: string[]): void {
488
545
 
489
546
  const fontFamily = branding.fontFamily;
490
547
  if (fontFamily !== undefined && typeof fontFamily !== 'string') {
491
- warnings.push(
548
+ d.warn(
492
549
  `course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`,
493
550
  );
494
551
  }
@@ -497,9 +554,9 @@ function validateBranding(raw: unknown, warnings: string[]): void {
497
554
  // ---------- a11y Config Validation ----------
498
555
 
499
556
  /** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
500
- function validateA11yConfig(raw: unknown, errors: string[]): void {
557
+ function validateA11yConfig(raw: unknown, d: Diagnostics): void {
501
558
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
502
- errors.push(
559
+ d.error(
503
560
  `course.config.js: "a11y" must be an object, got ${describeType(raw)}`,
504
561
  );
505
562
  return;
@@ -510,7 +567,7 @@ function validateA11yConfig(raw: unknown, errors: string[]): void {
510
567
  a11y.level !== undefined &&
511
568
  !VALID_A11Y_LEVELS.includes(a11y.level as string)
512
569
  ) {
513
- errors.push(
570
+ d.error(
514
571
  `course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`,
515
572
  );
516
573
  }
@@ -518,7 +575,7 @@ function validateA11yConfig(raw: unknown, errors: string[]): void {
518
575
  a11y.standard !== undefined &&
519
576
  !VALID_A11Y_STANDARDS.includes(a11y.standard as string)
520
577
  ) {
521
- errors.push(
578
+ d.error(
522
579
  `course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`,
523
580
  );
524
581
  }
@@ -527,7 +584,7 @@ function validateA11yConfig(raw: unknown, errors: string[]): void {
527
584
  !Array.isArray(a11y.ignore) ||
528
585
  a11y.ignore.some((x) => typeof x !== 'string')
529
586
  ) {
530
- errors.push(
587
+ d.error(
531
588
  `course.config.js: "a11y.ignore" must be an array of rule-ID strings`,
532
589
  );
533
590
  }
@@ -542,8 +599,7 @@ const UUID_RE =
542
599
  function validateXAPIConfig(
543
600
  raw: unknown,
544
601
  standard: string,
545
- errors: string[],
546
- warnings: string[],
602
+ d: Diagnostics,
547
603
  ): void {
548
604
  if (raw === undefined || raw === null) return;
549
605
 
@@ -553,7 +609,7 @@ function validateXAPIConfig(
553
609
 
554
610
  if (Array.isArray(raw)) {
555
611
  if (entries.length === 0) {
556
- errors.push(
612
+ d.error(
557
613
  'course.config.js: xapi must contain at least one destination, or be omitted',
558
614
  );
559
615
  return;
@@ -566,8 +622,8 @@ function validateXAPIConfig(
566
622
  (e as { endpoint?: unknown }).endpoint === 'lms',
567
623
  ).length;
568
624
  if (lmsCount > 1) {
569
- errors.push(
570
- "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed",
625
+ d.error(
626
+ "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one launch-inherited destination is allowed",
571
627
  );
572
628
  }
573
629
  // Warn on duplicate explicit endpoints.
@@ -582,16 +638,14 @@ function validateXAPIConfig(
582
638
  }
583
639
  for (const [ep, count] of seen) {
584
640
  if (count > 1) {
585
- warnings.push(
641
+ d.warn(
586
642
  `course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
587
643
  'fan-out to the same LRS with different actors/activityIds is supported but uncommon.',
588
644
  );
589
645
  }
590
646
  }
591
647
  } else if (typeof raw !== 'object') {
592
- errors.push(
593
- 'course.config.js: xapi must be an object or an array of objects',
594
- );
648
+ d.error('course.config.js: xapi must be an object or an array of objects');
595
649
  return;
596
650
  }
597
651
 
@@ -599,15 +653,14 @@ function validateXAPIConfig(
599
653
  const entry = entries[i];
600
654
  const label = Array.isArray(raw) ? `xapi[${i}]` : 'xapi';
601
655
  if (!entry || typeof entry !== 'object') {
602
- errors.push(`course.config.js: ${label} must be an object`);
656
+ d.error(`course.config.js: ${label} must be an object`);
603
657
  continue;
604
658
  }
605
659
  validateSingleXAPIEntry(
606
660
  entry as Record<string, unknown>,
607
661
  label,
608
662
  standard,
609
- errors,
610
- warnings,
663
+ d,
611
664
  );
612
665
  }
613
666
  }
@@ -616,28 +669,28 @@ function validateSingleXAPIEntry(
616
669
  entry: Record<string, unknown>,
617
670
  label: string,
618
671
  standard: string,
619
- errors: string[],
620
- warnings: string[],
672
+ d: Diagnostics,
621
673
  ): void {
622
674
  const endpoint = entry.endpoint;
623
675
  if (endpoint === undefined) {
624
- errors.push(`course.config.js: ${label}.endpoint is required`);
676
+ d.error(`course.config.js: ${label}.endpoint is required`);
625
677
  return;
626
678
  }
627
679
  if (typeof endpoint !== 'string') {
628
- errors.push(`course.config.js: ${label}.endpoint must be a string`);
680
+ d.error(`course.config.js: ${label}.endpoint must be a string`);
629
681
  return;
630
682
  }
631
683
 
632
684
  if (endpoint === 'lms') {
633
- // Forbid under non-cmi5 export.
634
- if (standard !== 'cmi5') {
635
- errors.push(
636
- `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
685
+ // 'lms' inherits the LRS from the launch — only the launch-based
686
+ // standards (cmi5, plain xAPI) carry one.
687
+ if (standard !== 'cmi5' && standard !== 'xapi') {
688
+ d.error(
689
+ `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' or 'xapi' (you have "${standard}"). ` +
637
690
  'Either change the export standard or specify an explicit LRS endpoint.',
638
691
  );
639
692
  }
640
- // Forbid extra fields — everything is inherited from the cmi5 launch.
693
+ // Forbid extra fields — everything is inherited from the launch.
641
694
  const forbidden = [
642
695
  'auth',
643
696
  'actor',
@@ -647,8 +700,8 @@ function validateSingleXAPIEntry(
647
700
  ];
648
701
  for (const f of forbidden) {
649
702
  if (entry[f] !== undefined) {
650
- errors.push(
651
- `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`,
703
+ d.error(
704
+ `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the launch.`,
652
705
  );
653
706
  }
654
707
  }
@@ -660,24 +713,24 @@ function validateSingleXAPIEntry(
660
713
  try {
661
714
  url = new URL(endpoint);
662
715
  } catch {
663
- errors.push(
716
+ d.error(
664
717
  `course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`,
665
718
  );
666
719
  return;
667
720
  }
668
721
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
669
- errors.push(
722
+ d.error(
670
723
  `course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`,
671
724
  );
672
725
  return;
673
726
  }
674
727
  if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
675
- warnings.push(
728
+ d.warn(
676
729
  `course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`,
677
730
  );
678
731
  }
679
732
  if (!endpoint.endsWith('/')) {
680
- warnings.push(
733
+ d.warn(
681
734
  `course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
682
735
  `(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`,
683
736
  );
@@ -686,21 +739,19 @@ function validateSingleXAPIEntry(
686
739
  // auth — required for explicit endpoints.
687
740
  const auth = entry.auth;
688
741
  if (auth === undefined) {
689
- errors.push(`course.config.js: ${label}.auth is required`);
742
+ d.error(`course.config.js: ${label}.auth is required`);
690
743
  } else if (typeof auth === 'string') {
691
744
  const authErr = validateAuthCredential(auth);
692
745
  if (authErr) {
693
- errors.push(
694
- `course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`,
695
- );
746
+ d.error(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
696
747
  } else {
697
- warnings.push(
748
+ d.warn(
698
749
  `course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
699
750
  'For production, pass a function that fetches a short-lived token from a server endpoint.',
700
751
  );
701
752
  }
702
753
  } else if (typeof auth !== 'function') {
703
- errors.push(
754
+ d.error(
704
755
  `course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`,
705
756
  );
706
757
  }
@@ -708,15 +759,15 @@ function validateSingleXAPIEntry(
708
759
  // activityId — required IRI.
709
760
  const activityId = entry.activityId;
710
761
  if (activityId === undefined || activityId === '') {
711
- errors.push(`course.config.js: ${label}.activityId is required`);
762
+ d.error(`course.config.js: ${label}.activityId is required`);
712
763
  } else if (typeof activityId !== 'string') {
713
- errors.push(`course.config.js: ${label}.activityId must be a string`);
764
+ d.error(`course.config.js: ${label}.activityId must be a string`);
714
765
  } else {
715
766
  try {
716
767
  // Any absolute IRI — the URL constructor accepts uncommon schemes.
717
768
  new URL(activityId);
718
769
  } catch {
719
- errors.push(
770
+ d.error(
720
771
  `course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`,
721
772
  );
722
773
  }
@@ -726,7 +777,7 @@ function validateSingleXAPIEntry(
726
777
  const actor = entry.actor;
727
778
  if (actor === undefined) {
728
779
  if (standard === 'web') {
729
- errors.push(
780
+ d.error(
730
781
  `course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
731
782
  'Provide either a static actor object or a function that resolves one (e.g. from your auth system).',
732
783
  );
@@ -734,10 +785,10 @@ function validateSingleXAPIEntry(
734
785
  } else if (typeof actor === 'object' && actor !== null) {
735
786
  const err = validateAgent(actor);
736
787
  if (err) {
737
- errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
788
+ d.error(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
738
789
  }
739
790
  } else if (typeof actor !== 'function') {
740
- errors.push(
791
+ d.error(
741
792
  `course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`,
742
793
  );
743
794
  }
@@ -747,25 +798,25 @@ function validateSingleXAPIEntry(
747
798
  const aahp = entry.actorAccountHomePage;
748
799
  if (aahp !== undefined) {
749
800
  if (typeof aahp !== 'string') {
750
- errors.push(
801
+ d.error(
751
802
  `course.config.js: ${label}.actorAccountHomePage must be a string`,
752
803
  );
753
804
  } else {
754
805
  try {
755
806
  new URL(aahp);
756
807
  } catch {
757
- errors.push(
808
+ d.error(
758
809
  `course.config.js: ${label}.actorAccountHomePage must be an absolute URL`,
759
810
  );
760
811
  }
761
812
  }
762
813
  if (actor !== undefined) {
763
- warnings.push(
814
+ d.warn(
764
815
  `course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`,
765
816
  );
766
817
  }
767
- if (standard === 'cmi5' || standard === 'web') {
768
- warnings.push(
818
+ if (standard === 'cmi5' || standard === 'xapi' || standard === 'web') {
819
+ d.warn(
769
820
  `course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`,
770
821
  );
771
822
  }
@@ -780,7 +831,7 @@ function validateSingleXAPIEntry(
780
831
  httpOrigin(activityId) === null &&
781
832
  aahp === undefined
782
833
  ) {
783
- errors.push(
834
+ d.error(
784
835
  `course.config.js: ${label}.activityId is not an http(s) URL, so its origin can't be used as the SCORM actor's account.homePage. ` +
785
836
  `Provide ${label}.actorAccountHomePage explicitly.`,
786
837
  );
@@ -790,12 +841,12 @@ function validateSingleXAPIEntry(
790
841
  const registration = entry.registration;
791
842
  if (registration !== undefined) {
792
843
  if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
793
- errors.push(
844
+ d.error(
794
845
  `course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`,
795
846
  );
796
847
  }
797
- if (standard !== 'cmi5') {
798
- warnings.push(
848
+ if (standard !== 'cmi5' && standard !== 'xapi') {
849
+ d.warn(
799
850
  `course.config.js: ${label}.registration is a cmi5 concept; the LRS will accept it under "${standard}" but most analytics tools won't know what to do with it.`,
800
851
  );
801
852
  }
@@ -812,7 +863,7 @@ interface PageInfo {
812
863
  completesOnView: boolean;
813
864
  }
814
865
 
815
- interface PagesValidationResult extends ValidationResult {
866
+ interface PagesValidationResult {
816
867
  totalPages: number;
817
868
  totalQuizzes: number;
818
869
  hasGradedQuiz: boolean;
@@ -830,8 +881,7 @@ function validatePageFile(
830
881
  projectRoot: string,
831
882
  assetsDir: string,
832
883
  navIndex: number,
833
- errors: string[],
834
- warnings: string[],
884
+ d: Diagnostics,
835
885
  assetExistsCache: Map<string, boolean>,
836
886
  exportStandard?: string,
837
887
  ): {
@@ -845,7 +895,7 @@ function validatePageFile(
845
895
 
846
896
  const parseError = getParseError(content);
847
897
  if (parseError) {
848
- errors.push(`${fileRel}: could not parse — ${parseError}`);
898
+ d.error(`${fileRel}: could not parse — ${parseError}`);
849
899
  return {
850
900
  page: {
851
901
  fileRel,
@@ -860,37 +910,31 @@ function validatePageFile(
860
910
  };
861
911
  }
862
912
 
863
- const pageConfig = validatePageConfig(content, fileRel, errors);
913
+ const pageConfig = validatePageConfig(content, fileRel, d);
864
914
 
865
915
  const isQuiz = !!pageConfig?.quiz;
866
916
  let isGradedQuiz = false;
867
917
  if (pageConfig?.quiz) {
868
- validateQuizConfig(pageConfig.quiz, fileRel, errors);
918
+ validateQuizConfig(pageConfig.quiz, fileRel, d);
869
919
  if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
870
920
  isGradedQuiz = true;
871
921
  }
872
922
  }
873
923
 
874
- const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
924
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, d);
875
925
 
876
- validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
877
- validateQuestionComponents(
878
- content,
879
- fileRel,
880
- errors,
881
- warnings,
882
- exportStandard,
883
- );
884
- validateMediaComponents(content, fileRel, errors, warnings);
885
- validateHeadingOrder(content, fileRel, warnings);
886
- validateContractBypass(content, fileRel, errors);
926
+ validateAssetRefs(content, fileRel, assetsDir, d, assetExistsCache);
927
+ validateQuestionComponents(content, fileRel, d, exportStandard);
928
+ validateMediaComponents(content, fileRel, d);
929
+ validateHeadingOrder(content, fileRel, d);
930
+ validateContractBypass(content, fileRel, d);
887
931
  if (
888
932
  pageConfig?.quiz &&
889
933
  !HAS_USE_QUESTION_RE.test(content) &&
890
934
  !HAS_QUESTION_TAG_RE.test(content) &&
891
935
  !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)
892
936
  ) {
893
- warnings.push(
937
+ d.warn(
894
938
  `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
895
939
  `the quiz will have nothing to score`,
896
940
  );
@@ -914,10 +958,9 @@ function validatePages(
914
958
  pagesDir: string,
915
959
  assetsDir: string,
916
960
  projectRoot: string,
961
+ d: Diagnostics,
917
962
  exportStandard?: string,
918
963
  ): PagesValidationResult {
919
- const errors: string[] = [];
920
- const warnings: string[] = [];
921
964
  const pages: PageInfo[] = [];
922
965
  let totalPages = 0;
923
966
  let totalQuizzes = 0;
@@ -927,18 +970,10 @@ function validatePages(
927
970
  const assetExistsCache = new Map<string, boolean>();
928
971
 
929
972
  const noPages = (): PagesValidationResult => {
930
- errors.push(
973
+ d.error(
931
974
  'No pages found. Create at least one section with a lesson and page in pages/',
932
975
  );
933
- return {
934
- errors,
935
- warnings,
936
- totalPages,
937
- totalQuizzes,
938
- hasGradedQuiz,
939
- hasParseErrors,
940
- pages,
941
- };
976
+ return { totalPages, totalQuizzes, hasGradedQuiz, hasParseErrors, pages };
942
977
  };
943
978
 
944
979
  if (!existsSync(pagesDir)) return noPages();
@@ -947,7 +982,7 @@ function validatePages(
947
982
  for (const entry of readdirSync(pagesDir)) {
948
983
  const fullPath = resolve(pagesDir, entry);
949
984
  if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
950
- warnings.push(
985
+ d.warn(
951
986
  `${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`,
952
987
  );
953
988
  }
@@ -965,7 +1000,7 @@ function validatePages(
965
1000
  for (const pageName of meta.pages) {
966
1001
  const fileName = ensureSvelteSuffix(pageName);
967
1002
  if (!lesson.files.includes(fileName)) {
968
- errors.push(
1003
+ d.error(
969
1004
  `${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
970
1005
  );
971
1006
  }
@@ -975,7 +1010,7 @@ function validatePages(
975
1010
  const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
976
1011
  for (const file of lesson.files) {
977
1012
  if (!listedSet.has(file)) {
978
- warnings.push(
1013
+ d.warn(
979
1014
  `${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`,
980
1015
  );
981
1016
  }
@@ -988,8 +1023,7 @@ function validatePages(
988
1023
  projectRoot,
989
1024
  assetsDir,
990
1025
  totalPages,
991
- errors,
992
- warnings,
1026
+ d,
993
1027
  assetExistsCache,
994
1028
  exportStandard,
995
1029
  );
@@ -1005,7 +1039,7 @@ function validatePages(
1005
1039
  const sectionRel = relative(projectRoot, section.dir);
1006
1040
  const pagesBeforeSection = totalPages;
1007
1041
 
1008
- const sectionMeta = validateMetaFile(section.metaPath, sectionRel, errors);
1042
+ const sectionMeta = validateMetaFile(section.metaPath, sectionRel, d);
1009
1043
 
1010
1044
  for (const lesson of section.lessons) {
1011
1045
  if (lesson.name === null) {
@@ -1015,7 +1049,7 @@ function validatePages(
1015
1049
  const meta = validateMetaFile(
1016
1050
  lesson.metaPath,
1017
1051
  relative(projectRoot, lesson.dir),
1018
- errors,
1052
+ d,
1019
1053
  );
1020
1054
  validateLesson(lesson, meta);
1021
1055
  }
@@ -1023,23 +1057,13 @@ function validatePages(
1023
1057
 
1024
1058
  // The page-count delta covers both the no-lessons and empty-lessons cases.
1025
1059
  if (totalPages === pagesBeforeSection) {
1026
- warnings.push(
1027
- `${sectionRel}: section contributed no pages and will be empty`,
1028
- );
1060
+ d.warn(`${sectionRel}: section contributed no pages and will be empty`);
1029
1061
  }
1030
1062
  }
1031
1063
 
1032
1064
  if (totalPages === 0) return noPages();
1033
1065
 
1034
- return {
1035
- errors,
1036
- warnings,
1037
- totalPages,
1038
- totalQuizzes,
1039
- hasGradedQuiz,
1040
- hasParseErrors,
1041
- pages,
1042
- };
1066
+ return { totalPages, totalQuizzes, hasGradedQuiz, hasParseErrors, pages };
1043
1067
  }
1044
1068
 
1045
1069
  // ---------- _meta.js Validation ----------
@@ -1047,7 +1071,7 @@ function validatePages(
1047
1071
  function validateMetaFile(
1048
1072
  metaPath: string,
1049
1073
  parentRel: string,
1050
- errors: string[],
1074
+ d: Diagnostics,
1051
1075
  ): { title?: string; pages?: string[] } | null {
1052
1076
  if (!existsSync(metaPath)) return null;
1053
1077
 
@@ -1057,13 +1081,11 @@ function validateMetaFile(
1057
1081
  );
1058
1082
 
1059
1083
  if (result.kind === 'parse-error') {
1060
- errors.push(`${metaRel}: could not parse — JavaScript syntax error`);
1084
+ d.error(`${metaRel}: could not parse — JavaScript syntax error`);
1061
1085
  return null;
1062
1086
  }
1063
1087
  if (result.kind !== 'literal') {
1064
- errors.push(
1065
- `${metaRel}: syntax error — must export default { title: "..." }`,
1066
- );
1088
+ d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
1067
1089
  return null;
1068
1090
  }
1069
1091
 
@@ -1071,14 +1093,12 @@ function validateMetaFile(
1071
1093
  try {
1072
1094
  meta = JSON5.parse(result.text);
1073
1095
  } catch {
1074
- errors.push(
1075
- `${metaRel}: syntax error — must export default { title: "..." }`,
1076
- );
1096
+ d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
1077
1097
  return null;
1078
1098
  }
1079
1099
 
1080
1100
  if (!meta.title) {
1081
- errors.push(`${metaRel}: missing required "title" field`);
1101
+ d.error(`${metaRel}: missing required "title" field`);
1082
1102
  }
1083
1103
 
1084
1104
  return meta;
@@ -1089,12 +1109,12 @@ function validateMetaFile(
1089
1109
  function validatePageConfig(
1090
1110
  content: string,
1091
1111
  fileRel: string,
1092
- errors: string[],
1112
+ d: Diagnostics,
1093
1113
  ): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
1094
1114
  const result = parsePageConfigFromSource(content);
1095
1115
  if (result.kind === 'ok') return result.value;
1096
1116
  if (result.kind === 'invalid') {
1097
- errors.push(
1117
+ d.error(
1098
1118
  `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`,
1099
1119
  );
1100
1120
  }
@@ -1104,11 +1124,11 @@ function validatePageConfig(
1104
1124
  function validateCompletesOn(
1105
1125
  pageConfig: { completesOn?: unknown } | null,
1106
1126
  fileRel: string,
1107
- errors: string[],
1127
+ d: Diagnostics,
1108
1128
  ): boolean {
1109
1129
  if (!pageConfig || pageConfig.completesOn === undefined) return false;
1110
1130
  if (pageConfig.completesOn === 'view') return true;
1111
- errors.push(
1131
+ d.error(
1112
1132
  `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`,
1113
1133
  );
1114
1134
  return false;
@@ -1119,7 +1139,7 @@ function validateCompletesOn(
1119
1139
  function validateQuizConfig(
1120
1140
  quiz: unknown,
1121
1141
  fileRel: string,
1122
- errors: string[],
1142
+ d: Diagnostics,
1123
1143
  ): void {
1124
1144
  if (!quiz || typeof quiz !== 'object') return;
1125
1145
  const cfg = quiz as Record<string, unknown>;
@@ -1130,7 +1150,7 @@ function validateQuizConfig(
1130
1150
  val !== Infinity &&
1131
1151
  (typeof val !== 'number' || val <= 0 || !Number.isFinite(val))
1132
1152
  ) {
1133
- errors.push(
1153
+ d.error(
1134
1154
  `${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`,
1135
1155
  );
1136
1156
  }
@@ -1138,7 +1158,7 @@ function validateQuizConfig(
1138
1158
 
1139
1159
  for (const field of ['graded', 'gatesProgress']) {
1140
1160
  if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
1141
- errors.push(
1161
+ d.error(
1142
1162
  `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`,
1143
1163
  );
1144
1164
  }
@@ -1148,7 +1168,7 @@ function validateQuizConfig(
1148
1168
  cfg.feedbackMode !== undefined &&
1149
1169
  !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode as string)
1150
1170
  ) {
1151
- errors.push(
1171
+ d.error(
1152
1172
  `${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`,
1153
1173
  );
1154
1174
  }
@@ -1156,7 +1176,7 @@ function validateQuizConfig(
1156
1176
  cfg.retryMode !== undefined &&
1157
1177
  !VALID_RETRY_MODES.includes(cfg.retryMode as string)
1158
1178
  ) {
1159
- errors.push(
1179
+ d.error(
1160
1180
  `${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`,
1161
1181
  );
1162
1182
  }
@@ -1194,8 +1214,7 @@ function staticNumber(prop: PropValue | undefined): number | null {
1194
1214
  function validateQuestionComponents(
1195
1215
  content: string,
1196
1216
  fileRel: string,
1197
- errors: string[],
1198
- warnings: string[],
1217
+ d: Diagnostics,
1199
1218
  exportStandard?: string,
1200
1219
  ): void {
1201
1220
  const components = findComponents(
@@ -1208,7 +1227,7 @@ function validateQuestionComponents(
1208
1227
  for (const { name, props, hasSpread } of components) {
1209
1228
  for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
1210
1229
  if (!hasSpread && !props.has(req)) {
1211
- errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
1230
+ d.error(`${fileRel}: <${name}> is missing required prop "${req}"`);
1212
1231
  }
1213
1232
  }
1214
1233
 
@@ -1216,7 +1235,7 @@ function validateQuestionComponents(
1216
1235
  for (const labelProp of ['options', 'answers']) {
1217
1236
  const entries = staticArray(props.get(labelProp));
1218
1237
  if (entries?.some((e) => typeof e === 'string' && e.trim() === '')) {
1219
- warnings.push(
1238
+ d.warn(
1220
1239
  tag(
1221
1240
  A11Y_IDS.questionLabel,
1222
1241
  `${fileRel}: <${name}> has an empty ${labelProp === 'options' ? 'option' : 'answer'} label`,
@@ -1228,7 +1247,7 @@ function validateQuestionComponents(
1228
1247
  const idProp = props.get('id');
1229
1248
  if (idProp?.kind === 'string') {
1230
1249
  if (seenIds.has(idProp.value)) {
1231
- errors.push(
1250
+ d.error(
1232
1251
  `${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`,
1233
1252
  );
1234
1253
  } else if (exportStandard === 'scorm12') {
@@ -1237,12 +1256,12 @@ function validateQuestionComponents(
1237
1256
  // flagged above) to avoid double-reporting the same id.
1238
1257
  const sane = shortIdentifier(idProp.value);
1239
1258
  if (sane !== idProp.value) {
1240
- warnings.push(
1259
+ d.warn(
1241
1260
  `${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`,
1242
1261
  );
1243
1262
  }
1244
1263
  if (seenSanitized.has(sane)) {
1245
- errors.push(
1264
+ d.error(
1246
1265
  `${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`,
1247
1266
  );
1248
1267
  }
@@ -1253,18 +1272,18 @@ function validateQuestionComponents(
1253
1272
 
1254
1273
  const weightProp = props.get('weight');
1255
1274
  if (weightProp?.kind === 'string') {
1256
- warnings.push(
1275
+ d.warn(
1257
1276
  `${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`,
1258
1277
  );
1259
1278
  } else {
1260
1279
  const weight = staticNumber(weightProp);
1261
1280
  if (weight !== null) {
1262
1281
  if (!Number.isFinite(weight)) {
1263
- errors.push(
1282
+ d.error(
1264
1283
  `${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`,
1265
1284
  );
1266
1285
  } else if (!(weight > 0)) {
1267
- warnings.push(
1286
+ d.warn(
1268
1287
  `${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`,
1269
1288
  );
1270
1289
  }
@@ -1280,14 +1299,14 @@ function validateQuestionComponents(
1280
1299
  correct < 0 ||
1281
1300
  correct >= options.length
1282
1301
  ) {
1283
- errors.push(
1302
+ d.error(
1284
1303
  `${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`,
1285
1304
  );
1286
1305
  }
1287
1306
  }
1288
1307
  const optionFeedback = staticArray(props.get('optionFeedback'));
1289
1308
  if (options && optionFeedback && optionFeedback.length > options.length) {
1290
- warnings.push(
1309
+ d.warn(
1291
1310
  `${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`,
1292
1311
  );
1293
1312
  }
@@ -1296,7 +1315,7 @@ function validateQuestionComponents(
1296
1315
  const targets = staticArray(props.get('targets'));
1297
1316
  const correct = staticArray(props.get('correct'));
1298
1317
  if (items && correct && correct.length !== items.length) {
1299
- errors.push(
1318
+ d.error(
1300
1319
  `${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`,
1301
1320
  );
1302
1321
  }
@@ -1308,7 +1327,7 @@ function validateQuestionComponents(
1308
1327
  idx < 0 ||
1309
1328
  idx >= targets.length
1310
1329
  ) {
1311
- errors.push(
1330
+ d.error(
1312
1331
  `${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`,
1313
1332
  );
1314
1333
  break;
@@ -1326,7 +1345,7 @@ function validateQuestionComponents(
1326
1345
  typeof (p as { right?: unknown }).right !== 'string',
1327
1346
  );
1328
1347
  if (bad) {
1329
- errors.push(
1348
+ d.error(
1330
1349
  `${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`,
1331
1350
  );
1332
1351
  }
@@ -1335,9 +1354,9 @@ function validateQuestionComponents(
1335
1354
  const answers = staticArray(props.get('answers'));
1336
1355
  if (answers) {
1337
1356
  if (answers.length === 0) {
1338
- errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1357
+ d.error(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1339
1358
  } else if (answers.some((a) => typeof a !== 'string')) {
1340
- errors.push(
1359
+ d.error(
1341
1360
  `${fileRel}: <FillInTheBlank> answers must be an array of strings`,
1342
1361
  );
1343
1362
  }
@@ -1368,14 +1387,13 @@ function stripRepeated(input: string, patterns: RegExp[]): string {
1368
1387
 
1369
1388
  /**
1370
1389
  * Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
1371
- * so media isn't treated as gradable questions. Declares `warnings` directly.
1390
+ * so media isn't treated as gradable questions.
1372
1391
  * Non-static (kind 'expr') values are skipped, matching the rest of the linter.
1373
1392
  */
1374
1393
  function validateMediaComponents(
1375
1394
  content: string,
1376
1395
  fileRel: string,
1377
- errors: string[],
1378
- warnings: string[],
1396
+ d: Diagnostics,
1379
1397
  ): void {
1380
1398
  const components = findComponents(
1381
1399
  content,
@@ -1389,7 +1407,7 @@ function validateMediaComponents(
1389
1407
  // A string value is truthy at runtime (so decorative="false" hides the
1390
1408
  // image), but the parser sees a string, not a boolean — flag the misuse.
1391
1409
  if (decorative?.kind === 'string') {
1392
- errors.push(
1410
+ d.error(
1393
1411
  tag(
1394
1412
  A11Y_IDS.imageAlt,
1395
1413
  `${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`,
@@ -1402,7 +1420,7 @@ function validateMediaComponents(
1402
1420
  (decorative?.kind === 'expr' && decorative.raw.trim() === 'true');
1403
1421
  const altIsEmpty = alt?.kind === 'string' && alt.value.trim() === '';
1404
1422
  if (!hasDecorative && !hasSpread && (alt === undefined || altIsEmpty)) {
1405
- errors.push(
1423
+ d.error(
1406
1424
  tag(
1407
1425
  A11Y_IDS.imageAlt,
1408
1426
  `${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`,
@@ -1410,7 +1428,7 @@ function validateMediaComponents(
1410
1428
  );
1411
1429
  }
1412
1430
  if (hasDecorative && alt?.kind === 'string' && alt.value.trim() !== '') {
1413
- warnings.push(
1431
+ d.warn(
1414
1432
  tag(
1415
1433
  A11Y_IDS.imageAlt,
1416
1434
  `${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`,
@@ -1424,7 +1442,7 @@ function validateMediaComponents(
1424
1442
  const title = props.get('title');
1425
1443
  const titleIsEmpty = title?.kind === 'string' && title.value.trim() === '';
1426
1444
  if (!hasSpread && (title === undefined || titleIsEmpty)) {
1427
- errors.push(
1445
+ d.error(
1428
1446
  tag(
1429
1447
  A11Y_IDS.mediaTitle,
1430
1448
  `${fileRel}: <${name}> needs a title — it's the accessible name for the player`,
@@ -1439,7 +1457,7 @@ function validateMediaComponents(
1439
1457
  isEmbed &&
1440
1458
  props.get('transcript') === undefined
1441
1459
  ) {
1442
- warnings.push(
1460
+ d.warn(
1443
1461
  tag(
1444
1462
  A11Y_IDS.mediaTranscript,
1445
1463
  `${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`,
@@ -1454,7 +1472,7 @@ function validateMediaComponents(
1454
1472
  props.get('tracks') === undefined &&
1455
1473
  props.get('transcript') === undefined
1456
1474
  ) {
1457
- warnings.push(
1475
+ d.warn(
1458
1476
  tag(
1459
1477
  A11Y_IDS.mediaCaptions,
1460
1478
  `${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`,
@@ -1466,7 +1484,7 @@ function validateMediaComponents(
1466
1484
  !hasSpread &&
1467
1485
  props.get('transcript') === undefined
1468
1486
  ) {
1469
- warnings.push(
1487
+ d.warn(
1470
1488
  tag(
1471
1489
  A11Y_IDS.mediaTranscript,
1472
1490
  `${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`,
@@ -1488,14 +1506,14 @@ function validateMediaComponents(
1488
1506
  function validateHeadingOrder(
1489
1507
  content: string,
1490
1508
  fileRel: string,
1491
- warnings: string[],
1509
+ d: Diagnostics,
1492
1510
  ): void {
1493
1511
  const html = stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]);
1494
1512
  const levels = [...html.matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1495
1513
  let prevSeen: number | null = null;
1496
1514
  for (const level of levels) {
1497
1515
  if (prevSeen !== null && level - prevSeen > 1) {
1498
- warnings.push(
1516
+ d.warn(
1499
1517
  tag(
1500
1518
  A11Y_IDS.headingOrder,
1501
1519
  `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`,
@@ -1528,16 +1546,16 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
1528
1546
  function validateContractBypass(
1529
1547
  content: string,
1530
1548
  fileRel: string,
1531
- errors: string[],
1549
+ d: Diagnostics,
1532
1550
  ): void {
1533
1551
  if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
1534
- errors.push(
1552
+ d.error(
1535
1553
  `${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
1536
1554
  `useQuiz().submit() so the result reaches the LMS`,
1537
1555
  );
1538
1556
  }
1539
1557
  if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
1540
- errors.push(
1558
+ d.error(
1541
1559
  `${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
1542
1560
  `(useQuiz, useQuestion, useNavigation, …) instead`,
1543
1561
  );
@@ -1563,7 +1581,7 @@ function validateAssetRefs(
1563
1581
  content: string,
1564
1582
  fileRel: string,
1565
1583
  assetsDir: string,
1566
- warnings: string[],
1584
+ d: Diagnostics,
1567
1585
  existsCache: Map<string, boolean>,
1568
1586
  ): void {
1569
1587
  for (const assetPath of collectAssetRefs(content)) {
@@ -1574,7 +1592,7 @@ function validateAssetRefs(
1574
1592
  existsCache.set(fullAssetPath, exists);
1575
1593
  }
1576
1594
  if (!exists) {
1577
- warnings.push(
1595
+ d.warn(
1578
1596
  `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`,
1579
1597
  );
1580
1598
  }
@@ -1586,8 +1604,7 @@ function validateAssetRefs(
1586
1604
  function crossValidate(
1587
1605
  config: ParsedConfig,
1588
1606
  pageResults: PagesValidationResult,
1589
- errors: string[],
1590
- warnings: string[],
1607
+ d: Diagnostics,
1591
1608
  ): void {
1592
1609
  // completion.mode "quiz" but no graded quizzes
1593
1610
  if (
@@ -1595,7 +1612,7 @@ function crossValidate(
1595
1612
  !pageResults.hasGradedQuiz &&
1596
1613
  !pageResults.hasParseErrors
1597
1614
  ) {
1598
- errors.push(
1615
+ d.error(
1599
1616
  'completion.mode is "quiz" but no pages have quiz config with graded: true',
1600
1617
  );
1601
1618
  }
@@ -1606,7 +1623,7 @@ function crossValidate(
1606
1623
  config.completion?.mode === 'quiz' &&
1607
1624
  config.scoring?.passingScore === undefined
1608
1625
  ) {
1609
- warnings.push(
1626
+ d.warn(
1610
1627
  'completion.mode is "quiz" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.',
1611
1628
  );
1612
1629
  }
@@ -1620,7 +1637,7 @@ function crossValidate(
1620
1637
  completesOnPages.length === 0 &&
1621
1638
  !pageResults.hasParseErrors
1622
1639
  ) {
1623
- errors.push(
1640
+ d.error(
1624
1641
  'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
1625
1642
  'Either add a completesOn page or remove the trigger field to drop the static check.',
1626
1643
  );
@@ -1629,7 +1646,7 @@ function crossValidate(
1629
1646
  if (isManual) {
1630
1647
  for (const page of pageResults.pages) {
1631
1648
  if (page.hasGradedQuiz) {
1632
- warnings.push(
1649
+ d.warn(
1633
1650
  `${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
1634
1651
  'The score will be reported to the LMS for transcripts, but it will not drive ' +
1635
1652
  "completion or success status — `markComplete()` / completesOn does. If that's " +
@@ -1640,20 +1657,20 @@ function crossValidate(
1640
1657
  }
1641
1658
 
1642
1659
  if (isManual && config.completion?.percentageThreshold !== undefined) {
1643
- warnings.push(
1660
+ d.warn(
1644
1661
  'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"',
1645
1662
  );
1646
1663
  }
1647
1664
  if (!isManual) {
1648
1665
  for (const page of completesOnPages) {
1649
- warnings.push(
1666
+ d.warn(
1650
1667
  `${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`,
1651
1668
  );
1652
1669
  }
1653
1670
  }
1654
1671
  for (const page of pageResults.pages) {
1655
1672
  if (page.completesOnView && page.hasQuiz) {
1656
- warnings.push(
1673
+ d.warn(
1657
1674
  `${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`,
1658
1675
  );
1659
1676
  }
@@ -1662,7 +1679,7 @@ function crossValidate(
1662
1679
  if (isManual) {
1663
1680
  const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
1664
1681
  if (firstPage?.completesOnView) {
1665
- warnings.push(
1682
+ d.warn(
1666
1683
  `${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`,
1667
1684
  );
1668
1685
  }
@@ -1699,7 +1716,7 @@ function crossValidate(
1699
1716
  userStateBuffer;
1700
1717
 
1701
1718
  if (estimatedSize > 3200) {
1702
- warnings.push(
1719
+ d.warn(
1703
1720
  `Course has ${pageResults.totalPages} pages with ${pageResults.totalQuizzes} quizzes — estimated SCORM 1.2 suspend_data ~${estimatedSize} bytes may exceed the 4096-byte limit when fully populated (visited + chunks + standalone scores + usePersistence). Consider using "scorm2004" or "cmi5".`,
1704
1721
  );
1705
1722
  }