coursecode 0.1.58 → 0.1.59
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/framework/css/components/accordions.css +117 -31
- package/framework/css/layouts/article.css +7 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
- package/framework/js/components/interactions/drag-drop.js +4 -4
- package/framework/js/components/interactions/fill-in.js +7 -4
- package/framework/js/components/interactions/hotspot.js +2 -2
- package/framework/js/components/interactions/matching.js +4 -3
- package/framework/js/dev/runtime-linter.js +6 -2
- package/framework/js/utilities/portable-assets.js +20 -0
- package/framework/version.json +1 -1
- package/lib/build-linter.js +192 -2
- package/lib/build.js +68 -7
- package/package.json +1 -1
- package/template/course/slides/example-ui-showcase.js +1 -1
|
@@ -247,8 +247,10 @@
|
|
|
247
247
|
ACCORDION STYLE VARIANTS
|
|
248
248
|
============================================================================
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
Global theme tokens are bridged to data attributes on <html>. Authors may
|
|
251
|
+
override that default on an individual .accordion. Keep every variant rule
|
|
252
|
+
scoped through the accordion container so a local attribute wins over an
|
|
253
|
+
inherited global attribute.
|
|
252
254
|
|
|
253
255
|
USAGE:
|
|
254
256
|
<div class="accordion" data-accordion-style="flush">
|
|
@@ -260,33 +262,51 @@
|
|
|
260
262
|
/* --------------------------------------
|
|
261
263
|
Flush Style - No outer border, dividers only
|
|
262
264
|
-------------------------------------- */
|
|
263
|
-
|
|
265
|
+
:is(
|
|
266
|
+
[data-accordion-style="flush"] .accordion:not([data-accordion-style]),
|
|
267
|
+
.accordion[data-accordion-style="flush"]
|
|
268
|
+
) {
|
|
264
269
|
border: none;
|
|
265
270
|
border-radius: 0;
|
|
266
271
|
box-shadow: none;
|
|
267
272
|
background: transparent;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
|
-
|
|
275
|
+
:is(
|
|
276
|
+
[data-accordion-style="flush"] .accordion:not([data-accordion-style]),
|
|
277
|
+
.accordion[data-accordion-style="flush"]
|
|
278
|
+
) .accordion-item {
|
|
271
279
|
background: transparent;
|
|
272
280
|
border-bottom: 1px solid var(--color-gray-200);
|
|
273
281
|
}
|
|
274
282
|
|
|
275
|
-
|
|
283
|
+
:is(
|
|
284
|
+
[data-accordion-style="flush"] .accordion:not([data-accordion-style]),
|
|
285
|
+
.accordion[data-accordion-style="flush"]
|
|
286
|
+
) .accordion-item:last-child {
|
|
276
287
|
border-bottom: 1px solid var(--color-gray-200);
|
|
277
288
|
}
|
|
278
289
|
|
|
279
|
-
|
|
290
|
+
:is(
|
|
291
|
+
[data-accordion-style="flush"] .accordion:not([data-accordion-style]),
|
|
292
|
+
.accordion[data-accordion-style="flush"]
|
|
293
|
+
) .accordion-button {
|
|
280
294
|
padding-left: 0;
|
|
281
295
|
padding-right: 0;
|
|
282
296
|
}
|
|
283
297
|
|
|
284
|
-
|
|
298
|
+
:is(
|
|
299
|
+
[data-accordion-style="flush"] .accordion:not([data-accordion-style]),
|
|
300
|
+
.accordion[data-accordion-style="flush"]
|
|
301
|
+
) .accordion-body {
|
|
285
302
|
padding-left: 0;
|
|
286
303
|
padding-right: 0;
|
|
287
304
|
}
|
|
288
305
|
|
|
289
|
-
|
|
306
|
+
:is(
|
|
307
|
+
[data-accordion-style="flush"] .accordion:not([data-accordion-style]),
|
|
308
|
+
.accordion[data-accordion-style="flush"]
|
|
309
|
+
) .accordion-content {
|
|
290
310
|
background: transparent;
|
|
291
311
|
}
|
|
292
312
|
|
|
@@ -295,7 +315,10 @@
|
|
|
295
315
|
/* --------------------------------------
|
|
296
316
|
Separated Style - Gap between items
|
|
297
317
|
-------------------------------------- */
|
|
298
|
-
|
|
318
|
+
:is(
|
|
319
|
+
[data-accordion-style="separated"] .accordion:not([data-accordion-style]),
|
|
320
|
+
.accordion[data-accordion-style="separated"]
|
|
321
|
+
) {
|
|
299
322
|
border: none;
|
|
300
323
|
border-radius: 0;
|
|
301
324
|
box-shadow: none;
|
|
@@ -305,18 +328,27 @@
|
|
|
305
328
|
gap: var(--space-3);
|
|
306
329
|
}
|
|
307
330
|
|
|
308
|
-
|
|
331
|
+
:is(
|
|
332
|
+
[data-accordion-style="separated"] .accordion:not([data-accordion-style]),
|
|
333
|
+
.accordion[data-accordion-style="separated"]
|
|
334
|
+
) .accordion-item {
|
|
309
335
|
border: 1px solid var(--color-gray-200);
|
|
310
336
|
border-radius: var(--radius-lg);
|
|
311
337
|
overflow: hidden;
|
|
312
338
|
box-shadow: var(--shadow-sm);
|
|
313
339
|
}
|
|
314
340
|
|
|
315
|
-
|
|
341
|
+
:is(
|
|
342
|
+
[data-accordion-style="separated"] .accordion:not([data-accordion-style]),
|
|
343
|
+
.accordion[data-accordion-style="separated"]
|
|
344
|
+
) .accordion-item:last-child {
|
|
316
345
|
border-bottom: 1px solid var(--color-gray-200);
|
|
317
346
|
}
|
|
318
347
|
|
|
319
|
-
|
|
348
|
+
:is(
|
|
349
|
+
[data-accordion-style="separated"] .accordion:not([data-accordion-style]),
|
|
350
|
+
.accordion[data-accordion-style="separated"]
|
|
351
|
+
) .accordion-button:not(.collapsed) {
|
|
320
352
|
border-bottom: none;
|
|
321
353
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
322
354
|
}
|
|
@@ -326,56 +358,86 @@
|
|
|
326
358
|
/* --------------------------------------
|
|
327
359
|
Minimal Style - No borders, subtle hover
|
|
328
360
|
-------------------------------------- */
|
|
329
|
-
|
|
361
|
+
:is(
|
|
362
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
363
|
+
.accordion[data-accordion-style="minimal"]
|
|
364
|
+
) {
|
|
330
365
|
border: none;
|
|
331
366
|
border-radius: 0;
|
|
332
367
|
box-shadow: none;
|
|
333
368
|
background: transparent;
|
|
334
369
|
}
|
|
335
370
|
|
|
336
|
-
|
|
371
|
+
:is(
|
|
372
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
373
|
+
.accordion[data-accordion-style="minimal"]
|
|
374
|
+
) .accordion-item {
|
|
337
375
|
background: transparent;
|
|
338
376
|
border-bottom: none;
|
|
339
377
|
}
|
|
340
378
|
|
|
341
|
-
|
|
379
|
+
:is(
|
|
380
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
381
|
+
.accordion[data-accordion-style="minimal"]
|
|
382
|
+
) .accordion-button {
|
|
342
383
|
padding: var(--space-3) var(--space-4);
|
|
343
384
|
border-radius: var(--radius-md);
|
|
344
385
|
}
|
|
345
386
|
|
|
346
|
-
|
|
387
|
+
:is(
|
|
388
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
389
|
+
.accordion[data-accordion-style="minimal"]
|
|
390
|
+
) .accordion-button:hover {
|
|
347
391
|
background: var(--bg-muted);
|
|
348
392
|
}
|
|
349
393
|
|
|
350
|
-
|
|
394
|
+
:is(
|
|
395
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
396
|
+
.accordion[data-accordion-style="minimal"]
|
|
397
|
+
) .accordion-button:not(.collapsed) {
|
|
351
398
|
background: var(--color-primary-alpha-05);
|
|
352
399
|
border-bottom: none;
|
|
353
400
|
}
|
|
354
401
|
|
|
355
|
-
|
|
402
|
+
:is(
|
|
403
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
404
|
+
.accordion[data-accordion-style="minimal"]
|
|
405
|
+
) .accordion-content {
|
|
356
406
|
background: transparent;
|
|
357
407
|
}
|
|
358
408
|
|
|
359
|
-
|
|
409
|
+
:is(
|
|
410
|
+
[data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
411
|
+
.accordion[data-accordion-style="minimal"]
|
|
412
|
+
) .accordion-body {
|
|
360
413
|
padding: var(--space-2) var(--space-4) var(--space-4);
|
|
361
414
|
}
|
|
362
415
|
|
|
363
416
|
/* Dark mode for minimal */
|
|
364
|
-
|
|
417
|
+
:is(
|
|
418
|
+
[data-theme="dark"][data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
|
|
419
|
+
[data-theme="dark"] .accordion[data-accordion-style="minimal"]
|
|
420
|
+
) .accordion-button:not(.collapsed) {
|
|
365
421
|
background: var(--color-primary-alpha-20);
|
|
366
422
|
}
|
|
367
423
|
|
|
368
424
|
/* --------------------------------------
|
|
369
425
|
Boxed Style - Each item is a bordered box
|
|
370
426
|
-------------------------------------- */
|
|
371
|
-
|
|
427
|
+
:is(
|
|
428
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
429
|
+
.accordion[data-accordion-style="boxed"]
|
|
430
|
+
) {
|
|
372
431
|
border: none;
|
|
373
432
|
border-radius: 0;
|
|
374
433
|
box-shadow: none;
|
|
375
434
|
background: transparent;
|
|
376
435
|
}
|
|
377
436
|
|
|
378
|
-
|
|
437
|
+
:is(
|
|
438
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
439
|
+
.accordion[data-accordion-style="boxed"]
|
|
440
|
+
) .accordion-item {
|
|
379
441
|
border: 2px solid var(--color-gray-200);
|
|
380
442
|
border-radius: var(--radius-lg);
|
|
381
443
|
margin-bottom: var(--space-3);
|
|
@@ -383,38 +445,62 @@
|
|
|
383
445
|
background: var(--bg-surface);
|
|
384
446
|
}
|
|
385
447
|
|
|
386
|
-
|
|
448
|
+
:is(
|
|
449
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
450
|
+
.accordion[data-accordion-style="boxed"]
|
|
451
|
+
) .accordion-item:last-child {
|
|
387
452
|
margin-bottom: 0;
|
|
388
453
|
}
|
|
389
454
|
|
|
390
|
-
|
|
455
|
+
:is(
|
|
456
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
457
|
+
.accordion[data-accordion-style="boxed"]
|
|
458
|
+
) .accordion-button {
|
|
391
459
|
background: var(--bg-subtle);
|
|
392
460
|
}
|
|
393
461
|
|
|
394
|
-
|
|
462
|
+
:is(
|
|
463
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
464
|
+
.accordion[data-accordion-style="boxed"]
|
|
465
|
+
) .accordion-button:hover {
|
|
395
466
|
background: var(--bg-muted);
|
|
396
467
|
}
|
|
397
468
|
|
|
398
|
-
|
|
469
|
+
:is(
|
|
470
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
471
|
+
.accordion[data-accordion-style="boxed"]
|
|
472
|
+
) .accordion-button:not(.collapsed) {
|
|
399
473
|
background: var(--color-primary);
|
|
400
474
|
color: var(--color-white);
|
|
401
475
|
border-bottom: none;
|
|
402
476
|
}
|
|
403
477
|
|
|
404
|
-
|
|
478
|
+
:is(
|
|
479
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
480
|
+
.accordion[data-accordion-style="boxed"]
|
|
481
|
+
) .accordion-button:not(.collapsed) .accordion-icon {
|
|
405
482
|
color: var(--color-white);
|
|
406
483
|
}
|
|
407
484
|
|
|
408
|
-
|
|
485
|
+
:is(
|
|
486
|
+
[data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
487
|
+
.accordion[data-accordion-style="boxed"]
|
|
488
|
+
) .accordion-content {
|
|
409
489
|
border-top: 2px solid var(--color-gray-200);
|
|
410
490
|
}
|
|
411
491
|
|
|
412
492
|
/* Dark mode for boxed */
|
|
413
|
-
|
|
493
|
+
:is(
|
|
494
|
+
[data-theme="dark"][data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
495
|
+
[data-theme="dark"] .accordion[data-accordion-style="boxed"]
|
|
496
|
+
) .accordion-item {
|
|
414
497
|
border-color: var(--border-default);
|
|
415
498
|
}
|
|
416
499
|
|
|
417
|
-
|
|
500
|
+
:is(
|
|
501
|
+
[data-theme="dark"][data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
|
|
502
|
+
[data-theme="dark"] .accordion[data-accordion-style="boxed"]
|
|
503
|
+
) .accordion-content {
|
|
418
504
|
border-top-color: var(--border-default);
|
|
419
505
|
}
|
|
420
506
|
|
|
@@ -707,4 +793,4 @@
|
|
|
707
793
|
[data-theme="dark"] .accordion-badge-outline {
|
|
708
794
|
border-color: rgba(var(--color-primary-rgb), 0.4);
|
|
709
795
|
color: var(--color-primary-light);
|
|
710
|
-
}
|
|
796
|
+
}
|
|
@@ -101,6 +101,13 @@
|
|
|
101
101
|
margin: 0 auto;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/* Breadcrumbs are positioned absolutely and do not reserve layout space.
|
|
105
|
+
Add clearance only while they are visible; mobile hides them entirely. */
|
|
106
|
+
html:not(.bp-max-mobile-landscape)[data-layout="article"]
|
|
107
|
+
.breadcrumbs:not(.hidden):not([hidden]) ~ #slide-container {
|
|
108
|
+
padding-top: var(--space-4);
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
/* Footer styling moved to components/footer.css - DATA-LAYOUT VARIANTS section */
|
|
105
112
|
|
|
106
113
|
/* Audio player styling moved to components/audio-player.css - DATA-LAYOUT VARIANTS section */
|
|
@@ -618,7 +618,7 @@ createNumericQuestion({
|
|
|
618
618
|
```javascript
|
|
619
619
|
createHotspotQuestion({
|
|
620
620
|
id: 'hot1', prompt: 'Click the correct region:',
|
|
621
|
-
image: { src: 'assets/images/diagram.png', alt: 'Diagram' },
|
|
621
|
+
image: { src: 'assets/images/diagram.png', alt: 'Diagram' }, // Relative to course/assets/
|
|
622
622
|
hotspots: [
|
|
623
623
|
// pos = [x%, y%, width%, height%]
|
|
624
624
|
{ id: 'zone1', pos: [10, 20, 15, 10], correct: true, label: 'Correct Zone', feedback: 'Good choice!' },
|
|
@@ -627,6 +627,8 @@ createHotspotQuestion({
|
|
|
627
627
|
});
|
|
628
628
|
```
|
|
629
629
|
|
|
630
|
+
Interaction APIs such as `createHotspotQuestion()` resolve relative asset paths against `course/assets/`. In direct HTML attributes such as `<img src>`, use the packaged path `course/assets/...` as shown in the interactive-image example below. Declarative components may document their own path base; for example, lightbox `href` values are relative to `course/` and normally begin with `assets/`.
|
|
631
|
+
|
|
630
632
|
**Appearance themes**: `'correct'`, `'incorrect'`, `'primary'`, `'accent'` — or provide custom `appearance` object.
|
|
631
633
|
|
|
632
634
|
### Sequencing
|
|
@@ -1477,7 +1479,7 @@ parent.postMessage({ type: 'coursecode:resize', height: 400 }, '*');
|
|
|
1477
1479
|
|
|
1478
1480
|
```html
|
|
1479
1481
|
<div data-component="interactive-image" id="diagram">
|
|
1480
|
-
<img src="assets/images/diagram.png" alt="Diagram" />
|
|
1482
|
+
<img src="course/assets/images/diagram.png" alt="Diagram" />
|
|
1481
1483
|
<button data-hotspot-id="zone1" data-title="Component A" data-body="Description here..."
|
|
1482
1484
|
class="interactive-image-hotspot" style="top: 20%; left: 30%;">A</button>
|
|
1483
1485
|
<button data-hotspot-id="zone2" data-title="Component B" data-body="More details..."
|
|
@@ -77,8 +77,7 @@ export const schema = {
|
|
|
77
77
|
description: 'Draggable items',
|
|
78
78
|
itemSchema: {
|
|
79
79
|
id: { type: 'string', required: true },
|
|
80
|
-
|
|
81
|
-
correctZone: { type: 'string', required: true }
|
|
80
|
+
content: { type: 'string', required: true }
|
|
82
81
|
}
|
|
83
82
|
},
|
|
84
83
|
dropZones: {
|
|
@@ -88,7 +87,9 @@ export const schema = {
|
|
|
88
87
|
description: 'Drop target zones',
|
|
89
88
|
itemSchema: {
|
|
90
89
|
id: { type: 'string', required: true },
|
|
91
|
-
label: { type: 'string', required: true }
|
|
90
|
+
label: { type: 'string', required: true },
|
|
91
|
+
accepts: { type: 'array', required: true, minItems: 1 },
|
|
92
|
+
maxItems: { type: 'number' }
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -881,4 +882,3 @@ function removeItemFromZone(container, itemId) {
|
|
|
881
882
|
// Update state
|
|
882
883
|
delete state.placements[itemId];
|
|
883
884
|
}
|
|
884
|
-
|
|
@@ -137,10 +137,13 @@ export const schema = {
|
|
|
137
137
|
type: 'object',
|
|
138
138
|
required: true,
|
|
139
139
|
description: 'Map of blank IDs to their correct answers',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
mapValueSchema: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
valueSchema: {
|
|
143
|
+
correct: { type: ['string', 'array'], required: true },
|
|
144
|
+
typoTolerance: { type: 'number', default: 0 },
|
|
145
|
+
hint: { type: 'string' }
|
|
146
|
+
}
|
|
144
147
|
}
|
|
145
148
|
},
|
|
146
149
|
template: {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
registerCoreInteraction
|
|
11
11
|
} from './interaction-base.js';
|
|
12
12
|
import { logger } from '../../utilities/logger.js';
|
|
13
|
+
import { resolveCourseAssetUrl } from '../../utilities/portable-assets.js';
|
|
13
14
|
|
|
14
15
|
// Default appearance themes for hotspots
|
|
15
16
|
const HOTSPOT_DEFAULTS = {
|
|
@@ -282,7 +283,7 @@ export function createHotspotQuestion(config) {
|
|
|
282
283
|
<div class="hotspot-container">
|
|
283
284
|
<div class="image-container relative">
|
|
284
285
|
<img
|
|
285
|
-
src="${image.src}"
|
|
286
|
+
src="${resolveCourseAssetUrl(image.src)}"
|
|
286
287
|
alt="${image.alt || ''}"
|
|
287
288
|
class="w-full h-auto"
|
|
288
289
|
id="${id}_image"
|
|
@@ -699,4 +700,3 @@ function toNumber(value, fallback = 0) {
|
|
|
699
700
|
}
|
|
700
701
|
return 0;
|
|
701
702
|
}
|
|
702
|
-
|
|
@@ -94,10 +94,11 @@ export const schema = {
|
|
|
94
94
|
type: 'array',
|
|
95
95
|
required: true,
|
|
96
96
|
minItems: 2,
|
|
97
|
-
description: '
|
|
97
|
+
description: 'Items and their matching target text',
|
|
98
98
|
itemSchema: {
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
id: { type: 'string', required: true },
|
|
100
|
+
text: { type: 'string', required: true },
|
|
101
|
+
match: { type: 'string', required: true }
|
|
101
102
|
}
|
|
102
103
|
},
|
|
103
104
|
feedbackMode: {
|
|
@@ -42,7 +42,7 @@ const DYNAMIC_CLASSES = new Set([
|
|
|
42
42
|
// Component-internal classes — styled via [data-component] selectors in individual component CSS files
|
|
43
43
|
'intro-card', 'card-icon',
|
|
44
44
|
// Interaction-internal classes — used by interaction JS for DOM structure
|
|
45
|
-
'drag-drop', 'matching-items', 'matching-targets',
|
|
45
|
+
'drag-drop', 'matching-items', 'matching-targets', 'image-container', 'hotspot-feedback',
|
|
46
46
|
// Slide-specific JS selectors — queried by slide scripts for event binding
|
|
47
47
|
'resources', 'complete-remedial-btn',
|
|
48
48
|
]);
|
|
@@ -833,6 +833,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
|
|
|
833
833
|
const textElements = renderedContent.querySelectorAll('p, span:not(.accordion-icon), li, a, h1, h2, h3, h4, h5, h6, button');
|
|
834
834
|
for (const el of textElements) {
|
|
835
835
|
if (isLintIgnored(el, 'contrast')) continue;
|
|
836
|
+
if (el.classList.contains('sr-only') || el.closest('.sr-only')) continue;
|
|
836
837
|
// Skip elements that are not visible or have no text
|
|
837
838
|
if (el.offsetParent === null || el.textContent.trim() === '') continue;
|
|
838
839
|
|
|
@@ -1042,7 +1043,8 @@ function validateTextProximityToVisualElements(slideId, renderedContent, warning
|
|
|
1042
1043
|
'dropdown', 'dropdown-menu', 'dropdown-item',
|
|
1043
1044
|
'table', // Tables manage their own cell spacing
|
|
1044
1045
|
'step-number', 'step-content', 'step', // Pattern-steps elements have intentional circular styling
|
|
1045
|
-
'btn-link' // Link-styled button intentionally has minimal padding
|
|
1046
|
+
'btn-link', // Link-styled button intentionally has minimal padding
|
|
1047
|
+
'hotspot-area', 'interactive-image-hotspot'
|
|
1046
1048
|
]);
|
|
1047
1049
|
|
|
1048
1050
|
// HTML elements that manage their own spacing (exclude from checks)
|
|
@@ -1171,6 +1173,8 @@ function validateElementOverlap(slideId, renderedContent, warnings) {
|
|
|
1171
1173
|
const absoluteElements = renderedContent.querySelectorAll('[style*="position:absolute"], [style*="position: absolute"]');
|
|
1172
1174
|
for (const el of absoluteElements) {
|
|
1173
1175
|
if (el.offsetParent === null) continue;
|
|
1176
|
+
// Hotspots intentionally overlap their background image and sometimes each other.
|
|
1177
|
+
if (el.matches('[data-hotspot-id], .hotspot-area, .interactive-image-hotspot')) continue;
|
|
1174
1178
|
|
|
1175
1179
|
const rect = el.getBoundingClientRect();
|
|
1176
1180
|
const elText = el.textContent.trim().substring(0, 30);
|
|
@@ -72,6 +72,26 @@ export function resolvePortableAssetUrl(value) {
|
|
|
72
72
|
return value;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Resolve an author-facing course asset path for both normal and portable builds.
|
|
77
|
+
* Component APIs may use paths relative to course/assets (for example,
|
|
78
|
+
* "images/diagram.svg") or the legacy "assets/..." form documented by older
|
|
79
|
+
* CourseCode releases. Direct HTML should use "course/assets/...".
|
|
80
|
+
* @param {string} value
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function resolveCourseAssetUrl(value) {
|
|
84
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
85
|
+
|
|
86
|
+
const portableValue = resolvePortableAssetUrl(value);
|
|
87
|
+
if (portableValue !== value) return portableValue;
|
|
88
|
+
if (/^(?:data:|blob:|https?:|mailto:|tel:|#|\/\/)/i.test(value)) return value;
|
|
89
|
+
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) return value;
|
|
90
|
+
if (value.startsWith('course/assets/')) return `./${value}`;
|
|
91
|
+
if (value.startsWith('assets/')) return `./course/${value}`;
|
|
92
|
+
return `./course/assets/${value}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
/**
|
|
76
96
|
* Rewrite URL-bearing attributes in freshly rendered slide DOM before
|
|
77
97
|
* declarative components initialize and begin loading media.
|
package/framework/version.json
CHANGED
package/lib/build-linter.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { parseSlideSource, extractAssessment } from './course-parser.js';
|
|
15
|
-
import { getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
|
|
15
|
+
import { getAllIcons, getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
|
|
16
16
|
import { getValidCssClasses, lintCssSelectors } from './css-index.js';
|
|
17
17
|
import {
|
|
18
18
|
parseSlideNarration,
|
|
@@ -51,7 +51,7 @@ const DYNAMIC_CLASSES = new Set([
|
|
|
51
51
|
// Component-internal classes — styled via [data-component] selectors in individual component CSS files
|
|
52
52
|
'intro-card', 'card-icon',
|
|
53
53
|
// Interaction-internal classes — used by interaction JS for DOM structure
|
|
54
|
-
'drag-drop', 'matching-items', 'matching-targets',
|
|
54
|
+
'drag-drop', 'matching-items', 'matching-targets', 'image-container', 'hotspot-feedback',
|
|
55
55
|
// Slide-specific JS selectors — queried by slide scripts for event binding
|
|
56
56
|
'resources', 'complete-remedial-btn',
|
|
57
57
|
]);
|
|
@@ -93,6 +93,7 @@ export async function lintCourse(courseConfig, coursePath) {
|
|
|
93
93
|
slideFilesOnDisk
|
|
94
94
|
);
|
|
95
95
|
warnings.push(...globalWarnings);
|
|
96
|
+
validateMenuIcons(courseConfig, coursePath, warnings);
|
|
96
97
|
|
|
97
98
|
// Build valid CSS class index once for all slides
|
|
98
99
|
const validCssIndex = getValidCssClasses();
|
|
@@ -219,8 +220,12 @@ async function validateSlide(slide, coursePath, objectiveIds, errors, warnings,
|
|
|
219
220
|
if (interaction.id) {
|
|
220
221
|
registerInteractionId(interaction.id, slide.id, 'DOM Interaction', interactionIdRegistry, errors);
|
|
221
222
|
}
|
|
223
|
+
validateInteractionSchema(slide.id, interaction, errors);
|
|
224
|
+
validateInteractionAssetReferences(slide.id, interaction, coursePath, errors);
|
|
222
225
|
}
|
|
223
226
|
|
|
227
|
+
validateDirectAssetReferences(slide.id, slideData.elements || [], coursePath, errors);
|
|
228
|
+
|
|
224
229
|
// Static CSS class validation — checks class attributes in source template
|
|
225
230
|
validateCssClassesStatic(slide.id, source, validCssIndex, warnings);
|
|
226
231
|
|
|
@@ -244,6 +249,191 @@ function validateRequirementContent(slideId, requirement, slideData, engagementT
|
|
|
244
249
|
}
|
|
245
250
|
}
|
|
246
251
|
|
|
252
|
+
function schemaTypeMatches(value, expectedType) {
|
|
253
|
+
if (Array.isArray(expectedType)) {
|
|
254
|
+
return expectedType.some(type => schemaTypeMatches(value, type));
|
|
255
|
+
}
|
|
256
|
+
if (expectedType === 'array') return Array.isArray(value);
|
|
257
|
+
if (expectedType === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
258
|
+
if (expectedType === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
259
|
+
return typeof value === expectedType;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateSchemaValue(reference, value, definition, errors, enforceRequiredChildren = true) {
|
|
263
|
+
if (!definition || value === undefined) return;
|
|
264
|
+
|
|
265
|
+
if (definition.type && !schemaTypeMatches(value, definition.type)) {
|
|
266
|
+
const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
267
|
+
const expectedType = Array.isArray(definition.type) ? definition.type.join(' or ') : definition.type;
|
|
268
|
+
errors.push(`${reference} must be ${expectedType}, got ${actualType}.`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (definition.enum && !definition.enum.includes(value)) {
|
|
273
|
+
errors.push(`${reference} must be one of: ${definition.enum.join(', ')}.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
if (definition.minItems != null && value.length < definition.minItems) {
|
|
278
|
+
errors.push(`${reference} must contain at least ${definition.minItems} item(s).`);
|
|
279
|
+
}
|
|
280
|
+
if (definition.itemSchema) {
|
|
281
|
+
value.forEach((item, index) => {
|
|
282
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return;
|
|
283
|
+
for (const [key, childDefinition] of Object.entries(definition.itemSchema)) {
|
|
284
|
+
if (childDefinition.required && !Object.hasOwn(item, key)) {
|
|
285
|
+
errors.push(`${reference}[${index}] is missing required property "${key}".`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
validateSchemaValue(`${reference}[${index}].${key}`, item[key], childDefinition, errors);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (definition.type === 'object' && definition.valueSchema) {
|
|
295
|
+
for (const [key, childDefinition] of Object.entries(definition.valueSchema)) {
|
|
296
|
+
if (enforceRequiredChildren && childDefinition.required && !Object.hasOwn(value, key)) {
|
|
297
|
+
errors.push(`${reference} is missing required property "${key}".`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
validateSchemaValue(`${reference}.${key}`, value[key], childDefinition, errors);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (definition.type === 'object' && definition.mapValueSchema) {
|
|
305
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
306
|
+
validateSchemaValue(`${reference}.${key}`, childValue, definition.mapValueSchema, errors);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validate statically parsed interaction values against the registered schema.
|
|
313
|
+
* Top-level missing values are skipped because they may be supplied by a runtime
|
|
314
|
+
* variable that the lightweight source parser cannot resolve. Known literal
|
|
315
|
+
* values, nested objects, and array items are validated fully.
|
|
316
|
+
*/
|
|
317
|
+
export function validateInteractionSchema(slideId, interaction, errors) {
|
|
318
|
+
const properties = interaction?.schema?.properties;
|
|
319
|
+
if (!properties) return;
|
|
320
|
+
|
|
321
|
+
for (const [key, definition] of Object.entries(properties)) {
|
|
322
|
+
if (!Object.hasOwn(interaction, key)) continue;
|
|
323
|
+
validateSchemaValue(`Slide "${slideId}" interaction "${interaction.id}" property "${key}"`, interaction[key], definition, errors);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const DIRECT_ASSET_ATTRIBUTES = new Set([
|
|
328
|
+
'src', 'href', 'poster', 'data-src', 'data-audio-src', 'data-video-src',
|
|
329
|
+
'data-video-poster', 'data-video-captions', 'data-lightbox-src',
|
|
330
|
+
'data-lightbox-thumbnail', 'data-md-src'
|
|
331
|
+
]);
|
|
332
|
+
const NATIVE_ASSET_ATTRIBUTES = new Set(['src', 'href', 'poster']);
|
|
333
|
+
|
|
334
|
+
function isExternalAssetReference(reference) {
|
|
335
|
+
return /^(?:data:|blob:|https?:|mailto:|tel:|#|\/\/|@)/i.test(reference);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function assetPathForReference(reference, coursePath, directHtml) {
|
|
339
|
+
if (typeof reference !== 'string' || !reference || reference.includes('${')) return null;
|
|
340
|
+
if (isExternalAssetReference(reference)) return null;
|
|
341
|
+
|
|
342
|
+
const clean = reference.split('#', 1)[0].split('?', 1)[0].replace(/\\/g, '/');
|
|
343
|
+
const withoutDot = clean.replace(/^\.\//, '');
|
|
344
|
+
|
|
345
|
+
if (directHtml && withoutDot.startsWith('assets/')) {
|
|
346
|
+
return {
|
|
347
|
+
error: `Direct HTML asset path "${reference}" will not exist in LMS packages. Use "course/${withoutDot}".`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let relativePath = null;
|
|
352
|
+
if (withoutDot.startsWith('course/assets/')) {
|
|
353
|
+
relativePath = withoutDot.slice('course/assets/'.length);
|
|
354
|
+
} else if (!directHtml && withoutDot.startsWith('assets/')) {
|
|
355
|
+
relativePath = withoutDot.slice('assets/'.length);
|
|
356
|
+
} else if (!directHtml && !withoutDot.startsWith('/') && !withoutDot.startsWith('../')) {
|
|
357
|
+
relativePath = withoutDot;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!relativePath) return null;
|
|
361
|
+
return { filePath: path.join(coursePath, 'assets', relativePath), relativePath };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateAssetReference(slideId, reference, coursePath, directHtml, errors) {
|
|
365
|
+
const resolved = assetPathForReference(reference, coursePath, directHtml);
|
|
366
|
+
if (!resolved) return;
|
|
367
|
+
if (resolved.error) {
|
|
368
|
+
errors.push(`Slide "${slideId}": ${resolved.error}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!fs.existsSync(resolved.filePath)) {
|
|
372
|
+
errors.push(`Slide "${slideId}": local course asset not found: course/assets/${resolved.relativePath}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function validateDirectAssetReferences(slideId, elements, coursePath, errors) {
|
|
377
|
+
for (const element of elements || []) {
|
|
378
|
+
for (const [attribute, value] of Object.entries(element.attributes || {})) {
|
|
379
|
+
if (!DIRECT_ASSET_ATTRIBUTES.has(attribute)) continue;
|
|
380
|
+
const componentOwnsHref = attribute === 'href' && element.attributes?.['data-component'] === 'lightbox';
|
|
381
|
+
validateAssetReference(
|
|
382
|
+
slideId,
|
|
383
|
+
value,
|
|
384
|
+
coursePath,
|
|
385
|
+
NATIVE_ASSET_ATTRIBUTES.has(attribute) && !componentOwnsHref,
|
|
386
|
+
errors
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function validateInteractionAssetReferences(slideId, interaction, coursePath, errors) {
|
|
393
|
+
const visit = (value, key = '') => {
|
|
394
|
+
if (!value || typeof value !== 'object') return;
|
|
395
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
396
|
+
if (childKey === 'schema') continue;
|
|
397
|
+
if (childKey === 'src' && typeof childValue === 'string') {
|
|
398
|
+
validateAssetReference(slideId, childValue, coursePath, false, errors);
|
|
399
|
+
} else if (childValue && typeof childValue === 'object') {
|
|
400
|
+
visit(childValue, childKey || key);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
visit(interaction);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function customIconNames(coursePath) {
|
|
408
|
+
const iconFile = path.join(coursePath, 'icons.js');
|
|
409
|
+
if (!fs.existsSync(iconFile)) return [];
|
|
410
|
+
const source = fs.readFileSync(iconFile, 'utf-8');
|
|
411
|
+
const declaration = source.match(/export\s+const\s+customIcons\s*=\s*\{([\s\S]*?)\n\};/);
|
|
412
|
+
if (!declaration) return [];
|
|
413
|
+
return [...declaration[1].matchAll(/^\s*['"]([^'"]+)['"]\s*:/gm)].map(match => match[1]);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isEmojiIcon(value) {
|
|
417
|
+
return /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1FA00}-\u{1FAFF}]/u.test(value);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function validateMenuIcons(courseConfig, coursePath, warnings) {
|
|
421
|
+
const validIcons = new Set([...Object.keys(getAllIcons()), ...customIconNames(coursePath)]);
|
|
422
|
+
const inspectIcon = (icon, owner) => {
|
|
423
|
+
if (typeof icon !== 'string' || !icon || validIcons.has(icon) || isEmojiIcon(icon)) return;
|
|
424
|
+
warnings.push(`Unknown menu icon "${icon}" on ${owner}. Add it to course/icons.js or use a registered icon.`);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const walk = items => {
|
|
428
|
+
for (const item of items || []) {
|
|
429
|
+
inspectIcon(item.menu?.icon, `${item.type || 'item'} "${item.id || item.title || 'unknown'}"`);
|
|
430
|
+
if (item.children) walk(item.children);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
walk(courseConfig.structure);
|
|
434
|
+
inspectIcon(courseConfig.navigation?.documentGallery?.icon, 'navigation.documentGallery');
|
|
435
|
+
}
|
|
436
|
+
|
|
247
437
|
/**
|
|
248
438
|
* Static CSS class validation — extracts class="..." values from slide source
|
|
249
439
|
* and checks them against the valid CSS class index built from PostCSS.
|
package/lib/build.js
CHANGED
|
@@ -9,6 +9,69 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import { validateProject } from './project-utils.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
export function snapshotZipFiles(directory) {
|
|
14
|
+
const snapshot = new Map();
|
|
15
|
+
if (!fs.existsSync(directory)) return snapshot;
|
|
16
|
+
|
|
17
|
+
for (const filename of fs.readdirSync(directory)) {
|
|
18
|
+
if (!filename.endsWith('.zip')) continue;
|
|
19
|
+
const stats = fs.statSync(path.join(directory, filename));
|
|
20
|
+
snapshot.set(filename, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findProducedZipFiles(directory, before = new Map()) {
|
|
27
|
+
if (!fs.existsSync(directory)) return [];
|
|
28
|
+
|
|
29
|
+
return fs.readdirSync(directory)
|
|
30
|
+
.filter(filename => filename.endsWith('.zip'))
|
|
31
|
+
.map(filename => {
|
|
32
|
+
const stats = fs.statSync(path.join(directory, filename));
|
|
33
|
+
return { filename, mtimeMs: stats.mtimeMs, size: stats.size };
|
|
34
|
+
})
|
|
35
|
+
.filter(file => {
|
|
36
|
+
const previous = before.get(file.filename);
|
|
37
|
+
return !previous || previous.mtimeMs !== file.mtimeMs || previous.size !== file.size;
|
|
38
|
+
})
|
|
39
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs || a.filename.localeCompare(b.filename))
|
|
40
|
+
.map(file => file.filename);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function detectBuiltFormat(distDirectory, requestedFormat) {
|
|
44
|
+
const indexPath = path.join(distDirectory, 'index.html');
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(indexPath)) {
|
|
47
|
+
const html = fs.readFileSync(indexPath, 'utf8');
|
|
48
|
+
const match = html.match(/<meta\s+name=["']lms-format["']\s+content=["']([^"']+)["']/i);
|
|
49
|
+
if (match?.[1]) return match[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(path.join(distDirectory, 'cmi5.xml'))) return 'cmi5';
|
|
53
|
+
if (fs.existsSync(path.join(distDirectory, 'lti-tool-config.json'))) return 'lti';
|
|
54
|
+
|
|
55
|
+
return requestedFormat || 'scorm2004';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatBuildOutput(format, zipFiles = []) {
|
|
59
|
+
const normalizedFormat = format || 'scorm2004';
|
|
60
|
+
const displayNames = {
|
|
61
|
+
scorm2004: 'SCORM 2004',
|
|
62
|
+
'scorm1.2': 'SCORM 1.2',
|
|
63
|
+
cmi5: 'cmi5',
|
|
64
|
+
lti: 'LTI 1.3',
|
|
65
|
+
'scorm2004-proxy': 'SCORM 2004 proxy',
|
|
66
|
+
'scorm1.2-proxy': 'SCORM 1.2 proxy',
|
|
67
|
+
'cmi5-remote': 'cmi5 remote'
|
|
68
|
+
};
|
|
69
|
+
const displayName = displayNames[normalizedFormat] || normalizedFormat;
|
|
70
|
+
const archives = zipFiles.map(filename => ` - ${filename} Ready for LMS upload`).join('\n');
|
|
71
|
+
|
|
72
|
+
return ` Output:\n - dist/ ${displayName} package files${archives ? `\n${archives}` : ''}\n\n To test locally, launch dist/ or the archive in a ${displayName}-compatible LMS or conformance tool.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
12
75
|
/**
|
|
13
76
|
* Run a command and return a promise
|
|
14
77
|
*/
|
|
@@ -42,6 +105,7 @@ export async function build(options = {}) {
|
|
|
42
105
|
`);
|
|
43
106
|
|
|
44
107
|
const startTime = Date.now();
|
|
108
|
+
const zipSnapshot = snapshotZipFiles(process.cwd());
|
|
45
109
|
|
|
46
110
|
try {
|
|
47
111
|
// Build environment — pass lib dir so vite.config.js can resolve coursecode utilities
|
|
@@ -64,17 +128,14 @@ export async function build(options = {}) {
|
|
|
64
128
|
|
|
65
129
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
66
130
|
|
|
67
|
-
|
|
68
|
-
const
|
|
131
|
+
const zipFiles = findProducedZipFiles(process.cwd(), zipSnapshot);
|
|
132
|
+
const builtFormat = detectBuiltFormat(path.join(process.cwd(), 'dist'), options.format);
|
|
133
|
+
const output = formatBuildOutput(builtFormat, zipFiles);
|
|
69
134
|
|
|
70
135
|
console.log(`
|
|
71
136
|
✅ Build completed in ${elapsed}s
|
|
72
137
|
|
|
73
|
-
|
|
74
|
-
- dist/ SCORM package files
|
|
75
|
-
${zipFiles.length > 0 ? `- ${zipFiles[zipFiles.length - 1]} Ready for LMS upload` : ''}
|
|
76
|
-
|
|
77
|
-
To test locally, load dist/ directory in a SCORM testing tool.
|
|
138
|
+
${output}
|
|
78
139
|
`);
|
|
79
140
|
|
|
80
141
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1386,7 +1386,7 @@ export const slide = {
|
|
|
1386
1386
|
<div class="flex gap-4 flex-wrap items-start justify-between">
|
|
1387
1387
|
<a href="assets/docs/example_md_1.md" data-component="lightbox" data-lightbox-caption="Sample Document" data-lightbox-subtitle="Sample Document" style="width: 200px; height: 150px;"></a>
|
|
1388
1388
|
<a href="assets/docs/example_md_2.md" data-component="lightbox" data-lightbox-caption="Reference Table" data-lightbox-subtitle="Reference Table" style="width: 200px; height: 150px;"></a>
|
|
1389
|
-
<a href="assets/docs/
|
|
1389
|
+
<a href="assets/docs/example_pdf_2.pdf" data-component="lightbox" data-lightbox-caption="Quick Reference Guide" data-lightbox-thumbnail="assets/docs/example_pdf_1_thumbnail.png" data-lightbox-subtitle="Quick Reference Guide" style="width: 200px; height: 150px;"></a>
|
|
1390
1390
|
</div>
|
|
1391
1391
|
</section>
|
|
1392
1392
|
|