tessera-learn 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +50 -21
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
  8. package/dist/inline-config-DVvOCKht.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +91 -49
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +287 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +3 -3
  16. package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +7 -7
  19. package/src/components/DefaultLayout.svelte +2 -5
  20. package/src/components/MultipleChoice.svelte +1 -2
  21. package/src/components/Quiz.svelte +18 -26
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/build-commands.ts +7 -4
  24. package/src/plugin/cli.ts +96 -46
  25. package/src/plugin/csp.ts +59 -0
  26. package/src/plugin/duplicate-cli.ts +37 -1
  27. package/src/plugin/export.ts +56 -27
  28. package/src/plugin/index.ts +138 -93
  29. package/src/plugin/inline-config.ts +4 -2
  30. package/src/plugin/manifest.ts +24 -23
  31. package/src/plugin/new-cli.ts +2 -0
  32. package/src/plugin/validate-cli.ts +5 -2
  33. package/src/plugin/validation.ts +255 -238
  34. package/src/runtime/App.svelte +14 -9
  35. package/src/runtime/Sidebar.svelte +3 -1
  36. package/src/runtime/adapters/cmi5.ts +59 -402
  37. package/src/runtime/adapters/discovery.ts +11 -0
  38. package/src/runtime/adapters/index.ts +27 -60
  39. package/src/runtime/adapters/lms-error.ts +61 -0
  40. package/src/runtime/adapters/scorm-base.ts +15 -14
  41. package/src/runtime/adapters/scorm12.ts +6 -25
  42. package/src/runtime/adapters/scorm2004.ts +12 -54
  43. package/src/runtime/adapters/web.ts +11 -4
  44. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  45. package/src/runtime/adapters/xapi.ts +26 -0
  46. package/src/runtime/fingerprint.ts +28 -0
  47. package/src/runtime/interaction-format.ts +0 -1
  48. package/src/runtime/persistence.ts +4 -0
  49. package/src/runtime/types.ts +22 -1
  50. package/src/runtime/xapi/publisher.ts +16 -15
  51. package/src/runtime/xapi/setup.ts +24 -15
  52. package/src/virtual.d.ts +4 -1
  53. package/templates/course/course.config.js +1 -0
  54. package/dist/audit--fSWIOgK.js.map +0 -1
  55. package/dist/build-commands-Qyrlsp3n.js.map +0 -1
  56. package/dist/inline-config-DqAKsCNl.js.map +0 -1
  57. package/dist/plugin-B-aiL9-V.js.map +0 -1
@@ -8,9 +8,11 @@ import { parse } from "svelte/compiler";
8
8
  import { spawn } from "node:child_process";
9
9
  //#region src/plugin/ast.ts
10
10
  const rootCache = /* @__PURE__ */ new Map();
11
+ const jsModuleCache = /* @__PURE__ */ new Map();
11
12
  /** Drop every cached root. Call at the start of a run to scope the cache. */
12
13
  function clearParseCache() {
13
14
  rootCache.clear();
15
+ jsModuleCache.clear();
14
16
  }
