tessera-learn 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +2 -1
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +17 -3
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +21 -18
  34. package/src/components/util.ts +3 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. package/dist/validation-D9DXlqNP.js.map +0 -1
@@ -14,6 +14,10 @@ import {
14
14
  validateAuthCredential,
15
15
  joinFieldError,
16
16
  } from '../runtime/xapi/agent-rules.js';
17
+ import { shortIdentifier } from '../runtime/interaction-format.js';
18
+ import { FEEDBACK_MODES, RETRY_MODES } from '../runtime/types.js';
19
+ import { contrastRatio } from './a11y/contrast.js';
20
+ import { isVideoEmbed } from '../components/video-embed.js';
17
21
 
18
22
  // ---------- Types ----------
19
23
 
@@ -22,8 +26,103 @@ export interface ValidationResult {
22
26
  warnings: string[];
23
27
  }
24
28
 
29
+ // ---------- A11y rule IDs ----------
30
+
31
+ /** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
32
+ const A11Y_IDS = {
33
+ imageAlt: 'tessera/image-alt',
34
+ mediaTitle: 'tessera/media-title',
35
+ mediaTranscript: 'tessera/media-transcript',
36
+ mediaCaptions: 'tessera/media-captions',
37
+ questionLabel: 'tessera/question-label',
38
+ headingOrder: 'tessera/heading-order',
39
+ primaryContrast: 'tessera/primary-contrast',
40
+ lang: 'tessera/lang',
41
+ } as const;
42
+
43
+ /** Promotable by `a11y.level: 'error'`; the rest are hard contract errors. */
44
+ const PROMOTABLE_A11Y_IDS = new Set<string>([
45
+ A11Y_IDS.mediaTranscript,
46
+ A11Y_IDS.mediaCaptions,
47
+ A11Y_IDS.questionLabel,
48
+ A11Y_IDS.headingOrder,
49
+ A11Y_IDS.primaryContrast,
50
+ A11Y_IDS.lang,
51
+ ]);
52
+
53
+ /** Prefix a diagnostic with its rule ID so `a11y.ignore` / `level` can match it. */
54
+ function tag(id: string, message: string): string {
55
+ return `[${id}] ${message}`;
56
+ }
57
+
58
+ function diagnosticId(message: string): string | null {
59
+ const m = /^\[([^\]]+)\] /.exec(message);
60
+ return m ? m[1] : null;
61
+ }
62
+
63
+ /** True when a tagged diagnostic's rule ID is in the ignore set. */
64
+ export function isIgnored(
65
+ message: string,
66
+ ignore: ReadonlySet<string>,
67
+ ): boolean {
68
+ const id = diagnosticId(message);
69
+ return id !== null && ignore.has(id);
70
+ }
71
+
72
+ export interface A11ySettings {
73
+ level: 'warn' | 'error';
74
+ standard: 'wcag2a' | 'wcag2aa' | 'wcag21aa';
75
+ ignore: string[];
76
+ }
77
+
78
+ const VALID_A11Y_LEVELS = ['warn', 'error'];
79
+ const VALID_A11Y_STANDARDS = ['wcag2a', 'wcag2aa', 'wcag21aa'];
80
+
81
+ /** Normalize the raw `a11y` config to defaults, ignoring malformed pieces. */
82
+ export function normalizeA11y(raw: unknown): A11ySettings {
83
+ const a11y =
84
+ raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
85
+ const level = a11y.level === 'error' ? 'error' : 'warn';
86
+ const standard = VALID_A11Y_STANDARDS.includes(a11y.standard as string)
87
+ ? (a11y.standard as A11ySettings['standard'])
88
+ : 'wcag2aa';
89
+ const ignore = Array.isArray(a11y.ignore)
90
+ ? a11y.ignore.filter((x): x is string => typeof x === 'string')
91
+ : [];
92
+ return { level, standard, ignore };
93
+ }
94
+
95
+ /**
96
+ * Apply `a11y.ignore` (drop tagged diagnostics) and `a11y.level` (promote the
97
+ * promotable a11y warnings to errors) to a result in place. `ignore` suppresses
98
+ * at any severity, including hard contract errors; `level` only re-rates.
99
+ */
100
+ function applyA11ySettings(
101
+ result: ValidationResult,
102
+ settings: A11ySettings,
103
+ ): void {
104
+ if (settings.ignore.length > 0) {
105
+ const ignored = new Set(settings.ignore);
106
+ const keep = (msg: string) => !isIgnored(msg, ignored);
107
+ result.errors = result.errors.filter(keep);
108
+ result.warnings = result.warnings.filter(keep);
109
+ }
110
+ if (settings.level === 'error') {
111
+ const remaining: string[] = [];
112
+ for (const msg of result.warnings) {
113
+ const id = diagnosticId(msg);
114
+ if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) result.errors.push(msg);
115
+ else remaining.push(msg);
116
+ }
117
+ result.warnings = remaining;
118
+ }
119
+ }
120
+
25
121
  /** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
26
- export function reportValidationIssues({ errors, warnings }: ValidationResult): void {
122
+ export function reportValidationIssues({
123
+ errors,
124
+ warnings,
125
+ }: ValidationResult): void {
27
126
  for (const warning of warnings) {
28
127
  console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
29
128
  }
@@ -38,6 +137,7 @@ const KNOWN_CONFIG_FIELDS = new Set([
38
137
  'description',
39
138
  'author',
40
139
  'version',
140
+ 'language',
41
141
  'branding',
42
142
  'navigation',
43
143
  'completion',
@@ -45,13 +145,27 @@ const KNOWN_CONFIG_FIELDS = new Set([
45
145
  'export',
46
146
  'chrome',
47
147
  'xapi',
148
+ 'a11y',
48
149
  ]);
49
150
 
151
+ // Heuristic, not a full BCP-47 grammar: a 2–3 letter primary subtag (any case)
152
+ // plus any number of 1–8 alphanumeric subtags (script/region/variant/singleton).
153
+ const BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
154
+
155
+ /** Plausible BCP-47 tag? Shared by the linter and the <html lang> emitter. */
156
+ export function isPlausibleLanguageTag(value: unknown): value is string {
157
+ return typeof value === 'string' && BCP47_RE.test(value);
158
+ }
159
+
50
160
  const VALID_NAV_MODES = ['free', 'sequential'];
51
161
  const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
52
162
  const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
53
163
  const VALID_MANUAL_TRIGGERS = ['page'];
54
164
  const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
165
+ // Derived from the runtime types (single source of truth) — widened to
166
+ // string[] so .includes() accepts an arbitrary author-supplied value.
167
+ const VALID_FEEDBACK_MODES: readonly string[] = FEEDBACK_MODES;
168
+ const VALID_RETRY_MODES: readonly string[] = RETRY_MODES;
55
169
 
56
170
  // ---------- Main ----------
57
171
 
