tessera-learn 0.3.0 → 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 +17 -12
- package/README.md +1 -1
- package/dist/{audit-DkXqQTqn.js → audit-DsYqXbqm.js} +211 -183
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-CyzuCDXg.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-BEXyRqsJ.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 +38 -7
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +7 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/{plugin-CFUFgwHB.js → plugin-BuMiDTmU.js} +29 -38
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +1 -1
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +54 -3
- package/src/plugin/index.ts +31 -42
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +21 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +214 -233
- package/src/runtime/App.svelte +4 -1
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -55
- package/src/runtime/adapters/web.ts +5 -13
- 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 +3 -0
- package/src/runtime/xapi/publisher.ts +11 -14
- package/dist/audit-DkXqQTqn.js.map +0 -1
- package/dist/build-commands-CyzuCDXg.js.map +0 -1
- package/dist/inline-config-BEXyRqsJ.js.map +0 -1
- package/dist/plugin-CFUFgwHB.js.map +0 -1
|
@@ -282,6 +282,33 @@ function readCourseConfig(projectRoot) {
|
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
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
|
+
/**
|
|
285
312
|
* Read a _meta.js file and extract its default export object.
|
|
286
313
|
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
|
|
287
314
|
* after `export default` and parse it.
|
|
@@ -676,6 +703,17 @@ function isVideoEmbed(src) {
|
|
|
676
703
|
}
|
|
677
704
|
//#endregion
|
|
678
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
|
+
};
|
|
679
717
|
/** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
|
|
680
718
|
const A11Y_IDS = {
|
|
681
719
|
imageAlt: "tessera/image-alt",
|
|
@@ -729,21 +767,21 @@ function normalizeA11y(raw) {
|
|
|
729
767
|
* promotable a11y warnings to errors) to a result in place. `ignore` suppresses
|
|
730
768
|
* at any severity, including hard contract errors; `level` only re-rates.
|
|
731
769
|
*/
|
|
732
|
-
function applyA11ySettings(
|
|
770
|
+
function applyA11ySettings(d, settings) {
|
|
733
771
|
if (settings.ignore.length > 0) {
|
|
734
772
|
const ignored = new Set(settings.ignore);
|
|
735
773
|
const keep = (msg) => !isIgnored(msg, ignored);
|
|
736
|
-
|
|
737
|
-
|
|
774
|
+
d.errors = d.errors.filter(keep);
|
|
775
|
+
d.warnings = d.warnings.filter(keep);
|
|
738
776
|
}
|
|
739
777
|
if (settings.level === "error") {
|
|
740
778
|
const remaining = [];
|
|
741
|
-
for (const msg of
|
|
779
|
+
for (const msg of d.warnings) {
|
|
742
780
|
const id = diagnosticId(msg);
|
|
743
|
-
if (id !== null && PROMOTABLE_A11Y_IDS.has(id))
|
|
781
|
+
if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) d.error(msg);
|
|
744
782
|
else remaining.push(msg);
|
|
745
783
|
}
|
|
746
|
-
|
|
784
|
+
d.warnings = remaining;
|
|
747
785
|
}
|
|
748
786
|
}
|
|
749
787
|
/** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
|
|
@@ -757,6 +795,7 @@ const KNOWN_CONFIG_FIELDS = /* @__PURE__ */ new Set([
|
|
|
757
795
|
"description",
|
|
758
796
|
"author",
|
|
759
797
|
"version",
|
|
798
|
+
"resume",
|
|
760
799
|
"language",
|
|
761
800
|
"branding",
|
|
762
801
|
"navigation",
|
|
@@ -793,82 +832,77 @@ const VALID_RETRY_MODES = RETRY_MODES;
|
|
|
793
832
|
* Validate a Tessera project at the given root.
|
|
794
833
|
* Returns errors (block build) and warnings (informational).
|
|
795
834
|
*/
|
|
796
|
-
function validateProject(projectRoot) {
|
|
835
|
+
function validateProject(projectRoot, standardOverride) {
|
|
797
836
|
clearParseCache();
|
|
798
|
-
const
|
|
799
|
-
const warnings = [];
|
|
837
|
+
const d = new Diagnostics();
|
|
800
838
|
if (!existsSync(resolve(projectRoot, "course.config.js"))) {
|
|
801
|
-
|
|
802
|
-
return
|
|
803
|
-
errors,
|
|
804
|
-
warnings
|
|
805
|
-
};
|
|
839
|
+
d.error("course.config.js not found in project root");
|
|
840
|
+
return d;
|
|
806
841
|
}
|
|
807
|
-
const config = parseConfig(projectRoot,
|
|
808
|
-
const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot, config?.export?.standard);
|
|
809
|
-
errors.push(...pageResults.errors);
|
|
810
|
-
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);
|
|
811
844
|
for (const shellFile of ["layout.svelte", "quiz.svelte"]) {
|
|
812
845
|
const shellPath = resolve(projectRoot, shellFile);
|
|
813
|
-
if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile,
|
|
846
|
+
if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, d);
|
|
814
847
|
}
|
|
815
|
-
if (config) crossValidate(config, pageResults,
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
warnings
|
|
819
|
-
};
|
|
820
|
-
applyA11ySettings(result, normalizeA11y(config?.a11y));
|
|
821
|
-
return result;
|
|
848
|
+
if (config) crossValidate(config, pageResults, d);
|
|
849
|
+
applyA11ySettings(d, normalizeA11y(config?.a11y));
|
|
850
|
+
return d;
|
|
822
851
|
}
|
|
823
|
-
function parseConfig(projectRoot,
|
|
852
|
+
function parseConfig(projectRoot, d, standardOverride) {
|
|
824
853
|
const read = readCourseConfig(projectRoot);
|
|
825
854
|
if (!read.ok) {
|
|
826
|
-
if (read.reason === "no-export")
|
|
827
|
-
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");
|
|
828
857
|
return null;
|
|
829
858
|
}
|
|
830
859
|
const config = read.config;
|
|
831
|
-
for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key))
|
|
832
|
-
if (config.title !== void 0 && typeof config.title !== "string")
|
|
833
|
-
else if (config.title === void 0 || config.title === "")
|
|
834
|
-
else if (config.title.trim() === "")
|
|
835
|
-
if (config.branding !== void 0) validateBranding(config.branding,
|
|
836
|
-
if (config.language === void 0)
|
|
837
|
-
else if (!isPlausibleLanguageTag(config.language))
|
|
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
|
+
};
|
|
838
874
|
const standard = config.export?.standard;
|
|
839
|
-
if ((standard === void 0 || standard === "web" || standard === "cmi5" || standard === "xapi") && !courseIdentity(config))
|
|
840
|
-
if (config.a11y !== void 0) validateA11yConfig(config.a11y,
|
|
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);
|
|
841
877
|
if (config.navigation?.mode !== void 0) {
|
|
842
|
-
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}"`);
|
|
843
879
|
}
|
|
844
880
|
if (config.completion?.mode !== void 0) {
|
|
845
|
-
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}"`);
|
|
846
882
|
}
|
|
847
883
|
if (config.completion?.trigger !== void 0) {
|
|
848
|
-
if (config.completion.mode !== "manual")
|
|
849
|
-
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}"`);
|
|
850
886
|
}
|
|
851
887
|
if (config.completion?.requireSuccessStatus !== void 0) {
|
|
852
|
-
if (config.completion.mode !== "manual")
|
|
853
|
-
else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus))
|
|
854
|
-
}
|
|
855
|
-
if (config.export?.standard !== void 0) {
|
|
856
|
-
if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", "cmi5", or "xapi", got "${config.export.standard}"`);
|
|
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}"`);
|
|
857
890
|
}
|
|
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}"`);
|
|
858
892
|
if (config.export?.csp !== void 0) {
|
|
859
893
|
const csp = config.export.csp;
|
|
860
|
-
if (csp !== false && !isCspOverrides(csp))
|
|
861
|
-
else if ((config.export.standard ?? "web") !== "web")
|
|
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)`);
|
|
862
896
|
}
|
|
863
897
|
if (config.scoring?.passingScore !== void 0) {
|
|
864
898
|
const score = config.scoring.passingScore;
|
|
865
|
-
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}`);
|
|
866
900
|
}
|
|
867
901
|
if (config.completion?.percentageThreshold !== void 0) {
|
|
868
902
|
const threshold = config.completion.percentageThreshold;
|
|
869
|
-
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}`);
|
|
870
904
|
}
|
|
871
|
-
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);
|
|
872
906
|
return config;
|
|
873
907
|
}
|
|
874
908
|
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
@@ -886,142 +920,142 @@ function isPlausibleColor(value) {
|
|
|
886
920
|
function describeType(raw) {
|
|
887
921
|
return raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
|
|
888
922
|
}
|
|
889
|
-
function validateBranding(raw,
|
|
923
|
+
function validateBranding(raw, d) {
|
|
890
924
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
891
|
-
|
|
925
|
+
d.warn(`course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`);
|
|
892
926
|
return;
|
|
893
927
|
}
|
|
894
928
|
const branding = raw;
|
|
895
929
|
const logo = branding.logo;
|
|
896
930
|
if (logo !== void 0) {
|
|
897
|
-
if (typeof logo !== "string")
|
|
898
|
-
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.");
|
|
899
933
|
}
|
|
900
934
|
const primaryColor = branding.primaryColor;
|
|
901
|
-
if (primaryColor !== void 0) if (typeof primaryColor !== "string")
|
|
902
|
-
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`);
|
|
903
937
|
else {
|
|
904
938
|
const ratio = contrastRatio(primaryColor, "#ffffff");
|
|
905
|
-
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`));
|
|
906
940
|
}
|
|
907
941
|
const fontFamily = branding.fontFamily;
|
|
908
|
-
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}`);
|
|
909
943
|
}
|
|
910
944
|
/** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
|
|
911
|
-
function validateA11yConfig(raw,
|
|
945
|
+
function validateA11yConfig(raw, d) {
|
|
912
946
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
913
|
-
|
|
947
|
+
d.error(`course.config.js: "a11y" must be an object, got ${describeType(raw)}`);
|
|
914
948
|
return;
|
|
915
949
|
}
|
|
916
950
|
const a11y = raw;
|
|
917
|
-
if (a11y.level !== void 0 && !VALID_A11Y_LEVELS.includes(a11y.level))
|
|
918
|
-
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)}`);
|
|
919
953
|
if (a11y.ignore !== void 0) {
|
|
920
|
-
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`);
|
|
921
955
|
}
|
|
922
956
|
}
|
|
923
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;
|
|
924
|
-
function validateXAPIConfig(raw, standard,
|
|
958
|
+
function validateXAPIConfig(raw, standard, d) {
|
|
925
959
|
if (raw === void 0 || raw === null) return;
|
|
926
960
|
const entries = Array.isArray(raw) ? raw : [raw];
|
|
927
961
|
if (Array.isArray(raw)) {
|
|
928
962
|
if (entries.length === 0) {
|
|
929
|
-
|
|
963
|
+
d.error("course.config.js: xapi must contain at least one destination, or be omitted");
|
|
930
964
|
return;
|
|
931
965
|
}
|
|
932
|
-
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");
|
|
933
967
|
const seen = /* @__PURE__ */ new Map();
|
|
934
968
|
for (const e of entries) if (e && typeof e === "object") {
|
|
935
969
|
const ep = e.endpoint;
|
|
936
970
|
if (typeof ep === "string" && ep !== "lms") seen.set(ep, (seen.get(ep) ?? 0) + 1);
|
|
937
971
|
}
|
|
938
|
-
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.`);
|
|
939
973
|
} else if (typeof raw !== "object") {
|
|
940
|
-
|
|
974
|
+
d.error("course.config.js: xapi must be an object or an array of objects");
|
|
941
975
|
return;
|
|
942
976
|
}
|
|
943
977
|
for (let i = 0; i < entries.length; i++) {
|
|
944
978
|
const entry = entries[i];
|
|
945
979
|
const label = Array.isArray(raw) ? `xapi[${i}]` : "xapi";
|
|
946
980
|
if (!entry || typeof entry !== "object") {
|
|
947
|
-
|
|
981
|
+
d.error(`course.config.js: ${label} must be an object`);
|
|
948
982
|
continue;
|
|
949
983
|
}
|
|
950
|
-
validateSingleXAPIEntry(entry, label, standard,
|
|
984
|
+
validateSingleXAPIEntry(entry, label, standard, d);
|
|
951
985
|
}
|
|
952
986
|
}
|
|
953
|
-
function validateSingleXAPIEntry(entry, label, standard,
|
|
987
|
+
function validateSingleXAPIEntry(entry, label, standard, d) {
|
|
954
988
|
const endpoint = entry.endpoint;
|
|
955
989
|
if (endpoint === void 0) {
|
|
956
|
-
|
|
990
|
+
d.error(`course.config.js: ${label}.endpoint is required`);
|
|
957
991
|
return;
|
|
958
992
|
}
|
|
959
993
|
if (typeof endpoint !== "string") {
|
|
960
|
-
|
|
994
|
+
d.error(`course.config.js: ${label}.endpoint must be a string`);
|
|
961
995
|
return;
|
|
962
996
|
}
|
|
963
997
|
if (endpoint === "lms") {
|
|
964
|
-
if (standard !== "cmi5" && standard !== "xapi")
|
|
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.`);
|
|
965
999
|
for (const f of [
|
|
966
1000
|
"auth",
|
|
967
1001
|
"actor",
|
|
968
1002
|
"activityId",
|
|
969
1003
|
"registration",
|
|
970
1004
|
"actorAccountHomePage"
|
|
971
|
-
]) 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.`);
|
|
972
1006
|
return;
|
|
973
1007
|
}
|
|
974
1008
|
let url;
|
|
975
1009
|
try {
|
|
976
1010
|
url = new URL(endpoint);
|
|
977
1011
|
} catch {
|
|
978
|
-
|
|
1012
|
+
d.error(`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`);
|
|
979
1013
|
return;
|
|
980
1014
|
}
|
|
981
1015
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
982
|
-
|
|
1016
|
+
d.error(`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`);
|
|
983
1017
|
return;
|
|
984
1018
|
}
|
|
985
|
-
if (url.protocol === "http:" && process.env.NODE_ENV === "production")
|
|
986
|
-
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.`);
|
|
987
1021
|
const auth = entry.auth;
|
|
988
|
-
if (auth === void 0)
|
|
1022
|
+
if (auth === void 0) d.error(`course.config.js: ${label}.auth is required`);
|
|
989
1023
|
else if (typeof auth === "string") {
|
|
990
1024
|
const authErr = validateAuthCredential(auth);
|
|
991
|
-
if (authErr)
|
|
992
|
-
else
|
|
993
|
-
} 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}`);
|
|
994
1028
|
const activityId = entry.activityId;
|
|
995
|
-
if (activityId === void 0 || activityId === "")
|
|
996
|
-
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`);
|
|
997
1031
|
else try {
|
|
998
1032
|
new URL(activityId);
|
|
999
1033
|
} catch {
|
|
1000
|
-
|
|
1034
|
+
d.error(`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`);
|
|
1001
1035
|
}
|
|
1002
1036
|
const actor = entry.actor;
|
|
1003
1037
|
if (actor === void 0) {
|
|
1004
|
-
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).`);
|
|
1005
1039
|
} else if (typeof actor === "object" && actor !== null) {
|
|
1006
1040
|
const err = validateAgent(actor);
|
|
1007
|
-
if (err)
|
|
1008
|
-
} 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}`);
|
|
1009
1043
|
const aahp = entry.actorAccountHomePage;
|
|
1010
1044
|
if (aahp !== void 0) {
|
|
1011
|
-
if (typeof aahp !== "string")
|
|
1045
|
+
if (typeof aahp !== "string") d.error(`course.config.js: ${label}.actorAccountHomePage must be a string`);
|
|
1012
1046
|
else try {
|
|
1013
1047
|
new URL(aahp);
|
|
1014
1048
|
} catch {
|
|
1015
|
-
|
|
1049
|
+
d.error(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
|
|
1016
1050
|
}
|
|
1017
|
-
if (actor !== void 0)
|
|
1018
|
-
if (standard === "cmi5" || standard === "xapi" || 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}".`);
|
|
1019
1053
|
}
|
|
1020
|
-
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.`);
|
|
1021
1055
|
const registration = entry.registration;
|
|
1022
1056
|
if (registration !== void 0) {
|
|
1023
|
-
if (typeof registration !== "string" || !UUID_RE.test(registration))
|
|
1024
|
-
if (standard !== "cmi5" && standard !== "xapi")
|
|
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.`);
|
|
1025
1059
|
}
|
|
1026
1060
|
}
|
|
1027
1061
|
/**
|
|
@@ -1029,12 +1063,12 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
1029
1063
|
* lesson-level pages — the validation is identical, only the containing
|
|
1030
1064
|
* directory differs.
|
|
1031
1065
|
*/
|
|
1032
|
-
function validatePageFile(filePath, projectRoot, assetsDir, navIndex,
|
|
1066
|
+
function validatePageFile(filePath, projectRoot, assetsDir, navIndex, d, assetExistsCache, exportStandard) {
|
|
1033
1067
|
const fileRel = relative(projectRoot, filePath);
|
|
1034
1068
|
const content = readSourceFileCached(filePath);
|
|
1035
1069
|
const parseError = getParseError(content);
|
|
1036
1070
|
if (parseError) {
|
|
1037
|
-
|
|
1071
|
+
d.error(`${fileRel}: could not parse — ${parseError}`);
|
|
1038
1072
|
return {
|
|
1039
1073
|
page: {
|
|
1040
1074
|
fileRel,
|
|
@@ -1048,20 +1082,20 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
|
|
|
1048
1082
|
parseError: true
|
|
1049
1083
|
};
|
|
1050
1084
|
}
|
|
1051
|
-
const pageConfig = validatePageConfig(content, fileRel,
|
|
1085
|
+
const pageConfig = validatePageConfig(content, fileRel, d);
|
|
1052
1086
|
const isQuiz = !!pageConfig?.quiz;
|
|
1053
1087
|
let isGradedQuiz = false;
|
|
1054
1088
|
if (pageConfig?.quiz) {
|
|
1055
|
-
validateQuizConfig(pageConfig.quiz, fileRel,
|
|
1089
|
+
validateQuizConfig(pageConfig.quiz, fileRel, d);
|
|
1056
1090
|
if (pageConfig.quiz.graded === true) isGradedQuiz = true;
|
|
1057
1091
|
}
|
|
1058
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel,
|
|
1059
|
-
validateAssetRefs(content, fileRel, assetsDir,
|
|
1060
|
-
validateQuestionComponents(content, fileRel,
|
|
1061
|
-
validateMediaComponents(content, fileRel,
|
|
1062
|
-
validateHeadingOrder(content, fileRel,
|
|
1063
|
-
validateContractBypass(content, fileRel,
|
|
1064
|
-
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`);
|
|
1065
1099
|
return {
|
|
1066
1100
|
page: {
|
|
1067
1101
|
fileRel,
|
|
@@ -1075,9 +1109,7 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
|
|
|
1075
1109
|
parseError: false
|
|
1076
1110
|
};
|
|
1077
1111
|
}
|
|
1078
|
-
function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
1079
|
-
const errors = [];
|
|
1080
|
-
const warnings = [];
|
|
1112
|
+
function validatePages(pagesDir, assetsDir, projectRoot, d, exportStandard) {
|
|
1081
1113
|
const pages = [];
|
|
1082
1114
|
let totalPages = 0;
|
|
1083
1115
|
let totalQuizzes = 0;
|
|
@@ -1085,10 +1117,8 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
|
1085
1117
|
let hasParseErrors = false;
|
|
1086
1118
|
const assetExistsCache = /* @__PURE__ */ new Map();
|
|
1087
1119
|
const noPages = () => {
|
|
1088
|
-
|
|
1120
|
+
d.error("No pages found. Create at least one section with a lesson and page in pages/");
|
|
1089
1121
|
return {
|
|
1090
|
-
errors,
|
|
1091
|
-
warnings,
|
|
1092
1122
|
totalPages,
|
|
1093
1123
|
totalQuizzes,
|
|
1094
1124
|
hasGradedQuiz,
|
|
@@ -1099,21 +1129,21 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
|
1099
1129
|
if (!existsSync(pagesDir)) return noPages();
|
|
1100
1130
|
for (const entry of readdirSync(pagesDir)) {
|
|
1101
1131
|
const fullPath = resolve(pagesDir, entry);
|
|
1102
|
-
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`);
|
|
1103
1133
|
}
|
|
1104
1134
|
const sections = walkPages(pagesDir);
|
|
1105
1135
|
if (sections.length === 0) return noPages();
|
|
1106
1136
|
const validateLesson = (lesson, meta) => {
|
|
1107
1137
|
if (meta?.pages) for (const pageName of meta.pages) {
|
|
1108
1138
|
const fileName = ensureSvelteSuffix(pageName);
|
|
1109
|
-
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`);
|
|
1110
1140
|
}
|
|
1111
1141
|
if (meta?.pages && meta.pages.length > 0) {
|
|
1112
1142
|
const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
|
|
1113
|
-
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`);
|
|
1114
1144
|
}
|
|
1115
1145
|
for (const fileName of orderPageFiles(lesson.files, meta?.pages)) {
|
|
1116
|
-
const result = validatePageFile(resolve(lesson.dir, fileName), projectRoot, assetsDir, totalPages,
|
|
1146
|
+
const result = validatePageFile(resolve(lesson.dir, fileName), projectRoot, assetsDir, totalPages, d, assetExistsCache, exportStandard);
|
|
1117
1147
|
totalPages++;
|
|
1118
1148
|
if (result.isQuiz) totalQuizzes++;
|
|
1119
1149
|
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
@@ -1124,15 +1154,13 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
|
1124
1154
|
for (const section of sections) {
|
|
1125
1155
|
const sectionRel = relative(projectRoot, section.dir);
|
|
1126
1156
|
const pagesBeforeSection = totalPages;
|
|
1127
|
-
const sectionMeta = validateMetaFile(section.metaPath, sectionRel,
|
|
1157
|
+
const sectionMeta = validateMetaFile(section.metaPath, sectionRel, d);
|
|
1128
1158
|
for (const lesson of section.lessons) if (lesson.name === null) validateLesson(lesson, sectionMeta);
|
|
1129
|
-
else validateLesson(lesson, validateMetaFile(lesson.metaPath, relative(projectRoot, lesson.dir),
|
|
1130
|
-
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`);
|
|
1131
1161
|
}
|
|
1132
1162
|
if (totalPages === 0) return noPages();
|
|
1133
1163
|
return {
|
|
1134
|
-
errors,
|
|
1135
|
-
warnings,
|
|
1136
1164
|
totalPages,
|
|
1137
1165
|
totalQuizzes,
|
|
1138
1166
|
hasGradedQuiz,
|
|
@@ -1140,50 +1168,50 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
|
1140
1168
|
pages
|
|
1141
1169
|
};
|
|
1142
1170
|
}
|
|
1143
|
-
function validateMetaFile(metaPath, parentRel,
|
|
1171
|
+
function validateMetaFile(metaPath, parentRel, d) {
|
|
1144
1172
|
if (!existsSync(metaPath)) return null;
|
|
1145
1173
|
const metaRel = `${parentRel}/_meta.js`;
|
|
1146
1174
|
const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
1147
1175
|
if (result.kind === "parse-error") {
|
|
1148
|
-
|
|
1176
|
+
d.error(`${metaRel}: could not parse — JavaScript syntax error`);
|
|
1149
1177
|
return null;
|
|
1150
1178
|
}
|
|
1151
1179
|
if (result.kind !== "literal") {
|
|
1152
|
-
|
|
1180
|
+
d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
1153
1181
|
return null;
|
|
1154
1182
|
}
|
|
1155
1183
|
let meta;
|
|
1156
1184
|
try {
|
|
1157
1185
|
meta = JSON5.parse(result.text);
|
|
1158
1186
|
} catch {
|
|
1159
|
-
|
|
1187
|
+
d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
1160
1188
|
return null;
|
|
1161
1189
|
}
|
|
1162
|
-
if (!meta.title)
|
|
1190
|
+
if (!meta.title) d.error(`${metaRel}: missing required "title" field`);
|
|
1163
1191
|
return meta;
|
|
1164
1192
|
}
|
|
1165
|
-
function validatePageConfig(content, fileRel,
|
|
1193
|
+
function validatePageConfig(content, fileRel, d) {
|
|
1166
1194
|
const result = parsePageConfigFromSource(content);
|
|
1167
1195
|
if (result.kind === "ok") return result.value;
|
|
1168
|
-
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)`);
|
|
1169
1197
|
return null;
|
|
1170
1198
|
}
|
|
1171
|
-
function validateCompletesOn(pageConfig, fileRel,
|
|
1199
|
+
function validateCompletesOn(pageConfig, fileRel, d) {
|
|
1172
1200
|
if (!pageConfig || pageConfig.completesOn === void 0) return false;
|
|
1173
1201
|
if (pageConfig.completesOn === "view") return true;
|
|
1174
|
-
|
|
1202
|
+
d.error(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
|
|
1175
1203
|
return false;
|
|
1176
1204
|
}
|
|
1177
|
-
function validateQuizConfig(quiz, fileRel,
|
|
1205
|
+
function validateQuizConfig(quiz, fileRel, d) {
|
|
1178
1206
|
if (!quiz || typeof quiz !== "object") return;
|
|
1179
1207
|
const cfg = quiz;
|
|
1180
1208
|
if (cfg.maxAttempts !== void 0) {
|
|
1181
1209
|
const val = cfg.maxAttempts;
|
|
1182
|
-
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)}`);
|
|
1183
1211
|
}
|
|
1184
|
-
for (const field of ["graded", "gatesProgress"]) if (cfg[field] !== void 0 && typeof cfg[field] !== "boolean")
|
|
1185
|
-
if (cfg.feedbackMode !== void 0 && !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode))
|
|
1186
|
-
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)}"`);
|
|
1187
1215
|
}
|
|
1188
1216
|
const QUESTION_COMPONENT_REQUIRED = {
|
|
1189
1217
|
MultipleChoice: [
|
|
@@ -1218,63 +1246,63 @@ function staticNumber(prop) {
|
|
|
1218
1246
|
return null;
|
|
1219
1247
|
}
|
|
1220
1248
|
}
|
|
1221
|
-
function validateQuestionComponents(content, fileRel,
|
|
1249
|
+
function validateQuestionComponents(content, fileRel, d, exportStandard) {
|
|
1222
1250
|
const components = findComponents(content, new Set(Object.keys(QUESTION_COMPONENT_REQUIRED)));
|
|
1223
1251
|
if (!components) return;
|
|
1224
1252
|
const seenIds = /* @__PURE__ */ new Set();
|
|
1225
1253
|
const seenSanitized = /* @__PURE__ */ new Set();
|
|
1226
1254
|
for (const { name, props, hasSpread } of components) {
|
|
1227
|
-
for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req))
|
|
1228
|
-
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`));
|
|
1229
1257
|
const idProp = props.get("id");
|
|
1230
1258
|
if (idProp?.kind === "string") {
|
|
1231
|
-
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`);
|
|
1232
1260
|
else if (exportStandard === "scorm12") {
|
|
1233
1261
|
const sane = shortIdentifier(idProp.value);
|
|
1234
|
-
if (sane !== idProp.value)
|
|
1235
|
-
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}")`);
|
|
1236
1264
|
seenSanitized.add(sane);
|
|
1237
1265
|
}
|
|
1238
1266
|
seenIds.add(idProp.value);
|
|
1239
1267
|
}
|
|
1240
1268
|
const weightProp = props.get("weight");
|
|
1241
|
-
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}}`);
|
|
1242
1270
|
else {
|
|
1243
1271
|
const weight = staticNumber(weightProp);
|
|
1244
1272
|
if (weight !== null) {
|
|
1245
|
-
if (!Number.isFinite(weight))
|
|
1246
|
-
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)`);
|
|
1247
1275
|
}
|
|
1248
1276
|
}
|
|
1249
1277
|
if (name === "MultipleChoice") {
|
|
1250
1278
|
const options = staticArray(props.get("options"));
|
|
1251
1279
|
const correct = staticNumber(props.get("correct"));
|
|
1252
1280
|
if (options && correct !== null) {
|
|
1253
|
-
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})`);
|
|
1254
1282
|
}
|
|
1255
1283
|
const optionFeedback = staticArray(props.get("optionFeedback"));
|
|
1256
|
-
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`);
|
|
1257
1285
|
} else if (name === "Sorting") {
|
|
1258
1286
|
const items = staticArray(props.get("items"));
|
|
1259
1287
|
const targets = staticArray(props.get("targets"));
|
|
1260
1288
|
const correct = staticArray(props.get("correct"));
|
|
1261
|
-
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`);
|
|
1262
1290
|
if (targets && correct) {
|
|
1263
1291
|
for (const idx of correct) if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx >= targets.length) {
|
|
1264
|
-
|
|
1292
|
+
d.error(`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`);
|
|
1265
1293
|
break;
|
|
1266
1294
|
}
|
|
1267
1295
|
}
|
|
1268
1296
|
} else if (name === "Matching") {
|
|
1269
1297
|
const pairs = staticArray(props.get("pairs"));
|
|
1270
1298
|
if (pairs) {
|
|
1271
|
-
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`);
|
|
1272
1300
|
}
|
|
1273
1301
|
} else if (name === "FillInTheBlank") {
|
|
1274
1302
|
const answers = staticArray(props.get("answers"));
|
|
1275
1303
|
if (answers) {
|
|
1276
|
-
if (answers.length === 0)
|
|
1277
|
-
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`);
|
|
1278
1306
|
}
|
|
1279
1307
|
}
|
|
1280
1308
|
}
|
|
@@ -1295,10 +1323,10 @@ function stripRepeated(input, patterns) {
|
|
|
1295
1323
|
}
|
|
1296
1324
|
/**
|
|
1297
1325
|
* Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
|
|
1298
|
-
* so media isn't treated as gradable questions.
|
|
1326
|
+
* so media isn't treated as gradable questions.
|
|
1299
1327
|
* Non-static (kind 'expr') values are skipped, matching the rest of the linter.
|
|
1300
1328
|
*/
|
|
1301
|
-
function validateMediaComponents(content, fileRel,
|
|
1329
|
+
function validateMediaComponents(content, fileRel, d) {
|
|
1302
1330
|
const components = findComponents(content, /* @__PURE__ */ new Set([
|
|
1303
1331
|
"Image",
|
|
1304
1332
|
"Video",
|
|
@@ -1310,23 +1338,23 @@ function validateMediaComponents(content, fileRel, errors, warnings) {
|
|
|
1310
1338
|
const alt = props.get("alt");
|
|
1311
1339
|
const decorative = props.get("decorative");
|
|
1312
1340
|
if (decorative?.kind === "string") {
|
|
1313
|
-
|
|
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)}`));
|
|
1314
1342
|
continue;
|
|
1315
1343
|
}
|
|
1316
1344
|
const hasDecorative = decorative?.kind === "bool" || decorative?.kind === "expr" && decorative.raw.trim() === "true";
|
|
1317
1345
|
const altIsEmpty = alt?.kind === "string" && alt.value.trim() === "";
|
|
1318
|
-
if (!hasDecorative && !hasSpread && (alt === void 0 || altIsEmpty))
|
|
1319
|
-
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`));
|
|
1320
1348
|
continue;
|
|
1321
1349
|
}
|
|
1322
1350
|
const title = props.get("title");
|
|
1323
1351
|
const titleIsEmpty = title?.kind === "string" && title.value.trim() === "";
|
|
1324
|
-
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`));
|
|
1325
1353
|
const src = props.get("src");
|
|
1326
1354
|
const isEmbed = src?.kind === "string" && isVideoEmbed(src.value);
|
|
1327
|
-
if (name === "Video" && !hasSpread && isEmbed && props.get("transcript") === void 0)
|
|
1328
|
-
if (name === "Video" && !hasSpread && src?.kind === "string" && !isEmbed && props.get("tracks") === void 0 && props.get("transcript") === void 0)
|
|
1329
|
-
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`));
|
|
1330
1358
|
}
|
|
1331
1359
|
}
|
|
1332
1360
|
/**
|
|
@@ -1336,11 +1364,11 @@ function validateMediaComponents(content, fileRel, errors, warnings) {
|
|
|
1336
1364
|
* components emit headings a static scan can't see; that belongs to the Tier-2
|
|
1337
1365
|
* audit.
|
|
1338
1366
|
*/
|
|
1339
|
-
function validateHeadingOrder(content, fileRel,
|
|
1367
|
+
function validateHeadingOrder(content, fileRel, d) {
|
|
1340
1368
|
const levels = [...stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]).matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
|
|
1341
1369
|
let prevSeen = null;
|
|
1342
1370
|
for (const level of levels) {
|
|
1343
|
-
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)`));
|
|
1344
1372
|
prevSeen = level;
|
|
1345
1373
|
}
|
|
1346
1374
|
}
|
|
@@ -1354,9 +1382,9 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
|
1354
1382
|
* source text for known escape hatches — they never inspect course content,
|
|
1355
1383
|
* so they constrain how you wire things up, not what you build.
|
|
1356
1384
|
*/
|
|
1357
|
-
function validateContractBypass(content, fileRel,
|
|
1358
|
-
if (QUIZ_COMPLETE_DISPATCH_RE.test(content))
|
|
1359
|
-
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`);
|
|
1360
1388
|
}
|
|
1361
1389
|
const ASSET_REF_RE = /\$assets\/([^\s"'`)]+)/g;
|
|
1362
1390
|
/** Match $assets/... refs in any context (src attrs, import statements, url() etc) and dedupe. */
|
|
@@ -1367,7 +1395,7 @@ function collectAssetRefs(content) {
|
|
|
1367
1395
|
while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1].replace(/[?#].*$/, ""));
|
|
1368
1396
|
return [...seen];
|
|
1369
1397
|
}
|
|
1370
|
-
function validateAssetRefs(content, fileRel, assetsDir,
|
|
1398
|
+
function validateAssetRefs(content, fileRel, assetsDir, d, existsCache) {
|
|
1371
1399
|
for (const assetPath of collectAssetRefs(content)) {
|
|
1372
1400
|
const fullAssetPath = resolve(assetsDir, assetPath);
|
|
1373
1401
|
let exists = existsCache.get(fullAssetPath);
|
|
@@ -1375,24 +1403,24 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
|
1375
1403
|
exists = existsSync(fullAssetPath);
|
|
1376
1404
|
existsCache.set(fullAssetPath, exists);
|
|
1377
1405
|
}
|
|
1378
|
-
if (!exists)
|
|
1406
|
+
if (!exists) d.warn(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
|
|
1379
1407
|
}
|
|
1380
1408
|
}
|
|
1381
|
-
function crossValidate(config, pageResults,
|
|
1382
|
-
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz && !pageResults.hasParseErrors)
|
|
1383
|
-
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.");
|
|
1384
1412
|
const isManual = config.completion?.mode === "manual";
|
|
1385
1413
|
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
1386
|
-
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.");
|
|
1387
1415
|
if (isManual) {
|
|
1388
|
-
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.`);
|
|
1389
1417
|
}
|
|
1390
|
-
if (isManual && config.completion?.percentageThreshold !== void 0)
|
|
1391
|
-
if (!isManual) for (const page of completesOnPages)
|
|
1392
|
-
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`);
|
|
1393
1421
|
if (isManual) {
|
|
1394
1422
|
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
1395
|
-
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.`);
|
|
1396
1424
|
}
|
|
1397
1425
|
if (config.export?.standard === "scorm12") {
|
|
1398
1426
|
let visitedChars = 0;
|
|
@@ -1402,7 +1430,7 @@ function crossValidate(config, pageResults, errors, warnings) {
|
|
|
1402
1430
|
const chunkBytes = pageResults.totalPages * 12;
|
|
1403
1431
|
const standaloneBytes = pageResults.totalPages * 30;
|
|
1404
1432
|
const estimatedSize = overhead + visitedChars + quizBytes + chunkBytes + standaloneBytes + 256;
|
|
1405
|
-
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".`);
|
|
1406
1434
|
}
|
|
1407
1435
|
}
|
|
1408
1436
|
//#endregion
|
|
@@ -1631,7 +1659,7 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
|
|
|
1631
1659
|
const disableRules = axeIgnoreRules(settings.ignore);
|
|
1632
1660
|
const manifest = generateManifest(resolve(projectRoot, "pages"));
|
|
1633
1661
|
const vite = await import("vite");
|
|
1634
|
-
const { resolveTesseraConfig } = await import("./inline-config-
|
|
1662
|
+
const { resolveTesseraConfig } = await import("./inline-config-DVvOCKht.js");
|
|
1635
1663
|
const auditBaseConfig = await resolveTesseraConfig(projectRoot, workspaceRoot, {
|
|
1636
1664
|
command: "build",
|
|
1637
1665
|
mode: "production"
|
|
@@ -1782,6 +1810,6 @@ function printSummary(report, reportPath) {
|
|
|
1782
1810
|
}
|
|
1783
1811
|
}
|
|
1784
1812
|
//#endregion
|
|
1785
|
-
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 };
|
|
1786
1814
|
|
|
1787
|
-
//# sourceMappingURL=audit-
|
|
1815
|
+
//# sourceMappingURL=audit-DsYqXbqm.js.map
|