15
17
  function parseRoot(source) {
16
18
  const cached = rootCache.get(source);
@@ -119,14 +121,19 @@ function findComponents(source, names) {
119
121
  }
120
122
  const TsParser = Parser.extend(tsPlugin());
121
123
  function parseJsModule(source) {
124
+ const cached = jsModuleCache.get(source);
125
+ if (cached !== void 0) return cached;
126
+ let result;
122
127
  try {
123
- return TsParser.parse(source, {
128
+ result = TsParser.parse(source, {
124
129
  ecmaVersion: "latest",
125
130
  sourceType: "module"
126
131
  });
127
132
  } catch {
128
- return null;
133
+ result = null;
129
134
  }
135
+ jsModuleCache.set(source, result);
136
+ return result;
130
137
  }
131
138
  function unwrapTsCast(node) {
132
139
  let current = node;
@@ -240,15 +247,6 @@ function deriveSlug(name, isFile = false) {
240
247
  return stripPrefix(name);
241
248
  }
242
249
  /**
243
- * Locate `export default { ... }` and return its object-literal text. Returns
244
- * a discriminated result so callers can tell parse failure from a missing or
245
- * non-literal default export. Used by both manifest extraction and project
246
- * validation.
247
- */
248
- function extractDefaultExportObjectLiteral(source) {
249
- return defaultExportObjectLiteral(source);
250
- }
251
- /**
252
250
  * Read and JSON5-parse the `export default { ... }` literal from a project's
253
251
  * course.config.js. Shared by the build plugin and the validator so the read,
254
252
  * cache, and parse rules live in one place. The discriminated `reason` lets
@@ -261,7 +259,7 @@ function readCourseConfig(projectRoot) {
261
259
  ok: false,
262
260
  reason: "missing"
263
261
  };
264
- const result = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
262
+ const result = defaultExportObjectLiteral(readSourceFileCached(configPath));
265
263
  if (result.kind === "parse-error") return {
266
264
  ok: false,
267
265
  reason: "parse-error"
@@ -284,13 +282,40 @@ function readCourseConfig(projectRoot) {
284
282
  }
285
283
  }
286
284
  /**
285
+ * Resolve a project's effective export standard once: the CLI `--standard`
286
+ * override wins, else `export.standard`, else `'web'`. An unreadable config with
287
+ * no override fails closed with `'unknown'` so callers withhold standard-specific
288
+ * output rather than guess. The returned `config` already has the override
289
+ * applied, so consumers read it back directly. Exported for tests.
290
+ */
291
+ function readResolvedConfig(projectRoot, standardOverride) {
292
+ const read = readCourseConfig(projectRoot);
293
+ if (!read.ok) return {
294
+ ...read,
295
+ standard: standardOverride ?? "unknown"
296
+ };
297
+ const override = standardOverride;
298
+ const config = override ? {
299
+ ...read.config,
300
+ export: {
301
+ ...read.config.export,
302
+ standard: override
303
+ }
304
+ } : read.config;
305
+ return {
306
+ ok: true,
307
+ config,
308
+ standard: config.export?.standard || "web"
309
+ };
310
+ }
311
+ /**
287
312
  * Read a _meta.js file and extract its default export object.
288
313
  * Uses the same JSON5 approach as pageConfig extraction — find the object literal
289
314
  * after `export default` and parse it.
290
315
  */
291
316
  function readMetaFile(metaPath) {
292
317
  if (!existsSync(metaPath)) return {};
293
- const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
318
+ const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
294
319
  if (result.kind !== "literal") return {};
295
320
  try {
296
321
  return JSON5.parse(result.text);
@@ -548,6 +573,14 @@ const FEEDBACK_MODES = [
548
573
  "never"
549
574
  ];
550
575
  const RETRY_MODES = ["full", "incorrect-only"];
576
+ /**
577
+ * Trimmed course identity, or '' when absent. Single source of truth for the
578
+ * "is there a usable id?" check shared by the web storage key, the cmi5/xAPI
579
+ * id derivation, and the config validator.
580
+ */
581
+ function courseIdentity(config) {
582
+ return typeof config.id === "string" && config.id.trim() || "";
583
+ }
551
584
  //#endregion
552
585
  //#region src/plugin/a11y/contrast.ts
553
586
  /**
@@ -606,6 +639,48 @@ function contrastRatio(a, b) {
606
639
  return (lighter + .05) / (darker + .05);
607
640
  }
608
641
  //#endregion
642
+ //#region src/plugin/csp.ts
643
+ const WEB_CSP_BASELINE = {
644
+ "default-src": ["'self'"],
645
+ "img-src": [
646
+ "'self'",
647
+ "data:",
648
+ "https:"
649
+ ],
650
+ "media-src": [
651
+ "'self'",
652
+ "blob:",
653
+ "data:",
654
+ "https:"
655
+ ],
656
+ "style-src": ["'self'", "'unsafe-inline'"],
657
+ "script-src": ["'self'", "'unsafe-inline'"],
658
+ "font-src": ["'self'", "data:"],
659
+ "connect-src": ["'self'", "https:"],
660
+ "frame-src": [
661
+ "'self'",
662
+ "blob:",
663
+ "https:"
664
+ ],
665
+ "worker-src": ["'self'", "blob:"],
666
+ "object-src": ["'none'"],
667
+ "base-uri": ["'self'"]
668
+ };
669
+ const CSP_DIRECTIVE = /^[a-zA-Z][a-zA-Z-]*$/;
670
+ const CSP_SOURCE = /^[^\s;,"<>]+$/;
671
+ function isCspOverrides(v) {
672
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.entries(v).every(([directive, sources]) => CSP_DIRECTIVE.test(directive) && Array.isArray(sources) && sources.every((s) => typeof s === "string" && CSP_SOURCE.test(s)));
673
+ }
674
+ function buildCsp(overrides) {
675
+ const merged = new Map(Object.entries(WEB_CSP_BASELINE).map(([k, v]) => [k, [...v]]));
676
+ if (isCspOverrides(overrides)) for (const [directive, sources] of Object.entries(overrides)) {
677
+ const existing = merged.get(directive) ?? [];
678
+ for (const src of sources) if (!existing.includes(src)) existing.push(src);
679
+ merged.set(directive, existing);
680
+ }
681
+ return [...merged].map(([directive, sources]) => `${directive} ${sources.join(" ")}`).join("; ");
682
+ }
683
+ //#endregion
609
684
  //#region src/components/video-embed.ts
610
685
  /**
611
686
  * Shared YouTube/Vimeo embed detection. Used by Video.svelte to pick the iframe
@@ -628,6 +703,17 @@ function isVideoEmbed(src) {
628
703
  }
629
704
  //#endregion
630
705
  //#region src/plugin/validation.ts
706
+ /** Collects errors and warnings so checkers thread one argument, not a pair. */
707
+ var Diagnostics = class {
708
+ errors = [];
709
+ warnings = [];
710
+ error(message) {
711
+ this.errors.push(message);
712
+ }
713
+ warn(message) {
714
+ this.warnings.push(message);
715
+ }
716
+ };
631
717
  /** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
632
718
  const A11Y_IDS = {
633
719
  imageAlt: "tessera/image-alt",
@@ -640,7 +726,7 @@ const A11Y_IDS = {
640
726
  lang: "tessera/lang"
641
727
  };
642
728
  /** Promotable by `a11y.level: 'error'`; the rest are hard contract errors. */
643
- const PROMOTABLE_A11Y_IDS = new Set([
729
+ const PROMOTABLE_A11Y_IDS = /* @__PURE__ */ new Set([
644
730
  A11Y_IDS.mediaTranscript,
645
731
  A11Y_IDS.mediaCaptions,
646
732
  A11Y_IDS.questionLabel,
@@ -681,21 +767,21 @@ function normalizeA11y(raw) {
681
767
  * promotable a11y warnings to errors) to a result in place. `ignore` suppresses
682
768
  * at any severity, including hard contract errors; `level` only re-rates.
683
769
  */
684
- function applyA11ySettings(result, settings) {
770
+ function applyA11ySettings(d, settings) {
685
771
  if (settings.ignore.length > 0) {
686
772
  const ignored = new Set(settings.ignore);
687
773
  const keep = (msg) => !isIgnored(msg, ignored);
688
- result.errors = result.errors.filter(keep);
689
- result.warnings = result.warnings.filter(keep);
774
+ d.errors = d.errors.filter(keep);
775
+ d.warnings = d.warnings.filter(keep);
690
776
  }
691
777
  if (settings.level === "error") {
692
778
  const remaining = [];
693
- for (const msg of result.warnings) {
779
+ for (const msg of d.warnings) {
694
780
  const id = diagnosticId(msg);
695
- if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) result.errors.push(msg);
781
+ if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) d.error(msg);
696
782
  else remaining.push(msg);
697
783
  }
698
- result.warnings = remaining;
784
+ d.warnings = remaining;
699
785
  }
700
786
  }
701
787
  /** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
@@ -703,11 +789,13 @@ function reportValidationIssues({ errors, warnings }) {
703
789
  for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
704
790
  for (const error of errors) console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
705
791
  }
706
- const KNOWN_CONFIG_FIELDS = new Set([
792
+ const KNOWN_CONFIG_FIELDS = /* @__PURE__ */ new Set([
707
793
  "title",
794
+ "id",
708
795
  "description",
709
796
  "author",
710
797
  "version",
798
+ "resume",
711
799
  "language",
712
800
  "branding",
713
801
  "navigation",
@@ -733,7 +821,8 @@ const VALID_EXPORT_STANDARDS = [
733
821
  "web",
734
822
  "scorm12",
735
823
  "scorm2004",
736
- "cmi5"
824
+ "cmi5",
825
+ "xapi"
737
826
  ];
738
827
  const VALID_MANUAL_TRIGGERS = ["page"];
739
828
  const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
@@ -743,75 +832,77 @@ const VALID_RETRY_MODES = RETRY_MODES;
743
832
  * Validate a Tessera project at the given root.
744
833
  * Returns errors (block build) and warnings (informational).
745
834
  */
746
- function validateProject(projectRoot) {
835
+ function validateProject(projectRoot, standardOverride) {
747
836
  clearParseCache();
748
- const errors = [];
749
- const warnings = [];
837
+ const d = new Diagnostics();
750
838
  if (!existsSync(resolve(projectRoot, "course.config.js"))) {
751
- errors.push("course.config.js not found in project root");
752
- return {
753
- errors,
754
- warnings
755
- };
839
+ d.error("course.config.js not found in project root");
840
+ return d;
756
841
  }
757
- const config = parseConfig(projectRoot, errors, warnings);
758
- const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot, config?.export?.standard);
759
- errors.push(...pageResults.errors);
760
- warnings.push(...pageResults.warnings);
842
+ const config = parseConfig(projectRoot, d, standardOverride);
843
+ const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot, d, config?.export?.standard);
761
844
  for (const shellFile of ["layout.svelte", "quiz.svelte"]) {
762
845
  const shellPath = resolve(projectRoot, shellFile);
763
- if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
846
+ if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, d);
764
847
  }
765
- if (config) crossValidate(config, pageResults, errors, warnings);
766
- const result = {
767
- errors,
768
- warnings
769
- };
770
- applyA11ySettings(result, normalizeA11y(config?.a11y));
771
- return result;
848
+ if (config) crossValidate(config, pageResults, d);
849
+ applyA11ySettings(d, normalizeA11y(config?.a11y));
850
+ return d;
772
851
  }
773
- function parseConfig(projectRoot, errors, warnings) {
852
+ function parseConfig(projectRoot, d, standardOverride) {
774
853
  const read = readCourseConfig(projectRoot);
775
854
  if (!read.ok) {
776
- if (read.reason === "no-export") errors.push("course.config.js: must use `export default { ... }` syntax");
777
- else if (read.reason === "parse-error") errors.push("course.config.js: could not parse — JavaScript syntax error");
855
+ if (read.reason === "no-export") d.error("course.config.js: must use `export default { ... }` syntax");
856
+ else if (read.reason === "parse-error") d.error("course.config.js: could not parse — JavaScript syntax error");
778
857
  return null;
779
858
  }
780
859
  const config = read.config;
781
- for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key)) warnings.push(`course.config.js: unknown field "${key}" — will be ignored`);
782
- if (config.title !== void 0 && typeof config.title !== "string") errors.push(`course.config.js: "title" must be a string, got ${typeof config.title}`);
783
- 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\"");
784
- 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\"");
785
- if (config.branding !== void 0) validateBranding(config.branding, warnings);
786
- 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.`));
787
- 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"`));
788
- if (config.a11y !== void 0) validateA11yConfig(config.a11y, errors);
860
+ for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key)) d.warn(`course.config.js: unknown field "${key}" — will be ignored`);
861
+ if (config.title !== void 0 && typeof config.title !== "string") d.error(`course.config.js: "title" must be a string, got ${typeof config.title}`);
862
+ else if (config.title === void 0 || config.title === "") d.warn("course.config.js: \"title\" is missing or empty — the course will ship as \"Untitled Course\"");
863
+ else if (config.title.trim() === "") d.warn("course.config.js: \"title\" is only whitespace — it ships verbatim and will not fall back to \"Untitled Course\"");
864
+ if (config.branding !== void 0) validateBranding(config.branding, d);
865
+ if (config.language === void 0) d.warn(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.`));
866
+ else if (!isPlausibleLanguageTag(config.language)) d.warn(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"`));
867
+ if (config.export?.standard !== void 0) {
868
+ if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) d.error(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", "cmi5", or "xapi", got "${config.export.standard}"`);
869
+ }
870
+ if (standardOverride) config.export = {
871
+ ...config.export,
872
+ standard: standardOverride
873
+ };
874
+ const standard = config.export?.standard;
875
+ if ((standard === void 0 || standard === "web" || standard === "cmi5" || standard === "xapi") && !courseIdentity(config)) d.warn(`course.config.js: no "id" set — the web storage key and cmi5/xAPI activity id then share a fixed fallback that collides across courses. Add a unique id (e.g. "urn:uuid:…"); scaffolded courses include one.`);
876
+ if (config.a11y !== void 0) validateA11yConfig(config.a11y, d);
789
877
  if (config.navigation?.mode !== void 0) {
790
- if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
878
+ if (!VALID_NAV_MODES.includes(config.navigation.mode)) d.error(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
791
879
  }
792
880
  if (config.completion?.mode !== void 0) {
793
- if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`);
881
+ if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) d.error(`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`);
794
882
  }
795
883
  if (config.completion?.trigger !== void 0) {
796
- if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`);
797
- else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) errors.push(`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`);
884
+ if (config.completion.mode !== "manual") d.warn(`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`);
885
+ else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) d.error(`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`);
798
886
  }
799
887
  if (config.completion?.requireSuccessStatus !== void 0) {
800
- if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`);
801
- else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) errors.push(`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`);
888
+ if (config.completion.mode !== "manual") d.warn(`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`);
889
+ else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) d.error(`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`);
802
890
  }
803
- if (config.export?.standard !== void 0) {
804
- if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`);
891
+ if (config.resume !== void 0 && config.resume !== "auto" && config.resume !== "never") d.error(`course.config.js: "resume" must be "auto" or "never", got "${config.resume}"`);
892
+ if (config.export?.csp !== void 0) {
893
+ const csp = config.export.csp;
894
+ if (csp !== false && !isCspOverrides(csp)) d.warn("course.config.js: \"export.csp\" must be false or an object of directive → string[]; ignoring it and using the baseline CSP");
895
+ else if ((config.export.standard ?? "web") !== "web") d.warn(`course.config.js: "export.csp" is ignored when "export.standard" is "${config.export.standard}" (the CSP meta is web-export only)`);
805
896
  }
806
897
  if (config.scoring?.passingScore !== void 0) {
807
898
  const score = config.scoring.passingScore;
808
- if (typeof score !== "number" || score < 0 || score > 100) errors.push(`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`);
899
+ if (typeof score !== "number" || score < 0 || score > 100) d.error(`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`);
809
900
  }
810
901
  if (config.completion?.percentageThreshold !== void 0) {
811
902
  const threshold = config.completion.percentageThreshold;
812
- if (typeof threshold !== "number" || threshold < 0 || threshold > 100) errors.push(`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`);
903
+ if (typeof threshold !== "number" || threshold < 0 || threshold > 100) d.error(`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`);
813
904
  }
814
- if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", errors, warnings);
905
+ if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", d);
815
906
  return config;
816
907
  }
