tessera-learn 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +2 -1
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +17 -3
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +21 -18
  34. package/src/components/util.ts +3 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. package/dist/validation-D9DXlqNP.js.map +0 -1
@@ -336,7 +336,178 @@ function validateAuthCredential(auth) {
336
336
  return null;
337
337
  }
338
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
+ }
439
+ //#endregion
339
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
+ }
340
511
  /** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
341
512
  function reportValidationIssues({ errors, warnings }) {
342
513
  for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
@@ -347,14 +518,21 @@ const KNOWN_CONFIG_FIELDS = new Set([
347
518
  "description",
348
519
  "author",
349
520
  "version",
521
+ "language",
350
522
  "branding",
351
523
  "navigation",
352
524
  "completion",
353
525
  "scoring",
354
526
  "export",
355
527
  "chrome",
356
- "xapi"
528
+ "xapi",
529
+ "a11y"
357
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
+ }
358
536
  const VALID_NAV_MODES = ["free", "sequential"];
359
537
  const VALID_COMPLETION_MODES = [
360
538
  "quiz",
@@ -369,6 +547,8 @@ const VALID_EXPORT_STANDARDS = [
369
547
  ];
370
548
  const VALID_MANUAL_TRIGGERS = ["page"];
371
549
  const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
550
+ const VALID_FEEDBACK_MODES = FEEDBACK_MODES;
551
+ const VALID_RETRY_MODES = RETRY_MODES;
372
552
  /**
373
553
  * Validate a Tessera project at the given root.
374
554
  * Returns errors (block build) and warnings (informational).
@@ -384,7 +564,7 @@ function validateProject(projectRoot) {
384
564
  };
385
565
  }
386
566
  const config = parseConfig(projectRoot, errors, warnings);
387
- const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot);
567
+ const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot, config?.export?.standard);
388
568
  errors.push(...pageResults.errors);
389
569
  warnings.push(...pageResults.warnings);
390
570
  for (const shellFile of ["layout.svelte", "quiz.svelte"]) {
@@ -392,10 +572,12 @@ function validateProject(projectRoot) {
392
572
  if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
393
573
  }
394
574
  if (config) crossValidate(config, pageResults, errors, warnings);
395
- return {
575
+ const result = {
396
576
  errors,
397
577
  warnings
398
578
  };
579
+ applyA11ySettings(result, normalizeA11y(config?.a11y));
580
+ return result;
399
581
  }
400
582
  function parseConfig(projectRoot, errors, warnings) {
401
583
  const read = readCourseConfig(projectRoot);
@@ -406,6 +588,13 @@ function parseConfig(projectRoot, errors, warnings) {
406
588
  }
407
589
  const config = read.config;
408
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);
409
598
  if (config.navigation?.mode !== void 0) {
410
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}"`);
411
600
  }
@@ -434,6 +623,52 @@ function parseConfig(projectRoot, errors, warnings) {
434
623
  if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", errors, warnings);
435
624
  return config;
436
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
+ }
437
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;
438
673
  function validateXAPIConfig(raw, standard, errors, warnings) {
439
674
  if (raw === void 0 || raw === null) return;
@@ -532,7 +767,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
532
767
  if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
533
768
  }
534
769
  if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
535
- let isHttp = false;
770
+ let isHttp;
536
771
  try {
537
772
  const u = new URL(activityId);
538
773
  isHttp = u.protocol === "http:" || u.protocol === "https:";
@@ -552,7 +787,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
552
787
  * lesson-level pages — the validation is identical, only the containing
553
788
  * directory differs.
554
789
  */
555
- function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache) {
790
+ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache, exportStandard) {
556
791
  const fileRel = relative(projectRoot, filePath);
557
792
  const content = readSourceFileCached(filePath);
558
793
  const pageConfig = validatePageConfig(content, fileRel, errors);
@@ -564,7 +799,9 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
564
799
  }
565
800
  const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
566
801
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
567
- validateQuestionComponents(content, fileRel, errors);
802
+ validateQuestionComponents(content, fileRel, errors, warnings, exportStandard);
803
+ validateMediaComponents(content, fileRel, errors, warnings);
804
+ validateHeadingOrder(content, fileRel, warnings);
568
805
  validateContractBypass(content, fileRel, errors);
