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
@@ -57,6 +57,37 @@ function extractDefaultExportObjectLiteral(source) {
57
57
  return extractObjectLiteral(source, startIndex);
58
58
  }
59
59
  /**
60
+ * Read and JSON5-parse the `export default { ... }` literal from a project's
61
+ * course.config.js. Shared by the build plugin and the validator so the read,
62
+ * cache, and parse rules live in one place. The discriminated `reason` lets
63
+ * callers that care (export, validation) emit precise errors while callers
64
+ * that just need a value can fall back on `!ok`.
65
+ */
66
+ function readCourseConfig(projectRoot) {
67
+ const configPath = resolve(projectRoot, "course.config.js");
68
+ if (!existsSync(configPath)) return {
69
+ ok: false,
70
+ reason: "missing"
71
+ };
72
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
73
+ if (!objectStr) return {
74
+ ok: false,
75
+ reason: "no-export"
76
+ };
77
+ try {
78
+ return {
79
+ ok: true,
80
+ config: JSON5.parse(objectStr)
81
+ };
82
+ } catch (error) {
83
+ return {
84
+ ok: false,
85
+ reason: "parse-error",
86
+ error
87
+ };
88
+ }
89
+ }
90
+ /**
60
91
  * Read a _meta.js file and extract its default export object.
61
92
  * Uses the same JSON5 approach as pageConfig extraction — find the object literal
62
93
  * after `export default` and parse it.
@@ -100,11 +131,14 @@ function extractPageConfig(filePath) {
100
131
  return {};
101
132
  }
102
133
  /**
103
- * Extract an object literal from source starting at the opening brace.
104
- * Tracks brace depth to find the matching closing brace.
134
+ * Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
135
+ * skipping strings and comments. Returns the substring (inclusive) or null if
136
+ * the open char is wrong or no matching close is found. Shared by manifest
137
+ * extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
105
138
  */