817
908
  const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
@@ -829,142 +920,142 @@ function isPlausibleColor(value) {
829
920
  function describeType(raw) {
830
921
  return raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
831
922
  }
832
- function validateBranding(raw, warnings) {
923
+ function validateBranding(raw, d) {
833
924
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
834
- warnings.push(`course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`);
925
+ d.warn(`course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`);
835
926
  return;
836
927
  }
837
928
  const branding = raw;
838
929
  const logo = branding.logo;
839
930
  if (logo !== void 0) {
840
- if (typeof logo !== "string") warnings.push(`course.config.js: "branding.logo" must be a string, got ${typeof logo}`);
841
- 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.");
931
+ if (typeof logo !== "string") d.warn(`course.config.js: "branding.logo" must be a string, got ${typeof logo}`);
932
+ else if (logo.startsWith("$assets/")) d.warn("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.");
842
933
  }
843
934
  const primaryColor = branding.primaryColor;
844
- if (primaryColor !== void 0) if (typeof primaryColor !== "string") warnings.push(`course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`);
845
- 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`);
935
+ if (primaryColor !== void 0) if (typeof primaryColor !== "string") d.warn(`course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`);
936
+ else if (!isPlausibleColor(primaryColor)) d.warn(`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`);
846
937
  else {
847
938
  const ratio = contrastRatio(primaryColor, "#ffffff");
848
- 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`));
939
+ if (ratio !== null && ratio < 4.5) d.warn(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`));
849
940
  }
850
941
  const fontFamily = branding.fontFamily;
851
- if (fontFamily !== void 0 && typeof fontFamily !== "string") warnings.push(`course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`);
942
+ if (fontFamily !== void 0 && typeof fontFamily !== "string") d.warn(`course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`);
852
943
  }
853
944
  /** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
854
- function validateA11yConfig(raw, errors) {
945
+ function validateA11yConfig(raw, d) {
855
946
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
856
- errors.push(`course.config.js: "a11y" must be an object, got ${describeType(raw)}`);
947
+ d.error(`course.config.js: "a11y" must be an object, got ${describeType(raw)}`);
857
948
  return;
858
949
  }
859
950
  const a11y = raw;
860
- 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)}`);
861
- 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)}`);
951
+ if (a11y.level !== void 0 && !VALID_A11Y_LEVELS.includes(a11y.level)) d.error(`course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`);
952
+ if (a11y.standard !== void 0 && !VALID_A11Y_STANDARDS.includes(a11y.standard)) d.error(`course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`);
862
953
  if (a11y.ignore !== void 0) {
863
- 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`);
954
+ if (!Array.isArray(a11y.ignore) || a11y.ignore.some((x) => typeof x !== "string")) d.error(`course.config.js: "a11y.ignore" must be an array of rule-ID strings`);
864
955
  }
