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
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
weight = 1,
|
|
20
20
|
} = $props();
|
|
21
21
|
|
|
22
|
-
let queue = $state([]);
|
|
23
|
-
|
|
24
|
-
let dragOver = $state(null);
|
|
22
|
+
let queue = $state([]); // item indices not yet placed; queue[0] is current
|
|
23
|
+
const placements = new SvelteMap(); // itemIdx → targetIdx
|
|
24
|
+
let dragOver = $state(null); // target index highlighted during drag
|
|
25
25
|
let isDragging = $state(false);
|
|
26
|
-
let cardSelected = $state(false);
|
|
26
|
+
let cardSelected = $state(false); // current card selected via tap/click
|
|
27
27
|
|
|
28
28
|
function initQueue() {
|
|
29
29
|
queue = shuffle(items.map((_, i) => i));
|
|
30
|
-
placements
|
|
30
|
+
placements.clear();
|
|
31
31
|
cardSelected = false;
|
|
32
32
|
dragOver = null;
|
|
33
33
|
isDragging = false;
|
|
@@ -50,12 +50,21 @@
|
|
|
50
50
|
// cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
|
|
51
51
|
// pairs as stringified ids.
|
|
52
52
|
const q = useQuestion({
|
|
53
|
-
get id() {
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
get id() {
|
|
54
|
+
return id ?? `sorting-${slugFromQuestion(question)}`;
|
|
55
|
+
},
|
|
56
|
+
get weight() {
|
|
57
|
+
return weight;
|
|
58
|
+
},
|
|
59
|
+
get maxRetries() {
|
|
60
|
+
return maxRetries;
|
|
61
|
+
},
|
|
56
62
|
response: () => ({
|
|
57
63
|
type: 'matching',
|
|
58
|
-
response: [...placements.entries()].map(([i, t]) => [
|
|
64
|
+
response: [...placements.entries()].map(([i, t]) => [
|
|
65
|
+
String(i),
|
|
66
|
+
String(t),
|
|
67
|
+
]),
|
|
59
68
|
correct: items.map((_, i) => [String(i), String(correct[i])]),
|
|
60
69
|
}),
|
|
61
70
|
reset: resetState,
|
|
@@ -165,7 +174,6 @@
|
|
|
165
174
|
onTargetClick(targetIdx);
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
|
-
|
|
169
177
|
</script>
|
|
170
178
|
|
|
171
179
|
{#snippet sortingContent()}
|
|
@@ -198,7 +206,9 @@
|
|
|
198
206
|
{queue.length} of {items.length} to sort
|
|
199
207
|
</p>
|
|
200
208
|
{#if cardSelected}
|
|
201
|
-
<p class="tessera-sorting-hint">
|
|
209
|
+
<p class="tessera-sorting-hint">
|
|
210
|
+
Click a target below to place this card
|
|
211
|
+
</p>
|
|
202
212
|
{/if}
|
|
203
213
|
</div>
|
|
204
214
|
{:else}
|
|
@@ -210,8 +220,11 @@
|
|
|
210
220
|
{/if}
|
|
211
221
|
|
|
212
222
|
<!-- Drop targets -->
|
|
213
|
-
<div
|
|
214
|
-
|
|
223
|
+
<div
|
|
224
|
+
class="tessera-sorting-targets"
|
|
225
|
+
class:targets-active={cardSelected && !q.locked}
|
|
226
|
+
>
|
|
227
|
+
{#each targets as targetLabel, targetIdx (targetIdx)}
|
|
215
228
|
{@const targetItems = getItemsForTarget(targetIdx)}
|
|
216
229
|
<div
|
|
217
230
|
class="tessera-sorting-target"
|
|
@@ -220,7 +233,9 @@
|
|
|
220
233
|
role="button"
|
|
221
234
|
tabindex="0"
|
|
222
235
|
aria-disabled={!(cardSelected && !q.locked)}
|
|
223
|
-
aria-label="Target: {targetLabel}{cardSelected && !q.locked
|
|
236
|
+
aria-label="Target: {targetLabel}{cardSelected && !q.locked
|
|
237
|
+
? ` (activate to place ${items[currentItemIdx]})`
|
|
238
|
+
: ''}"
|
|
224
239
|
ondragover={(e) => onDragOver(e, targetIdx)}
|
|
225
240
|
ondragleave={onDragLeave}
|
|
226
241
|
ondrop={(e) => onDrop(e, targetIdx)}
|
|
@@ -230,19 +245,23 @@
|
|
|
230
245
|
<div class="tessera-sorting-target-label">{targetLabel}</div>
|
|
231
246
|
{#if targetItems.length > 0}
|
|
232
247
|
<div class="tessera-sorting-target-items">
|
|
233
|
-
{#each targetItems as itemIdx}
|
|
248
|
+
{#each targetItems as itemIdx (itemIdx)}
|
|
234
249
|
<div
|
|
235
250
|
class="tessera-sorting-placed-item"
|
|
236
251
|
class:correct={q.feedbackVisible && isCorrectPlacement(itemIdx)}
|
|
237
|
-
class:incorrect={q.feedbackVisible &&
|
|
252
|
+
class:incorrect={q.feedbackVisible &&
|
|
253
|
+
!isCorrectPlacement(itemIdx)}
|
|
238
254
|
>
|
|
239
255
|
<span class="tessera-sorting-item-text">{items[itemIdx]}</span>
|
|
240
256
|
{#if !q.locked}
|
|
241
257
|
<button
|
|
242
258
|
class="tessera-sorting-remove"
|
|
243
259
|
aria-label="Return '{items[itemIdx]}' to deck"
|
|
244
|
-
onclick={(e) => {
|
|
245
|
-
|
|
260
|
+
onclick={(e) => {
|
|
261
|
+
e.stopPropagation();
|
|
262
|
+
returnCard(itemIdx);
|
|
263
|
+
}}>×</button
|
|
264
|
+
>
|
|
246
265
|
{:else if q.feedbackVisible}
|
|
247
266
|
<span class="tessera-sorting-item-icon" aria-hidden="true">
|
|
248
267
|
{isCorrectPlacement(itemIdx) ? '✓' : '✗'}
|
|
@@ -275,8 +294,10 @@
|
|
|
275
294
|
</div>
|
|
276
295
|
<div class="tessera-sorting-correct-list">
|
|
277
296
|
<p class="tessera-sorting-correct-title">Correct arrangement:</p>
|
|
278
|
-
{#each items as item, i}
|
|
279
|
-
<p class="tessera-sorting-correct-item">
|
|
297
|
+
{#each items as item, i (i)}
|
|
298
|
+
<p class="tessera-sorting-correct-item">
|
|
299
|
+
{item} → {targets[correct[i]]}
|
|
300
|
+
</p>
|
|
280
301
|
{/each}
|
|
281
302
|
</div>
|
|
282
303
|
{#if incorrectFeedback}
|
|
@@ -292,7 +313,10 @@
|
|
|
292
313
|
<!-- Standalone Check button (shown once all cards are placed) -->
|
|
293
314
|
{#if !inQuiz && !q.submitted && placements.size === items.length}
|
|
294
315
|
<div class="tessera-sorting-actions">
|
|
295
|
-
<button
|
|
316
|
+
<button
|
|
317
|
+
class="tessera-btn-primary tessera-sorting-check"
|
|
318
|
+
onclick={() => q.submit()}
|
|
319
|
+
>
|
|
296
320
|
Check Answer
|
|
297
321
|
</button>
|
|
298
322
|
</div>
|
|
@@ -359,7 +383,10 @@
|
|
|
359
383
|
font-family: var(--tessera-font-family);
|
|
360
384
|
color: var(--tessera-text);
|
|
361
385
|
cursor: grab;
|
|
362
|
-
transition:
|
|
386
|
+
transition:
|
|
387
|
+
border-color 0.15s,
|
|
388
|
+
transform 0.15s,
|
|
389
|
+
box-shadow 0.15s;
|
|
363
390
|
text-align: center;
|
|
364
391
|
user-select: none;
|
|
365
392
|
}
|
|
@@ -424,7 +451,9 @@
|
|
|
424
451
|
border: 2px dashed var(--tessera-border);
|
|
425
452
|
border-radius: 10px;
|
|
426
453
|
background: var(--tessera-bg-secondary);
|
|
427
|
-
transition:
|
|
454
|
+
transition:
|
|
455
|
+
border-color 0.15s,
|
|
456
|
+
background 0.15s;
|
|
428
457
|
overflow: hidden;
|
|
429
458
|
display: flex;
|
|
430
459
|
flex-direction: column;
|
|
@@ -478,7 +507,9 @@
|
|
|
478
507
|
font-size: 0.875rem;
|
|
479
508
|
font-family: var(--tessera-font-family);
|
|
480
509
|
color: var(--tessera-text);
|
|
481
|
-
transition:
|
|
510
|
+
transition:
|
|
511
|
+
border-color 0.15s,
|
|
512
|
+
background 0.15s;
|
|
482
513
|
pointer-events: all;
|
|
483
514
|
}
|
|
484
515
|
|
|
@@ -550,8 +581,12 @@
|
|
|
550
581
|
margin-bottom: var(--tessera-spacing-sm);
|
|
551
582
|
}
|
|
552
583
|
|
|
553
|
-
.tessera-sorting-result.correct {
|
|
554
|
-
|
|
584
|
+
.tessera-sorting-result.correct {
|
|
585
|
+
color: var(--tessera-success);
|
|
586
|
+
}
|
|
587
|
+
.tessera-sorting-result.incorrect {
|
|
588
|
+
color: var(--tessera-error);
|
|
589
|
+
}
|
|
555
590
|
|
|
556
591
|
.tessera-sorting-correct-list {
|
|
557
592
|
margin: var(--tessera-spacing-sm) 0;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @component Transcript
|
|
4
|
+
* Shared transcript disclosure for Audio/Video (WCAG 1.2). Renders nothing
|
|
5
|
+
* when empty.
|
|
6
|
+
*
|
|
7
|
+
* @prop {string} [text] - Transcript text shown in a <details> disclosure.
|
|
8
|
+
*/
|
|
9
|
+
let { text = '' } = $props();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
{#if text}
|
|
13
|
+
<details class="tessera-transcript">
|
|
14
|
+
<summary>Transcript</summary>
|
|
15
|
+
<div class="tessera-transcript-body">{text}</div>
|
|
16
|
+
</details>
|
|
17
|
+
{/if}
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.tessera-transcript {
|
|
21
|
+
margin-top: var(--tessera-spacing-sm);
|
|
22
|
+
margin-bottom: var(--tessera-spacing-lg);
|
|
23
|
+
font-size: 0.875rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.tessera-transcript summary {
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
color: var(--tessera-text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.tessera-transcript-body {
|
|
33
|
+
margin-top: var(--tessera-spacing-sm);
|
|
34
|
+
color: var(--tessera-text-light);
|
|
35
|
+
white-space: pre-line;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
@@ -5,29 +5,26 @@
|
|
|
5
5
|
* Lazy-loads via IntersectionObserver.
|
|
6
6
|
*
|
|
7
7
|
* @prop {string} src - Video URL (YouTube, Vimeo, direct video file, or $assets/ path)
|
|
8
|
-
* @prop {string}
|
|
8
|
+
* @prop {string} title - Accessible label for the video (required; rule 1.4)
|
|
9
|
+
* @prop {Array<{ src: string, kind?: 'captions'|'subtitles', srclang?: string, label?: string }>} [tracks] -
|
|
10
|
+
* Caption/subtitle tracks for native (non-embed) video, rendered as <track>.
|
|
11
|
+
* Ignored for YouTube/Vimeo embeds — the platform owns their captions.
|
|
12
|
+
* @prop {string} [transcript] - Transcript text shown in a <details> disclosure
|
|
13
|
+
* below the player. To load it from a file, import the file with Vite's ?raw
|
|
14
|
+
* suffix: `import t from '$assets/x.txt?raw'` then `transcript={t}`.
|
|
9
15
|
*/
|
|
10
16
|
import { onMount } from 'svelte';
|
|
11
17
|
import { resolveAsset } from './util.js';
|
|
18
|
+
import { resolveVideoEmbedUrl } from './video-embed.js';
|
|
19
|
+
import MediaTracks from './MediaTracks.svelte';
|
|
20
|
+
import Transcript from './Transcript.svelte';
|
|
12
21
|
|
|
13
|
-
let { src, title = '' } = $props();
|
|
22
|
+
let { src, title, tracks = [], transcript = '' } = $props();
|
|
14
23
|
let resolvedSrc = $derived(resolveAsset(src));
|
|
15
24
|
let containerRef = $state(null);
|
|
16
25
|
let visible = $state(false);
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
const vimeoRegex = /vimeo\.com\/(?:video\/)?(\d+)/;
|
|
20
|
-
|
|
21
|
-
let embedUrl = $derived.by(() => {
|
|
22
|
-
const ytMatch = src.match(youtubeRegex);
|
|
23
|
-
if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`;
|
|
24
|
-
|
|
25
|
-
const vimeoMatch = src.match(vimeoRegex);
|
|
26
|
-
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
|
27
|
-
|
|
28
|
-
return null;
|
|
29
|
-
});
|
|
30
|
-
|
|
27
|
+
let embedUrl = $derived(resolveVideoEmbedUrl(src));
|
|
31
28
|
let isEmbed = $derived(embedUrl !== null);
|
|
32
29
|
|
|
33
30
|
onMount(() => {
|
|
@@ -40,7 +37,7 @@
|
|
|
40
37
|
observer.disconnect();
|
|
41
38
|
}
|
|
42
39
|
},
|
|
43
|
-
{ rootMargin: '200px' }
|
|
40
|
+
{ rootMargin: '200px' },
|
|
44
41
|
);
|
|
45
42
|
|
|
46
43
|
observer.observe(containerRef);
|
|
@@ -48,7 +45,11 @@
|
|
|
48
45
|
});
|
|
49
46
|
</script>
|
|
50
47
|
|
|
51
|
-
<div
|
|
48
|
+
<div
|
|
49
|
+
class="tessera-video"
|
|
50
|
+
bind:this={containerRef}
|
|
51
|
+
aria-label={title || 'Video'}
|
|
52
|
+
>
|
|
52
53
|
{#if visible}
|
|
53
54
|
{#if isEmbed}
|
|
54
55
|
<div class="tessera-video-embed">
|
|
@@ -61,9 +62,9 @@
|
|
|
61
62
|
></iframe>
|
|
62
63
|
</div>
|
|
63
64
|
{:else}
|
|
64
|
-
<!-- svelte-ignore a11y_media_has_caption -->
|
|
65
65
|
<video controls class="tessera-video-native" aria-label={title}>
|
|
66
66
|
<source src={resolvedSrc} />
|
|
67
|
+
<MediaTracks {tracks} />
|
|
67
68
|
Your browser does not support the video element.
|
|
68
69
|
</video>
|
|
69
70
|
{/if}
|
|
@@ -74,6 +75,8 @@
|
|
|
74
75
|
{/if}
|
|
75
76
|
</div>
|
|
76
77
|
|
|
78
|
+
<Transcript text={transcript} />
|
|
79
|
+
|
|
77
80
|
<style>
|
|
78
81
|
.tessera-video {
|
|
79
82
|
margin-bottom: var(--tessera-spacing-lg);
|
package/src/components/util.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export function resolveAsset(src: string): string {
|
|
10
10
|
if (!src) return src;
|
|
11
|
-
return src.startsWith('$assets/')
|
|
11
|
+
return src.startsWith('$assets/')
|
|
12
|
+
? src.replace('$assets/', './assets/')
|
|
13
|
+
: src;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared YouTube/Vimeo embed detection. Used by Video.svelte to pick the iframe
|
|
3
|
+
* vs native-<video> render path, and by the Tier-1b linter (rule 1.4) so its
|
|
4
|
+
* caption/transcript guidance matches what the component actually renders.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const YOUTUBE_RE =
|
|
8
|
+
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
|
9
|
+
const VIMEO_RE = /vimeo\.com\/(?:video\/)?(\d+)/;
|
|
10
|
+
|
|
11
|
+
/** Resolve a source URL to its embed URL, or null if it's not a known embed. */
|
|
12
|
+
export function resolveVideoEmbedUrl(src: string): string | null {
|
|
13
|
+
const yt = src.match(YOUTUBE_RE);
|
|
14
|
+
if (yt) return `https://www.youtube.com/embed/${yt[1]}`;
|
|
15
|
+
|
|
16
|
+
const vimeo = src.match(VIMEO_RE);
|
|
17
|
+
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
|
18
|
+
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True when the component will render an iframe embed rather than <video>. */
|
|
23
|
+
export function isVideoEmbed(src: string): boolean {
|
|
24
|
+
return resolveVideoEmbedUrl(src) !== null;
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,10 +18,7 @@ export {
|
|
|
18
18
|
sequentialAccess,
|
|
19
19
|
resolveAccess,
|
|
20
20
|
} from './runtime/access.js';
|
|
21
|
-
export type {
|
|
22
|
-
AccessFn,
|
|
23
|
-
AccessContext,
|
|
24
|
-
} from './runtime/access.js';
|
|
21
|
+
export type { AccessFn, AccessContext } from './runtime/access.js';
|
|
25
22
|
|
|
26
23
|
// ---- xAPI ----
|
|
27
24
|
export { useXAPI } from './runtime/xapi/registry.js';
|
|
@@ -40,9 +37,7 @@ export type {
|
|
|
40
37
|
} from './runtime/xapi/types.js';
|
|
41
38
|
|
|
42
39
|
// ---- Types ----
|
|
43
|
-
export type {
|
|
44
|
-
Interaction,
|
|
45
|
-
} from './runtime/interaction.js';
|
|
40
|
+
export type { Interaction } from './runtime/interaction.js';
|
|
46
41
|
export { isCorrect } from './runtime/interaction.js';
|
|
47
42
|
export type {
|
|
48
43
|
UseQuestionOptions,
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { generateManifest, readCourseConfig } from '../manifest.js';
|
|
4
|
+
import { normalizeA11y, type A11ySettings } from '../validation.js';
|
|
5
|
+
|
|
6
|
+
export interface AuditOptions {
|
|
7
|
+
/** Minimum violation impact that fails the run (CI gate). Default 'serious'. */
|
|
8
|
+
threshold?: ImpactLevel;
|
|
9
|
+
/** Force a fresh `vite build` even if dist/ exists. */
|
|
10
|
+
rebuild?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ImpactLevel = 'minor' | 'moderate' | 'serious' | 'critical';
|
|
14
|
+
|
|
15
|
+
const IMPACT_RANK: Record<ImpactLevel, number> = {
|
|
16
|
+
minor: 1,
|
|
17
|
+
moderate: 2,
|
|
18
|
+
serious: 3,
|
|
19
|
+
critical: 4,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Set by runAudit during its build/preview; the plugin forces the WebAdapter,
|
|
23
|
+
// skips export packaging, and stubs xAPI while it's set. See plugin/index.ts.
|
|
24
|
+
export const AUDIT_ENV_FLAG = 'TESSERA_A11Y_AUDIT';
|
|
25
|
+
|
|
26
|
+
interface AxeViolation {
|
|
27
|
+
id: string;
|
|
28
|
+
impact: ImpactLevel | null;
|
|
29
|
+
help: string;
|
|
30
|
+
helpUrl: string;
|
|
31
|
+
nodes: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PageAuditResult {
|
|
35
|
+
index: number;
|
|
36
|
+
title: string;
|
|
37
|
+
violations: AxeViolation[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface AuditReport {
|
|
41
|
+
standard: A11ySettings['standard'];
|
|
42
|
+
threshold: ImpactLevel;
|
|
43
|
+
pages: PageAuditResult[];
|
|
44
|
+
totalViolations: number;
|
|
45
|
+
failingViolations: number;
|
|
46
|
+
passed: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Map the `a11y.standard` enum to axe's cumulative `runOnly` tag list. */
|
|
50
|
+
export function axeTags(standard: A11ySettings['standard']): string[] {
|
|
51
|
+
switch (standard) {
|
|
52
|
+
case 'wcag2a':
|
|
53
|
+
return ['wcag2a'];
|
|
54
|
+
case 'wcag21aa':
|
|
55
|
+
return ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
|
|
56
|
+
case 'wcag2aa':
|
|
57
|
+
default:
|
|
58
|
+
return ['wcag2a', 'wcag2aa'];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** axe-applicable ignore entries: drop the Tier-1a/1b namespaces. */
|
|
63
|
+
export function axeIgnoreRules(ignore: string[]): string[] {
|
|
64
|
+
return ignore.filter(
|
|
65
|
+
(id) => !id.startsWith('tessera/') && !id.startsWith('a11y_'),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A violation with no impact is treated as failing rather than slipping the
|
|
70
|
+
// gate at every threshold.
|
|
71
|
+
function isFailing(v: AxeViolation, thresholdRank: number): boolean {
|
|
72
|
+
return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Optional deps loaded by variable specifier so tsc doesn't require them to be
|
|
76
|
+
// installed — Tier 2 is opt-in and the absence is handled with a clear message.
|
|
77
|
+
async function tryImport(specifier: string): Promise<unknown> {
|
|
78
|
+
return import(specifier);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface LoadedDeps {
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
chromium: any;
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
AxeBuilder: any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadDeps(): Promise<
|
|
89
|
+
{ ok: true; deps: LoadedDeps } | { ok: false; missing: string }
|
|
90
|
+
> {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
let chromium: any;
|
|
93
|
+
for (const spec of ['playwright', '@playwright/test']) {
|
|
94
|
+
try {
|
|
95
|
+
const mod = (await tryImport(spec)) as { chromium?: unknown };
|
|
96
|
+
if (mod.chromium) {
|
|
97
|
+
chromium = mod.chromium;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// try the next specifier
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!chromium) return { ok: false, missing: 'playwright' };
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const mod = (await tryImport('@axe-core/playwright')) as {
|
|
108
|
+
default?: unknown;
|
|
109
|
+
};
|
|
110
|
+
if (!mod.default) return { ok: false, missing: '@axe-core/playwright' };
|
|
111
|
+
return { ok: true, deps: { chromium, AxeBuilder: mod.default } };
|
|
112
|
+
} catch {
|
|
113
|
+
return { ok: false, missing: '@axe-core/playwright' };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run the Tier-2 runtime accessibility audit against a built course. Builds (or
|
|
119
|
+
* reuses) dist/, serves it, drives Playwright + axe-core over each page, writes
|
|
120
|
+
* a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).
|
|
121
|
+
*/
|
|
122
|
+
export async function runAudit(
|
|
123
|
+
projectRoot: string,
|
|
124
|
+
options: AuditOptions = {},
|
|
125
|
+
): Promise<number> {
|
|
126
|
+
const threshold: ImpactLevel = options.threshold ?? 'serious';
|
|
127
|
+
|
|
128
|
+
const deps = await loadDeps();
|
|
129
|
+
if (!deps.ok) {
|
|
130
|
+
console.error(
|
|
131
|
+
`\x1b[31m[tessera a11y]\x1b[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n` +
|
|
132
|
+
` Install them to run the runtime audit:\n` +
|
|
133
|
+
` npm i -D playwright @axe-core/playwright\n` +
|
|
134
|
+
` npx playwright install chromium`,
|
|
135
|
+
);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
const { chromium, AxeBuilder } = deps.deps;
|
|
139
|
+
|
|
140
|
+
const read = readCourseConfig(projectRoot);
|
|
141
|
+
const settings = normalizeA11y(read.ok ? read.config.a11y : undefined);
|
|
142
|
+
const tags = axeTags(settings.standard);
|
|
143
|
+
const disableRules = axeIgnoreRules(settings.ignore);
|
|
144
|
+
|
|
145
|
+
const manifest = generateManifest(resolve(projectRoot, 'pages'));
|
|
146
|
+
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
148
|
+
const vite = (await import('vite')) as any;
|
|
149
|
+
|
|
150
|
+
// A throwaway web build, kept out of dist/ so a real LMS export is untouched.
|
|
151
|
+
const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
|
|
152
|
+
const distHtml = resolve(auditDist, 'index.html');
|
|
153
|
+
|
|
154
|
+
const prevEnv = process.env[AUDIT_ENV_FLAG];
|
|
155
|
+
process.env[AUDIT_ENV_FLAG] = '1';
|
|
156
|
+
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
let server: any;
|
|
159
|
+
try {
|
|
160
|
+
if (options.rebuild || !existsSync(distHtml)) {
|
|
161
|
+
console.log('[tessera a11y] Building course…');
|
|
162
|
+
await vite.build({
|
|
163
|
+
root: projectRoot,
|
|
164
|
+
build: { outDir: auditDist, emptyOutDir: true },
|
|
165
|
+
logLevel: 'warn',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
server = await vite.preview({
|
|
170
|
+
root: projectRoot,
|
|
171
|
+
build: { outDir: auditDist },
|
|
172
|
+
preview: { port: 0, host: '127.0.0.1' },
|
|
173
|
+
logLevel: 'warn',
|
|
174
|
+
});
|
|
175
|
+
const baseUrl: string | undefined = server.resolvedUrls?.local?.[0];
|
|
176
|
+
if (!baseUrl) {
|
|
177
|
+
console.error('[tessera a11y] Could not determine preview server URL.');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const browser = await chromium.launch();
|
|
182
|
+
const pages: PageAuditResult[] = [];
|
|
183
|
+
try {
|
|
184
|
+
// axe-core/playwright requires a page from an explicit context.
|
|
185
|
+
const context = await browser.newContext();
|
|
186
|
+
const page = await context.newPage();
|
|
187
|
+
// ?__tessera_audit unlocks navigation so quiz-gated pages can be scanned.
|
|
188
|
+
const auditUrl = new URL(baseUrl);
|
|
189
|
+
auditUrl.searchParams.set('__tessera_audit', '1');
|
|
190
|
+
await page.goto(auditUrl.href, { waitUntil: 'networkidle' });
|
|
191
|
+
await page.waitForSelector('#tessera-app', { timeout: 20_000 });
|
|
192
|
+
|
|
193
|
+
const scan = async (): Promise<AxeViolation[]> => {
|
|
194
|
+
const builder = new AxeBuilder({ page }).withTags(tags);
|
|
195
|
+
if (disableRules.length > 0) builder.disableRules(disableRules);
|
|
196
|
+
const out = await builder.analyze();
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
198
|
+
return out.violations.map((v: any) => ({
|
|
199
|
+
id: v.id,
|
|
200
|
+
impact: v.impact ?? null,
|
|
201
|
+
help: v.help,
|
|
202
|
+
helpUrl: v.helpUrl,
|
|
203
|
+
nodes: v.nodes.length,
|
|
204
|
+
}));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const navCount = await page.locator('button.tessera-nav-page').count();
|
|
208
|
+
if (navCount === 0) {
|
|
209
|
+
// No sidebar (custom chrome) — audit whatever is rendered at the entry.
|
|
210
|
+
pages.push({
|
|
211
|
+
index: 0,
|
|
212
|
+
title: manifest.pages[0]?.title ?? '(entry)',
|
|
213
|
+
violations: await scan(),
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
for (let i = 0; i < navCount; i++) {
|
|
217
|
+
const btn = page.locator('button.tessera-nav-page').nth(i);
|
|
218
|
+
const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
|
|
219
|
+
await btn.click();
|
|
220
|
+
await page.waitForFunction(
|
|
221
|
+
(idx: number) =>
|
|
222
|
+
document
|
|
223
|
+
.querySelectorAll('button.tessera-nav-page')
|
|
224
|
+
[idx]?.getAttribute('aria-current') === 'page',
|
|
225
|
+
i,
|
|
226
|
+
{ timeout: 20_000 },
|
|
227
|
+
);
|
|
228
|
+
await page.waitForLoadState('networkidle');
|
|
229
|
+
pages.push({ index: i, title, violations: await scan() });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
await browser.close();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const thresholdRank = IMPACT_RANK[threshold];
|
|
237
|
+
let totalViolations = 0;
|
|
238
|
+
let failingViolations = 0;
|
|
239
|
+
for (const p of pages) {
|
|
240
|
+
for (const v of p.violations) {
|
|
241
|
+
totalViolations++;
|
|
242
|
+
if (isFailing(v, thresholdRank)) failingViolations++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const report: AuditReport = {
|
|
247
|
+
standard: settings.standard,
|
|
248
|
+
threshold,
|
|
249
|
+
pages,
|
|
250
|
+
totalViolations,
|
|
251
|
+
failingViolations,
|
|
252
|
+
passed: failingViolations === 0,
|
|
253
|
+
};
|
|
254
|
+
const reportPath = resolve(projectRoot, 'a11y-report.json');
|
|
255
|
+
writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
256
|
+
|
|
257
|
+
printSummary(report, reportPath);
|
|
258
|
+
return report.passed ? 0 : 1;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(
|
|
261
|
+
`\x1b[31m[tessera a11y]\x1b[0m Audit could not complete: ${
|
|
262
|
+
err instanceof Error ? err.message : String(err)
|
|
263
|
+
}`,
|
|
264
|
+
);
|
|
265
|
+
return 1;
|
|
266
|
+
} finally {
|
|
267
|
+
server?.httpServer?.close?.();
|
|
268
|
+
if (prevEnv === undefined) delete process.env[AUDIT_ENV_FLAG];
|
|
269
|
+
else process.env[AUDIT_ENV_FLAG] = prevEnv;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function printSummary(report: AuditReport, reportPath: string): void {
|
|
274
|
+
const thresholdRank = IMPACT_RANK[report.threshold];
|
|
275
|
+
for (const p of report.pages) {
|
|
276
|
+
if (p.violations.length === 0) {
|
|
277
|
+
console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const failing = p.violations.some((v) => isFailing(v, thresholdRank));
|
|
281
|
+
const mark = failing ? '\x1b[31m ✗\x1b[0m' : '\x1b[33m ⚠\x1b[0m';
|
|
282
|
+
console.log(`${mark} ${p.title}`);
|
|
283
|
+
for (const v of p.violations) {
|
|
284
|
+
console.log(
|
|
285
|
+
` [${v.impact ?? 'n/a'}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? '' : 's'})`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log(`\n[tessera a11y] Report written to ${reportPath}`);
|
|
290
|
+
if (report.passed) {
|
|
291
|
+
console.log(
|
|
292
|
+
`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`,
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
console.log(
|
|
296
|
+
`\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|