106
139
  function extractObjectLiteral(source, startIndex) {
107
- if (source[startIndex] !== "{") return null;
140
+ const open = source[startIndex];
141
+ if (open !== "{" && open !== "[") return null;
108
142
  let depth = 0;
109
143
  let inString = null;
110
144
  let escaped = false;
@@ -136,8 +170,8 @@ function extractObjectLiteral(source, startIndex) {
136
170
  i = end === -1 ? source.length : end + 1;
137
171
  continue;
138
172
  }
139
- if (char === "{") depth++;
140
- if (char === "}") {
173
+ if (char === "{" || char === "[") depth++;
174
+ if (char === "}" || char === "]") {
141
175
  depth--;
142
176
  if (depth === 0) return source.slice(startIndex, i + 1);
143
177
  }
@@ -240,6 +274,10 @@ function orderPageFiles(allFiles, pagesArray) {
240
274
  * (build-time validation of static `course.config.js` actor / auth).
241
275
  * Keeping the rules in one place prevents the two callsites from drifting.
242
276
  */
277
+ /** Join a field label with a validator suffix: `.foo` chains, others get `: `. */
278
+ function joinFieldError(label, suffix) {
279
+ return suffix.startsWith(".") ? `${label}${suffix}` : `${label}: ${suffix}`;
280
+ }
243
281
  /**
244
282
  * Validate that a candidate is an Identified Agent per xAPI 1.0.3.
245
283
  * Returns null on success or a human-readable error suffix on failure.
@@ -286,21 +324,215 @@ function validateAgent(actor) {
286
324
  }
287
325
  return null;
288
326
  }
327
+ /**
328
+ * Validate a Basic-auth credential string (the value after "Basic ").
329
+ * v1 supports Basic only. Bearer is a hard error so OAuth users see the
330
+ * non-goal explicitly.
331
+ */
332
+ function validateAuthCredential(auth) {
333
+ if (typeof auth !== "string" || !auth) return "must be a non-empty string";
334
+ if (/^basic\s/i.test(auth)) return "must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
335
+ if (/^bearer\s/i.test(auth)) return "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.";
336
+ return null;
337
+ }
338
+ //#endregion
339
+ //#region src/runtime/interaction-format.ts
340
+ /**
341
+ * SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
342
+ * underscore, max 250 chars. Strict validators (SCORM Cloud) reject raw
343
+ * option labels with spaces or punctuation with error 405/406.
344
+ */
345
+ function shortIdentifier(value) {
346
+ return value.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 250) || "_";
347
+ }
348
+ //#endregion
349
+ //#region src/runtime/types.ts
350
+ /**
351
+ * Quiz enum domains as runtime tuples. The unions below derive from these, and
352
+ * the build-time validator imports them too — so the accepted value set has a
353
+ * single source and can't drift between the types and the validator.
354
+ */
355
+ const FEEDBACK_MODES = [
356
+ "review",
357
+ "immediate",
358
+ "never"
359
+ ];
360
+ const RETRY_MODES = ["full", "incorrect-only"];
361
+ //#endregion
362
+ //#region src/plugin/a11y/contrast.ts
363
+ /**
364
+ * Pure-JS WCAG contrast helpers. App.svelte's parseColor is canvas-based and
365
+ * browser-only; this runs at build time in the linter (rule 1.7). Only opaque
366
+ * #hex (3/4/6/8) is parsed — other CSS color forms and translucent hex return
367
+ * null and fall through to the Tier-2 axe audit, which uses the browser's own
368
+ * parser.
369
+ */
370
+ function parseHex(input) {
371
+ const v = input.trim();
372
+ const m = /^#([0-9a-fA-F]{3,8})$/.exec(v);
373
+ if (!m) return null;
374
+ const h = m[1];
375
+ let r, g, b, a = 255;
376
+ if (h.length === 3 || h.length === 4) {
377
+ r = parseInt(h[0] + h[0], 16);
378
+ g = parseInt(h[1] + h[1], 16);
379
+ b = parseInt(h[2] + h[2], 16);
380
+ if (h.length === 4) a = parseInt(h[3] + h[3], 16);
381
+ } else if (h.length === 6 || h.length === 8) {
382
+ r = parseInt(h.slice(0, 2), 16);
383
+ g = parseInt(h.slice(2, 4), 16);
384
+ b = parseInt(h.slice(4, 6), 16);
385
+ if (h.length === 8) a = parseInt(h.slice(6, 8), 16);
386
+ } else return null;
387
+ return {
388
+ r,
389
+ g,
390
+ b,
391
+ a
392
+ };
393
+ }
394
+ function linearize(channel) {
395
+ const c = channel / 255;
396
+ return c <= .03928 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
397
+ }
398
+ /** sRGB hex → relative luminance (0–1), or null if not a parseable opaque hex. */
399
+ function relativeLuminance(hex) {
400
+ const rgb = parseHex(hex);
401
+ if (!rgb) return null;
402
+ if (rgb.a !== 255) return null;
403
+ return .2126 * linearize(rgb.r) + .7152 * linearize(rgb.g) + .0722 * linearize(rgb.b);
404
+ }
405
+ /**
406
+ * WCAG contrast ratio between two colors. Order-independent — the lighter/darker
407
+ * ordering is handled internally, so callers may pass the colors in any order.
408
+ * Returns null if either color isn't a parseable hex.
409
+ */
410
+ function contrastRatio(a, b) {
411
+ const la = relativeLuminance(a);
412
+ const lb = relativeLuminance(b);
413
+ if (la === null || lb === null) return null;
414
+ const lighter = Math.max(la, lb);
415
+ const darker = Math.min(la, lb);
416
+ return (lighter + .05) / (darker + .05);
417
+ }
418
+ //#endregion
419
+ //#region src/components/video-embed.ts
420
+ /**
421
+ * Shared YouTube/Vimeo embed detection. Used by Video.svelte to pick the iframe
422
+ * vs native-<video> render path, and by the Tier-1b linter (rule 1.4) so its
423
+ * caption/transcript guidance matches what the component actually renders.
424
+ */
425
+ const YOUTUBE_RE = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
426
+ const VIMEO_RE = /vimeo\.com\/(?:video\/)?(\d+)/;
427
+ /** Resolve a source URL to its embed URL, or null if it's not a known embed. */
428
+ function resolveVideoEmbedUrl(src) {
429
+ const yt = src.match(YOUTUBE_RE);
430
+ if (yt) return `https://www.youtube.com/embed/${yt[1]}`;
431
+ const vimeo = src.match(VIMEO_RE);
432
+ if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
433
+ return null;
434
+ }
435
+ /** True when the component will render an iframe embed rather than <video>. */
436
+ function isVideoEmbed(src) {
437
+ return resolveVideoEmbedUrl(src) !== null;
438
+ }
289
439
  //#endregion
290
440
  //#region src/plugin/validation.ts
441
+ /** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
442
+ const A11Y_IDS = {
443
+ imageAlt: "tessera/image-alt",
444
+ mediaTitle: "tessera/media-title",
445
+ mediaTranscript: "tessera/media-transcript",
446
+ mediaCaptions: "tessera/media-captions",
447
+ questionLabel: "tessera/question-label",
448
+ headingOrder: "tessera/heading-order",
449
+ primaryContrast: "tessera/primary-contrast",
450
+ lang: "tessera/lang"
451
+ };
452
+ /** Promotable by `a11y.level: 'error'`; the rest are hard contract errors. */
453
+ const PROMOTABLE_A11Y_IDS = new Set([
454
+ A11Y_IDS.mediaTranscript,
455
+ A11Y_IDS.mediaCaptions,
456
+ A11Y_IDS.questionLabel,
457
+ A11Y_IDS.headingOrder,
458
+ A11Y_IDS.primaryContrast,
459
+ A11Y_IDS.lang
460
+ ]);
461
+ /** Prefix a diagnostic with its rule ID so `a11y.ignore` / `level` can match it. */
462
+ function tag(id, message) {
463
+ return `[${id}] ${message}`;
464
+ }
465
+ function diagnosticId(message) {
466
+ const m = /^\[([^\]]+)\] /.exec(message);
467
+ return m ? m[1] : null;
468
+ }
469
+ /** True when a tagged diagnostic's rule ID is in the ignore set. */
470
+ function isIgnored(message, ignore) {
471
+ const id = diagnosticId(message);
472
+ return id !== null && ignore.has(id);
473
+ }
474
+ const VALID_A11Y_LEVELS = ["warn", "error"];
475
+ const VALID_A11Y_STANDARDS = [
476
+ "wcag2a",
477
+ "wcag2aa",
478
+ "wcag21aa"
479
+ ];
480
+ /** Normalize the raw `a11y` config to defaults, ignoring malformed pieces. */
481
+ function normalizeA11y(raw) {
482
+ const a11y = raw && typeof raw === "object" ? raw : {};
483
+ return {
484
+ level: a11y.level === "error" ? "error" : "warn",
485
+ standard: VALID_A11Y_STANDARDS.includes(a11y.standard) ? a11y.standard : "wcag2aa",
486
+ ignore: Array.isArray(a11y.ignore) ? a11y.ignore.filter((x) => typeof x === "string") : []
487
+ };
488
+ }
489
+ /**
490
+ * Apply `a11y.ignore` (drop tagged diagnostics) and `a11y.level` (promote the
491
+ * promotable a11y warnings to errors) to a result in place. `ignore` suppresses
492
+ * at any severity, including hard contract errors; `level` only re-rates.
493
+ */
494
+ function applyA11ySettings(result, settings) {
495
+ if (settings.ignore.length > 0) {
496
+ const ignored = new Set(settings.ignore);
497
+ const keep = (msg) => !isIgnored(msg, ignored);
498
+ result.errors = result.errors.filter(keep);
499
+ result.warnings = result.warnings.filter(keep);
500
+ }
501
+ if (settings.level === "error") {
502
+ const remaining = [];
503
+ for (const msg of result.warnings) {
504
+ const id = diagnosticId(msg);
505
+ if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) result.errors.push(msg);
506
+ else remaining.push(msg);
507
+ }
508
+ result.warnings = remaining;
509
+ }
510
+ }
511
+ /** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
512
+ function reportValidationIssues({ errors, warnings }) {
513
+ for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
514
+ for (const error of errors) console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
515
+ }
291
516
  const KNOWN_CONFIG_FIELDS = new Set([
292
517
  "title",
293
518
  "description",
294
519
  "author",
295
520
  "version",
521
+ "language",
296
522
  "branding",
297
523
  "navigation",
298
524
  "completion",
299
525
  "scoring",
300
526
  "export",
301
527
  "chrome",
302
- "xapi"
528
+ "xapi",
529
+ "a11y"
303
530
  ]);
531
+ const BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
532
+ /** Plausible BCP-47 tag? Shared by the linter and the <html lang> emitter. */
533
+ function isPlausibleLanguageTag(value) {
534
+ return typeof value === "string" && BCP47_RE.test(value);
535
+ }
304
536
  const VALID_NAV_MODES = ["free", "sequential"];