865
956
  }
866
957
  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;
867
- function validateXAPIConfig(raw, standard, errors, warnings) {
958
+ function validateXAPIConfig(raw, standard, d) {
868
959
  if (raw === void 0 || raw === null) return;
869
960
  const entries = Array.isArray(raw) ? raw : [raw];
870
961
  if (Array.isArray(raw)) {
871
962
  if (entries.length === 0) {
872
- errors.push("course.config.js: xapi must contain at least one destination, or be omitted");
963
+ d.error("course.config.js: xapi must contain at least one destination, or be omitted");
873
964
  return;
874
965
  }
875
- if (entries.filter((e) => e && typeof e === "object" && e.endpoint === "lms").length > 1) errors.push("course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed");
966
+ if (entries.filter((e) => e && typeof e === "object" && e.endpoint === "lms").length > 1) d.error("course.config.js: xapi has multiple entries with endpoint: 'lms' — only one launch-inherited destination is allowed");
876
967
  const seen = /* @__PURE__ */ new Map();
877
968
  for (const e of entries) if (e && typeof e === "object") {
878
969
  const ep = e.endpoint;
879
970
  if (typeof ep === "string" && ep !== "lms") seen.set(ep, (seen.get(ep) ?? 0) + 1);
880
971
  }
881
- for (const [ep, count] of seen) if (count > 1) warnings.push(`course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; fan-out to the same LRS with different actors/activityIds is supported but uncommon.`);
972
+ for (const [ep, count] of seen) if (count > 1) d.warn(`course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; fan-out to the same LRS with different actors/activityIds is supported but uncommon.`);
882
973
  } else if (typeof raw !== "object") {
883
- errors.push("course.config.js: xapi must be an object or an array of objects");
974
+ d.error("course.config.js: xapi must be an object or an array of objects");
884
975
  return;
885
976
  }
886
977
  for (let i = 0; i < entries.length; i++) {
887
978
  const entry = entries[i];
888
979
  const label = Array.isArray(raw) ? `xapi[${i}]` : "xapi";
889
980
  if (!entry || typeof entry !== "object") {
890
- errors.push(`course.config.js: ${label} must be an object`);
981
+ d.error(`course.config.js: ${label} must be an object`);
891
982
  continue;
892
983
  }
893
- validateSingleXAPIEntry(entry, label, standard, errors, warnings);
984
+ validateSingleXAPIEntry(entry, label, standard, d);
894
985
  }
895
986
  }
896
- function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
987
+ function validateSingleXAPIEntry(entry, label, standard, d) {
897
988
  const endpoint = entry.endpoint;
898
989
  if (endpoint === void 0) {
899
- errors.push(`course.config.js: ${label}.endpoint is required`);
990
+ d.error(`course.config.js: ${label}.endpoint is required`);
900
991
  return;
901
992
  }
902
993
  if (typeof endpoint !== "string") {
903
- errors.push(`course.config.js: ${label}.endpoint must be a string`);
994
+ d.error(`course.config.js: ${label}.endpoint must be a string`);
904
995
  return;
905
996
  }
906
997
  if (endpoint === "lms") {
907
- if (standard !== "cmi5") errors.push(`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). Either change the export standard or specify an explicit LRS endpoint.`);
998
+ if (standard !== "cmi5" && standard !== "xapi") d.error(`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' or 'xapi' (you have "${standard}"). Either change the export standard or specify an explicit LRS endpoint.`);
908
999
  for (const f of [
909
1000
  "auth",
910
1001
  "actor",
911
1002
  "activityId",
912
1003
  "registration",
913
1004
  "actorAccountHomePage"
914
- ]) if (entry[f] !== void 0) errors.push(`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`);
1005
+ ]) if (entry[f] !== void 0) d.error(`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the launch.`);
915
1006
  return;
916
1007
  }
917
1008
  let url;
918
1009
  try {
919
1010
  url = new URL(endpoint);
920
1011
  } catch {
921
- errors.push(`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`);
1012
+ d.error(`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`);
922
1013
  return;
923
1014
  }
924
1015
  if (url.protocol !== "http:" && url.protocol !== "https:") {
925
- errors.push(`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`);
1016
+ d.error(`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`);
926
1017
  return;
927
1018
  }
928
- if (url.protocol === "http:" && process.env.NODE_ENV === "production") warnings.push(`course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`);
929
- 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.`);
1019
+ if (url.protocol === "http:" && process.env.NODE_ENV === "production") d.warn(`course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`);
1020
+ if (!endpoint.endsWith("/")) d.warn(`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.`);
930
1021
  const auth = entry.auth;
931
- if (auth === void 0) errors.push(`course.config.js: ${label}.auth is required`);
1022
+ if (auth === void 0) d.error(`course.config.js: ${label}.auth is required`);
932
1023
  else if (typeof auth === "string") {
933
1024
  const authErr = validateAuthCredential(auth);
934
- if (authErr) errors.push(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
935
- 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.`);
936
- } else if (typeof auth !== "function") errors.push(`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`);
1025
+ if (authErr) d.error(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
1026
+ else d.warn(`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.`);
1027
+ } else if (typeof auth !== "function") d.error(`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`);
937
1028
  const activityId = entry.activityId;
