tessera-learn 0.0.10 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.js.map +0 -1
@@ -3,11 +3,21 @@ import { resolve, relative } from 'node:path';
3
3
  import JSON5 from 'json5';
4
4
  import {
5
5
  extractDefaultExportObjectLiteral,
6
+ extractObjectLiteral,
6
7
  parsePageConfigFromSource,
7
8
  readSourceFileCached,
8
9
  ensureSvelteSuffix,
10
+ readCourseConfig,
9
11
  } from './manifest.js';
10
- import { validateAgent } from '../runtime/xapi/agent-rules.js';
12
+ import {
13
+ validateAgent,
14
+ validateAuthCredential,
15
+ joinFieldError,
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';
11
21
 
12
22
  // ---------- Types ----------
13
23
 
@@ -16,12 +26,118 @@ export interface ValidationResult {
16
26
  warnings: string[];
17
27
  }
18
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
+
121
+ /** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
122
+ export function reportValidationIssues({
123
+ errors,
124
+ warnings,
125
+ }: ValidationResult): void {
126
+ for (const warning of warnings) {
127
+ console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
128
+ }
129
+ for (const error of errors) {
130
+ console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
131
+ }
132
+ }
133
+
19
134
  // Known top-level config fields
20
135
  const KNOWN_CONFIG_FIELDS = new Set([
21
136
  'title',
22
137
  'description',
23
138
  'author',
24
139
  'version',
140
+ 'language',
25
141
  'branding',
26
142
  'navigation',
27
143
  'completion',
@@ -29,13 +145,27 @@ const KNOWN_CONFIG_FIELDS = new Set([
29
145
  'export',
30
146
  'chrome',
31
147
  'xapi',
148
+ 'a11y',
32
149
  ]);
33
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
+
34
160
  const VALID_NAV_MODES = ['free', 'sequential'];
35
161
  const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
36
162
  const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
37
163
  const VALID_MANUAL_TRIGGERS = ['page'];
38
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;
39
169
 
40
170
  // ---------- Main ----------
41
171
 
@@ -55,12 +185,17 @@ export function validateProject(projectRoot: string): ValidationResult {
55
185
  }
56
186
 
57
187
  // 2. Parse and validate config
58
- const config = parseConfig(configPath, errors, warnings);
188
+ const config = parseConfig(projectRoot, errors, warnings);
59
189
 
60
190
  // 3. Validate pages directory
61
191
  const pagesDir = resolve(projectRoot, 'pages');
62
192
  const assetsDir = resolve(projectRoot, 'assets');
63
- const pageResults = validatePages(pagesDir, assetsDir, projectRoot);
193
+ const pageResults = validatePages(
194
+ pagesDir,
195
+ assetsDir,
196
+ projectRoot,
197
+ config?.export?.standard,
198
+ );
64
199
  errors.push(...pageResults.errors);
65
200
  warnings.push(...pageResults.warnings);
66
201
 
@@ -68,7 +203,11 @@ export function validateProject(projectRoot: string): ValidationResult {
68
203
  for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
69
204
  const shellPath = resolve(projectRoot, shellFile);
70
205
  if (existsSync(shellPath)) {
71
- validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
206
+ validateContractBypass(
207
+ readSourceFileCached(shellPath),
208
+ shellFile,
209
+ errors,
210
+ );
72
211
  }
73
212
  }
74
213
 
@@ -77,7 +216,9 @@ export function validateProject(projectRoot: string): ValidationResult {
77
216
  crossValidate(config, pageResults, errors, warnings);
78
217
  }
79
218
 
80
- return { errors, warnings };
219
+ const result: ValidationResult = { errors, warnings };
220
+ applyA11ySettings(result, normalizeA11y(config?.a11y));
221
+ return result;
81
222
  }
82
223
 
83
224
  // ---------- Config Validation ----------
@@ -97,42 +238,86 @@ interface ParsedConfig {
97
238
  }
98
239
 
99
240
  function parseConfig(
100
- configPath: string,
241
+ projectRoot: string,
101
242
  errors: string[],
102
- warnings: string[]
243
+ warnings: string[],
103
244
  ): ParsedConfig | null {
104
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
105
- if (!objectStr) {
106
- errors.push(
107
- 'course.config.js: could not parse — must use `export default { ... }` syntax'
108
- );
109
- return null;
110
- }
111
-
112
- let config: ParsedConfig;
113
- try {
114
- config = JSON5.parse(objectStr);
115
- } catch {
116
- errors.push(
117
- 'course.config.js: syntax error — must export a static object literal'
118
- );
245
+ const read = readCourseConfig(projectRoot);
246
+ if (!read.ok) {
247
+ // 'missing' can't occur — validateProject checks existsSync first.
248
+ if (read.reason === 'no-export') {
249
+ errors.push(
250
+ 'course.config.js: could not parse — must use `export default { ... }` syntax',
251
+ );
252
+ } else if (read.reason === 'parse-error') {
253
+ errors.push(
254
+ 'course.config.js: syntax error — must export a static object literal',
255
+ );
256
+ }
119
257
  return null;
120
258
  }
259
+ const config = read.config as ParsedConfig;
121
260
 
122
261
  // Check for unknown fields
123
262
  for (const key of Object.keys(config)) {
124
263
  if (!KNOWN_CONFIG_FIELDS.has(key)) {
125
264
  warnings.push(
126
- `course.config.js: unknown field "${key}" — will be ignored`
265
+ `course.config.js: unknown field "${key}" — will be ignored`,
127
266
  );
128
267
  }
129
268
  }
130
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
+
131
316
  // Validate navigation.mode
132
317
  if (config.navigation?.mode !== undefined) {
133
318
  if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
134
319
  errors.push(
135
- `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}"`,
136
321
  );
137
322
  }
138
323
  }
@@ -141,7 +326,7 @@ function parseConfig(
141
326
  if (config.completion?.mode !== undefined) {
142
327
  if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
143
328
  errors.push(
144
- `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}"`,
145
330
  );
146
331
  }
147
332
  }
@@ -149,11 +334,11 @@ function parseConfig(
149
334
  if (config.completion?.trigger !== undefined) {
150
335
  if (config.completion.mode !== 'manual') {
151
336
  warnings.push(
152
- `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"`,
153
338
  );
154
339
  } else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
155
340
  errors.push(
156
- `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}"`,
157
342
  );
158
343
  }
159
344
  }
@@ -161,11 +346,15 @@ function parseConfig(
161
346
  if (config.completion?.requireSuccessStatus !== undefined) {
162
347
  if (config.completion.mode !== 'manual') {
163
348
  warnings.push(
164
- `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"`,
165
350
  );
166
- } 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
+ ) {
167
356
  errors.push(
168
- `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}"`,
169
358
  );
170
359
  }
171
360
  }
@@ -174,7 +363,7 @@ function parseConfig(
174
363
  if (config.export?.standard !== undefined) {
175
364
  if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
176
365
  errors.push(
177
- `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}"`,
178
367
  );
179
368
  }
180
369
  }
@@ -184,7 +373,7 @@ function parseConfig(
184
373
  const score = config.scoring.passingScore;
185
374
  if (typeof score !== 'number' || score < 0 || score > 100) {
186
375
  errors.push(
187
- `course.config.js: "scoring.passingScore" must be 0–100, got ${score}`
376
+ `course.config.js: "scoring.passingScore" must be 0–100, got ${score}`,
188
377
  );
189
378
  }
190
379
  }
@@ -194,7 +383,7 @@ function parseConfig(
194
383
  const threshold = config.completion.percentageThreshold;
195
384
  if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
196
385
  errors.push(
197
- `course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`
386
+ `course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`,
198
387
  );
199
388
  }
200
389
  }
@@ -205,23 +394,144 @@ function parseConfig(
205
394
  config.xapi,
206
395
  config.export?.standard ?? 'web',
207
396
  errors,
208
- warnings
397
+ warnings,
209
398
  );
210
399
  }
211
400
 
212
401
  return config;
213
402
  }
214
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
+
215
525
  // ---------- xAPI Config Validation ----------
216
526
 
217
- 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;
218
- 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;
219
529
 
220
530
  function validateXAPIConfig(
221
531
  raw: unknown,
222
532
  standard: string,
223
533
  errors: string[],
224
- warnings: string[]
534
+ warnings: string[],
225
535
  ): void {
226
536
  if (raw === undefined || raw === null) return;
227
537
 
@@ -232,7 +542,7 @@ function validateXAPIConfig(
232
542
  if (Array.isArray(raw)) {
233
543
  if (entries.length === 0) {
234
544
  errors.push(
235
- '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',
236
546
  );
237
547
  return;
238
548
  }
@@ -241,11 +551,11 @@ function validateXAPIConfig(
241
551
  (e) =>
242
552
  e &&
243
553
  typeof e === 'object' &&
244
- (e as { endpoint?: unknown }).endpoint === 'lms'
554
+ (e as { endpoint?: unknown }).endpoint === 'lms',
245
555
  ).length;
246
556
  if (lmsCount > 1) {
247
557
  errors.push(
248
- "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",
249
559
  );
250
560
  }
251
561
  // Warn on duplicate explicit endpoints.
@@ -262,13 +572,13 @@ function validateXAPIConfig(
262
572
  if (count > 1) {
263
573
  warnings.push(
264
574
  `course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
265
- '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.',
266
576
  );
267
577
  }
268
578
  }
269
579
  } else if (typeof raw !== 'object') {
270
580
  errors.push(
271
- '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',
272
582
  );
273
583
  return;
274
584
  }
@@ -285,7 +595,7 @@ function validateXAPIConfig(
285
595
  label,
286
596
  standard,
287
597
  errors,
288
- warnings
598
+ warnings,
289
599
  );
290
600
  }
291
601
  }
@@ -295,7 +605,7 @@ function validateSingleXAPIEntry(
295
605
  label: string,
296
606
  standard: string,
297
607
  errors: string[],
298
- warnings: string[]
608
+ warnings: string[],
299
609
  ): void {
300
610
  const endpoint = entry.endpoint;
301
611
  if (endpoint === undefined) {
@@ -312,15 +622,21 @@ function validateSingleXAPIEntry(
312
622
  if (standard !== 'cmi5') {
313
623
  errors.push(
314
624
  `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
315
- 'Either change the export standard or specify an explicit LRS endpoint.'
625
+ 'Either change the export standard or specify an explicit LRS endpoint.',
316
626
  );
317
627
  }
318
628
  // Forbid extra fields — everything is inherited from the cmi5 launch.
319
- const forbidden = ['auth', 'actor', 'activityId', 'registration', 'actorAccountHomePage'];
629
+ const forbidden = [
630
+ 'auth',
631
+ 'actor',
632
+ 'activityId',
633
+ 'registration',
634
+ 'actorAccountHomePage',
635
+ ];
320
636
  for (const f of forbidden) {
321
637
  if (entry[f] !== undefined) {
322
638
  errors.push(
323
- `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.`,
324
640
  );
325
641
  }
326
642
  }
@@ -333,25 +649,25 @@ function validateSingleXAPIEntry(
333
649
  url = new URL(endpoint);
334
650
  } catch {
335
651
  errors.push(
336
- `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}"`,
337
653
  );
338
654
  return;
339
655
  }
340
656
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
341
657
  errors.push(
342
- `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}"`,
343
659
  );
344
660
  return;
345
661
  }
346
662
  if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
347
663
  warnings.push(
348
- `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.`,
349
665
  );
350
666
  }
351
667
  if (!endpoint.endsWith('/')) {
352
668
  warnings.push(
353
669
  `course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
354
- `(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.`,
355
671
  );
356
672
  }
357
673
 
@@ -360,25 +676,20 @@ function validateSingleXAPIEntry(
360
676
  if (auth === undefined) {
361
677
  errors.push(`course.config.js: ${label}.auth is required`);
362
678
  } else if (typeof auth === 'string') {
363
- if (!auth) {
364
- errors.push(`course.config.js: ${label}.auth must be a non-empty string`);
365
- } else if (/^basic\s/i.test(auth)) {
366
- errors.push(
367
- `course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`
368
- );
369
- } else if (/^bearer\s/i.test(auth)) {
679
+ const authErr = validateAuthCredential(auth);
680
+ if (authErr) {
370
681
  errors.push(
371
- `course.config.js: ${label}.auth: Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.`
682
+ `course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`,
372
683
  );
373
684
  } else {
374
685
  warnings.push(
375
686
  `course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
376
- '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.',
377
688
  );
378
689
  }
379
690
  } else if (typeof auth !== 'function') {
380
691
  errors.push(
381
- `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}`,
382
693
  );
383
694
  }
384
695
 
@@ -394,7 +705,7 @@ function validateSingleXAPIEntry(
394
705
  new URL(activityId);
395
706
  } catch {
396
707
  errors.push(
397
- `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}"`,
398
709
  );
399
710
  }
400
711
  }
@@ -405,20 +716,17 @@ function validateSingleXAPIEntry(
405
716
  if (standard === 'web') {
406
717
  errors.push(
407
718
  `course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
408
- '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).',
409
720
  );
410
721
  }
411
722
  } else if (typeof actor === 'object' && actor !== null) {
412
723
  const err = validateAgent(actor);
413
724
  if (err) {
414
- const joined = err.startsWith('.')
415
- ? `${label}.actor${err}`
416
- : `${label}.actor ${err}`;
417
- errors.push(`course.config.js: ${joined}`);
725
+ errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
418
726
  }
419
727
  } else if (typeof actor !== 'function') {
420
728
  errors.push(
421
- `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}`,
422
730
  );
423
731
  }
424
732
 
@@ -428,25 +736,25 @@ function validateSingleXAPIEntry(
428
736
  if (aahp !== undefined) {
429
737
  if (typeof aahp !== 'string') {
430
738
  errors.push(
431
- `course.config.js: ${label}.actorAccountHomePage must be a string`
739
+ `course.config.js: ${label}.actorAccountHomePage must be a string`,
432
740
  );
433
741
  } else {
434
742
  try {
435
743
  new URL(aahp);
436
744
  } catch {
437
745
  errors.push(
438
- `course.config.js: ${label}.actorAccountHomePage must be an absolute URL`
746
+ `course.config.js: ${label}.actorAccountHomePage must be an absolute URL`,
439
747
  );
440
748
  }
441
749
  }
442
750
  if (actor !== undefined) {
443
751
  warnings.push(
444
- `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.`,
445
753
  );
446
754
  }
447
755
  if (standard === 'cmi5' || standard === 'web') {
448
756
  warnings.push(
449
- `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}".`,
450
758
  );
451
759
  }
452
760
  }
@@ -458,7 +766,7 @@ function validateSingleXAPIEntry(
458
766
  (standard === 'scorm12' || standard === 'scorm2004') &&
459
767
  typeof activityId === 'string'
460
768
  ) {
461
- let isHttp = false;
769
+ let isHttp: boolean;
462
770
  try {
463
771
  const u = new URL(activityId);
464
772
  isHttp = u.protocol === 'http:' || u.protocol === 'https:';
@@ -468,7 +776,7 @@ function validateSingleXAPIEntry(
468
776
  if (!isHttp && aahp === undefined) {
469
777
  errors.push(
470
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. ` +
471
- `Provide ${label}.actorAccountHomePage explicitly.`
779
+ `Provide ${label}.actorAccountHomePage explicitly.`,
472
780
  );
473
781
  }
474
782
  }
@@ -478,12 +786,12 @@ function validateSingleXAPIEntry(
478
786
  if (registration !== undefined) {
479
787
  if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
480
788
  errors.push(
481
- `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)}"`,
482
790
  );
483
791
  }
484
792
  if (standard !== 'cmi5') {
485
793
  warnings.push(
486
- `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.`,
487
795
  );
488
796
  }
489
797
  }
@@ -518,7 +826,8 @@ function validatePageFile(
518
826
  navIndex: number,
519
827
  errors: string[],
520
828
  warnings: string[],
521
- assetExistsCache: Map<string, boolean>
829
+ assetExistsCache: Map<string, boolean>,
830
+ exportStandard?: string,
522
831
  ): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
523
832
  const fileRel = relative(projectRoot, filePath);
524
833
  const content = readSourceFileCached(filePath);
@@ -537,7 +846,15 @@ function validatePageFile(
537
846
  const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
538
847
 
539
848
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
540
- 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);
541
858
  validateContractBypass(content, fileRel, errors);
542
859
  if (
543
860
  pageConfig?.quiz &&
@@ -547,12 +864,18 @@ function validatePageFile(
547
864
  ) {
548
865
  warnings.push(
549
866
  `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
550
- `the quiz will have nothing to score`
867
+ `the quiz will have nothing to score`,
551
868
  );
552
869
  }
553
870
 
554
871
  return {
555
- page: { fileRel, navIndex, hasGradedQuiz: isGradedQuiz, hasQuiz: isQuiz, completesOnView },
872
+ page: {
873
+ fileRel,
874
+ navIndex,
875
+ hasGradedQuiz: isGradedQuiz,
876
+ hasQuiz: isQuiz,
877
+ completesOnView,
878
+ },
556
879
  isQuiz,
557
880
  isGradedQuiz,
558
881
  };
@@ -561,7 +884,8 @@ function validatePageFile(
561
884
  function validatePages(
562
885
  pagesDir: string,
563
886
  assetsDir: string,
564
- projectRoot: string
887
+ projectRoot: string,
888
+ exportStandard?: string,
565
889
  ): PagesValidationResult {
566
890
  const errors: string[] = [];
567
891
  const warnings: string[] = [];
@@ -574,9 +898,16 @@ function validatePages(
574
898
 
575
899
  if (!existsSync(pagesDir)) {
576
900
  errors.push(
577
- '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/',
578
902
  );
579
- 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
+ };
580
911
  }
581
912
 
582
913
  const topLevelEntries = readdirSync(pagesDir);
@@ -587,7 +918,7 @@ function validatePages(
587
918
  if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
588
919
  const relPath = relative(projectRoot, fullPath);
589
920
  warnings.push(
590
- `${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`,
591
922
  );
592
923
  }
593
924
  }
@@ -602,20 +933,28 @@ function validatePages(
602
933
 
603
934
  if (sectionDirs.length === 0) {
604
935
  errors.push(
605
- '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/',
606
937
  );
607
- 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
+ };
608
946
  }
609
947
 
610
948
  for (const sectionName of sectionDirs) {
611
949
  const sectionPath = resolve(pagesDir, sectionName);
612
950
  const sectionRel = relative(projectRoot, sectionPath);
951
+ const pagesBeforeSection = totalPages;
613
952
 
614
953
  // Validate section _meta.js
615
954
  const sectionMeta = validateMetaFile(
616
955
  resolve(sectionPath, '_meta.js'),
617
956
  sectionRel,
618
- errors
957
+ errors,
619
958
  );
620
959
 
621
960
  // Flat mode: .svelte files directly at section level are pages of an
@@ -632,9 +971,12 @@ function validatePages(
632
971
  for (const pageName of sectionMeta.pages) {
633
972
  const fileName = ensureSvelteSuffix(pageName);
634
973
  if (!sectionSvelteFiles.includes(fileName)) {
635
- const metaRel = relative(projectRoot, resolve(sectionPath, '_meta.js'));
974
+ const metaRel = relative(
975
+ projectRoot,
976
+ resolve(sectionPath, '_meta.js'),
977
+ );
636
978
  errors.push(
637
- `${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`,
638
980
  );
639
981
  }
640
982
  }
@@ -648,7 +990,8 @@ function validatePages(
648
990
  totalPages,
649
991
  errors,
650
992
  warnings,
651
- assetExistsCache
993
+ assetExistsCache,
994
+ exportStandard,
652
995
  );
653
996
  totalPages++;
654
997
  if (result.isQuiz) totalQuizzes++;
@@ -672,7 +1015,7 @@ function validatePages(
672
1015
  const meta = validateMetaFile(
673
1016
  resolve(lessonPath, '_meta.js'),
674
1017
  lessonRel,
675
- errors
1018
+ errors,
676
1019
  );
677
1020
 
678
1021
  // Get .svelte files
@@ -685,9 +1028,12 @@ function validatePages(
685
1028
  for (const pageName of meta.pages) {
686
1029
  const fileName = ensureSvelteSuffix(pageName);
687
1030
  if (!svelteFiles.includes(fileName)) {
688
- const metaRel = relative(projectRoot, resolve(lessonPath, '_meta.js'));
1031
+ const metaRel = relative(
1032
+ projectRoot,
1033
+ resolve(lessonPath, '_meta.js'),
1034
+ );
689
1035
  errors.push(
690
- `${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`,
691
1037
  );
692
1038
  }
693
1039
  }
@@ -700,7 +1046,7 @@ function validatePages(
700
1046
  if (!listedSet.has(file)) {
701
1047
  const relPath = relative(projectRoot, resolve(lessonPath, file));
702
1048
  warnings.push(
703
- `${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`,
704
1050
  );
705
1051
  }
706
1052
  }
@@ -715,7 +1061,8 @@ function validatePages(
715
1061
  totalPages,
716
1062
  errors,
717
1063
  warnings,
718
- assetExistsCache
1064
+ assetExistsCache,
1065
+ exportStandard,
719
1066
  );
720
1067
  totalPages++;
721
1068
  if (result.isQuiz) totalQuizzes++;
@@ -723,11 +1070,18 @@ function validatePages(
723
1070
  pages.push(result.page);
724
1071
  }
725
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
+ }
726
1080
  }
727
1081
 
728
1082
  if (totalPages === 0) {
729
1083
  errors.push(
730
- '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/',
731
1085
  );
732
1086
  }
733
1087
 
@@ -739,15 +1093,19 @@ function validatePages(
739
1093
  function validateMetaFile(
740
1094
  metaPath: string,
741
1095
  parentRel: string,
742
- errors: string[]
1096
+ errors: string[],
743
1097
  ): { title?: string; pages?: string[] } | null {
744
1098
  if (!existsSync(metaPath)) return null;
745
1099
 
746
1100
  const metaRel = `${parentRel}/_meta.js`;
747
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
1101
+ const objectStr = extractDefaultExportObjectLiteral(
1102
+ readSourceFileCached(metaPath),
1103
+ );
748
1104
 
749
1105
  if (!objectStr) {
750
- errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
1106
+ errors.push(
1107
+ `${metaRel}: syntax error — must export default { title: "..." }`,
1108
+ );
751
1109
  return null;
752
1110
  }
753
1111
 
@@ -755,7 +1113,9 @@ function validateMetaFile(
755
1113
  try {
756
1114
  meta = JSON5.parse(objectStr);
757
1115
  } catch {
758
- errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
1116
+ errors.push(
1117
+ `${metaRel}: syntax error — must export default { title: "..." }`,
1118
+ );
759
1119
  return null;
760
1120
  }
761
1121
 
@@ -771,13 +1131,13 @@ function validateMetaFile(
771
1131
  function validatePageConfig(
772
1132
  content: string,
773
1133
  fileRel: string,
774
- errors: string[]
1134
+ errors: string[],
775
1135
  ): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
776
1136
  const result = parsePageConfigFromSource(content);
777
1137
  if (result.kind === 'ok') return result.value;
778
1138
  if (result.kind === 'invalid') {
779
1139
  errors.push(
780
- `${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)`,
781
1141
  );
782
1142
  }
783
1143
  return null;
@@ -786,27 +1146,34 @@ function validatePageConfig(
786
1146
  function validateCompletesOn(
787
1147
  pageConfig: { completesOn?: unknown } | null,
788
1148
  fileRel: string,
789
- errors: string[]
1149
+ errors: string[],
790
1150
  ): boolean {
791
1151
  if (!pageConfig || pageConfig.completesOn === undefined) return false;
792
1152
  if (pageConfig.completesOn === 'view') return true;
793
1153
  errors.push(
794
- `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`
1154
+ `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`,
795
1155
  );
796
1156
  return false;
797
1157
  }
798
1158
 
799
1159
  // ---------- Quiz Config Validation ----------
800
1160
 
801
- function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
1161
+ function validateQuizConfig(
1162
+ quiz: unknown,
1163
+ fileRel: string,
1164
+ errors: string[],
1165
+ ): void {
802
1166
  if (!quiz || typeof quiz !== 'object') return;
803
1167
  const cfg = quiz as Record<string, unknown>;
804
1168
 
805
1169
  if (cfg.maxAttempts !== undefined) {
806
1170
  const val = cfg.maxAttempts;
807
- 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
+ ) {
808
1175
  errors.push(
809
- `${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)}`,
810
1177
  );
811
1178
  }
812
1179
  }
@@ -814,10 +1181,27 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
814
1181
  for (const field of ['graded', 'gatesProgress']) {
815
1182
  if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
816
1183
  errors.push(
817
- `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
1184
+ `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`,
818
1185
  );
819
1186
  }
820
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
+ }
821
1205
  }
822
1206
 
823
1207
  // ---------- Question Component Validation ----------
@@ -834,58 +1218,31 @@ type PropValue =
834
1218
  | { kind: 'expr'; raw: string }
835
1219
  | { kind: 'bool' };
836
1220
 
837
- /** Extract a balanced {...} or [...] span starting at startIndex, or null. */
838
- function extractBalanced(source: string, startIndex: number): string | null {
839
- const open = source[startIndex];
840
- if (open !== '{' && open !== '[') return null;
841
- let depth = 0;
842
- let inString: string | null = null;
843
- let escaped = false;
844
- for (let i = startIndex; i < source.length; i++) {
845
- const char = source[i];
846
- if (escaped) {
847
- escaped = false;
848
- continue;
849
- }
850
- if (char === '\\' && inString) {
851
- escaped = true;
852
- continue;
853
- }
854
- if (inString) {
855
- if (char === inString) inString = null;
856
- continue;
857
- }
858
- if (char === '"' || char === "'" || char === '`') {
859
- inString = char;
860
- continue;
861
- }
862
- if (char === '{' || char === '[') depth++;
863
- if (char === '}' || char === ']') {
864
- depth--;
865
- if (depth === 0) return source.slice(startIndex, i + 1);
866
- }
867
- }
868
- return null;
869
- }
870
-
871
1221
  /**
872
1222
  * Parse the props of an opening tag starting just after the component name.
873
1223
  * Returns null if the tag can't be parsed cleanly — callers then skip it
874
1224
  * rather than risk a false positive.
875
1225
  */
876
- 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 {
877
1230
  const props = new Map<string, PropValue>();
1231
+ let hasSpread = false;
878
1232
  let i = start;
879
1233
  while (i < content.length) {
880
1234
  while (i < content.length && /\s/.test(content[i])) i++;
881
1235
  if (i >= content.length) return null;
882
1236
  const c = content[i];
883
- if (c === '>') return props;
884
- if (c === '/' && content[i + 1] === '>') return props;
885
- // 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.
886
1242
  if (c === '{') {
887
- const block = extractBalanced(content, i);
1243
+ const block = extractObjectLiteral(content, i);
888
1244
  if (!block) return null;
1245
+ hasSpread = true;
889
1246
  i += block.length;
890
1247
  continue;
891
1248
  }
@@ -907,7 +1264,7 @@ function parseTagProps(content: string, start: number): Map<string, PropValue> |
907
1264
  props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
908
1265
  i = end + 1;
909
1266
  } else if (v === '{') {
910
- const block = extractBalanced(content, i);
1267
+ const block = extractObjectLiteral(content, i);
911
1268
  if (!block) return null;
912
1269
  props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
913
1270
  i += block.length;
@@ -941,50 +1298,113 @@ function staticNumber(prop: PropValue | undefined): number | null {
941
1298
  function validateQuestionComponents(
942
1299
  content: string,
943
1300
  fileRel: string,
944
- errors: string[]
1301
+ errors: string[],
1302
+ warnings: string[],
1303
+ exportStandard?: string,
945
1304
  ): void {
946
1305
  const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
947
1306
  const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
948
1307
  const seenIds = new Set<string>();
1308
+ const seenSanitized = new Set<string>();
949
1309
  let m: RegExpExecArray | null;
950
1310
  while ((m = tagStartRe.exec(content)) !== null) {
951
1311
  const name = m[1];
952
- const props = parseTagProps(content, m.index + m[0].length);
953
- if (!props) continue;
1312
+ const parsed = parseTagProps(content, m.index + m[0].length);
1313
+ if (!parsed) continue;
1314
+ const { props, hasSpread } = parsed;
954
1315
 
955
1316
  for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
956
- if (!props.has(req)) {
1317
+ if (!hasSpread && !props.has(req)) {
957
1318
  errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
958
1319
  }
959
1320
  }
960
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
+
961
1335
  const idProp = props.get('id');
962
1336
  if (idProp?.kind === 'string') {
963
1337
  if (seenIds.has(idProp.value)) {
964
1338
  errors.push(
965
- `${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`,
966
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);
967
1357
  }
968
1358
  seenIds.add(idProp.value);
969
1359
  }
970
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
+
971
1381
  if (name === 'MultipleChoice') {
972
1382
  const options = staticArray(props.get('options'));
973
1383
  const correct = staticNumber(props.get('correct'));
974
1384
  if (options && correct !== null) {
975
- if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) {
1385
+ if (
1386
+ !Number.isInteger(correct) ||
1387
+ correct < 0 ||
1388
+ correct >= options.length
1389
+ ) {
976
1390
  errors.push(
977
- `${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})`,
978
1392
  );
979
1393
  }
980
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
+ }
981
1401
  } else if (name === 'Sorting') {
982
1402
  const items = staticArray(props.get('items'));
983
1403
  const targets = staticArray(props.get('targets'));
984
1404
  const correct = staticArray(props.get('correct'));
985
1405
  if (items && correct && correct.length !== items.length) {
986
1406
  errors.push(
987
- `${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`,
988
1408
  );
989
1409
  }
990
1410
  if (targets && correct) {
@@ -996,7 +1416,7 @@ function validateQuestionComponents(
996
1416
  idx >= targets.length
997
1417
  ) {
998
1418
  errors.push(
999
- `${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})`,
1000
1420
  );
1001
1421
  break;
1002
1422
  }
@@ -1010,11 +1430,11 @@ function validateQuestionComponents(
1010
1430
  typeof p !== 'object' ||
1011
1431
  p === null ||
1012
1432
  typeof (p as { left?: unknown }).left !== 'string' ||
1013
- typeof (p as { right?: unknown }).right !== 'string'
1433
+ typeof (p as { right?: unknown }).right !== 'string',
1014
1434
  );
1015
1435
  if (bad) {
1016
1436
  errors.push(
1017
- `${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`,
1018
1438
  );
1019
1439
  }
1020
1440
  }
@@ -1025,7 +1445,7 @@ function validateQuestionComponents(
1025
1445
  errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1026
1446
  } else if (answers.some((a) => typeof a !== 'string')) {
1027
1447
  errors.push(
1028
- `${fileRel}: <FillInTheBlank> answers must be an array of strings`
1448
+ `${fileRel}: <FillInTheBlank> answers must be an array of strings`,
1029
1449
  );
1030
1450
  }
1031
1451
  }
@@ -1033,6 +1453,156 @@ function validateQuestionComponents(
1033
1453
  }
1034
1454
  }
1035
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
+
1036
1606
  // ---------- Contract Bypass Detection ----------
1037
1607
 
1038
1608
  const QUIZ_COMPLETE_DISPATCH_RE =
@@ -1040,7 +1610,7 @@ const QUIZ_COMPLETE_DISPATCH_RE =
1040
1610
  const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
1041
1611
  const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
1042
1612
  const HAS_QUESTION_TAG_RE = new RegExp(
1043
- `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
1613
+ `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`,
1044
1614
  );
1045
1615
  // Custom widget imported from a local `.svelte` file may wrap useQuestion.
1046
1616
  // Treat its presence as enough to suppress the "no questions" warning —
@@ -1055,18 +1625,18 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
1055
1625
  function validateContractBypass(
1056
1626
  content: string,
1057
1627
  fileRel: string,
1058
- errors: string[]
1628
+ errors: string[],
1059
1629
  ): void {
1060
1630
  if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
1061
1631
  errors.push(
1062
1632
  `${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
1063
- `useQuiz().submit() so the result reaches the LMS`
1633
+ `useQuiz().submit() so the result reaches the LMS`,
1064
1634
  );
1065
1635
  }
1066
1636
  if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
1067
1637
  errors.push(
1068
1638
  `${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
1069
- `(useQuiz, useQuestion, useNavigation, …) instead`
1639
+ `(useQuiz, useQuestion, useNavigation, …) instead`,
1070
1640
  );
1071
1641
  }
1072
1642
  }
@@ -1081,7 +1651,7 @@ function collectAssetRefs(content: string): string[] {
1081
1651
  let match: RegExpExecArray | null;
1082
1652
  ASSET_REF_RE.lastIndex = 0;
1083
1653
  while ((match = ASSET_REF_RE.exec(content)) !== null) {
1084
- seen.add(match[1]);
1654
+ seen.add(match[1].replace(/[?#].*$/, ''));
1085
1655
  }
1086
1656
  return [...seen];
1087
1657
  }
@@ -1091,7 +1661,7 @@ function validateAssetRefs(
1091
1661
  fileRel: string,
1092
1662
  assetsDir: string,
1093
1663
  warnings: string[],
1094
- existsCache: Map<string, boolean>
1664
+ existsCache: Map<string, boolean>,
1095
1665
  ): void {
1096
1666
  for (const assetPath of collectAssetRefs(content)) {
1097
1667
  const fullAssetPath = resolve(assetsDir, assetPath);
@@ -1102,7 +1672,7 @@ function validateAssetRefs(
1102
1672
  }
1103
1673
  if (!exists) {
1104
1674
  warnings.push(
1105
- `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`
1675
+ `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`,
1106
1676
  );
1107
1677
  }
1108
1678
  }
@@ -1114,22 +1684,37 @@ function crossValidate(
1114
1684
  config: ParsedConfig,
1115
1685
  pageResults: PagesValidationResult,
1116
1686
  errors: string[],
1117
- warnings: string[]
1687
+ warnings: string[],
1118
1688
  ): void {
1119
1689
  // completion.mode "quiz" but no graded quizzes
1120
1690
  if (config.completion?.mode === 'quiz' && !pageResults.hasGradedQuiz) {
1121
1691
  errors.push(
1122
- '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.',
1123
1704
  );
1124
1705
  }
1125
1706
 
1126
1707
  const isManual = config.completion?.mode === 'manual';
1127
1708
  const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
1128
1709
 
1129
- if (isManual && config.completion?.trigger === 'page' && completesOnPages.length === 0) {
1710
+ if (
1711
+ isManual &&
1712
+ config.completion?.trigger === 'page' &&
1713
+ completesOnPages.length === 0
1714
+ ) {
1130
1715
  errors.push(
1131
1716
  'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
1132
- '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.',
1133
1718
  );
1134
1719
  }
1135
1720
 
@@ -1139,8 +1724,8 @@ function crossValidate(
1139
1724
  warnings.push(
1140
1725
  `${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
1141
1726
  'The score will be reported to the LMS for transcripts, but it will not drive ' +
1142
- 'completion or success status — `markComplete()` / completesOn does. If that\'s ' +
1143
- '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.',
1144
1729
  );
1145
1730
  }
1146
1731
  }
@@ -1148,20 +1733,20 @@ function crossValidate(
1148
1733
 
1149
1734
  if (isManual && config.completion?.percentageThreshold !== undefined) {
1150
1735
  warnings.push(
1151
- 'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"'
1736
+ 'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"',
1152
1737
  );
1153
1738
  }
1154
1739
  if (!isManual) {
1155
1740
  for (const page of completesOnPages) {
1156
1741
  warnings.push(
1157
- `${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'}"`,
1158
1743
  );
1159
1744
  }
1160
1745
  }
1161
1746
  for (const page of pageResults.pages) {
1162
1747
  if (page.completesOnView && page.hasQuiz) {
1163
1748
  warnings.push(
1164
- `${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`,
1165
1750
  );
1166
1751
  }
1167
1752
  }
@@ -1170,7 +1755,7 @@ function crossValidate(
1170
1755
  const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
1171
1756
  if (firstPage?.completesOnView) {
1172
1757
  warnings.push(
1173
- `${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.`,
1174
1759
  );
1175
1760
  }
1176
1761
  }
@@ -1192,11 +1777,11 @@ function crossValidate(
1192
1777
  for (let i = 0; i < pageResults.totalPages; i++) {
1193
1778
  visitedChars += String(i).length + 1; // digit chars + comma
1194
1779
  }
1195
- const overhead = 60; // top-level JSON overhead with all keys
1196
- const quizBytes = pageResults.totalQuizzes * 15; // q: "NNN":100,
1197
- const chunkBytes = pageResults.totalPages * 12; // c: "NNN":NN,
1198
- const standaloneBytes = pageResults.totalPages * 30;// s/gs: conservative buffer per page
1199
- 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
1200
1785
  const estimatedSize =
1201
1786
  overhead +
1202
1787
  visitedChars +
@@ -1207,7 +1792,7 @@ function crossValidate(
1207
1792
 
1208
1793
  if (estimatedSize > 3200) {
1209
1794
  warnings.push(
1210
- `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".`,
1211
1796
  );
1212
1797
  }
1213
1798
  }