@@ -76,7 +190,12 @@ export function validateProject(projectRoot: string): ValidationResult {
76
190
  // 3. Validate pages directory
77
191
  const pagesDir = resolve(projectRoot, 'pages');
78
192
  const assetsDir = resolve(projectRoot, 'assets');
79
- const pageResults = validatePages(pagesDir, assetsDir, projectRoot);
193
+ const pageResults = validatePages(
194
+ pagesDir,
195
+ assetsDir,
196
+ projectRoot,
197
+ config?.export?.standard,
198
+ );
80
199
  errors.push(...pageResults.errors);
81
200
  warnings.push(...pageResults.warnings);
82
201
 
@@ -84,7 +203,11 @@ export function validateProject(projectRoot: string): ValidationResult {
84
203
  for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
85
204
  const shellPath = resolve(projectRoot, shellFile);
86
205
  if (existsSync(shellPath)) {
87
- validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
206
+ validateContractBypass(
207
+ readSourceFileCached(shellPath),
208
+ shellFile,
209
+ errors,
210
+ );
88
211
  }
89
212
  }
90
213
 
@@ -93,7 +216,9 @@ export function validateProject(projectRoot: string): ValidationResult {
93
216
  crossValidate(config, pageResults, errors, warnings);
94
217
  }
95
218
 
96
- return { errors, warnings };
219
+ const result: ValidationResult = { errors, warnings };
220
+ applyA11ySettings(result, normalizeA11y(config?.a11y));
221
+ return result;
97
222
  }
98
223
 
99
224
  // ---------- Config Validation ----------
@@ -115,18 +240,18 @@ interface ParsedConfig {
115
240
  function parseConfig(
116
241
  projectRoot: string,
117
242
  errors: string[],
118
- warnings: string[]
243
+ warnings: string[],
119
244
  ): ParsedConfig | null {
120
245
  const read = readCourseConfig(projectRoot);
121
246
  if (!read.ok) {
122
247
  // 'missing' can't occur — validateProject checks existsSync first.
123
248
  if (read.reason === 'no-export') {
124
249
  errors.push(
125
- 'course.config.js: could not parse — must use `export default { ... }` syntax'
250
+ 'course.config.js: could not parse — must use `export default { ... }` syntax',
126
251
  );
127
252
  } else if (read.reason === 'parse-error') {
128
253
  errors.push(
129
- 'course.config.js: syntax error — must export a static object literal'
254
+ 'course.config.js: syntax error — must export a static object literal',
130
255
  );
131
256
  }
132
257
  return null;
@@ -137,16 +262,62 @@ function parseConfig(
137
262
  for (const key of Object.keys(config)) {
138
263
  if (!KNOWN_CONFIG_FIELDS.has(key)) {
139
264
  warnings.push(
140
- `course.config.js: unknown field "${key}" — will be ignored`
265
+ `course.config.js: unknown field "${key}" — will be ignored`,
141
266
  );
142
267
  }
143
268
  }
144
269
 
270
+ // Validate title against the runtime merge `userConfig.title || "Untitled
271
+ // Course"`: a missing or empty string falls back to the default (warn), a
272
+ // whitespace-only string is truthy and ships verbatim (warn), and a
273
+ // non-string is a misconfiguration — a truthy one ships as-is, a falsy one
274
+ // falls back, but either way the author should fix it (error).
275
+ if (config.title !== undefined && typeof config.title !== 'string') {
276
+ errors.push(
277
+ `course.config.js: "title" must be a string, got ${typeof config.title}`,
278
+ );
279
+ } else if (config.title === undefined || config.title === '') {
280
+ warnings.push(
281
+ 'course.config.js: "title" is missing or empty — the course will ship as "Untitled Course"',
282
+ );
283
+ } else if (config.title.trim() === '') {
284
+ warnings.push(
285
+ 'course.config.js: "title" is only whitespace — it ships verbatim and will not fall back to "Untitled Course"',
286
+ );
287
+ }
288
+
289
+ // Validate branding
290
+ if (config.branding !== undefined) {
291
+ validateBranding(config.branding, warnings);
292
+ }
293
+
294
+ // Rule 1.8: language present and well-formed (BCP-47)
295
+ if (config.language === undefined) {
296
+ warnings.push(
297
+ tag(
298
+ A11Y_IDS.lang,
299
+ `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.`,
300
+ ),
301
+ );
302
+ } else if (!isPlausibleLanguageTag(config.language)) {
303
+ warnings.push(
304
+ tag(
305
+ A11Y_IDS.lang,
306
+ `course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`,
307
+ ),
308
+ );
309
+ }
310
+
311
+ // Validate a11y config block
312
+ if (config.a11y !== undefined) {
313
+ validateA11yConfig(config.a11y, errors);
314
+ }
315
+
145
316
  // Validate navigation.mode
146
317
  if (config.navigation?.mode !== undefined) {
147
318
  if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
148
319
  errors.push(
149
- `course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`
320
+ `course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`,
150
321
  );
151
322
  }
152
323
  }
@@ -155,7 +326,7 @@ function parseConfig(
155
326
  if (config.completion?.mode !== undefined) {
156
327
  if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
157
328
  errors.push(
158
- `course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`
329
+ `course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`,
159
330
  );
160
331
  }
161
332
  }
@@ -163,11 +334,11 @@ function parseConfig(
163
334
  if (config.completion?.trigger !== undefined) {
164
335
  if (config.completion.mode !== 'manual') {
165
336
  warnings.push(
166
- `course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`
337
+ `course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`,
167
338
  );
168
339
  } else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
169
340
  errors.push(
170
- `course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`
341
+ `course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`,
171
342
  );
172
343
  }
173
344
  }
@@ -175,11 +346,15 @@ function parseConfig(
175
346
  if (config.completion?.requireSuccessStatus !== undefined) {
176
347
  if (config.completion.mode !== 'manual') {
177
348
  warnings.push(
178
- `course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`
349
+ `course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`,
179
350
  );
180
- } else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) {
351
+ } else if (
352
+ !VALID_REQUIRE_SUCCESS_STATUS.includes(
353
+ config.completion.requireSuccessStatus,
354
+ )
355
+ ) {
181
356
  errors.push(
182
- `course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`
357
+ `course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`,
183
358
  );
184
359
  }
185
360
  }
@@ -188,7 +363,7 @@ function parseConfig(
188
363
  if (config.export?.standard !== undefined) {
189
364
  if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
190
365
  errors.push(
191
- `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`
366
+ `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`,
192
367
  );
193
368
  }
194
369
  }
@@ -198,7 +373,7 @@ function parseConfig(
198
373
  const score = config.scoring.passingScore;
199
374
  if (typeof score !== 'number' || score < 0 || score > 100) {
200
375
  errors.push(
201
- `course.config.js: "scoring.passingScore" must be 0–100, got ${score}`
376
+ `course.config.js: "scoring.passingScore" must be 0–100, got ${score}`,
202
377
  );
203
378
  }
204
379
  }
@@ -208,7 +383,7 @@ function parseConfig(
208
383
  const threshold = config.completion.percentageThreshold;
209
384
  if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
210
385
  errors.push(
211
- `course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`
386
+ `course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`,
212
387
  );
213
388
  }
214
389
  }
@@ -219,23 +394,144 @@ function parseConfig(
219
394
  config.xapi,
220
395
  config.export?.standard ?? 'web',
221
396
  errors,
222
- warnings
397
+ warnings,
223
398
  );
224
399
  }
225
400
 
226
401
  return config;
227
402
  }
228
403
 