938
- if (activityId === void 0 || activityId === "") errors.push(`course.config.js: ${label}.activityId is required`);
939
- else if (typeof activityId !== "string") errors.push(`course.config.js: ${label}.activityId must be a string`);
1029
+ if (activityId === void 0 || activityId === "") d.error(`course.config.js: ${label}.activityId is required`);
1030
+ else if (typeof activityId !== "string") d.error(`course.config.js: ${label}.activityId must be a string`);
940
1031
  else try {
941
1032
  new URL(activityId);
942
1033
  } catch {
943
- errors.push(`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`);
1034
+ d.error(`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`);
944
1035
  }
945
1036
  const actor = entry.actor;
946
1037
  if (actor === void 0) {
947
- 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).`);
1038
+ if (standard === "web") d.error(`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).`);
948
1039
  } else if (typeof actor === "object" && actor !== null) {
949
1040
  const err = validateAgent(actor);
950
- if (err) errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
951
- } else if (typeof actor !== "function") errors.push(`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`);
1041
+ if (err) d.error(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
1042
+ } else if (typeof actor !== "function") d.error(`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`);
952
1043
  const aahp = entry.actorAccountHomePage;
953
1044
  if (aahp !== void 0) {
954
- if (typeof aahp !== "string") errors.push(`course.config.js: ${label}.actorAccountHomePage must be a string`);
1045
+ if (typeof aahp !== "string") d.error(`course.config.js: ${label}.actorAccountHomePage must be a string`);
955
1046
  else try {
956
1047
  new URL(aahp);
957
1048
  } catch {
958
- errors.push(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
1049
+ d.error(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
959
1050
  }
960
- if (actor !== void 0) warnings.push(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
961
- if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
1051
+ if (actor !== void 0) d.warn(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
1052
+ if (standard === "cmi5" || standard === "xapi" || standard === "web") d.warn(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
962
1053
  }
963
- if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string" && httpOrigin(activityId) === null && aahp === void 0) errors.push(`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. Provide ${label}.actorAccountHomePage explicitly.`);
1054
+ if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string" && httpOrigin(activityId) === null && aahp === void 0) d.error(`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. Provide ${label}.actorAccountHomePage explicitly.`);
964
1055
  const registration = entry.registration;
965
1056
  if (registration !== void 0) {
966
- if (typeof registration !== "string" || !UUID_RE.test(registration)) errors.push(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
967
- if (standard !== "cmi5") warnings.push(`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.`);
1057
+ if (typeof registration !== "string" || !UUID_RE.test(registration)) d.error(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
1058
+ if (standard !== "cmi5" && standard !== "xapi") d.warn(`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.`);
968
1059
  }
969
1060
  }
970
1061
  /**
@@ -972,12 +1063,12 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
972
1063
  * lesson-level pages — the validation is identical, only the containing
973
1064
  * directory differs.
974
1065
  */
975
- function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache, exportStandard) {
1066
+ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, d, assetExistsCache, exportStandard) {
976
1067
  const fileRel = relative(projectRoot, filePath);
977
1068
  const content = readSourceFileCached(filePath);
978
1069
  const parseError = getParseError(content);
979
1070
  if (parseError) {
980
- errors.push(`${fileRel}: could not parse — ${parseError}`);
1071
+ d.error(`${fileRel}: could not parse — ${parseError}`);
981
1072
  return {
982
1073
  page: {
983
1074
  fileRel,
@@ -991,20 +1082,20 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
991
1082
  parseError: true
992
1083
  };
993
1084
  }
994
- const pageConfig = validatePageConfig(content, fileRel, errors);
1085
+ const pageConfig = validatePageConfig(content, fileRel, d);
995
1086
  const isQuiz = !!pageConfig?.quiz;
996
1087
  let isGradedQuiz = false;
997
1088
  if (pageConfig?.quiz) {
998
- validateQuizConfig(pageConfig.quiz, fileRel, errors);
1089
+ validateQuizConfig(pageConfig.quiz, fileRel, d);
999
1090
  if (pageConfig.quiz.graded === true) isGradedQuiz = true;
1000
1091
  }
1001
- const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
1002
- validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
1003
- validateQuestionComponents(content, fileRel, errors, warnings, exportStandard);
1004
- validateMediaComponents(content, fileRel, errors, warnings);
1005
- validateHeadingOrder(content, fileRel, warnings);
1006
- validateContractBypass(content, fileRel, errors);
1007
- 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`);
1092
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, d);
1093
+ validateAssetRefs(content, fileRel, assetsDir, d, assetExistsCache);
1094
+ validateQuestionComponents(content, fileRel, d, exportStandard);
1095
+ validateMediaComponents(content, fileRel, d);
1096
+ validateHeadingOrder(content, fileRel, d);
1097
+ validateContractBypass(content, fileRel, d);
1098
+ if (pageConfig?.quiz && !HAS_USE_QUESTION_RE.test(content) && !HAS_QUESTION_TAG_RE.test(content) && !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)) d.warn(`${fileRel}: quiz page has no question components or useQuestion() calls — the quiz will have nothing to score`);
1008
1099
  return {
1009
1100
  page: {
1010
1101
  fileRel,
@@ -1018,9 +1109,7 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
1018
1109
  parseError: false
1019
1110
  };
1020
1111
  }
