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.
- package/AGENTS.md +50 -21
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
- package/dist/inline-config-DVvOCKht.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +91 -49
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +287 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +96 -46
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +138 -93
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +24 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +255 -238
- package/src/runtime/App.svelte +14 -9
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -54
- package/src/runtime/adapters/web.ts +11 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/fingerprint.ts +28 -0
- package/src/runtime/interaction-format.ts +0 -1
- package/src/runtime/persistence.ts +4 -0
- package/src/runtime/types.ts +22 -1
- package/src/runtime/xapi/publisher.ts +16 -15
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit--fSWIOgK.js.map +0 -1
- package/dist/build-commands-Qyrlsp3n.js.map +0 -1
- package/dist/inline-config-DqAKsCNl.js.map +0 -1
- 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
|
-
|
|
128
|
+
result = TsParser.parse(source, {
|
|
124
129
|
ecmaVersion: "latest",
|
|
125
130
|
sourceType: "module"
|
|
126
131
|
});
|
|
127
132
|
} catch {
|
|
128
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
689
|
-
|
|
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
|
|
779
|
+
for (const msg of d.warnings) {
|
|
694
780
|
const id = diagnosticId(msg);
|
|
695
|
-
if (id !== null && PROMOTABLE_A11Y_IDS.has(id))
|
|
781
|
+
if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) d.error(msg);
|
|
696
782
|
else remaining.push(msg);
|
|
697
783
|
}
|
|
698
|
-
|
|
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
|
|
749
|
-
const warnings = [];
|
|
837
|
+
const d = new Diagnostics();
|
|
750
838
|
if (!existsSync(resolve(projectRoot, "course.config.js"))) {
|
|
751
|
-
|
|
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,
|
|
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,
|
|
846
|
+
if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, d);
|
|
764
847
|
}
|
|
765
|
-
if (config) crossValidate(config, pageResults,
|
|
766
|
-
|
|
767
|
-
|
|
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,
|
|
852
|
+
function parseConfig(projectRoot, d, standardOverride) {
|
|
774
853
|
const read = readCourseConfig(projectRoot);
|
|
775
854
|
if (!read.ok) {
|
|
776
|
-
if (read.reason === "no-export")
|
|
777
|
-
else if (read.reason === "parse-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))
|
|
782
|
-
if (config.title !== void 0 && typeof config.title !== "string")
|
|
783
|
-
else if (config.title === void 0 || config.title === "")
|
|
784
|
-
else if (config.title.trim() === "")
|
|
785
|
-
if (config.branding !== void 0) validateBranding(config.branding,
|
|
786
|
-
if (config.language === void 0)
|
|
787
|
-
else if (!isPlausibleLanguageTag(config.language))
|
|
788
|
-
if (config.
|
|
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))
|
|
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))
|
|
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")
|
|
797
|
-
else if (!VALID_MANUAL_TRIGGERS.includes(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")
|
|
801
|
-
else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(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.
|
|
804
|
-
|
|
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)
|
|
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)
|
|
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",
|
|
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,
|
|
923
|
+
function validateBranding(raw, d) {
|
|
833
924
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
834
|
-
|
|
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")
|
|
841
|
-
else if (logo.startsWith("$assets/"))
|
|
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")
|
|
845
|
-
else if (!isPlausibleColor(primaryColor))
|
|
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)
|
|
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")
|
|
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,
|
|
945
|
+
function validateA11yConfig(raw, d) {
|
|
855
946
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
856
|
-
|
|
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))
|
|
861
|
-
if (a11y.standard !== void 0 && !VALID_A11Y_STANDARDS.includes(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"))
|
|
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,
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
981
|
+
d.error(`course.config.js: ${label} must be an object`);
|
|
891
982
|
continue;
|
|
892
983
|
}
|
|
893
|
-
validateSingleXAPIEntry(entry, label, standard,
|
|
984
|
+
validateSingleXAPIEntry(entry, label, standard, d);
|
|
894
985
|
}
|
|
895
986
|
}
|
|
896
|
-
function validateSingleXAPIEntry(entry, label, standard,
|
|
987
|
+
function validateSingleXAPIEntry(entry, label, standard, d) {
|
|
897
988
|
const endpoint = entry.endpoint;
|
|
898
989
|
if (endpoint === void 0) {
|
|
899
|
-
|
|
990
|
+
d.error(`course.config.js: ${label}.endpoint is required`);
|
|
900
991
|
return;
|
|
901
992
|
}
|
|
902
993
|
if (typeof endpoint !== "string") {
|
|
903
|
-
|
|
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")
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
929
|
-
if (!endpoint.endsWith("/"))
|
|
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)
|
|
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)
|
|
935
|
-
else
|
|
936
|
-
} else if (typeof auth !== "function")
|
|
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 === "")
|
|
939
|
-
else if (typeof activityId !== "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
|
-
|
|
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")
|
|
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)
|
|
951
|
-
} else if (typeof actor !== "function")
|
|
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")
|
|
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
|
-
|
|
1049
|
+
d.error(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
|
|
959
1050
|
}
|
|
960
|
-
if (actor !== void 0)
|
|
961
|
-
if (standard === "cmi5" || standard === "web")
|
|
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)
|
|
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))
|
|
967
|
-
if (standard !== "cmi5")
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1089
|
+
validateQuizConfig(pageConfig.quiz, fileRel, d);
|
|
999
1090
|
if (pageConfig.quiz.graded === true) isGradedQuiz = true;
|
|
1000
1091
|
}
|
|
1001
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel,
|
|
1002
|
-
validateAssetRefs(content, fileRel, assetsDir,
|
|
1003
|
-
validateQuestionComponents(content, fileRel,
|
|
1004
|
-
validateMediaComponents(content, fileRel,
|
|
1005
|
-
validateHeadingOrder(content, fileRel,
|
|
1006
|
-
validateContractBypass(content, fileRel,
|
|
1007
|
-
if (pageConfig?.quiz && !HAS_USE_QUESTION_RE.test(content) && !HAS_QUESTION_TAG_RE.test(content) && !HAS_LOCAL_SVELTE_IMPORT_RE.test(content))
|
|
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
|
-
|
|
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())
|
|
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))
|
|
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))
|
|
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,
|
|
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,
|
|
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),
|
|
1073
|
-
if (totalPages === pagesBeforeSection)
|
|
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,
|
|
1171
|
+
function validateMetaFile(metaPath, parentRel, d) {
|
|
1087
1172
|
if (!existsSync(metaPath)) return null;
|
|
1088
1173
|
const metaRel = `${parentRel}/_meta.js`;
|
|
1089
|
-
const result =
|
|
1174
|
+
const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
1090
1175
|
if (result.kind === "parse-error") {
|
|
1091
|
-
|
|
1176
|
+
d.error(`${metaRel}: could not parse — JavaScript syntax error`);
|
|
1092
1177
|
return null;
|
|
1093
1178
|
}
|
|
1094
1179
|
if (result.kind !== "literal") {
|
|
1095
|
-
|
|
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
|
-
|
|
1187
|
+
d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
1103
1188
|
return null;
|
|
1104
1189
|
}
|
|
1105
|
-
if (!meta.title)
|
|
1190
|
+
if (!meta.title) d.error(`${metaRel}: missing required "title" field`);
|
|
1106
1191
|
return meta;
|
|
1107
1192
|
}
|
|
1108
|
-
function validatePageConfig(content, fileRel,
|
|
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")
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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)))
|
|
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")
|
|
1128
|
-
if (cfg.feedbackMode !== void 0 && !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode))
|
|
1129
|
-
if (cfg.retryMode !== void 0 && !VALID_RETRY_MODES.includes(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,
|
|
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))
|
|
1171
|
-
for (const labelProp of ["options", "answers"]) if (staticArray(props.get(labelProp))?.some((e) => typeof e === "string" && e.trim() === ""))
|
|
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))
|
|
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)
|
|
1178
|
-
if (seenSanitized.has(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")
|
|
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))
|
|
1189
|
-
else if (!(weight > 0))
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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"))
|
|
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)
|
|
1220
|
-
else if (answers.some((a) => typeof a !== "string"))
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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))
|
|
1262
|
-
if (hasDecorative && alt?.kind === "string" && alt.value.trim() !== "")
|
|
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))
|
|
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)
|
|
1271
|
-
if (name === "Video" && !hasSpread && src?.kind === "string" && !isEmbed && props.get("tracks") === void 0 && props.get("transcript") === void 0)
|
|
1272
|
-
if (name === "Audio" && !hasSpread && props.get("transcript") === void 0)
|
|
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,
|
|
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)
|
|
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,
|
|
1301
|
-
if (QUIZ_COMPLETE_DISPATCH_RE.test(content))
|
|
1302
|
-
if (RUNTIME_INTERNAL_IMPORT_RE.test(content))
|
|
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,
|
|
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)
|
|
1406
|
+
if (!exists) d.warn(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
|
|
1322
1407
|
}
|
|
1323
1408
|
}
|
|
1324
|
-
function crossValidate(config, pageResults,
|
|
1325
|
-
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz && !pageResults.hasParseErrors)
|
|
1326
|
-
if (config.completion?.mode === "quiz" && config.scoring?.passingScore === void 0)
|
|
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)
|
|
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)
|
|
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)
|
|
1334
|
-
if (!isManual) for (const page of completesOnPages)
|
|
1335
|
-
for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz)
|
|
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)
|
|
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)
|
|
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-
|
|
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 {
|
|
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
|
|
1815
|
+
//# sourceMappingURL=audit-DsYqXbqm.js.map
|