404
+ // ---------- Branding Validation ----------
405
+
406
+ // Permissive approximation of the browser's accepted color set: hex 3/4/6/8,
407
+ // any CSS functional notation (rgb/hsl/hwb/lab/lch/oklab/oklch/color), or a
408
+ // bare keyword (named colors, transparent, currentColor). parseColor's real
409
+ // check (App.svelte) is browser-only and the runtime degrades gracefully, so
410
+ // an unrecognized value is advisory, never an error — lean permissive to avoid
411
+ // rejecting values the browser would accept.
412
+ const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
413
+ const FUNC_COLOR_RE =
414
+ /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(.*\)$/i;
415
+ const NAMED_COLOR_RE = /^[a-zA-Z]+$/;
416
+
417
+ function isPlausibleColor(value: string): boolean {
418
+ const v = value.trim();
419
+ return (
420
+ HEX_COLOR_RE.test(v) || FUNC_COLOR_RE.test(v) || NAMED_COLOR_RE.test(v)
421
+ );
422
+ }
423
+
424
+ /**
425
+ * Format checks on the branding block (advisory) plus rule 1.7's contrast check
426
+ * on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
427
+ * <img src>, an unparseable color falls back to theme defaults.
428
+ */
429
+ function validateBranding(raw: unknown, warnings: string[]): void {
430
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
431
+ warnings.push(
432
+ `course.config.js: "branding" must be an object, got ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw} — will be ignored`,
433
+ );
434
+ return;
435
+ }
436
+ const branding = raw as Record<string, unknown>;
437
+
438
+ const logo = branding.logo;
439
+ if (logo !== undefined) {
440
+ if (typeof logo !== 'string') {
441
+ warnings.push(
442
+ `course.config.js: "branding.logo" must be a string, got ${typeof logo}`,
443
+ );
444
+ } else if (logo.startsWith('$assets/')) {
445
+ warnings.push(
446
+ '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.',
447
+ );
448
+ }
449
+ }
450
+
451
+ const primaryColor = branding.primaryColor;
452
+ if (primaryColor !== undefined) {
453
+ if (typeof primaryColor !== 'string') {
454
+ warnings.push(
455
+ `course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`,
456
+ );
457
+ } else if (!isPlausibleColor(primaryColor)) {
458
+ warnings.push(
459
+ `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`,
460
+ );
461
+ } else {
462
+ // Rule 1.7: primaryColor is used both as links on the default white page
463
+ // background and as a button fill behind white text — symmetric, so one
464
+ // ratio covers both. Non-#hex valid colors return null and defer to Tier 2.
465
+ const ratio = contrastRatio(primaryColor, '#ffffff');
466
+ if (ratio !== null && ratio < 4.5) {
467
+ warnings.push(
468
+ tag(
469
+ A11Y_IDS.primaryContrast,
470
+ `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`,
471
+ ),
472
+ );
473
+ }
474
+ }
475
+ }
476
+
477
+ const fontFamily = branding.fontFamily;
478
+ if (fontFamily !== undefined && typeof fontFamily !== 'string') {
479
+ warnings.push(
480
+ `course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`,
481
+ );
482
+ }
483
+ }
484
+
485
+ // ---------- a11y Config Validation ----------
486
+
487
+ /** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
488
+ function validateA11yConfig(raw: unknown, errors: string[]): void {
489
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
490
+ errors.push(
491
+ `course.config.js: "a11y" must be an object, got ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw}`,
492
+ );
493
+ return;
494
+ }
495
+ const a11y = raw as Record<string, unknown>;
496
+
497
+ if (
498
+ a11y.level !== undefined &&
499
+ !VALID_A11Y_LEVELS.includes(a11y.level as string)
500
+ ) {
501
+ errors.push(
502
+ `course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`,
503
+ );
504
+ }
505
+ if (
506
+ a11y.standard !== undefined &&
507
+ !VALID_A11Y_STANDARDS.includes(a11y.standard as string)
508
+ ) {
509
+ errors.push(
510
+ `course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`,
511
+ );
512
+ }
513
+ if (a11y.ignore !== undefined) {
514
+ if (
515
+ !Array.isArray(a11y.ignore) ||
516
+ a11y.ignore.some((x) => typeof x !== 'string')
517
+ ) {
518
+ errors.push(
519
+ `course.config.js: "a11y.ignore" must be an array of rule-ID strings`,
520
+ );
521
+ }
522
+ }
523
+ }
524
+
229
525
  // ---------- xAPI Config Validation ----------
230
526
 
231
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
232
- const SHA1_RE = /^[0-9a-f]{40}$/i;
527
+ const UUID_RE =
528
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
233
529
 
234
530
  function validateXAPIConfig(
235
531
  raw: unknown,
236
532
  standard: string,
237
533
  errors: string[],
238
- warnings: string[]
534
+ warnings: string[],
239
535
  ): void {
240
536
  if (raw === undefined || raw === null) return;
241
537
 
@@ -246,7 +542,7 @@ function validateXAPIConfig(
246
542
  if (Array.isArray(raw)) {
247
543
  if (entries.length === 0) {
248
544
  errors.push(
249
- 'course.config.js: xapi must contain at least one destination, or be omitted'
545
+ 'course.config.js: xapi must contain at least one destination, or be omitted',
250
546
  );
251
547
  return;
252
548
  }
@@ -255,11 +551,11 @@ function validateXAPIConfig(
255
551
  (e) =>
256
552
  e &&
257
553
  typeof e === 'object' &&
258
- (e as { endpoint?: unknown }).endpoint === 'lms'
554
+ (e as { endpoint?: unknown }).endpoint === 'lms',
259
555
  ).length;
260
556
  if (lmsCount > 1) {
261
557
  errors.push(
262
- "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed"
558
+ "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed",
263
559
  );
264
560
  }
265
561
  // Warn on duplicate explicit endpoints.
@@ -276,13 +572,13 @@ function validateXAPIConfig(
276
572
  if (count > 1) {
277
573
  warnings.push(
278
574
  `course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
279
- 'fan-out to the same LRS with different actors/activityIds is supported but uncommon.'
575
+ 'fan-out to the same LRS with different actors/activityIds is supported but uncommon.',
280
576
  );
281
577
  }
282
578
  }
283
579
  } else if (typeof raw !== 'object') {
284
580
  errors.push(
285
- 'course.config.js: xapi must be an object or an array of objects'
581
+ 'course.config.js: xapi must be an object or an array of objects',
286
582
  );
287
583
  return;
288
584
  }
@@ -299,7 +595,7 @@ function validateXAPIConfig(
299
595
  label,
300
596
  standard,
301
597
  errors,
302
- warnings
598
+ warnings,
303
599
  );
304
600
  }
305
601
  }
@@ -309,7 +605,7 @@ function validateSingleXAPIEntry(
309
605
  label: string,
310
606
  standard: string,
311
607
  errors: string[],
312
- warnings: string[]
608
+ warnings: string[],
313
609
  ): void {
314
610
  const endpoint = entry.endpoint;
315
611
  if (endpoint === undefined) {
@@ -326,15 +622,21 @@ function validateSingleXAPIEntry(
326
622
  if (standard !== 'cmi5') {
327
623
  errors.push(
328
624
  `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
329
- 'Either change the export standard or specify an explicit LRS endpoint.'
625
+ 'Either change the export standard or specify an explicit LRS endpoint.',
330
626
  );
331
627
  }
332
628
  // Forbid extra fields — everything is inherited from the cmi5 launch.
333
- const forbidden = ['auth', 'actor', 'activityId', 'registration', 'actorAccountHomePage'];
629
+ const forbidden = [
630
+ 'auth',
631
+ 'actor',
632
+ 'activityId',
633
+ 'registration',
634
+ 'actorAccountHomePage',
635
+ ];
334
636
  for (const f of forbidden) {
335
637
  if (entry[f] !== undefined) {
336
638
  errors.push(
337
- `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`
639
+ `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`,
338
640
  );
339
641
  }
340
642
  }
@@ -347,25 +649,25 @@ function validateSingleXAPIEntry(
347
649
  url = new URL(endpoint);
348
650
  } catch {
349
651
  errors.push(
350
- `course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`
652
+ `course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`,
351
653
  );
352
654
  return;
353
655
  }
354
656
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
355
657
  errors.push(
356
- `course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`
658
+ `course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`,
357
659
  );
358
660
  return;
359
661
  }
360
662
  if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
361
663
  warnings.push(
362
- `course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`
664
+ `course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`,
363
665
  );
364
666
  }
365
667
  if (!endpoint.endsWith('/')) {
366
668
  warnings.push(
367
669
  `course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
368
- `(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`
670
+ `(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`,
369
671
  );
370
672
  }
371
673
 
@@ -376,16 +678,18 @@ function validateSingleXAPIEntry(
376
678
  } else if (typeof auth === 'string') {
377
679
  const authErr = validateAuthCredential(auth);
378
680
  if (authErr) {
379
- errors.push(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
681
+ errors.push(
682
+ `course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`,
683
+ );
380
684
  } else {
381
685
  warnings.push(
382
686
  `course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
383
- 'For production, pass a function that fetches a short-lived token from a server endpoint.'
687
+ 'For production, pass a function that fetches a short-lived token from a server endpoint.',
384
688
  );
385
689
  }
386
690
  } else if (typeof auth !== 'function') {
387
691
  errors.push(
388
- `course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`
692
+ `course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`,
389
693
  );
390
694
  }
391
695
 
@@ -401,7 +705,7 @@ function validateSingleXAPIEntry(
401
705
  new URL(activityId);
402
706
  } catch {
403
707
  errors.push(
404
- `course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`
708
+ `course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`,
405
709
  );
406
710
  }
407
711
  }
@@ -412,7 +716,7 @@ function validateSingleXAPIEntry(
412
716
  if (standard === 'web') {
413
717
  errors.push(
414
718
  `course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
415
- 'Provide either a static actor object or a function that resolves one (e.g. from your auth system).'
719
+ 'Provide either a static actor object or a function that resolves one (e.g. from your auth system).',
416
720
  );
417
721
  }
418
722
  } else if (typeof actor === 'object' && actor !== null) {
@@ -422,7 +726,7 @@ function validateSingleXAPIEntry(
422
726
  }
423
727
  } else if (typeof actor !== 'function') {
424
728
  errors.push(
425
- `course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`
729
+ `course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`,
426
730
  );
427
731
  }
428
732
 
@@ -432,25 +736,25 @@ function validateSingleXAPIEntry(
432
736
  if (aahp !== undefined) {
433
737
  if (typeof aahp !== 'string') {
434
738
  errors.push(
435
- `course.config.js: ${label}.actorAccountHomePage must be a string`
739
+ `course.config.js: ${label}.actorAccountHomePage must be a string`,
436
740
  );
437
741
  } else {
438
742
  try {
439
743
  new URL(aahp);
440
744
  } catch {
441
745
  errors.push(
442
- `course.config.js: ${label}.actorAccountHomePage must be an absolute URL`
746
+ `course.config.js: ${label}.actorAccountHomePage must be an absolute URL`,
443
747
  );
444
748
  }
445
749
  }
446
750
  if (actor !== undefined) {
447
751
  warnings.push(
448
- `course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`
752
+ `course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`,
449
753
  );
450
754
  }
451
755
  if (standard === 'cmi5' || standard === 'web') {
452
756
  warnings.push(
453
- `course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`
757
+ `course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`,
454
758
  );
455
759
  }
456
760
  }
@@ -462,7 +766,7 @@ function validateSingleXAPIEntry(
462
766
  (standard === 'scorm12' || standard === 'scorm2004') &&
463
767
  typeof activityId === 'string'
464
768
  ) {
465
- let isHttp = false;
769
+ let isHttp: boolean;
466
770
  try {
467
771
  const u = new URL(activityId);
468
772
  isHttp = u.protocol === 'http:' || u.protocol === 'https:';
@@ -472,7 +776,7 @@ function validateSingleXAPIEntry(
472
776
  if (!isHttp && aahp === undefined) {
473
777
  errors.push(
474
778
  `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. ` +
475
- `Provide ${label}.actorAccountHomePage explicitly.`
779
+ `Provide ${label}.actorAccountHomePage explicitly.`,
476
780
  );
477
781
  }
478
782
  }
@@ -482,12 +786,12 @@ function validateSingleXAPIEntry(
482
786
  if (registration !== undefined) {
483
787
  if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
484
788
  errors.push(
485
- `course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`
789
+ `course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`,
486
790
  );
487
791
  }
488
792
  if (standard !== 'cmi5') {
489
793
  warnings.push(
490
- `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.`
794
+ `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.`,
491
795
  );
492
796
  }
493
797
  }
@@ -522,7 +826,8 @@ function validatePageFile(
522
826
  navIndex: number,
523
827
  errors: string[],
524
828
  warnings: string[],
525
- assetExistsCache: Map<string, boolean>
829
+ assetExistsCache: Map<string, boolean>,
830
+ exportStandard?: string,
526
831
  ): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
527
832
  const fileRel = relative(projectRoot, filePath);
528
833
  const content = readSourceFileCached(filePath);
@@ -541,7 +846,15 @@ function validatePageFile(
541
846
  const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
542
847
 
543
848
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
544
- validateQuestionComponents(content, fileRel, errors);
849
+ validateQuestionComponents(
850
+ content,
851
+ fileRel,
852
+ errors,
853
+ warnings,
854
+ exportStandard,
855
+ );
856
+ validateMediaComponents(content, fileRel, errors, warnings);
857
+ validateHeadingOrder(content, fileRel, warnings);
545
858
  validateContractBypass(content, fileRel, errors);
546
859
  if (
547
860
  pageConfig?.quiz &&
@@ -551,12 +864,18 @@ function validatePageFile(
551
864
  ) {
552
865
  warnings.push(
553
866
  `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
554
- `the quiz will have nothing to score`
867
+ `the quiz will have nothing to score`,
555
868
  );
556
869
  }
557
870
 
558
871
  return {
559
- page: { fileRel, navIndex, hasGradedQuiz: isGradedQuiz, hasQuiz: isQuiz, completesOnView },
872
+ page: {
873
+ fileRel,
874
+ navIndex,
875
+ hasGradedQuiz: isGradedQuiz,
876
+ hasQuiz: isQuiz,
877
+ completesOnView,
878
+ },
560
879
  isQuiz,
561
880
  isGradedQuiz,
562
881
  };
@@ -565,7 +884,8 @@ function validatePageFile(
565
884
  function validatePages(
566
885
  pagesDir: string,
567
886
  assetsDir: string,
568
- projectRoot: string
887
+ projectRoot: string,
888
+ exportStandard?: string,
569
889
  ): PagesValidationResult {
570
890
  const errors: string[] = [];
571
891
  const warnings: string[] = [];
@@ -578,9 +898,16 @@ function validatePages(
578
898
 
579
899
  if (!existsSync(pagesDir)) {
580
900
  errors.push(
581
- 'No pages found. Create at least one section with a lesson and page in pages/'
901
+ 'No pages found. Create at least one section with a lesson and page in pages/',
582
902
  );
583
- return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false, pages };
903
+ return {
904
+ errors,
905
+ warnings,
906
+ totalPages: 0,
907
+ totalQuizzes: 0,
908
+ hasGradedQuiz: false,
909
+ pages,
910
+ };
584
911
  }
585
912
 
586
913
  const topLevelEntries = readdirSync(pagesDir);
@@ -591,7 +918,7 @@ function validatePages(
591
918
  if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
592
919
  const relPath = relative(projectRoot, fullPath);
593
920
  warnings.push(
594
- `${relPath}: this file is outside the section/lesson structure and will be ignored`
921
+ `${relPath}: this file is outside the section/lesson structure and will be ignored`,
595
922
  );
596
923
  }
597
924
  }
@@ -606,20 +933,28 @@ function validatePages(
606
933
 
607
934
  if (sectionDirs.length === 0) {
608
935
  errors.push(
609
- 'No pages found. Create at least one section with a lesson and page in pages/'
936
+ 'No pages found. Create at least one section with a lesson and page in pages/',
610
937
  );
611
- return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false, pages };
938
+ return {
939
+ errors,
940
+ warnings,
941
+ totalPages: 0,
942
+ totalQuizzes: 0,
943
+ hasGradedQuiz: false,
944
+ pages,
945
+ };
612
946
  }
613
947
 
614
948
  for (const sectionName of sectionDirs) {
615
949
  const sectionPath = resolve(pagesDir, sectionName);
616
950
  const sectionRel = relative(projectRoot, sectionPath);
951
+ const pagesBeforeSection = totalPages;
617
952
 
618
953
  // Validate section _meta.js
619
954
  const sectionMeta = validateMetaFile(
620
955
  resolve(sectionPath, '_meta.js'),
621
956
  sectionRel,
622
- errors
957
+ errors,
623
958
  );
624
959
 
625
960
  // Flat mode: .svelte files directly at section level are pages of an
@@ -636,9 +971,12 @@ function validatePages(
636
971
  for (const pageName of sectionMeta.pages) {
637
972
  const fileName = ensureSvelteSuffix(pageName);
638
973
  if (!sectionSvelteFiles.includes(fileName)) {
639
- const metaRel = relative(projectRoot, resolve(sectionPath, '_meta.js'));
974
+ const metaRel = relative(
975
+ projectRoot,
976
+ resolve(sectionPath, '_meta.js'),
977
+ );
640
978
  errors.push(
641
- `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`
979
+ `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
642
980
  );
643
981
  }
644
982
  }
@@ -652,7 +990,8 @@ function validatePages(
652
990
  totalPages,
653
991
  errors,
654
992
  warnings,
655
- assetExistsCache
993
+ assetExistsCache,
994
+ exportStandard,
656
995
  );
657
996
  totalPages++;
658
997
  if (result.isQuiz) totalQuizzes++;
@@ -676,7 +1015,7 @@ function validatePages(
676
1015
  const meta = validateMetaFile(
677
1016
  resolve(lessonPath, '_meta.js'),
678
1017
  lessonRel,
679
- errors
1018
+ errors,
680
1019
  );
681
1020
 
682
1021
  // Get .svelte files
@@ -689,9 +1028,12 @@ function validatePages(
689
1028
  for (const pageName of meta.pages) {
690
1029
  const fileName = ensureSvelteSuffix(pageName);
691
1030
  if (!svelteFiles.includes(fileName)) {
692
- const metaRel = relative(projectRoot, resolve(lessonPath, '_meta.js'));
1031
+ const metaRel = relative(
1032
+ projectRoot,
1033
+ resolve(lessonPath, '_meta.js'),
1034
+ );
693
1035
  errors.push(
694
- `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`
1036
+ `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
695
1037
  );
696
1038
  }
697
1039
  }
@@ -704,7 +1046,7 @@ function validatePages(
704
1046
  if (!listedSet.has(file)) {
705
1047
  const relPath = relative(projectRoot, resolve(lessonPath, file));
706
1048
  warnings.push(
707
- `${relPath}: not listed in _meta.js pages array — will be appended at end`
1049
+ `${relPath}: not listed in _meta.js pages array — will be appended at end`,
708
1050
  );
709
1051
  }
710
1052
  }
@@ -719,7 +1061,8 @@ function validatePages(
719
1061
  totalPages,
720
1062
  errors,
721
1063
  warnings,
722
- assetExistsCache
1064
+ assetExistsCache,
1065
+ exportStandard,
723
1066
  );
724
1067
  totalPages++;
725
1068
  if (result.isQuiz) totalQuizzes++;
@@ -727,11 +1070,18 @@ function validatePages(
727
1070
  pages.push(result.page);
728
1071
  }
729
1072
  }
1073
+
1074
+ // The page-count delta covers both the no-lessons and empty-lessons cases.
1075
+ if (totalPages === pagesBeforeSection) {
1076
+ warnings.push(
1077
+ `${sectionRel}: section contributed no pages and will be empty`,
1078
+ );
1079
+ }
730
1080
  }
731
1081
 
732
1082
  if (totalPages === 0) {
733
1083
  errors.push(
734
- 'No pages found. Create at least one section with a lesson and page in pages/'
1084
+ 'No pages found. Create at least one section with a lesson and page in pages/',
735
1085
  );
736
1086
  }
737
1087
 
@@ -743,15 +1093,19 @@ function validatePages(
743
1093
  function validateMetaFile(
744
1094
  metaPath: string,
745
1095
  parentRel: string,
746
- errors: string[]
1096
+ errors: string[],
747
1097
  ): { title?: string; pages?: string[] } | null {
748
1098
  if (!existsSync(metaPath)) return null;
749
1099
 
750
1100
  const metaRel = `${parentRel}/_meta.js`;
751
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
1101
+ const objectStr = extractDefaultExportObjectLiteral(
1102
+ readSourceFileCached(metaPath),
1103
+ );
752
1104
 
753
1105
  if (!objectStr) {
754
- errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
1106
+ errors.push(
1107
+ `${metaRel}: syntax error — must export default { title: "..." }`,
1108
+ );
755
1109
  return null;
756
1110
  }
757
1111
 
@@ -759,7 +1113,9 @@ function validateMetaFile(
759
1113
  try {
760
1114
  meta = JSON5.parse(objectStr);
761
1115
  } catch {
762
- errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
1116
+ errors.push(
1117
+ `${metaRel}: syntax error — must export default { title: "..." }`,
1118
+ );
763
1119
  return null;
764
1120
  }
765
1121
 
@@ -775,13 +1131,13 @@ function validateMetaFile(
775
1131
  function validatePageConfig(
776
1132
  content: string,
777
1133
  fileRel: string,
778
- errors: string[]
1134
+ errors: string[],
779
1135
  ): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
780
1136
  const result = parsePageConfigFromSource(content);
781
1137
  if (result.kind === 'ok') return result.value;
782
1138
  if (result.kind === 'invalid') {
783
1139
  errors.push(
784
- `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
1140
+ `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`,
785
1141
  );
786
1142
  }
787
1143
  return null;
@@ -790,27 +1146,34 @@ function validatePageConfig(
790
1146
  function validateCompletesOn(
791
1147
  pageConfig: { completesOn?: unknown } | null,
792
1148
  fileRel: string,
793
- errors: string[]
1149
+ errors: string[],
794
1150
  ): boolean {
795
1151
  if (!pageConfig || pageConfig.completesOn === undefined) return false;
796
1152
  if (pageConfig.completesOn === 'view') return true;
797
1153
  errors.push(
798
- `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`
1154
+ `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`,
799
1155
  );
800
1156
  return false;
801
1157
  }
802
1158
 
803
1159
  // ---------- Quiz Config Validation ----------
804
1160
 
805
- function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
1161
+ function validateQuizConfig(
1162
+ quiz: unknown,
1163
+ fileRel: string,
1164
+ errors: string[],
1165
+ ): void {
806
1166
  if (!quiz || typeof quiz !== 'object') return;
807
1167
  const cfg = quiz as Record<string, unknown>;
808
1168
 
809
1169
  if (cfg.maxAttempts !== undefined) {
810
1170
  const val = cfg.maxAttempts;
811
- if (val !== Infinity && (typeof val !== 'number' || val <= 0 || !Number.isFinite(val))) {
1171
+ if (
1172
+ val !== Infinity &&
1173
+ (typeof val !== 'number' || val <= 0 || !Number.isFinite(val))
1174
+ ) {
812
1175
  errors.push(
813
- `${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`
1176
+ `${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`,
814
1177
  );
815
1178
  }
816
1179
  }
@@ -818,10 +1181,27 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
818
1181
  for (const field of ['graded', 'gatesProgress']) {
819
1182
  if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
820
1183
  errors.push(
821
- `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
1184
+ `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`,
822
1185
  );
823
1186
  }
824
1187
  }
1188
+
1189
+ if (
1190
+ cfg.feedbackMode !== undefined &&
1191
+ !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode as string)
1192
+ ) {
1193
+ errors.push(
1194
+ `${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`,
1195
+ );
1196
+ }
1197
+ if (
1198
+ cfg.retryMode !== undefined &&
1199
+ !VALID_RETRY_MODES.includes(cfg.retryMode as string)
1200
+ ) {
1201
+ errors.push(
1202
+ `${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`,
1203
+ );
1204
+ }
825
1205
  }
826
1206
 
827
1207
  // ---------- Question Component Validation ----------
@@ -838,25 +1218,31 @@ type PropValue =
838
1218
  | { kind: 'expr'; raw: string }
839
1219
  | { kind: 'bool' };
840
1220
 
841
-
842
1221
  /**
843
1222
  * Parse the props of an opening tag starting just after the component name.
844
1223
  * Returns null if the tag can't be parsed cleanly — callers then skip it
845
1224
  * rather than risk a false positive.
846
1225
  */
