tessera-learn 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -1
- 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 +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- 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 +17 -3
- 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 +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- 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 +21 -18
- package/src/components/util.ts +3 -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 +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- 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 +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- 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-D9DXlqNP.js.map +0 -1
|
@@ -336,7 +336,178 @@ function validateAuthCredential(auth) {
|
|
|
336
336
|
return null;
|
|
337
337
|
}
|
|
338
338
|
//#endregion
|
|
339
|
+
//#region src/runtime/interaction-format.ts
|
|
340
|
+
/**
|
|
341
|
+
* SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
|
|
342
|
+
* underscore, max 250 chars. Strict validators (SCORM Cloud) reject raw
|
|
343
|
+
* option labels with spaces or punctuation with error 405/406.
|
|
344
|
+
*/
|
|
345
|
+
function shortIdentifier(value) {
|
|
346
|
+
return value.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 250) || "_";
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/runtime/types.ts
|
|
350
|
+
/**
|
|
351
|
+
* Quiz enum domains as runtime tuples. The unions below derive from these, and
|
|
352
|
+
* the build-time validator imports them too — so the accepted value set has a
|
|
353
|
+
* single source and can't drift between the types and the validator.
|
|
354
|
+
*/
|
|
355
|
+
const FEEDBACK_MODES = [
|
|
356
|
+
"review",
|
|
357
|
+
"immediate",
|
|
358
|
+
"never"
|
|
359
|
+
];
|
|
360
|
+
const RETRY_MODES = ["full", "incorrect-only"];
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/plugin/a11y/contrast.ts
|
|
363
|
+
/**
|
|
364
|
+
* Pure-JS WCAG contrast helpers. App.svelte's parseColor is canvas-based and
|
|
365
|
+
* browser-only; this runs at build time in the linter (rule 1.7). Only opaque
|
|
366
|
+
* #hex (3/4/6/8) is parsed — other CSS color forms and translucent hex return
|
|
367
|
+
* null and fall through to the Tier-2 axe audit, which uses the browser's own
|
|
368
|
+
* parser.
|
|
369
|
+
*/
|
|
370
|
+
function parseHex(input) {
|
|
371
|
+
const v = input.trim();
|
|
372
|
+
const m = /^#([0-9a-fA-F]{3,8})$/.exec(v);
|
|
373
|
+
if (!m) return null;
|
|
374
|
+
const h = m[1];
|
|
375
|
+
let r, g, b, a = 255;
|
|
376
|
+
if (h.length === 3 || h.length === 4) {
|
|
377
|
+
r = parseInt(h[0] + h[0], 16);
|
|
378
|
+
g = parseInt(h[1] + h[1], 16);
|
|
379
|
+
b = parseInt(h[2] + h[2], 16);
|
|
380
|
+
if (h.length === 4) a = parseInt(h[3] + h[3], 16);
|
|
381
|
+
} else if (h.length === 6 || h.length === 8) {
|
|
382
|
+
r = parseInt(h.slice(0, 2), 16);
|
|
383
|
+
g = parseInt(h.slice(2, 4), 16);
|
|
384
|
+
b = parseInt(h.slice(4, 6), 16);
|
|
385
|
+
if (h.length === 8) a = parseInt(h.slice(6, 8), 16);
|
|
386
|
+
} else return null;
|
|
387
|
+
return {
|
|
388
|
+
r,
|
|
389
|
+
g,
|
|
390
|
+
b,
|
|
391
|
+
a
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function linearize(channel) {
|
|
395
|
+
const c = channel / 255;
|
|
396
|
+
return c <= .03928 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
|
|
397
|
+
}
|
|
398
|
+
/** sRGB hex → relative luminance (0–1), or null if not a parseable opaque hex. */
|
|
399
|
+
function relativeLuminance(hex) {
|
|
400
|
+
const rgb = parseHex(hex);
|
|
401
|
+
if (!rgb) return null;
|
|
402
|
+
if (rgb.a !== 255) return null;
|
|
403
|
+
return .2126 * linearize(rgb.r) + .7152 * linearize(rgb.g) + .0722 * linearize(rgb.b);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* WCAG contrast ratio between two colors. Order-independent — the lighter/darker
|
|
407
|
+
* ordering is handled internally, so callers may pass the colors in any order.
|
|
408
|
+
* Returns null if either color isn't a parseable hex.
|
|
409
|
+
*/
|
|
410
|
+
function contrastRatio(a, b) {
|
|
411
|
+
const la = relativeLuminance(a);
|
|
412
|
+
const lb = relativeLuminance(b);
|
|
413
|
+
if (la === null || lb === null) return null;
|
|
414
|
+
const lighter = Math.max(la, lb);
|
|
415
|
+
const darker = Math.min(la, lb);
|
|
416
|
+
return (lighter + .05) / (darker + .05);
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/components/video-embed.ts
|
|
420
|
+
/**
|
|
421
|
+
* Shared YouTube/Vimeo embed detection. Used by Video.svelte to pick the iframe
|
|
422
|
+
* vs native-<video> render path, and by the Tier-1b linter (rule 1.4) so its
|
|
423
|
+
* caption/transcript guidance matches what the component actually renders.
|
|
424
|
+
*/
|
|
425
|
+
const YOUTUBE_RE = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
|
426
|
+
const VIMEO_RE = /vimeo\.com\/(?:video\/)?(\d+)/;
|
|
427
|
+
/** Resolve a source URL to its embed URL, or null if it's not a known embed. */
|
|
428
|
+
function resolveVideoEmbedUrl(src) {
|
|
429
|
+
const yt = src.match(YOUTUBE_RE);
|
|
430
|
+
if (yt) return `https://www.youtube.com/embed/${yt[1]}`;
|
|
431
|
+
const vimeo = src.match(VIMEO_RE);
|
|
432
|
+
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
/** True when the component will render an iframe embed rather than <video>. */
|
|
436
|
+
function isVideoEmbed(src) {
|
|
437
|
+
return resolveVideoEmbedUrl(src) !== null;
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
339
440
|
//#region src/plugin/validation.ts
|
|
441
|
+
/** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
|
|
442
|
+
const A11Y_IDS = {
|
|
443
|
+
imageAlt: "tessera/image-alt",
|
|
444
|
+
mediaTitle: "tessera/media-title",
|
|
445
|
+
mediaTranscript: "tessera/media-transcript",
|
|
446
|
+
mediaCaptions: "tessera/media-captions",
|
|
447
|
+
questionLabel: "tessera/question-label",
|
|
448
|
+
headingOrder: "tessera/heading-order",
|
|
449
|
+
primaryContrast: "tessera/primary-contrast",
|
|
450
|
+
lang: "tessera/lang"
|
|
451
|
+
};
|
|
452
|
+
/** Promotable by `a11y.level: 'error'`; the rest are hard contract errors. */
|
|
453
|
+
const PROMOTABLE_A11Y_IDS = new Set([
|
|
454
|
+
A11Y_IDS.mediaTranscript,
|
|
455
|
+
A11Y_IDS.mediaCaptions,
|
|
456
|
+
A11Y_IDS.questionLabel,
|
|
457
|
+
A11Y_IDS.headingOrder,
|
|
458
|
+
A11Y_IDS.primaryContrast,
|
|
459
|
+
A11Y_IDS.lang
|
|
460
|
+
]);
|
|
461
|
+
/** Prefix a diagnostic with its rule ID so `a11y.ignore` / `level` can match it. */
|
|
462
|
+
function tag(id, message) {
|
|
463
|
+
return `[${id}] ${message}`;
|
|
464
|
+
}
|
|
465
|
+
function diagnosticId(message) {
|
|
466
|
+
const m = /^\[([^\]]+)\] /.exec(message);
|
|
467
|
+
return m ? m[1] : null;
|
|
468
|
+
}
|
|
469
|
+
/** True when a tagged diagnostic's rule ID is in the ignore set. */
|
|
470
|
+
function isIgnored(message, ignore) {
|
|
471
|
+
const id = diagnosticId(message);
|
|
472
|
+
return id !== null && ignore.has(id);
|
|
473
|
+
}
|
|
474
|
+
const VALID_A11Y_LEVELS = ["warn", "error"];
|
|
475
|
+
const VALID_A11Y_STANDARDS = [
|
|
476
|
+
"wcag2a",
|
|
477
|
+
"wcag2aa",
|
|
478
|
+
"wcag21aa"
|
|
479
|
+
];
|
|
480
|
+
/** Normalize the raw `a11y` config to defaults, ignoring malformed pieces. */
|
|
481
|
+
function normalizeA11y(raw) {
|
|
482
|
+
const a11y = raw && typeof raw === "object" ? raw : {};
|
|
483
|
+
return {
|
|
484
|
+
level: a11y.level === "error" ? "error" : "warn",
|
|
485
|
+
standard: VALID_A11Y_STANDARDS.includes(a11y.standard) ? a11y.standard : "wcag2aa",
|
|
486
|
+
ignore: Array.isArray(a11y.ignore) ? a11y.ignore.filter((x) => typeof x === "string") : []
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Apply `a11y.ignore` (drop tagged diagnostics) and `a11y.level` (promote the
|
|
491
|
+
* promotable a11y warnings to errors) to a result in place. `ignore` suppresses
|
|
492
|
+
* at any severity, including hard contract errors; `level` only re-rates.
|
|
493
|
+
*/
|
|
494
|
+
function applyA11ySettings(result, settings) {
|
|
495
|
+
if (settings.ignore.length > 0) {
|
|
496
|
+
const ignored = new Set(settings.ignore);
|
|
497
|
+
const keep = (msg) => !isIgnored(msg, ignored);
|
|
498
|
+
result.errors = result.errors.filter(keep);
|
|
499
|
+
result.warnings = result.warnings.filter(keep);
|
|
500
|
+
}
|
|
501
|
+
if (settings.level === "error") {
|
|
502
|
+
const remaining = [];
|
|
503
|
+
for (const msg of result.warnings) {
|
|
504
|
+
const id = diagnosticId(msg);
|
|
505
|
+
if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) result.errors.push(msg);
|
|
506
|
+
else remaining.push(msg);
|
|
507
|
+
}
|
|
508
|
+
result.warnings = remaining;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
340
511
|
/** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
|
|
341
512
|
function reportValidationIssues({ errors, warnings }) {
|
|
342
513
|
for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
|
|
@@ -347,14 +518,21 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
347
518
|
"description",
|
|
348
519
|
"author",
|
|
349
520
|
"version",
|
|
521
|
+
"language",
|
|
350
522
|
"branding",
|
|
351
523
|
"navigation",
|
|
352
524
|
"completion",
|
|
353
525
|
"scoring",
|
|
354
526
|
"export",
|
|
355
527
|
"chrome",
|
|
356
|
-
"xapi"
|
|
528
|
+
"xapi",
|
|
529
|
+
"a11y"
|
|
357
530
|
]);
|
|
531
|
+
const BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
|
|
532
|
+
/** Plausible BCP-47 tag? Shared by the linter and the <html lang> emitter. */
|
|
533
|
+
function isPlausibleLanguageTag(value) {
|
|
534
|
+
return typeof value === "string" && BCP47_RE.test(value);
|
|
535
|
+
}
|
|
358
536
|
const VALID_NAV_MODES = ["free", "sequential"];
|
|
359
537
|
const VALID_COMPLETION_MODES = [
|
|
360
538
|
"quiz",
|
|
@@ -369,6 +547,8 @@ const VALID_EXPORT_STANDARDS = [
|
|
|
369
547
|
];
|
|
370
548
|
const VALID_MANUAL_TRIGGERS = ["page"];
|
|
371
549
|
const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
|
|
550
|
+
const VALID_FEEDBACK_MODES = FEEDBACK_MODES;
|
|
551
|
+
const VALID_RETRY_MODES = RETRY_MODES;
|
|
372
552
|
/**
|
|
373
553
|
* Validate a Tessera project at the given root.
|
|
374
554
|
* Returns errors (block build) and warnings (informational).
|
|
@@ -384,7 +564,7 @@ function validateProject(projectRoot) {
|
|
|
384
564
|
};
|
|
385
565
|
}
|
|
386
566
|
const config = parseConfig(projectRoot, errors, warnings);
|
|
387
|
-
const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot);
|
|
567
|
+
const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot, config?.export?.standard);
|
|
388
568
|
errors.push(...pageResults.errors);
|
|
389
569
|
warnings.push(...pageResults.warnings);
|
|
390
570
|
for (const shellFile of ["layout.svelte", "quiz.svelte"]) {
|
|
@@ -392,10 +572,12 @@ function validateProject(projectRoot) {
|
|
|
392
572
|
if (existsSync(shellPath)) validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
|
|
393
573
|
}
|
|
394
574
|
if (config) crossValidate(config, pageResults, errors, warnings);
|
|
395
|
-
|
|
575
|
+
const result = {
|
|
396
576
|
errors,
|
|
397
577
|
warnings
|
|
398
578
|
};
|
|
579
|
+
applyA11ySettings(result, normalizeA11y(config?.a11y));
|
|
580
|
+
return result;
|
|
399
581
|
}
|
|
400
582
|
function parseConfig(projectRoot, errors, warnings) {
|
|
401
583
|
const read = readCourseConfig(projectRoot);
|
|
@@ -406,6 +588,13 @@ function parseConfig(projectRoot, errors, warnings) {
|
|
|
406
588
|
}
|
|
407
589
|
const config = read.config;
|
|
408
590
|
for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key)) warnings.push(`course.config.js: unknown field "${key}" — will be ignored`);
|
|
591
|
+
if (config.title !== void 0 && typeof config.title !== "string") errors.push(`course.config.js: "title" must be a string, got ${typeof config.title}`);
|
|
592
|
+
else if (config.title === void 0 || config.title === "") warnings.push("course.config.js: \"title\" is missing or empty — the course will ship as \"Untitled Course\"");
|
|
593
|
+
else if (config.title.trim() === "") warnings.push("course.config.js: \"title\" is only whitespace — it ships verbatim and will not fall back to \"Untitled Course\"");
|
|
594
|
+
if (config.branding !== void 0) validateBranding(config.branding, warnings);
|
|
595
|
+
if (config.language === void 0) warnings.push(tag(A11Y_IDS.lang, `course.config.js: "language" is not set — defaulting <html lang> to "en". Set it to the course's language (BCP-47, e.g. "en", "fr-CA") for WCAG 3.1.1.`));
|
|
596
|
+
else if (!isPlausibleLanguageTag(config.language)) warnings.push(tag(A11Y_IDS.lang, `course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`));
|
|
597
|
+
if (config.a11y !== void 0) validateA11yConfig(config.a11y, errors);
|
|
409
598
|
if (config.navigation?.mode !== void 0) {
|
|
410
599
|
if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
|
|
411
600
|
}
|
|
@@ -434,6 +623,52 @@ function parseConfig(projectRoot, errors, warnings) {
|
|
|
434
623
|
if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", errors, warnings);
|
|
435
624
|
return config;
|
|
436
625
|
}
|
|
626
|
+
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
627
|
+
const FUNC_COLOR_RE = /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(.*\)$/i;
|
|
628
|
+
const NAMED_COLOR_RE = /^[a-zA-Z]+$/;
|
|
629
|
+
function isPlausibleColor(value) {
|
|
630
|
+
const v = value.trim();
|
|
631
|
+
return HEX_COLOR_RE.test(v) || FUNC_COLOR_RE.test(v) || NAMED_COLOR_RE.test(v);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Format checks on the branding block (advisory) plus rule 1.7's contrast check
|
|
635
|
+
* on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
|
|
636
|
+
* <img src>, an unparseable color falls back to theme defaults.
|
|
637
|
+
*/
|
|
638
|
+
function validateBranding(raw, warnings) {
|
|
639
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
640
|
+
warnings.push(`course.config.js: "branding" must be an object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw} — will be ignored`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const branding = raw;
|
|
644
|
+
const logo = branding.logo;
|
|
645
|
+
if (logo !== void 0) {
|
|
646
|
+
if (typeof logo !== "string") warnings.push(`course.config.js: "branding.logo" must be a string, got ${typeof logo}`);
|
|
647
|
+
else if (logo.startsWith("$assets/")) warnings.push("course.config.js: \"branding.logo\" starts with \"$assets/\", but branding paths are not asset-resolved — it will ship as a literal, broken src. Use a URL or a path relative to the deployed root.");
|
|
648
|
+
}
|
|
649
|
+
const primaryColor = branding.primaryColor;
|
|
650
|
+
if (primaryColor !== void 0) if (typeof primaryColor !== "string") warnings.push(`course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`);
|
|
651
|
+
else if (!isPlausibleColor(primaryColor)) warnings.push(`course.config.js: "branding.primaryColor" "${primaryColor}" does not look like a valid CSS color — the theme will fall back to its default shades if the browser can't parse it`);
|
|
652
|
+
else {
|
|
653
|
+
const ratio = contrastRatio(primaryColor, "#ffffff");
|
|
654
|
+
if (ratio !== null && ratio < 4.5) warnings.push(tag(A11Y_IDS.primaryContrast, `course.config.js: branding.primaryColor (${primaryColor}) is ${ratio.toFixed(2)}:1 against white — it's used both for links on the page background and as a button fill behind white text, and WCAG AA needs 4.5:1 for each`));
|
|
655
|
+
}
|
|
656
|
+
const fontFamily = branding.fontFamily;
|
|
657
|
+
if (fontFamily !== void 0 && typeof fontFamily !== "string") warnings.push(`course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`);
|
|
658
|
+
}
|
|
659
|
+
/** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
|
|
660
|
+
function validateA11yConfig(raw, errors) {
|
|
661
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
662
|
+
errors.push(`course.config.js: "a11y" must be an object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw}`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const a11y = raw;
|
|
666
|
+
if (a11y.level !== void 0 && !VALID_A11Y_LEVELS.includes(a11y.level)) errors.push(`course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`);
|
|
667
|
+
if (a11y.standard !== void 0 && !VALID_A11Y_STANDARDS.includes(a11y.standard)) errors.push(`course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`);
|
|
668
|
+
if (a11y.ignore !== void 0) {
|
|
669
|
+
if (!Array.isArray(a11y.ignore) || a11y.ignore.some((x) => typeof x !== "string")) errors.push(`course.config.js: "a11y.ignore" must be an array of rule-ID strings`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
437
672
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
438
673
|
function validateXAPIConfig(raw, standard, errors, warnings) {
|
|
439
674
|
if (raw === void 0 || raw === null) return;
|
|
@@ -532,7 +767,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
532
767
|
if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
|
|
533
768
|
}
|
|
534
769
|
if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
|
|
535
|
-
let isHttp
|
|
770
|
+
let isHttp;
|
|
536
771
|
try {
|
|
537
772
|
const u = new URL(activityId);
|
|
538
773
|
isHttp = u.protocol === "http:" || u.protocol === "https:";
|
|
@@ -552,7 +787,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
552
787
|
* lesson-level pages — the validation is identical, only the containing
|
|
553
788
|
* directory differs.
|
|
554
789
|
*/
|
|
555
|
-
function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache) {
|
|
790
|
+
function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache, exportStandard) {
|
|
556
791
|
const fileRel = relative(projectRoot, filePath);
|
|
557
792
|
const content = readSourceFileCached(filePath);
|
|
558
793
|
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
@@ -564,7 +799,9 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
|
|
|
564
799
|
}
|
|
565
800
|
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
566
801
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
567
|
-
validateQuestionComponents(content, fileRel, errors);
|
|
802
|
+
validateQuestionComponents(content, fileRel, errors, warnings, exportStandard);
|
|
803
|
+
validateMediaComponents(content, fileRel, errors, warnings);
|
|
804
|
+
validateHeadingOrder(content, fileRel, warnings);
|
|
568
805
|
validateContractBypass(content, fileRel, errors);
|
|
569
806
|
if (pageConfig?.quiz && !HAS_USE_QUESTION_RE.test(content) && !HAS_QUESTION_TAG_RE.test(content) && !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)) warnings.push(`${fileRel}: quiz page has no question components or useQuestion() calls — the quiz will have nothing to score`);
|
|
570
807
|
return {
|
|
@@ -579,7 +816,7 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
|
|
|
579
816
|
isGradedQuiz
|
|
580
817
|
};
|
|
581
818
|
}
|
|
582
|
-
function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
819
|
+
function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
583
820
|
const errors = [];
|
|
584
821
|
const warnings = [];
|
|
585
822
|
const pages = [];
|
|
@@ -623,6 +860,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
623
860
|
for (const sectionName of sectionDirs) {
|
|
624
861
|
const sectionPath = resolve(pagesDir, sectionName);
|
|
625
862
|
const sectionRel = relative(projectRoot, sectionPath);
|
|
863
|
+
const pagesBeforeSection = totalPages;
|
|
626
864
|
const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
|
|
627
865
|
const sectionEntries = readdirSync(sectionPath);
|
|
628
866
|
const sectionSvelteFiles = sectionEntries.filter((name) => {
|
|
@@ -637,7 +875,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
637
875
|
}
|
|
638
876
|
}
|
|
639
877
|
for (const fileName of sectionSvelteFiles) {
|
|
640
|
-
const result = validatePageFile(resolve(sectionPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache);
|
|
878
|
+
const result = validatePageFile(resolve(sectionPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
|
|
641
879
|
totalPages++;
|
|
642
880
|
if (result.isQuiz) totalQuizzes++;
|
|
643
881
|
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
@@ -666,13 +904,14 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
666
904
|
}
|
|
667
905
|
}
|
|
668
906
|
for (const fileName of svelteFiles) {
|
|
669
|
-
const result = validatePageFile(resolve(lessonPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache);
|
|
907
|
+
const result = validatePageFile(resolve(lessonPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
|
|
670
908
|
totalPages++;
|
|
671
909
|
if (result.isQuiz) totalQuizzes++;
|
|
672
910
|
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
673
911
|
pages.push(result.page);
|
|
674
912
|
}
|
|
675
913
|
}
|
|
914
|
+
if (totalPages === pagesBeforeSection) warnings.push(`${sectionRel}: section contributed no pages and will be empty`);
|
|
676
915
|
}
|
|
677
916
|
if (totalPages === 0) errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
678
917
|
return {
|
|
@@ -722,6 +961,8 @@ function validateQuizConfig(quiz, fileRel, errors) {
|
|
|
722
961
|
if (val !== Infinity && (typeof val !== "number" || val <= 0 || !Number.isFinite(val))) errors.push(`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`);
|
|
723
962
|
}
|
|
724
963
|
for (const field of ["graded", "gatesProgress"]) if (cfg[field] !== void 0 && typeof cfg[field] !== "boolean") errors.push(`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`);
|
|
964
|
+
if (cfg.feedbackMode !== void 0 && !VALID_FEEDBACK_MODES.includes(cfg.feedbackMode)) errors.push(`${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`);
|
|
965
|
+
if (cfg.retryMode !== void 0 && !VALID_RETRY_MODES.includes(cfg.retryMode)) errors.push(`${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`);
|
|
725
966
|
}
|
|
726
967
|
const QUESTION_COMPONENT_REQUIRED = {
|
|
727
968
|
MultipleChoice: [
|
|
@@ -745,16 +986,24 @@ const QUESTION_COMPONENT_REQUIRED = {
|
|
|
745
986
|
*/
|
|
746
987
|
function parseTagProps(content, start) {
|
|
747
988
|
const props = /* @__PURE__ */ new Map();
|
|
989
|
+
let hasSpread = false;
|
|
748
990
|
let i = start;
|
|
749
991
|
while (i < content.length) {
|
|
750
992
|
while (i < content.length && /\s/.test(content[i])) i++;
|
|
751
993
|
if (i >= content.length) return null;
|
|
752
994
|
const c = content[i];
|
|
753
|
-
if (c === ">") return
|
|
754
|
-
|
|
995
|
+
if (c === ">") return {
|
|
996
|
+
props,
|
|
997
|
+
hasSpread
|
|
998
|
+
};
|
|
999
|
+
if (c === "/" && content[i + 1] === ">") return {
|
|
1000
|
+
props,
|
|
1001
|
+
hasSpread
|
|
1002
|
+
};
|
|
755
1003
|
if (c === "{") {
|
|
756
1004
|
const block = extractObjectLiteral(content, i);
|
|
757
1005
|
if (!block) return null;
|
|
1006
|
+
hasSpread = true;
|
|
758
1007
|
i += block.length;
|
|
759
1008
|
continue;
|
|
760
1009
|
}
|
|
@@ -808,27 +1057,47 @@ function staticNumber(prop) {
|
|
|
808
1057
|
return null;
|
|
809
1058
|
}
|
|
810
1059
|
}
|
|
811
|
-
function validateQuestionComponents(content, fileRel, errors) {
|
|
1060
|
+
function validateQuestionComponents(content, fileRel, errors, warnings, exportStandard) {
|
|
812
1061
|
const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join("|");
|
|
813
1062
|
const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, "g");
|
|
814
1063
|
const seenIds = /* @__PURE__ */ new Set();
|
|
1064
|
+
const seenSanitized = /* @__PURE__ */ new Set();
|
|
815
1065
|
let m;
|
|
816
1066
|
while ((m = tagStartRe.exec(content)) !== null) {
|
|
817
1067
|
const name = m[1];
|
|
818
|
-
const
|
|
819
|
-
if (!
|
|
820
|
-
|
|
1068
|
+
const parsed = parseTagProps(content, m.index + m[0].length);
|
|
1069
|
+
if (!parsed) continue;
|
|
1070
|
+
const { props, hasSpread } = parsed;
|
|
1071
|
+
for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req)) errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
1072
|
+
for (const labelProp of ["options", "answers"]) if (staticArray(props.get(labelProp))?.some((e) => typeof e === "string" && e.trim() === "")) warnings.push(tag(A11Y_IDS.questionLabel, `${fileRel}: <${name}> has an empty ${labelProp === "options" ? "option" : "answer"} label`));
|
|
821
1073
|
const idProp = props.get("id");
|
|
822
1074
|
if (idProp?.kind === "string") {
|
|
823
1075
|
if (seenIds.has(idProp.value)) errors.push(`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`);
|
|
1076
|
+
else if (exportStandard === "scorm12") {
|
|
1077
|
+
const sane = shortIdentifier(idProp.value);
|
|
1078
|
+
if (sane !== idProp.value) warnings.push(`${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`);
|
|
1079
|
+
if (seenSanitized.has(sane)) errors.push(`${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`);
|
|
1080
|
+
seenSanitized.add(sane);
|
|
1081
|
+
}
|
|
824
1082
|
seenIds.add(idProp.value);
|
|
825
1083
|
}
|
|
1084
|
+
const weightProp = props.get("weight");
|
|
1085
|
+
if (weightProp?.kind === "string") warnings.push(`${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`);
|
|
1086
|
+
else {
|
|
1087
|
+
const weight = staticNumber(weightProp);
|
|
1088
|
+
if (weight !== null) {
|
|
1089
|
+
if (!Number.isFinite(weight)) errors.push(`${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`);
|
|
1090
|
+
else if (!(weight > 0)) warnings.push(`${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
826
1093
|
if (name === "MultipleChoice") {
|
|
827
1094
|
const options = staticArray(props.get("options"));
|
|
828
1095
|
const correct = staticNumber(props.get("correct"));
|
|
829
1096
|
if (options && correct !== null) {
|
|
830
1097
|
if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) errors.push(`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`);
|
|
831
1098
|
}
|
|
1099
|
+
const optionFeedback = staticArray(props.get("optionFeedback"));
|
|
1100
|
+
if (options && optionFeedback && optionFeedback.length > options.length) warnings.push(`${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`);
|
|
832
1101
|
} else if (name === "Sorting") {
|
|
833
1102
|
const items = staticArray(props.get("items"));
|
|
834
1103
|
const targets = staticArray(props.get("targets"));
|
|
@@ -854,6 +1123,60 @@ function validateQuestionComponents(content, fileRel, errors) {
|
|
|
854
1123
|
}
|
|
855
1124
|
}
|
|
856
1125
|
}
|
|
1126
|
+
/** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
|
|
1127
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
1128
|
+
/**
|
|
1129
|
+
* Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
|
|
1130
|
+
* so media isn't treated as gradable questions. Declares `warnings` directly.
|
|
1131
|
+
* Non-static (kind 'expr') values are skipped, matching the rest of the linter.
|
|
1132
|
+
*/
|
|
1133
|
+
function validateMediaComponents(content, fileRel, errors, warnings) {
|
|
1134
|
+
const scan = content.replace(HTML_COMMENT_RE, "");
|
|
1135
|
+
const tagStartRe = /<(Image|Video|Audio)(?=[\s/>])/g;
|
|
1136
|
+
let m;
|
|
1137
|
+
while ((m = tagStartRe.exec(scan)) !== null) {
|
|
1138
|
+
const name = m[1];
|
|
1139
|
+
const parsed = parseTagProps(scan, m.index + m[0].length);
|
|
1140
|
+
if (!parsed) continue;
|
|
1141
|
+
const { props, hasSpread } = parsed;
|
|
1142
|
+
if (name === "Image") {
|
|
1143
|
+
const alt = props.get("alt");
|
|
1144
|
+
const decorative = props.get("decorative");
|
|
1145
|
+
if (decorative?.kind === "string") {
|
|
1146
|
+
errors.push(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`));
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const hasDecorative = decorative?.kind === "bool" || decorative?.kind === "expr" && decorative.raw.trim() === "true";
|
|
1150
|
+
const altIsEmpty = alt?.kind === "string" && alt.value.trim() === "";
|
|
1151
|
+
if (!hasDecorative && !hasSpread && (alt === void 0 || altIsEmpty)) errors.push(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`));
|
|
1152
|
+
if (hasDecorative && alt?.kind === "string" && alt.value.trim() !== "") warnings.push(tag(A11Y_IDS.imageAlt, `${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`));
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const title = props.get("title");
|
|
1156
|
+
const titleIsEmpty = title?.kind === "string" && title.value.trim() === "";
|
|
1157
|
+
if (!hasSpread && (title === void 0 || titleIsEmpty)) errors.push(tag(A11Y_IDS.mediaTitle, `${fileRel}: <${name}> needs a title — it's the accessible name for the player`));
|
|
1158
|
+
const src = props.get("src");
|
|
1159
|
+
const isEmbed = src?.kind === "string" && isVideoEmbed(src.value);
|
|
1160
|
+
if (name === "Video" && !hasSpread && isEmbed && props.get("transcript") === void 0) warnings.push(tag(A11Y_IDS.mediaTranscript, `${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`));
|
|
1161
|
+
if (name === "Video" && !hasSpread && src?.kind === "string" && !isEmbed && props.get("tracks") === void 0 && props.get("transcript") === void 0) warnings.push(tag(A11Y_IDS.mediaCaptions, `${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`));
|
|
1162
|
+
if (name === "Audio" && !hasSpread && props.get("transcript") === void 0) warnings.push(tag(A11Y_IDS.mediaTranscript, `${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Warn on a skipped heading level (e.g. h2 → h4). Scripts, styles, and comments
|
|
1167
|
+
* are stripped first so string literals, CSS, and commented-out markup can't be
|
|
1168
|
+
* miscounted. No "one h1 per page" check — the layout owns the page h1 and child
|
|
1169
|
+
* components emit headings a static scan can't see; that belongs to the Tier-2
|
|
1170
|
+
* audit.
|
|
1171
|
+
*/
|
|
1172
|
+
function validateHeadingOrder(content, fileRel, warnings) {
|
|
1173
|
+
const levels = [...content.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "").replace(HTML_COMMENT_RE, "").matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
|
|
1174
|
+
let prevSeen = null;
|
|
1175
|
+
for (const level of levels) {
|
|
1176
|
+
if (prevSeen !== null && level - prevSeen > 1) warnings.push(tag(A11Y_IDS.headingOrder, `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`));
|
|
1177
|
+
prevSeen = level;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
857
1180
|
const QUIZ_COMPLETE_DISPATCH_RE = /(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
|
|
858
1181
|
const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
|
|
859
1182
|
const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
@@ -874,7 +1197,7 @@ function collectAssetRefs(content) {
|
|
|
874
1197
|
const seen = /* @__PURE__ */ new Set();
|
|
875
1198
|
let match;
|
|
876
1199
|
ASSET_REF_RE.lastIndex = 0;
|
|
877
|
-
while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1]);
|
|
1200
|
+
while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1].replace(/[?#].*$/, ""));
|
|
878
1201
|
return [...seen];
|
|
879
1202
|
}
|
|
880
1203
|
function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
@@ -890,6 +1213,7 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
|
890
1213
|
}
|
|
891
1214
|
function crossValidate(config, pageResults, errors, warnings) {
|
|
892
1215
|
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
|
|
1216
|
+
if (config.completion?.mode === "quiz" && config.scoring?.passingScore === void 0) warnings.push("completion.mode is \"quiz\" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.");
|
|
893
1217
|
const isManual = config.completion?.mode === "manual";
|
|
894
1218
|
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
895
1219
|
if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
|
|
@@ -915,6 +1239,6 @@ function crossValidate(config, pageResults, errors, warnings) {
|
|
|
915
1239
|
}
|
|
916
1240
|
}
|
|
917
1241
|
//#endregion
|
|
918
|
-
export {
|
|
1242
|
+
export { validateProject as a, reportValidationIssues as i, isPlausibleLanguageTag as n, generateManifest as o, normalizeA11y as r, readCourseConfig as s, isIgnored as t };
|
|
919
1243
|
|
|
920
|
-
//# sourceMappingURL=validation-
|
|
1244
|
+
//# sourceMappingURL=validation-B-xTvM9B.js.map
|