305
537
  const VALID_COMPLETION_MODES = [
306
538
  "quiz",
@@ -315,6 +547,8 @@ const VALID_EXPORT_STANDARDS = [
315
547
  ];
316
548
  const VALID_MANUAL_TRIGGERS = ["page"];
317
549
  const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
550
+ const VALID_FEEDBACK_MODES = FEEDBACK_MODES;
551
+ const VALID_RETRY_MODES = RETRY_MODES;
318
552
  /**
319
553
  * Validate a Tessera project at the given root.
320
554
  * Returns errors (block build) and warnings (informational).
@@ -322,16 +556,15 @@ const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
322
556
  function validateProject(projectRoot) {
323
557
  const errors = [];
324
558
  const warnings = [];
325
- const configPath = resolve(projectRoot, "course.config.js");
326
- if (!existsSync(configPath)) {
559
+ if (!existsSync(resolve(projectRoot, "course.config.js"))) {
327
560
  errors.push("course.config.js not found in project root");
328
561
  return {
329
562
  errors,
330
563
  warnings
331
564
  };
332
565
  }
333
- const config = parseConfig(configPath, errors, warnings);
334
- const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot);
566
+ const config = parseConfig(projectRoot, errors, warnings);
567
+ const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot, config?.export?.standard);
335
568
  errors.push(...pageResults.errors);
336
569
  warnings.push(...pageResults.warnings);
337
570
  for (const shellFile of ["layout.svelte", "quiz.svelte"]) {
@@ -339,25 +572,29 @@ function validateProject(projectRoot) {
339
572
  if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
340
573
  }
341
574
  if (config) crossValidate(config, pageResults, errors, warnings);
342
- return {
575
+ const result = {
343
576
  errors,
344
577
  warnings
345
578
  };
579
+ applyA11ySettings(result, normalizeA11y(config?.a11y));
580
+ return result;
346
581
  }
347
- function parseConfig(configPath, errors, warnings) {
348
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
349
- if (!objectStr) {
350
- errors.push("course.config.js: could not parse — must use `export default { ... }` syntax");
351
- return null;
352
- }
353
- let config;
354
- try {
355
- config = JSON5.parse(objectStr);
356
- } catch {
357
- errors.push("course.config.js: syntax error — must export a static object literal");
582
+ function parseConfig(projectRoot, errors, warnings) {
583
+ const read = readCourseConfig(projectRoot);
584
+ if (!read.ok) {
585
+ if (read.reason === "no-export") errors.push("course.config.js: could not parse — must use `export default { ... }` syntax");
586
+ else if (read.reason === "parse-error") errors.push("course.config.js: syntax error — must export a static object literal");
358
587
  return null;
359
588
  }
589
+ const config = read.config;
360
590
  for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key)) warnings.push(`course.config.js: unknown field "${key}" — will be ignored`);
591
+ if (config.title !== void 0 && typeof config.title !== "string") errors.push(`course.config.js: "title" must be a string, got ${typeof config.title}`);
592
+ else if (config.title === void 0 || config.title === "") warnings.push("course.config.js: \"title\" is missing or empty — the course will ship as \"Untitled Course\"");
593
+ else if (config.title.trim() === "") warnings.push("course.config.js: \"title\" is only whitespace — it ships verbatim and will not fall back to \"Untitled Course\"");
594
+ if (config.branding !== void 0) validateBranding(config.branding, warnings);
595
+ if (config.language === void 0) warnings.push(tag(A11Y_IDS.lang, `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.`));
596
+ else if (!isPlausibleLanguageTag(config.language)) warnings.push(tag(A11Y_IDS.lang, `course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`));
597
+ if (config.a11y !== void 0) validateA11yConfig(config.a11y, errors);
361
598
  if (config.navigation?.mode !== void 0) {
362
599
  if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
363
600
  }
@@ -386,6 +623,52 @@ function parseConfig(configPath, errors, warnings) {
386
623
  if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", errors, warnings);
387
624
  return config;
388
625
  }
626
+ const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
627
+ const FUNC_COLOR_RE = /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(.*\)$/i;
628
+ const NAMED_COLOR_RE = /^[a-zA-Z]+$/;
629
+ function isPlausibleColor(value) {
630
+ const v = value.trim();
631
+ return HEX_COLOR_RE.test(v) || FUNC_COLOR_RE.test(v) || NAMED_COLOR_RE.test(v);
632
+ }
633
+ /**
634
+ * Format checks on the branding block (advisory) plus rule 1.7's contrast check
635
+ * on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
636
+ * <img src>, an unparseable color falls back to theme defaults.
637
+ */
638
+ function validateBranding(raw, warnings) {
639
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
640
+ warnings.push(`course.config.js: "branding" must be an object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw} — will be ignored`);
641
+ return;
642
+ }
643
+ const branding = raw;
644
+ const logo = branding.logo;
645
+ if (logo !== void 0) {
646
+ if (typeof logo !== "string") warnings.push(`course.config.js: "branding.logo" must be a string, got ${typeof logo}`);
647
+ else if (logo.startsWith("$assets/")) warnings.push("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.");
648
+ }
649
+ const primaryColor = branding.primaryColor;
650
+ if (primaryColor !== void 0) if (typeof primaryColor !== "string") warnings.push(`course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`);
651
+ else if (!isPlausibleColor(primaryColor)) warnings.push(`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`);
652
+ else {
653
+ const ratio = contrastRatio(primaryColor, "#ffffff");
654
+ if (ratio !== null && ratio < 4.5) warnings.push(tag(A11Y_IDS.primaryContrast, `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`));
655
+ }
656
+ const fontFamily = branding.fontFamily;
657
+ if (fontFamily !== void 0 && typeof fontFamily !== "string") warnings.push(`course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`);
658
+ }
659
+ /** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
660
+ function validateA11yConfig(raw, errors) {
661
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
662
+ errors.push(`course.config.js: "a11y" must be an object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw}`);
663
+ return;
664
+ }
665
+ const a11y = raw;
666
+ if (a11y.level !== void 0 && !VALID_A11Y_LEVELS.includes(a11y.level)) errors.push(`course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`);
667
+ if (a11y.standard !== void 0 && !VALID_A11Y_STANDARDS.includes(a11y.standard)) errors.push(`course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`);
668
+ if (a11y.ignore !== void 0) {
669
+ if (!Array.isArray(a11y.ignore) || a11y.ignore.some((x) => typeof x !== "string")) errors.push(`course.config.js: "a11y.ignore" must be an array of rule-ID strings`);
670
+ }
671
+ }
389
672
  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;
390
673
  function validateXAPIConfig(raw, standard, errors, warnings) {
391
674
  if (raw === void 0 || raw === null) return;
@@ -452,11 +735,11 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
452
735
  if (!endpoint.endsWith("/")) warnings.push(`course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises (e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`);
453
736
  const auth = entry.auth;
454
737
  if (auth === void 0) errors.push(`course.config.js: ${label}.auth is required`);
455
- else if (typeof auth === "string") if (!auth) errors.push(`course.config.js: ${label}.auth must be a non-empty string`);
456
- else if (/^basic\s/i.test(auth)) errors.push(`course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`);
457
- else if (/^bearer\s/i.test(auth)) errors.push(`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.`);
458
- else warnings.push(`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. For production, pass a function that fetches a short-lived token from a server endpoint.`);
459
- else if (typeof auth !== "function") errors.push(`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`);
738
+ else if (typeof auth === "string") {
739
+ const authErr = validateAuthCredential(auth);
740
+ if (authErr) errors.push(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
741
+ else warnings.push(`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. For production, pass a function that fetches a short-lived token from a server endpoint.`);
742
+ } else if (typeof auth !== "function") errors.push(`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`);
460
743
  const activityId = entry.activityId;
461
744
  if (activityId === void 0 || activityId === "") errors.push(`course.config.js: ${label}.activityId is required`);
462
745
  else if (typeof activityId !== "string") errors.push(`course.config.js: ${label}.activityId must be a string`);
@@ -470,10 +753,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
470
753
  if (standard === "web") errors.push(`course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. Provide either a static actor object or a function that resolves one (e.g. from your auth system).`);
471
754
  } else if (typeof actor === "object" && actor !== null) {
472
755
  const err = validateAgent(actor);
473
- if (err) {
474
- const joined = err.startsWith(".") ? `${label}.actor${err}` : `${label}.actor ${err}`;
475
- errors.push(`course.config.js: ${joined}`);
476
- }
756
+ if (err) errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
477
757
  } else if (typeof actor !== "function") errors.push(`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`);
478
758
  const aahp = entry.actorAccountHomePage;
479
759
  if (aahp !== void 0) {
@@ -487,7 +767,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
487
767
  if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
488
768
  }
489
769
  if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
490
- let isHttp = false;
770
+ let isHttp;
491
771
  try {
492
772
  const u = new URL(activityId);
493
773
  isHttp = u.protocol === "http:" || u.protocol === "https:";
@@ -507,7 +787,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
507
787
  * lesson-level pages — the validation is identical, only the containing
508
788
  * directory differs.
509
789
  */