1021
- function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
1022
- const errors = [];
1023
- const warnings = [];
1112
+ function validatePages(pagesDir, assetsDir, projectRoot, d, exportStandard) {
1024
1113
  const pages = [];
1025
1114
  let totalPages = 0;
1026
1115
  let totalQuizzes = 0;
@@ -1028,10 +1117,8 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
1028
1117
  let hasParseErrors = false;
1029
1118
  const assetExistsCache = /* @__PURE__ */ new Map();
1030
1119
  const noPages = () => {
1031
- errors.push("No pages found. Create at least one section with a lesson and page in pages/");
1120
+ d.error("No pages found. Create at least one section with a lesson and page in pages/");
1032
1121
  return {
1033
- errors,
1034
- warnings,
1035
1122
  totalPages,
1036
1123
  totalQuizzes,
1037
1124
  hasGradedQuiz,
@@ -1042,21 +1129,21 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
1042
1129
  if (!existsSync(pagesDir)) return noPages();
1043
1130
  for (const entry of readdirSync(pagesDir)) {
1044
1131
  const fullPath = resolve(pagesDir, entry);
1045
- if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) warnings.push(`${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`);
1132
+ if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) d.warn(`${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`);
1046
1133
  }
1047
1134
  const sections = walkPages(pagesDir);
1048
1135
  if (sections.length === 0) return noPages();
1049
1136
  const validateLesson = (lesson, meta) => {
1050
1137
  if (meta?.pages) for (const pageName of meta.pages) {
1051
1138
  const fileName = ensureSvelteSuffix(pageName);
1052
- if (!lesson.files.includes(fileName)) errors.push(`${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
1139
+ if (!lesson.files.includes(fileName)) d.error(`${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
1053
1140
  }
1054
1141
  if (meta?.pages && meta.pages.length > 0) {
1055
1142
  const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
1056
- for (const file of lesson.files) if (!listedSet.has(file)) warnings.push(`${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`);
1143
+ for (const file of lesson.files) if (!listedSet.has(file)) d.warn(`${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`);
1057
1144
  }
1058
1145
  for (const fileName of orderPageFiles(lesson.files, meta?.pages)) {
1059
- const result = validatePageFile(resolve(lesson.dir, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
1146
+ const result = validatePageFile(resolve(lesson.dir, fileName), projectRoot, assetsDir, totalPages, d, assetExistsCache, exportStandard);
1060
1147
  totalPages++;
1061
1148
  if (result.isQuiz) totalQuizzes++;
1062
1149
  if (result.isGradedQuiz) hasGradedQuiz = true;
@@ -1067,15 +1154,13 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
1067
1154
  for (const section of sections) {
1068
1155
  const sectionRel = relative(projectRoot, section.dir);
1069
1156
  const pagesBeforeSection = totalPages;
1070
- const sectionMeta = validateMetaFile(section.metaPath, sectionRel, errors);
1157
+ const sectionMeta = validateMetaFile(section.metaPath, sectionRel, d);
1071
1158
  for (const lesson of section.lessons) if (lesson.name === null) validateLesson(lesson, sectionMeta);
1072
- else validateLesson(lesson, validateMetaFile(lesson.metaPath, relative(projectRoot, lesson.dir), errors));
1073
- if (totalPages === pagesBeforeSection) warnings.push(`${sectionRel}: section contributed no pages and will be empty`);
1159
+ else validateLesson(lesson, validateMetaFile(lesson.metaPath, relative(projectRoot, lesson.dir), d));
1160
+ if (totalPages === pagesBeforeSection) d.warn(`${sectionRel}: section contributed no pages and will be empty`);
1074
1161
  }
1075
1162
  if (totalPages === 0) return noPages();
1076
1163
  return {
1077
- errors,
1078
- warnings,
1079
1164
  totalPages,
1080
1165
  totalQuizzes,
1081
1166
  hasGradedQuiz,
@@ -1083,50 +1168,50 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
1083
1168
  pages
1084
1169
  };
1085
1170
  }
1086
- function validateMetaFile(metaPath, parentRel, errors) {
1171
+ function validateMetaFile(metaPath, parentRel, d) {
1087
1172
  if (!existsSync(metaPath)) return null;
1088
1173
  const metaRel = `${parentRel}/_meta.js`;
1089
- const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
1174
+ const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
1090
1175
  if (result.kind === "parse-error") {
1091
- errors.push(`${metaRel}: could not parse — JavaScript syntax error`);
1176
+ d.error(`${metaRel}: could not parse — JavaScript syntax error`);
1092
1177
  return null;
1093
1178
  }
1094
1179
  if (result.kind !== "literal") {
1095
- errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
1180
+ d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
1096
1181
  return null;
1097
1182
  }
1098
1183
  let meta;
1099
1184
  try {
1100
1185
  meta = JSON5.parse(result.text);
1101
1186
  } catch {
1102
- errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
1187
+ d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
1103
1188
  return null;
1104
1189
  }
1105
- if (!meta.title) errors.push(`${metaRel}: missing required "title" field`);
1190
+ if (!meta.title) d.error(`${metaRel}: missing required "title" field`);
1106
1191
  return meta;
1107
1192
  }
1108
- function validatePageConfig(content, fileRel, errors) {
1193
+ function validatePageConfig(content, fileRel, d) {
1109
1194
  const result = parsePageConfigFromSource(content);
1110
1195
  if (result.kind === "ok") return result.value;
1111
- if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
1196
+ if (result.kind === "invalid") d.error(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
1112
1197
  return null;
1113
1198
  }
1114
- function validateCompletesOn(pageConfig, fileRel, errors) {
1199
+ function validateCompletesOn(pageConfig, fileRel, d) {
1115
1200
  if (!pageConfig || pageConfig.completesOn === void 0) return false;
1116
1201
  if (pageConfig.completesOn === "view") return true;
1117
- errors.push(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
1202
+ d.error(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
1118
1203
  return false;
1119
1204
  }
1120
- function validateQuizConfig(quiz, fileRel, errors) {
1205
+ function validateQuizConfig(quiz, fileRel, d) {
1121
1206
  if (!quiz || typeof quiz !== "object") return;
1122
1207
  const cfg = quiz;
1123
1208
  if (cfg.maxAttempts !== void 0) {
1124
1209
  const val = cfg.maxAttempts;
1125
- 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)}`);
1210
+ if (val !== Infinity && (typeof val !== "number" || val <= 0 || !Number.isFinite(val))) d.error(`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`);
1126
1211
  }
1127
- 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]}`);
1128
- 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)}"`);
1129
- 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)}"`);
1212
+ for (const field of ["graded", "gatesProgress"]) if (cfg[field] !== void 0 && typeof cfg[field] !== "boolean") d.error(`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`);
1213
+ if (cfg.feedbackMode !== void 0 && !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode)) d.error(`${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`);
1214
+ if (cfg.retryMode !== void 0 && !VALID_RETRY_MODES.includes(cfg.retryMode)) d.error(`${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`);
1130
1215
  }
1131
1216
  const QUESTION_COMPONENT_REQUIRED = {
1132
1217
  MultipleChoice: [
@@ -1161,63 +1246,63 @@ function staticNumber(prop) {
1161
1246
  return null;
1162
1247
  }
1163
1248
  }
1164
- function validateQuestionComponents(content, fileRel, errors, warnings, exportStandard) {
1249
+ function validateQuestionComponents(content, fileRel, d, exportStandard) {
1165
1250
  const components = findComponents(content, new Set(Object.keys(QUESTION_COMPONENT_REQUIRED)));
1166
1251
  if (!components) return;
1167
1252
  const seenIds = /* @__PURE__ */ new Set();
1168
1253
  const seenSanitized = /* @__PURE__ */ new Set();
1169
1254
  for (const { name, props, hasSpread } of components) {
1170
- for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req)) errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
1171
- 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`));
1255
+ for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req)) d.error(`${fileRel}: <${name}> is missing required prop "${req}"`);
1256
+ for (const labelProp of ["options", "answers"]) if (staticArray(props.get(labelProp))?.some((e) => typeof e === "string" && e.trim() === "")) d.warn(tag(A11Y_IDS.questionLabel, `${fileRel}: <${name}> has an empty ${labelProp === "options" ? "option" : "answer"} label`));
1172
1257
  const idProp = props.get("id");
1173
1258
  if (idProp?.kind === "string") {
1174
- if (seenIds.has(idProp.value)) errors.push(`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`);
1259
+ if (seenIds.has(idProp.value)) d.error(`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`);
1175
1260
  else if (exportStandard === "scorm12") {
1176
1261
  const sane = shortIdentifier(idProp.value);
1177
- 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)`);
1178
- if (seenSanitized.has(sane)) errors.push(`${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`);
1262
+ if (sane !== idProp.value) d.warn(`${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`);
1263
+ if (seenSanitized.has(sane)) d.error(`${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`);
1179
1264
  seenSanitized.add(sane);
1180
1265
  }
1181
1266
  seenIds.add(idProp.value);
1182
1267
  }
1183
1268
  const weightProp = props.get("weight");
1184
- 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}}`);
1269
+ if (weightProp?.kind === "string") d.warn(`${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`);
1185
1270
  else {
1186
1271
  const weight = staticNumber(weightProp);
1187
1272
  if (weight !== null) {
1188
- if (!Number.isFinite(weight)) errors.push(`${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`);
1189
- else if (!(weight > 0)) warnings.push(`${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`);
1273
+ if (!Number.isFinite(weight)) d.error(`${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`);
1274
+ else if (!(weight > 0)) d.warn(`${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`);
1190
1275
  }
1191
1276
  }
1192
1277
  if (name === "MultipleChoice") {
1193
1278
  const options = staticArray(props.get("options"));
1194
1279
  const correct = staticNumber(props.get("correct"));
1195
1280
  if (options && correct !== null) {
1196
- 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})`);
1281
+ if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) d.error(`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`);
1197
1282
  }
1198
1283
  const optionFeedback = staticArray(props.get("optionFeedback"));
1199
- 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`);
1284
+ if (options && optionFeedback && optionFeedback.length > options.length) d.warn(`${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`);
1200
1285
  } else if (name === "Sorting") {
1201
1286
  const items = staticArray(props.get("items"));
1202
1287
  const targets = staticArray(props.get("targets"));
1203
1288
  const correct = staticArray(props.get("correct"));
1204
- if (items && correct && correct.length !== items.length) errors.push(`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`);
1289
+ if (items && correct && correct.length !== items.length) d.error(`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`);
1205
1290
  if (targets && correct) {
1206
1291
  for (const idx of correct) if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx >= targets.length) {
1207
- errors.push(`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`);
1292
+ d.error(`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`);
1208
1293
  break;
1209
1294
  }
1210
1295
  }
1211
1296
  } else if (name === "Matching") {
1212
1297
  const pairs = staticArray(props.get("pairs"));
1213
1298
  if (pairs) {
1214
- if (pairs.some((p) => typeof p !== "object" || p === null || typeof p.left !== "string" || typeof p.right !== "string")) errors.push(`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`);
1299
+ if (pairs.some((p) => typeof p !== "object" || p === null || typeof p.left !== "string" || typeof p.right !== "string")) d.error(`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`);
1215
1300
  }
1216
1301
  } else if (name === "FillInTheBlank") {
1217
1302
  const answers = staticArray(props.get("answers"));
1218
1303
  if (answers) {
1219
- if (answers.length === 0) errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1220
- else if (answers.some((a) => typeof a !== "string")) errors.push(`${fileRel}: <FillInTheBlank> answers must be an array of strings`);
1304
+ if (answers.length === 0) d.error(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1305
+ else if (answers.some((a) => typeof a !== "string")) d.error(`${fileRel}: <FillInTheBlank> answers must be an array of strings`);
1221
1306
  }
1222
1307
  }
1223
1308
  }
@@ -1238,11 +1323,11 @@ function stripRepeated(input, patterns) {
1238
1323
  }
1239
1324
  /**
1240
1325
  * Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
1241
- * so media isn't treated as gradable questions. Declares `warnings` directly.
1326
+ * so media isn't treated as gradable questions.
1242
1327
  * Non-static (kind 'expr') values are skipped, matching the rest of the linter.
1243
1328
  */
1244
- function validateMediaComponents(content, fileRel, errors, warnings) {
1245
- const components = findComponents(content, new Set([
1329
+ function validateMediaComponents(content, fileRel, d) {
1330
+ const components = findComponents(content, /* @__PURE__ */ new Set([
1246
1331
  "Image",
1247
1332
  "Video",
1248
1333
  "Audio"
@@ -1253,23 +1338,23 @@ function validateMediaComponents(content, fileRel, errors, warnings) {
1253
1338
  const alt = props.get("alt");
1254
1339
  const decorative = props.get("decorative");
1255
1340
  if (decorative?.kind === "string") {
1256
- 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)}`));
1341
+ d.error(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`));
1257
1342
  continue;
1258
1343
  }
1259
1344
  const hasDecorative = decorative?.kind === "bool" || decorative?.kind === "expr" && decorative.raw.trim() === "true";
1260
1345
  const altIsEmpty = alt?.kind === "string" && alt.value.trim() === "";
1261
- 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`));
1262
- 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`));
1346
+ if (!hasDecorative && !hasSpread && (alt === void 0 || altIsEmpty)) d.error(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`));
1347
+ if (hasDecorative && alt?.kind === "string" && alt.value.trim() !== "") d.warn(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`));
1263
1348
  continue;
1264
1349
  }
1265
1350
  const title = props.get("title");
1266
1351
  const titleIsEmpty = title?.kind === "string" && title.value.trim() === "";
1267
- 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`));
1352
+ if (!hasSpread && (title === void 0 || titleIsEmpty)) d.error(tag(A11Y_IDS.mediaTitle, `${fileRel}: <${name}> needs a title — it's the accessible name for the player`));
1268
1353
  const src = props.get("src");
