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