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