510
- function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache) {
790
+ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache, exportStandard) {
511
791
  const fileRel = relative(projectRoot, filePath);
512
792
  const content = readSourceFileCached(filePath);
513
793
  const pageConfig = validatePageConfig(content, fileRel, errors);
@@ -519,7 +799,9 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
519
799
  }
520
800
  const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
521
801
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
522
- validateQuestionComponents(content, fileRel, errors);
802
+ validateQuestionComponents(content, fileRel, errors, warnings, exportStandard);
803
+ validateMediaComponents(content, fileRel, errors, warnings);
804
+ validateHeadingOrder(content, fileRel, warnings);
523
805
  validateContractBypass(content, fileRel, errors);
524
806
  if (pageConfig?.quiz && !HAS_USE_QUESTION_RE.test(content) && !HAS_QUESTION_TAG_RE.test(content) && !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)) warnings.push(`${fileRel}: quiz page has no question components or useQuestion() calls — the quiz will have nothing to score`);
525
807
  return {
@@ -534,7 +816,7 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
534
816
  isGradedQuiz
535
817
  };
536
818
  }
537
- function validatePages(pagesDir, assetsDir, projectRoot) {
819
+ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
538
820
  const errors = [];
539
821
  const warnings = [];
540
822
  const pages = [];
@@ -578,6 +860,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
578
860
  for (const sectionName of sectionDirs) {
579
861
  const sectionPath = resolve(pagesDir, sectionName);
580
862
  const sectionRel = relative(projectRoot, sectionPath);
863
+ const pagesBeforeSection = totalPages;
581
864
  const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
582
865
  const sectionEntries = readdirSync(sectionPath);
583
866
  const sectionSvelteFiles = sectionEntries.filter((name) => {
@@ -592,7 +875,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
592
875
  }
593
876
  }
594
877
  for (const fileName of sectionSvelteFiles) {
595
- const result = validatePageFile(resolve(sectionPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache);
878
+ const result = validatePageFile(resolve(sectionPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
596
879
  totalPages++;
597
880
  if (result.isQuiz) totalQuizzes++;
598
881
  if (result.isGradedQuiz) hasGradedQuiz = true;
@@ -621,13 +904,14 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
621
904
  }
622
905
  }
623
906
  for (const fileName of svelteFiles) {
624
- const result = validatePageFile(resolve(lessonPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache);
907
+ const result = validatePageFile(resolve(lessonPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
625
908
  totalPages++;
626
909
  if (result.isQuiz) totalQuizzes++;
627
910
  if (result.isGradedQuiz) hasGradedQuiz = true;
628
911
  pages.push(result.page);
629
912
  }
630
913
  }
914
+ if (totalPages === pagesBeforeSection) warnings.push(`${sectionRel}: section contributed no pages and will be empty`);
631
915
  }
632
916
  if (totalPages === 0) errors.push("No pages found. Create at least one section with a lesson and page in pages/");
633
917
  return {
@@ -677,6 +961,8 @@ function validateQuizConfig(quiz, fileRel, errors) {
677
961
  if (val !== Infinity && (typeof val !== "number" || val <= 0 || !Number.isFinite(val))) errors.push(`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`);
678
962
  }
679
963
  for (const field of ["graded", "gatesProgress"]) if (cfg[field] !== void 0 && typeof cfg[field] !== "boolean") errors.push(`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`);
964
+ if (cfg.feedbackMode !== void 0 && !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode)) errors.push(`${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`);
965
+ if (cfg.retryMode !== void 0 && !VALID_RETRY_MODES.includes(cfg.retryMode)) errors.push(`${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`);
680
966
  }
681
967
  const QUESTION_COMPONENT_REQUIRED = {
682
968
  MultipleChoice: [
@@ -693,39 +979,6 @@ const QUESTION_COMPONENT_REQUIRED = {
693
979
  "correct"
694
980
  ]
695
981
  };
696
- /** Extract a balanced {...} or [...] span starting at startIndex, or null. */
697
- function extractBalanced(source, startIndex) {
698
- const open = source[startIndex];
699
- if (open !== "{" && open !== "[") return null;
700
- let depth = 0;
701
- let inString = null;
702
- let escaped = false;
703
- for (let i = startIndex; i < source.length; i++) {
704
- const char = source[i];
705
- if (escaped) {
706
- escaped = false;
707
- continue;
708
- }
709
- if (char === "\\" && inString) {
710
- escaped = true;
711
- continue;
712
- }
713
- if (inString) {
714
- if (char === inString) inString = null;
715
- continue;
716
- }
717
- if (char === "\"" || char === "'" || char === "`") {
718
- inString = char;
719
- continue;
720
- }
721
- if (char === "{" || char === "[") depth++;
722
- if (char === "}" || char === "]") {
723
- depth--;
724
- if (depth === 0) return source.slice(startIndex, i + 1);
725
- }
726
- }
727
- return null;
728
- }
729
982
  /**
730
983
  * Parse the props of an opening tag starting just after the component name.
731
984
  * Returns null if the tag can't be parsed cleanly — callers then skip it
@@ -733,16 +986,24 @@ function extractBalanced(source, startIndex) {
733
986
  */
734
987
  function parseTagProps(content, start) {
735
988
  const props = /* @__PURE__ */ new Map();
989
+ let hasSpread = false;
736
990
  let i = start;
737
991
  while (i < content.length) {
738
992
  while (i < content.length && /\s/.test(content[i])) i++;
739
993
  if (i >= content.length) return null;
740
994
  const c = content[i];
741
- if (c === ">") return props;
742
- if (c === "/" && content[i + 1] === ">") return props;
995
+ if (c === ">") return {
996
+ props,
997
+ hasSpread
998
+ };
999
+ if (c === "/" && content[i + 1] === ">") return {
1000
+ props,
1001
+ hasSpread
1002
+ };
743
1003
  if (c === "{") {
744
- const block = extractBalanced(content, i);
1004
+ const block = extractObjectLiteral(content, i);
745
1005
  if (!block) return null;
1006
+ hasSpread = true;
746
1007
  i += block.length;
747
1008
  continue;
748
1009
  }
@@ -767,7 +1028,7 @@ function parseTagProps(content, start) {
767
1028
  });
768
1029
  i = end + 1;
769
1030
  } else if (v === "{") {
770
- const block = extractBalanced(content, i);
1031
+ const block = extractObjectLiteral(content, i);
771
1032
  if (!block) return null;
772
1033
  props.set(propName, {
773
1034
  kind: "expr",
@@ -796,27 +1057,47 @@ function staticNumber(prop) {
796
1057
  return null;
797
1058
  }
798
1059
  }
799
- function validateQuestionComponents(content, fileRel, errors) {
1060
+ function validateQuestionComponents(content, fileRel, errors, warnings, exportStandard) {
800
1061
  const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join("|");
801
1062
  const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, "g");
802
1063
  const seenIds = /* @__PURE__ */ new Set();
1064
+ const seenSanitized = /* @__PURE__ */ new Set();
803
1065
  let m;
804
1066
  while ((m = tagStartRe.exec(content)) !== null) {
805
1067
  const name = m[1];
806
- const props = parseTagProps(content, m.index + m[0].length);
807
- if (!props) continue;
808
- for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!props.has(req)) errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
1068
+ const parsed = parseTagProps(content, m.index + m[0].length);
1069
+ if (!parsed) continue;
1070
+ const { props, hasSpread } = parsed;
1071
+ for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req)) errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
1072
+ for (const labelProp of ["options", "answers"]) if (staticArray(props.get(labelProp))?.some((e) => typeof e === "string" && e.trim() === "")) warnings.push(tag(A11Y_IDS.questionLabel, `${fileRel}: <${name}> has an empty ${labelProp === "options" ? "option" : "answer"} label`));
809
1073
  const idProp = props.get("id");
810
1074
  if (idProp?.kind === "string") {
811
1075
  if (seenIds.has(idProp.value)) errors.push(`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`);
1076
+ else if (exportStandard === "scorm12") {
1077
+ const sane = shortIdentifier(idProp.value);
1078
+ if (sane !== idProp.value) warnings.push(`${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`);
1079
+ if (seenSanitized.has(sane)) errors.push(`${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`);
1080
+ seenSanitized.add(sane);
1081
+ }
812
1082
  seenIds.add(idProp.value);
813
1083
  }
1084
+ const weightProp = props.get("weight");
1085
+ if (weightProp?.kind === "string") warnings.push(`${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`);
1086
+ else {
1087
+ const weight = staticNumber(weightProp);
1088
+ if (weight !== null) {
1089
+ if (!Number.isFinite(weight)) errors.push(`${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`);
1090
+ else if (!(weight > 0)) warnings.push(`${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`);
1091
+ }
1092
+ }
814
1093
  if (name === "MultipleChoice") {
815
1094
  const options = staticArray(props.get("options"));
816
1095
  const correct = staticNumber(props.get("correct"));
817
1096
  if (options && correct !== null) {
818
1097
  if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) errors.push(`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`);
819
1098
  }
1099
+ const optionFeedback = staticArray(props.get("optionFeedback"));
1100
+ if (options && optionFeedback && optionFeedback.length > options.length) warnings.push(`${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`);
820
1101
  } else if (name === "Sorting") {
821
1102
  const items = staticArray(props.get("items"));
822
1103
  const targets = staticArray(props.get("targets"));
@@ -842,6 +1123,60 @@ function validateQuestionComponents(content, fileRel, errors) {
842
1123
  }
843
1124
  }
844
1125
  }
1126
+ /** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
1127
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
1128
+ /**
1129
+ * Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
1130
+ * so media isn't treated as gradable questions. Declares `warnings` directly.
1131
+ * Non-static (kind 'expr') values are skipped, matching the rest of the linter.
1132
+ */
1133
+ function validateMediaComponents(content, fileRel, errors, warnings) {
1134
+ const scan = content.replace(HTML_COMMENT_RE, "");
1135
+ const tagStartRe = /<(Image|Video|Audio)(?=[\s/>])/g;
1136
+ let m;
1137
+ while ((m = tagStartRe.exec(scan)) !== null) {
1138
+ const name = m[1];
1139
+ const parsed = parseTagProps(scan, m.index + m[0].length);
1140
+ if (!parsed) continue;
1141
+ const { props, hasSpread } = parsed;
1142
+ if (name === "Image") {
1143
+ const alt = props.get("alt");
1144
+ const decorative = props.get("decorative");
1145
+ if (decorative?.kind === "string") {
1146
+ errors.push(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`));
1147
+ continue;
1148
+ }
1149
+ const hasDecorative = decorative?.kind === "bool" || decorative?.kind === "expr" && decorative.raw.trim() === "true";
1150
+ const altIsEmpty = alt?.kind === "string" && alt.value.trim() === "";
1151
+ if (!hasDecorative && !hasSpread && (alt === void 0 || altIsEmpty)) errors.push(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`));
1152
+ if (hasDecorative && alt?.kind === "string" && alt.value.trim() !== "") warnings.push(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`));
1153
+ continue;
1154
+ }
1155
+ const title = props.get("title");
1156
+ const titleIsEmpty = title?.kind === "string" && title.value.trim() === "";
1157
+ if (!hasSpread && (title === void 0 || titleIsEmpty)) errors.push(tag(A11Y_IDS.mediaTitle, `${fileRel}: <${name}> needs a title — it's the accessible name for the player`));
1158
+ const src = props.get("src");
1159
+ const isEmbed = src?.kind === "string" && isVideoEmbed(src.value);
1160
+ if (name === "Video" && !hasSpread && isEmbed && props.get("transcript") === void 0) warnings.push(tag(A11Y_IDS.mediaTranscript, `${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`));
1161
+ if (name === "Video" && !hasSpread && src?.kind === "string" && !isEmbed && props.get("tracks") === void 0 && props.get("transcript") === void 0) warnings.push(tag(A11Y_IDS.mediaCaptions, `${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`));
1162
+ if (name === "Audio" && !hasSpread && props.get("transcript") === void 0) warnings.push(tag(A11Y_IDS.mediaTranscript, `${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`));
1163
+ }
1164
+ }
1165
+ /**
1166
+ * Warn on a skipped heading level (e.g. h2 → h4). Scripts, styles, and comments
1167
+ * are stripped first so string literals, CSS, and commented-out markup can't be
1168
+ * miscounted. No "one h1 per page" check — the layout owns the page h1 and child
1169
+ * components emit headings a static scan can't see; that belongs to the Tier-2
1170
+ * audit.
1171
+ */
1172
+ function validateHeadingOrder(content, fileRel, warnings) {
1173
+ const levels = [...content.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "").replace(HTML_COMMENT_RE, "").matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1174
+ let prevSeen = null;
1175
+ for (const level of levels) {
1176
+ if (prevSeen !== null && level - prevSeen > 1) warnings.push(tag(A11Y_IDS.headingOrder, `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`));
1177
+ prevSeen = level;
1178
+ }
1179
+ }
845
1180
  const QUIZ_COMPLETE_DISPATCH_RE = /(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
846
1181
  const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
847
1182
  const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
@@ -862,7 +1197,7 @@ function collectAssetRefs(content) {
862
1197
  const seen = /* @__PURE__ */ new Set();
863
1198
  let match;
864
1199
  ASSET_REF_RE.lastIndex = 0;
865
- while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1]);
1200
+ while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1].replace(/[?#].*$/, ""));
866
1201
  return [...seen];
867
1202
  }
868
1203
  function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
@@ -878,6 +1213,7 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
878
1213
  }
879
1214
  function crossValidate(config, pageResults, errors, warnings) {
880
1215
  if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
1216
+ if (config.completion?.mode === "quiz" && config.scoring?.passingScore === void 0) warnings.push("completion.mode is \"quiz\" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.");
881
1217
  const isManual = config.completion?.mode === "manual";
882
1218
  const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
883
1219
  if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
@@ -903,6 +1239,6 @@ function crossValidate(config, pageResults, errors, warnings) {
903
1239
  }
904
1240
  }
905
1241
  //#endregion
906
- export { extractDefaultExportObjectLiteral as n, generateManifest as r, validateProject as t };
1242
+ export { validateProject as a, reportValidationIssues as i, isPlausibleLanguageTag as n, generateManifest as o, normalizeA11y as r, readCourseConfig as s, isIgnored as t };
907
1243
 
908
- //# sourceMappingURL=validation-BxWAMMnJ.js.map
1244
+ //# sourceMappingURL=validation-B-xTvM9B.js.map