1269
1354
  const isEmbed = src?.kind === "string" && isVideoEmbed(src.value);
1270
- 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`));
1271
- 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`));
1272
- 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`));
1355
+ if (name === "Video" && !hasSpread && isEmbed && props.get("transcript") === void 0) d.warn(tag(A11Y_IDS.mediaTranscript, `${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`));
1356
+ if (name === "Video" && !hasSpread && src?.kind === "string" && !isEmbed && props.get("tracks") === void 0 && props.get("transcript") === void 0) d.warn(tag(A11Y_IDS.mediaCaptions, `${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`));
1357
+ if (name === "Audio" && !hasSpread && props.get("transcript") === void 0) d.warn(tag(A11Y_IDS.mediaTranscript, `${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`));
1273
1358
  }
1274
1359
  }
1275
1360
  /**
@@ -1279,11 +1364,11 @@ function validateMediaComponents(content, fileRel, errors, warnings) {
1279
1364
  * components emit headings a static scan can't see; that belongs to the Tier-2
1280
1365
  * audit.
1281
1366
  */
1282
- function validateHeadingOrder(content, fileRel, warnings) {
1367
+ function validateHeadingOrder(content, fileRel, d) {
1283
1368
  const levels = [...stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]).matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1284
1369
  let prevSeen = null;
1285
1370
  for (const level of levels) {
1286
- 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)`));
1371
+ if (prevSeen !== null && level - prevSeen > 1) d.warn(tag(A11Y_IDS.headingOrder, `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`));
1287
1372
  prevSeen = level;
1288
1373
  }