847
- function parseTagProps(content: string, start: number): Map<string, PropValue> | null {
1226
+ function parseTagProps(
1227
+ content: string,
1228
+ start: number,
1229
+ ): { props: Map<string, PropValue>; hasSpread: boolean } | null {
848
1230
  const props = new Map<string, PropValue>();
1231
+ let hasSpread = false;
849
1232
  let i = start;
850
1233
  while (i < content.length) {
851
1234
  while (i < content.length && /\s/.test(content[i])) i++;
852
1235
  if (i >= content.length) return null;
853
1236
  const c = content[i];
854
- if (c === '>') return props;
855
- if (c === '/' && content[i + 1] === '>') return props;
856
- // Spread / shorthand expression — skip the whole {...} block.
1237
+ if (c === '>') return { props, hasSpread };
1238
+ if (c === '/' && content[i + 1] === '>') return { props, hasSpread };
1239
+ // Spread / shorthand expression — skip the whole {...} block, but record
1240
+ // that unseen props may be supplied here so callers can suppress
1241
+ // false-positive "missing required prop / alt / title" diagnostics.
857
1242
  if (c === '{') {
858
1243
  const block = extractObjectLiteral(content, i);
859
1244
  if (!block) return null;
1245
+ hasSpread = true;
860
1246
  i += block.length;
861
1247
  continue;
862
1248
  }
@@ -912,50 +1298,113 @@ function staticNumber(prop: PropValue | undefined): number | null {
912
1298
  function validateQuestionComponents(
913
1299
  content: string,
914
1300
  fileRel: string,
915
- errors: string[]
1301
+ errors: string[],
1302
+ warnings: string[],
1303
+ exportStandard?: string,
916
1304
  ): void {
917
1305
  const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
918
1306
  const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
919
1307
  const seenIds = new Set<string>();
1308
+ const seenSanitized = new Set<string>();
920
1309
  let m: RegExpExecArray | null;
921
1310
  while ((m = tagStartRe.exec(content)) !== null) {
922
1311
  const name = m[1];
923
- const props = parseTagProps(content, m.index + m[0].length);
924
- if (!props) continue;
1312
+ const parsed = parseTagProps(content, m.index + m[0].length);
1313
+ if (!parsed) continue;
1314
+ const { props, hasSpread } = parsed;
925
1315
 
926
1316
  for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
927
- if (!props.has(req)) {
1317
+ if (!hasSpread && !props.has(req)) {
928
1318
  errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
929
1319
  }
930
1320
  }
931
1321
 
1322
+ // Rule 1.5: empty option/answer labels are both an a11y and a scoring bug.
1323
+ for (const labelProp of ['options', 'answers']) {
1324
+ const entries = staticArray(props.get(labelProp));
1325
+ if (entries?.some((e) => typeof e === 'string' && e.trim() === '')) {
1326
+ warnings.push(
1327
+ tag(
1328
+ A11Y_IDS.questionLabel,
1329
+ `${fileRel}: <${name}> has an empty ${labelProp === 'options' ? 'option' : 'answer'} label`,
1330
+ ),
1331
+ );
1332
+ }
1333
+ }
1334
+
932
1335
  const idProp = props.get('id');
933
1336
  if (idProp?.kind === 'string') {
934
1337
  if (seenIds.has(idProp.value)) {
935
1338
  errors.push(
936
- `${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`
1339
+ `${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`,
937
1340
  );
1341
+ } else if (exportStandard === 'scorm12') {
1342
+ // scorm12-only: shortIdentifier strips non-alphanumerics, so distinct
1343
+ // raw ids can collide after sanitization. Skip raw duplicates (already
1344
+ // flagged above) to avoid double-reporting the same id.
1345
+ const sane = shortIdentifier(idProp.value);
1346
+ if (sane !== idProp.value) {
1347
+ warnings.push(
1348
+ `${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`,
1349
+ );
1350
+ }
1351
+ if (seenSanitized.has(sane)) {
1352
+ errors.push(
1353
+ `${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`,
1354
+ );
1355
+ }
1356
+ seenSanitized.add(sane);
938
1357
  }
939
1358
  seenIds.add(idProp.value);
940
1359
  }
941
1360
 
1361
+ const weightProp = props.get('weight');
1362
+ if (weightProp?.kind === 'string') {
1363
+ warnings.push(
1364
+ `${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`,
1365
+ );
1366
+ } else {
1367
+ const weight = staticNumber(weightProp);
1368
+ if (weight !== null) {
1369
+ if (!Number.isFinite(weight)) {
1370
+ errors.push(
1371
+ `${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`,
1372
+ );
1373
+ } else if (!(weight > 0)) {
1374
+ warnings.push(
1375
+ `${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`,
1376
+ );
1377
+ }
1378
+ }
1379
+ }
1380
+
942
1381
  if (name === 'MultipleChoice') {
943
1382
  const options = staticArray(props.get('options'));
944
1383
  const correct = staticNumber(props.get('correct'));
945
1384
  if (options && correct !== null) {
946
- if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) {
1385
+ if (
1386
+ !Number.isInteger(correct) ||
1387
+ correct < 0 ||
1388
+ correct >= options.length
1389
+ ) {
947
1390
  errors.push(
948
- `${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`
1391
+ `${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`,
949
1392
  );
950
1393
  }
951
1394
  }
1395
+ const optionFeedback = staticArray(props.get('optionFeedback'));
1396
+ if (options && optionFeedback && optionFeedback.length > options.length) {
1397
+ warnings.push(
1398
+ `${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`,
1399
+ );
1400
+ }
952
1401
  } else if (name === 'Sorting') {
953
1402
  const items = staticArray(props.get('items'));
954
1403
  const targets = staticArray(props.get('targets'));
955
1404
  const correct = staticArray(props.get('correct'));
956
1405
  if (items && correct && correct.length !== items.length) {
957
1406
  errors.push(
958
- `${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`
1407
+ `${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`,
959
1408
  );
960
1409
  }
961
1410
  if (targets && correct) {
@@ -967,7 +1416,7 @@ function validateQuestionComponents(
967
1416
  idx >= targets.length
968
1417
  ) {
969
1418
  errors.push(
970
- `${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`
1419
+ `${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`,
971
1420
  );
972
1421
  break;
973
1422
  }
@@ -981,11 +1430,11 @@ function validateQuestionComponents(
981
1430
  typeof p !== 'object' ||
982
1431
  p === null ||
983
1432
  typeof (p as { left?: unknown }).left !== 'string' ||
984
- typeof (p as { right?: unknown }).right !== 'string'
1433
+ typeof (p as { right?: unknown }).right !== 'string',
985
1434
  );
986
1435
  if (bad) {
987
1436
  errors.push(
988
- `${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`
1437
+ `${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`,
989
1438
  );
990
1439
  }
991
1440
  }
@@ -996,7 +1445,7 @@ function validateQuestionComponents(
996
1445
  errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
997
1446
  } else if (answers.some((a) => typeof a !== 'string')) {
998
1447
  errors.push(
999
- `${fileRel}: <FillInTheBlank> answers must be an array of strings`
1448
+ `${fileRel}: <FillInTheBlank> answers must be an array of strings`,
1000
1449
  );
1001
1450
  }
1002
1451
  }
@@ -1004,6 +1453,156 @@ function validateQuestionComponents(
1004
1453
  }
1005
1454
  }
1006
1455
 
1456
+ // ---------- Media Component Validation (rules 1.3 / 1.4) ----------
1457
+
1458
+ /** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
1459
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
1460
+
1461
+ /**
1462
+ * Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
1463
+ * so media isn't treated as gradable questions. Declares `warnings` directly.
1464
+ * Non-static (kind 'expr') values are skipped, matching the rest of the linter.
1465
+ */
1466
+ function validateMediaComponents(
1467
+ content: string,
1468
+ fileRel: string,
1469
+ errors: string[],
1470
+ warnings: string[],
1471
+ ): void {
1472
+ const scan = content.replace(HTML_COMMENT_RE, '');
1473
+ const tagStartRe = /<(Image|Video|Audio)(?=[\s/>])/g;
1474
+ let m: RegExpExecArray | null;
1475
+ while ((m = tagStartRe.exec(scan)) !== null) {
1476
+ const name = m[1];
1477
+ const parsed = parseTagProps(scan, m.index + m[0].length);
1478
+ if (!parsed) continue;
1479
+ const { props, hasSpread } = parsed;
1480
+
1481
+ if (name === 'Image') {
1482
+ const alt = props.get('alt');
1483
+ const decorative = props.get('decorative');
1484
+ // A string value is truthy at runtime (so decorative="false" hides the
1485
+ // image), but the parser sees a string, not a boolean — flag the misuse.
1486
+ if (decorative?.kind === 'string') {
1487
+ errors.push(
1488
+ tag(
1489
+ A11Y_IDS.imageAlt,
1490
+ `${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`,
1491
+ ),
1492
+ );
1493
+ continue;
1494
+ }
1495
+ const hasDecorative =
1496
+ decorative?.kind === 'bool' ||
1497
+ (decorative?.kind === 'expr' && decorative.raw.trim() === 'true');
1498
+ const altIsEmpty = alt?.kind === 'string' && alt.value.trim() === '';
1499
+ if (!hasDecorative && !hasSpread && (alt === undefined || altIsEmpty)) {
1500
+ errors.push(
1501
+ tag(
1502
+ A11Y_IDS.imageAlt,
1503
+ `${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`,
1504
+ ),
1505
+ );
1506
+ }
1507
+ if (hasDecorative && alt?.kind === 'string' && alt.value.trim() !== '') {
1508
+ warnings.push(
1509
+ tag(
1510
+ A11Y_IDS.imageAlt,
1511
+ `${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`,
1512
+ ),
1513
+ );
1514
+ }
1515
+ continue;
1516
+ }
1517
+
1518
+ // Video / Audio
1519
+ const title = props.get('title');
1520
+ const titleIsEmpty = title?.kind === 'string' && title.value.trim() === '';
1521
+ if (!hasSpread && (title === undefined || titleIsEmpty)) {
1522
+ errors.push(
1523
+ tag(
1524
+ A11Y_IDS.mediaTitle,
1525
+ `${fileRel}: <${name}> needs a title — it's the accessible name for the player`,
1526
+ ),
1527
+ );
1528
+ }
1529
+ const src = props.get('src');
1530
+ const isEmbed = src?.kind === 'string' && isVideoEmbed(src.value);
1531
+ if (
1532
+ name === 'Video' &&
1533
+ !hasSpread &&
1534
+ isEmbed &&
1535
+ props.get('transcript') === undefined
1536
+ ) {
1537
+ warnings.push(
1538
+ tag(
1539
+ A11Y_IDS.mediaTranscript,
1540
+ `${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`,
1541
+ ),
1542
+ );
1543
+ }
1544
+ if (
1545
+ name === 'Video' &&
1546
+ !hasSpread &&
1547
+ src?.kind === 'string' &&
1548
+ !isEmbed &&
1549
+ props.get('tracks') === undefined &&
1550
+ props.get('transcript') === undefined
1551
+ ) {
1552
+ warnings.push(
1553
+ tag(
1554
+ A11Y_IDS.mediaCaptions,
1555
+ `${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`,
1556
+ ),
1557
+ );
1558
+ }
1559
+ if (
1560
+ name === 'Audio' &&
1561
+ !hasSpread &&
1562
+ props.get('transcript') === undefined
1563
+ ) {
1564
+ warnings.push(
1565
+ tag(
1566
+ A11Y_IDS.mediaTranscript,
1567
+ `${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`,
1568
+ ),
1569
+ );
1570
+ }
1571
+ }
1572
+ }
1573
+
1574
+ // ---------- Heading Order Validation (rule 1.6) ----------
1575
+
1576
+ /**
1577
+ * Warn on a skipped heading level (e.g. h2 → h4). Scripts, styles, and comments
1578
+ * are stripped first so string literals, CSS, and commented-out markup can't be
1579
+ * miscounted. No "one h1 per page" check — the layout owns the page h1 and child
1580
+ * components emit headings a static scan can't see; that belongs to the Tier-2
1581
+ * audit.
1582
+ */
1583
+ function validateHeadingOrder(
1584
+ content: string,
1585
+ fileRel: string,
1586
+ warnings: string[],
1587
+ ): void {
1588
+ const html = content
1589
+ .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '')
1590
+ .replace(HTML_COMMENT_RE, '');
1591
+ const levels = [...html.matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1592
+ let prevSeen: number | null = null;
1593
+ for (const level of levels) {
1594
+ if (prevSeen !== null && level - prevSeen > 1) {
1595
+ warnings.push(
1596
+ tag(
1597
+ A11Y_IDS.headingOrder,
1598
+ `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`,
1599
+ ),
1600
+ );
1601
+ }
1602
+ prevSeen = level;
1603
+ }
1604
+ }
1605
+
1007
1606
  // ---------- Contract Bypass Detection ----------
1008
1607
 
1009
1608
  const QUIZ_COMPLETE_DISPATCH_RE =
@@ -1011,7 +1610,7 @@ const QUIZ_COMPLETE_DISPATCH_RE =
1011
1610
  const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
1012
1611
  const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
1013
1612
  const HAS_QUESTION_TAG_RE = new RegExp(
1014
- `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
1613
+ `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`,
1015
1614
  );
1016
1615
  // Custom widget imported from a local `.svelte` file may wrap useQuestion.
1017
1616
  // Treat its presence as enough to suppress the "no questions" warning —
@@ -1026,18 +1625,18 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
1026
1625
  function validateContractBypass(
1027
1626
  content: string,
1028
1627
  fileRel: string,
1029
- errors: string[]
1628
+ errors: string[],
1030
1629
  ): void {
1031
1630
  if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
1032
1631
  errors.push(
1033
1632
  `${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
1034
- `useQuiz().submit() so the result reaches the LMS`
1633
+ `useQuiz().submit() so the result reaches the LMS`,
1035
1634
  );
1036
1635
  }
1037
1636
  if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
1038
1637
  errors.push(
1039
1638
  `${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
1040
- `(useQuiz, useQuestion, useNavigation, …) instead`
1639
+ `(useQuiz, useQuestion, useNavigation, …) instead`,
1041
1640
  );
1042
1641
  }
1043
1642
  }
@@ -1052,7 +1651,7 @@ function collectAssetRefs(content: string): string[] {
1052
1651
  let match: RegExpExecArray | null;
1053
1652
  ASSET_REF_RE.lastIndex = 0;
1054
1653
  while ((match = ASSET_REF_RE.exec(content)) !== null) {
1055
- seen.add(match[1]);
1654
+ seen.add(match[1].replace(/[?#].*$/, ''));
1056
1655
  }
1057
1656
  return [...seen];
1058
1657
  }
@@ -1062,7 +1661,7 @@ function validateAssetRefs(
1062
1661
  fileRel: string,
1063
1662
  assetsDir: string,
1064
1663
  warnings: string[],
1065
- existsCache: Map<string, boolean>
1664
+ existsCache: Map<string, boolean>,
1066
1665
  ): void {
1067
1666
  for (const assetPath of collectAssetRefs(content)) {
1068
1667
  const fullAssetPath = resolve(assetsDir, assetPath);
@@ -1073,7 +1672,7 @@ function validateAssetRefs(
1073
1672
  }
1074
1673
  if (!exists) {
1075
1674
  warnings.push(
1076
- `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`
1675
+ `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`,
1077
1676
  );
1078
1677
  }
1079
1678
  }
@@ -1085,22 +1684,37 @@ function crossValidate(
1085
1684
  config: ParsedConfig,
1086
1685
  pageResults: PagesValidationResult,
1087
1686
  errors: string[],
1088
- warnings: string[]
1687
+ warnings: string[],
1089
1688
  ): void {
1090
1689
  // completion.mode "quiz" but no graded quizzes
1091
1690
  if (config.completion?.mode === 'quiz' && !pageResults.hasGradedQuiz) {
1092
1691
  errors.push(
1093
- 'completion.mode is "quiz" but no pages have quiz config with graded: true'
1692
+ 'completion.mode is "quiz" but no pages have quiz config with graded: true',
1693
+ );
1694
+ }
1695
+
1696
+ // completion.mode "quiz" with an implicit pass threshold — the merge defaults
1697
+ // to 70, so this is a nudge, not an error.
1698
+ if (
1699
+ config.completion?.mode === 'quiz' &&
1700
+ config.scoring?.passingScore === undefined
1701
+ ) {
1702
+ warnings.push(
1703
+ 'completion.mode is "quiz" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.',
1094
1704
  );
1095
1705
  }
1096
1706
 
1097
1707
  const isManual = config.completion?.mode === 'manual';
1098
1708
  const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
1099
1709
 
1100
- if (isManual && config.completion?.trigger === 'page' && completesOnPages.length === 0) {
1710
+ if (
1711
+ isManual &&
1712
+ config.completion?.trigger === 'page' &&
1713
+ completesOnPages.length === 0
1714
+ ) {
1101
1715
  errors.push(
1102
1716
  'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
1103
- 'Either add a completesOn page or remove the trigger field to drop the static check.'
1717
+ 'Either add a completesOn page or remove the trigger field to drop the static check.',
1104
1718
  );
1105
1719
  }
1106
1720
 
@@ -1110,8 +1724,8 @@ function crossValidate(
1110
1724
  warnings.push(
1111
1725
  `${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
1112
1726
  'The score will be reported to the LMS for transcripts, but it will not drive ' +
1113
- 'completion or success status — `markComplete()` / completesOn does. If that\'s ' +
1114
- 'not what you want, set graded: false or change completion.mode.'
1727
+ "completion or success status — `markComplete()` / completesOn does. If that's " +
1728
+ 'not what you want, set graded: false or change completion.mode.',
1115
1729
  );
1116
1730
  }
1117
1731
  }
@@ -1119,20 +1733,20 @@ function crossValidate(
1119
1733
 
1120
1734
  if (isManual && config.completion?.percentageThreshold !== undefined) {
1121
1735
  warnings.push(
1122
- 'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"'
1736
+ 'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"',
1123
1737
  );
1124
1738
  }
1125
1739
  if (!isManual) {
1126
1740
  for (const page of completesOnPages) {
1127
1741
  warnings.push(
1128
- `${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`
1742
+ `${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`,
1129
1743
  );
1130
1744
  }
1131
1745
  }
1132
1746
  for (const page of pageResults.pages) {
1133
1747
  if (page.completesOnView && page.hasQuiz) {
1134
1748
  warnings.push(
1135
- `${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`
1749
+ `${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`,
1136
1750
  );
1137
1751
  }
1138
1752
  }
@@ -1141,7 +1755,7 @@ function crossValidate(
1141
1755
  const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
1142
1756
  if (firstPage?.completesOnView) {
1143
1757
  warnings.push(
1144
- `${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`
1758
+ `${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`,
1145
1759
  );
1146
1760
  }
1147
1761
  }
@@ -1163,11 +1777,11 @@ function crossValidate(
1163
1777
  for (let i = 0; i < pageResults.totalPages; i++) {
1164
1778
  visitedChars += String(i).length + 1; // digit chars + comma
1165
1779
  }
1166
- const overhead = 60; // top-level JSON overhead with all keys
1167
- const quizBytes = pageResults.totalQuizzes * 15; // q: "NNN":100,
1168
- const chunkBytes = pageResults.totalPages * 12; // c: "NNN":NN,
1169
- const standaloneBytes = pageResults.totalPages * 30;// s/gs: conservative buffer per page
1170
- const userStateBuffer = 256; // usePersistence headroom
1780
+ const overhead = 60; // top-level JSON overhead with all keys
1781
+ const quizBytes = pageResults.totalQuizzes * 15; // q: "NNN":100,
1782
+ const chunkBytes = pageResults.totalPages * 12; // c: "NNN":NN,
1783
+ const standaloneBytes = pageResults.totalPages * 30; // s/gs: conservative buffer per page
1784
+ const userStateBuffer = 256; // usePersistence headroom
1171
1785
  const estimatedSize =
1172
1786
  overhead +
1173
1787
  visitedChars +
@@ -1178,7 +1792,7 @@ function crossValidate(
1178
1792
 
1179
1793
  if (estimatedSize > 3200) {
1180
1794
  warnings.push(
1181
- `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".`
1795
+ `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".`,
1182
1796
  );
1183
1797
  }
1184
1798
  }