569
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`);
570
807
  return {
@@ -579,7 +816,7 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
579
816
  isGradedQuiz
580
817
  };
581
818
  }
582
- function validatePages(pagesDir, assetsDir, projectRoot) {
819
+ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
583
820
  const errors = [];
584
821
  const warnings = [];
585
822
  const pages = [];
@@ -623,6 +860,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
623
860
  for (const sectionName of sectionDirs) {
624
861
  const sectionPath = resolve(pagesDir, sectionName);
625
862
  const sectionRel = relative(projectRoot, sectionPath);
863
+ const pagesBeforeSection = totalPages;
626
864
  const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
627
865
  const sectionEntries = readdirSync(sectionPath);
628
866
  const sectionSvelteFiles = sectionEntries.filter((name) => {
@@ -637,7 +875,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
637
875
  }
638
876
  }
639
877
  for (const fileName of sectionSvelteFiles) {
640
- 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);
641
879
  totalPages++;
642
880
  if (result.isQuiz) totalQuizzes++;
643
881
  if (result.isGradedQuiz) hasGradedQuiz = true;
@@ -666,13 +904,14 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
666
904
  }
667
905
  }
668
906
  for (const fileName of svelteFiles) {
669
- 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);
670
908
  totalPages++;
671
909
  if (result.isQuiz) totalQuizzes++;
672
910
  if (result.isGradedQuiz) hasGradedQuiz = true;
673
911
  pages.push(result.page);
674
912
  }
675
913
  }
914
+ if (totalPages === pagesBeforeSection) warnings.push(`${sectionRel}: section contributed no pages and will be empty`);
676
915
  }
677
916
  if (totalPages === 0) errors.push("No pages found. Create at least one section with a lesson and page in pages/");
678
917
  return {
@@ -722,6 +961,8 @@ function validateQuizConfig(quiz, fileRel, errors) {
722
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)}`);
723
962
  }
724
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)}"`);
725
966
  }
726
967
  const QUESTION_COMPONENT_REQUIRED = {
727
968
  MultipleChoice: [
@@ -745,16 +986,24 @@ const QUESTION_COMPONENT_REQUIRED = {
745
986
  */
746
987
  function parseTagProps(content, start) {
747
988
  const props = /* @__PURE__ */ new Map();
989
+ let hasSpread = false;
748
990
  let i = start;
749
991
  while (i < content.length) {
750
992
  while (i < content.length && /\s/.test(content[i])) i++;
751
993
  if (i >= content.length) return null;
752
994
  const c = content[i];
753
- if (c === ">") return props;
754
- 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
+ };
755
1003
  if (c === "{") {
756
1004
  const block = extractObjectLiteral(content, i);
757
1005
  if (!block) return null;
1006
+ hasSpread = true;
758
1007
  i += block.length;
759
1008
  continue;
760
1009
  }
@@ -808,27 +1057,47 @@ function staticNumber(prop) {
808
1057
  return null;
809
1058
  }
810
1059
  }
811
- function validateQuestionComponents(content, fileRel, errors) {
1060
+ function validateQuestionComponents(content, fileRel, errors, warnings, exportStandard) {
812
1061
  const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join("|");
813
1062
  const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, "g");
814
1063
  const seenIds = /* @__PURE__ */ new Set();
1064
+ const seenSanitized = /* @__PURE__ */ new Set();
815
1065
  let m;
816
1066
  while ((m = tagStartRe.exec(content)) !== null) {
817
1067
  const name = m[1];
818
- const props = parseTagProps(content, m.index + m[0].length);
819
- if (!props) continue;
820
- 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`));
821
1073
  const idProp = props.get("id");
822
1074
  if (idProp?.kind === "string") {
823
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
+ }
824
1082
  seenIds.add(idProp.value);
825
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
+ }
826
1093
  if (name === "MultipleChoice") {
827
1094
  const options = staticArray(props.get("options"));
828
1095
  const correct = staticNumber(props.get("correct"));
829
1096
  if (options && correct !== null) {
830
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})`);
831
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`);
832
1101
  } else if (name === "Sorting") {
833
1102
  const items = staticArray(props.get("items"));
834
1103
  const targets = staticArray(props.get("targets"));
@@ -854,6 +1123,60 @@ function validateQuestionComponents(content, fileRel, errors) {
854
1123
  }
855
1124
  }
856
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
+ }
857
1180
  const QUIZ_COMPLETE_DISPATCH_RE = /(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
858
1181
  const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
859
1182
  const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
@@ -874,7 +1197,7 @@ function collectAssetRefs(content) {
874
1197
  const seen = /* @__PURE__ */ new Set();
875
1198
  let match;
876
1199
  ASSET_REF_RE.lastIndex = 0;
877
- 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(/[?#].*$/, ""));
878
1201
  return [...seen];
879
1202
  }
880
1203
  function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
@@ -890,6 +1213,7 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
890
1213
  }
891
1214
  function crossValidate(config, pageResults, errors, warnings) {
892
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.");
893
1217
  const isManual = config.completion?.mode === "manual";
894
1218
  const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
895
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.");
@@ -915,6 +1239,6 @@ function crossValidate(config, pageResults, errors, warnings) {
915
1239
  }
916
1240
  }
917
1241
  //#endregion
918
- export { readCourseConfig as i, validateProject as n, generateManifest as r, reportValidationIssues 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 };
919
1243
 
920
- //# sourceMappingURL=validation-D9DXlqNP.js.map
1244
+ //# sourceMappingURL=validation-B-xTvM9B.js.map