1289
1374
  }
@@ -1297,9 +1382,9 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
1297
1382
  * source text for known escape hatches — they never inspect course content,
1298
1383
  * so they constrain how you wire things up, not what you build.
1299
1384
  */
1300
- function validateContractBypass(content, fileRel, errors) {
1301
- if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) errors.push(`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through useQuiz().submit() so the result reaches the LMS`);
1302
- if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) errors.push(`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks (useQuiz, useQuestion, useNavigation, …) instead`);
1385
+ function validateContractBypass(content, fileRel, d) {
1386
+ if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) d.error(`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through useQuiz().submit() so the result reaches the LMS`);
1387
+ if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) d.error(`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks (useQuiz, useQuestion, useNavigation, …) instead`);
1303
1388
  }
1304
1389
  const ASSET_REF_RE = /\$assets\/([^\s"'`)]+)/g;
1305
1390
  /** Match $assets/... refs in any context (src attrs, import statements, url() etc) and dedupe. */
@@ -1310,7 +1395,7 @@ function collectAssetRefs(content) {
1310
1395
  while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1].replace(/[?#].*$/, ""));
1311
1396
  return [...seen];
1312
1397
  }
1313
- function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
1398
+ function validateAssetRefs(content, fileRel, assetsDir, d, existsCache) {
1314
1399
  for (const assetPath of collectAssetRefs(content)) {
1315
1400
  const fullAssetPath = resolve(assetsDir, assetPath);
1316
1401
  let exists = existsCache.get(fullAssetPath);
@@ -1318,24 +1403,24 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
1318
1403
  exists = existsSync(fullAssetPath);
1319
1404
  existsCache.set(fullAssetPath, exists);
1320
1405
  }
1321
- if (!exists) warnings.push(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
1406
+ if (!exists) d.warn(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
1322
1407
  }
1323
1408
  }
1324
- function crossValidate(config, pageResults, errors, warnings) {
1325
- if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz && !pageResults.hasParseErrors) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
1326
- 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.");
1409
+ function crossValidate(config, pageResults, d) {
1410
+ if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz && !pageResults.hasParseErrors) d.error("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
1411
+ if (config.completion?.mode === "quiz" && config.scoring?.passingScore === void 0) d.warn("completion.mode is \"quiz\" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.");
1327
1412
  const isManual = config.completion?.mode === "manual";
1328
1413
  const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
1329
- if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0 && !pageResults.hasParseErrors) 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.");
1414
+ if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0 && !pageResults.hasParseErrors) d.error("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.");
1330
1415
  if (isManual) {
1331
- for (const page of pageResults.pages) if (page.hasGradedQuiz) warnings.push(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
1416
+ for (const page of pageResults.pages) if (page.hasGradedQuiz) d.warn(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
1332
1417
  }
1333
- if (isManual && config.completion?.percentageThreshold !== void 0) warnings.push("course.config.js: \"completion.percentageThreshold\" is ignored under completion.mode: \"manual\"");
1334
- if (!isManual) for (const page of completesOnPages) warnings.push(`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? "percentage"}"`);
1335
- for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz) warnings.push(`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`);
1418
+ if (isManual && config.completion?.percentageThreshold !== void 0) d.warn("course.config.js: \"completion.percentageThreshold\" is ignored under completion.mode: \"manual\"");
1419
+ if (!isManual) for (const page of completesOnPages) d.warn(`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? "percentage"}"`);
1420
+ for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz) d.warn(`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`);
1336
1421
  if (isManual) {
1337
1422
  const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
1338
- if (firstPage?.completesOnView) warnings.push(`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`);
1423
+ if (firstPage?.completesOnView) d.warn(`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`);
1339
1424
  }
1340
1425
  if (config.export?.standard === "scorm12") {
1341
1426
  let visitedChars = 0;
@@ -1345,7 +1430,7 @@ function crossValidate(config, pageResults, errors, warnings) {
1345
1430
  const chunkBytes = pageResults.totalPages * 12;
1346
1431
  const standaloneBytes = pageResults.totalPages * 30;
1347
1432
  const estimatedSize = overhead + visitedChars + quizBytes + chunkBytes + standaloneBytes + 256;
1348
- if (estimatedSize > 3200) warnings.push(`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".`);
1433
+ if (estimatedSize > 3200) d.warn(`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".`);
1349
1434
  }
1350
1435
  }
1351
1436
  //#endregion
@@ -1574,7 +1659,7 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1574
1659
  const disableRules = axeIgnoreRules(settings.ignore);
1575
1660
  const manifest = generateManifest(resolve(projectRoot, "pages"));
1576
1661
  const vite = await import("vite");
1577
- const { resolveTesseraConfig } = await import("./inline-config-DqAKsCNl.js");
1662
+ const { resolveTesseraConfig } = await import("./inline-config-DVvOCKht.js");
1578
1663
  const auditBaseConfig = await resolveTesseraConfig(projectRoot, workspaceRoot, {
1579
1664
  command: "build",
1580
1665
  mode: "production"
@@ -1725,6 +1810,6 @@ function printSummary(report, reportPath) {
1725
1810
  }
1726
1811
  }
1727
1812
  //#endregion
1728
- export { isPlausibleLanguageTag as a, validateProject as c, isIgnored as i, generateManifest as l, runAudit as n, normalizeA11y as o, resolvePackageRoot as r, reportValidationIssues as s, AUDIT_ENV_FLAG as t, readCourseConfig as u };
1813
+ export { isIgnored as a, reportValidationIssues as c, courseIdentity as d, generateManifest as f, VALID_EXPORT_STANDARDS as i, validateProject as l, readResolvedConfig as m, runAudit as n, isPlausibleLanguageTag as o, readCourseConfig as p, resolvePackageRoot as r, normalizeA11y as s, AUDIT_ENV_FLAG as t, buildCsp as u };
1729
1814
 
1730
- //# sourceMappingURL=audit--fSWIOgK.js.map
1815
+ //# sourceMappingURL=audit-DsYqXbqm.js.map