coursecode 0.1.58 → 0.1.60
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/components/buttons.css +2 -2
- package/framework/css/design-tokens.css +1 -0
- package/framework/css/layouts/article.css +7 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +6 -2
- package/framework/js/automation/api-interactions.js +4 -1
- 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 +7 -15
- package/framework/js/components/interactions/interaction-base.js +69 -4
- package/framework/js/components/interactions/matching.js +4 -3
- package/framework/js/components/ui-components/interactive-image.js +8 -2
- package/framework/js/dev/runtime-linter.js +29 -14
- package/framework/js/utilities/portable-assets.js +20 -0
- package/framework/version.json +2 -2
- package/lib/build-linter.js +192 -2
- package/lib/build.js +68 -7
- package/lib/headless-browser.js +8 -3
- package/lib/info.js +15 -3
- package/lib/upgrade.js +59 -8
- 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
|
+
}
|
|
@@ -166,14 +166,14 @@
|
|
|
166
166
|
.btn-success,
|
|
167
167
|
.btn-check {
|
|
168
168
|
background: var(--color-success);
|
|
169
|
-
color: var(--color-
|
|
169
|
+
color: var(--color-on-success);
|
|
170
170
|
border-color: var(--color-success);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
.btn-success:hover:not(:disabled),
|
|
174
174
|
.btn-check:hover:not(:disabled) {
|
|
175
175
|
background: var(--color-success-hover);
|
|
176
|
-
color: var(--color-
|
|
176
|
+
color: var(--color-on-success);
|
|
177
177
|
border-color: var(--color-success-hover);
|
|
178
178
|
box-shadow: var(--shadow-md);
|
|
179
179
|
transform: translateY(var(--btn-hover-translate-y));
|
|
@@ -153,6 +153,7 @@
|
|
|
153
153
|
--color-success-light: color-mix(in srgb, var(--palette-green) 4%, var(--palette-white));
|
|
154
154
|
--color-success-hover: color-mix(in srgb, var(--palette-green) 80%, var(--palette-black));
|
|
155
155
|
--color-success-text: color-mix(in srgb, var(--palette-green) 85%, var(--palette-black));
|
|
156
|
+
--color-on-success: var(--color-white); /* Text/icon color on success-filled backgrounds */
|
|
156
157
|
|
|
157
158
|
--color-partial: var(--palette-amber); /* Partial credit feedback */
|
|
158
159
|
|
|
@@ -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 */
|
|
@@ -102,6 +102,8 @@ Test your course with a stub LMS wrapper.
|
|
|
102
102
|
|
|
103
103
|
Runs Vite build in watch mode + stub LMS server. Changes to source files trigger automatic rebuilds:
|
|
104
104
|
|
|
105
|
+
> **MCP-first workflow for AI agents:** Start by calling `coursecode_state`. If it connects, reuse that running preview for the entire authoring session. After editing course files, rely on preview hot reload and use `coursecode_errors`, `coursecode_screenshot`, `coursecode_viewport`, and the other CourseCode MCP runtime tools to inspect the result. Do not restart preview or run a production build after routine edits. If `coursecode_state` reports that preview is unavailable, start `coursecode preview` once in a terminal, then return to the MCP tools. Use `coursecode_lint` for static preflight checks and reserve `coursecode_build` for final package verification or export.
|
|
106
|
+
|
|
105
107
|
```bash
|
|
106
108
|
coursecode preview # Open http://localhost:4173
|
|
107
109
|
```
|
|
@@ -618,7 +620,7 @@ createNumericQuestion({
|
|
|
618
620
|
```javascript
|
|
619
621
|
createHotspotQuestion({
|
|
620
622
|
id: 'hot1', prompt: 'Click the correct region:',
|
|
621
|
-
image: { src: 'assets/images/diagram.png', alt: 'Diagram' },
|
|
623
|
+
image: { src: 'assets/images/diagram.png', alt: 'Diagram' }, // Relative to course/assets/
|
|
622
624
|
hotspots: [
|
|
623
625
|
// pos = [x%, y%, width%, height%]
|
|
624
626
|
{ id: 'zone1', pos: [10, 20, 15, 10], correct: true, label: 'Correct Zone', feedback: 'Good choice!' },
|
|
@@ -627,6 +629,8 @@ createHotspotQuestion({
|
|
|
627
629
|
});
|
|
628
630
|
```
|
|
629
631
|
|
|
632
|
+
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/`.
|
|
633
|
+
|
|
630
634
|
**Appearance themes**: `'correct'`, `'incorrect'`, `'primary'`, `'accent'` — or provide custom `appearance` object.
|
|
631
635
|
|
|
632
636
|
### Sequencing
|
|
@@ -1477,7 +1481,7 @@ parent.postMessage({ type: 'coursecode:resize', height: 400 }, '*');
|
|
|
1477
1481
|
|
|
1478
1482
|
```html
|
|
1479
1483
|
<div data-component="interactive-image" id="diagram">
|
|
1480
|
-
<img src="assets/images/diagram.png" alt="Diagram" />
|
|
1484
|
+
<img src="course/assets/images/diagram.png" alt="Diagram" />
|
|
1481
1485
|
<button data-hotspot-id="zone1" data-title="Component A" data-body="Description here..."
|
|
1482
1486
|
class="interactive-image-hotspot" style="top: 20%; left: 30%;">A</button>
|
|
1483
1487
|
<button data-hotspot-id="zone2" data-title="Component B" data-body="More details..."
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import interactionRegistry from '../managers/interaction-registry.js';
|
|
8
8
|
import { courseConfig } from '../../../course/course-config.js';
|
|
9
|
-
import { getInteractionState, recordInteractionResult } from '../components/interactions/interaction-base.js';
|
|
9
|
+
import { dispatchInteractionChecked, getInteractionState, recordInteractionResult } from '../components/interactions/interaction-base.js';
|
|
10
10
|
import { logger } from '../utilities/logger.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -144,6 +144,9 @@ export function createInteractionMethods(logTrace) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
const interactionElement = document.querySelector(`[data-interaction-id="${CSS.escape(interactionId)}"]`);
|
|
148
|
+
dispatchInteractionChecked(interactionElement, evaluation);
|
|
149
|
+
|
|
147
150
|
return evaluation;
|
|
148
151
|
} catch (error) {
|
|
149
152
|
logTrace('checkAnswer:error', { interactionId, error: error.message });
|
|
@@ -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: {
|
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
clearFeedback,
|
|
7
7
|
validateContainer,
|
|
8
8
|
parseResponse,
|
|
9
|
-
recordInteractionResult,
|
|
10
9
|
registerCoreInteraction
|
|
11
10
|
} from './interaction-base.js';
|
|
12
11
|
import { logger } from '../../utilities/logger.js';
|
|
12
|
+
import { resolveCourseAssetUrl } from '../../utilities/portable-assets.js';
|
|
13
13
|
|
|
14
14
|
// Default appearance themes for hotspots
|
|
15
15
|
const HOTSPOT_DEFAULTS = {
|
|
@@ -282,7 +282,7 @@ export function createHotspotQuestion(config) {
|
|
|
282
282
|
<div class="hotspot-container">
|
|
283
283
|
<div class="image-container relative">
|
|
284
284
|
<img
|
|
285
|
-
src="${image.src}"
|
|
285
|
+
src="${resolveCourseAssetUrl(image.src)}"
|
|
286
286
|
alt="${image.alt || ''}"
|
|
287
287
|
class="w-full h-auto"
|
|
288
288
|
id="${id}_image"
|
|
@@ -329,6 +329,7 @@ export function createHotspotQuestion(config) {
|
|
|
329
329
|
if (!selections.length) {
|
|
330
330
|
displayFeedback(
|
|
331
331
|
targetContainer,
|
|
332
|
+
id,
|
|
332
333
|
'Select at least one hotspot before checking your answer.',
|
|
333
334
|
'error'
|
|
334
335
|
);
|
|
@@ -340,7 +341,8 @@ export function createHotspotQuestion(config) {
|
|
|
340
341
|
if (evaluation.correct) {
|
|
341
342
|
displayFeedback(
|
|
342
343
|
targetContainer,
|
|
343
|
-
|
|
344
|
+
id,
|
|
345
|
+
feedback?.correct || '✓ Excellent! You found all the correct areas.',
|
|
344
346
|
'correct'
|
|
345
347
|
);
|
|
346
348
|
} else {
|
|
@@ -348,21 +350,12 @@ export function createHotspotQuestion(config) {
|
|
|
348
350
|
const selectedCount = selections.length;
|
|
349
351
|
displayFeedback(
|
|
350
352
|
targetContainer,
|
|
351
|
-
|
|
353
|
+
id,
|
|
354
|
+
feedback?.incorrect || `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
|
|
352
355
|
'incorrect'
|
|
353
356
|
);
|
|
354
357
|
}
|
|
355
358
|
|
|
356
|
-
recordInteractionResult(
|
|
357
|
-
id,
|
|
358
|
-
'other',
|
|
359
|
-
evaluation.response,
|
|
360
|
-
evaluation.correct,
|
|
361
|
-
JSON.stringify(normalizedHotspots.filter(h => h.correct).map(h => h.id)),
|
|
362
|
-
prompt,
|
|
363
|
-
controlled
|
|
364
|
-
);
|
|
365
|
-
|
|
366
359
|
return evaluation;
|
|
367
360
|
},
|
|
368
361
|
|
|
@@ -699,4 +692,3 @@ function toNumber(value, fallback = 0) {
|
|
|
699
692
|
}
|
|
700
693
|
return 0;
|
|
701
694
|
}
|
|
702
|
-
|
|
@@ -195,6 +195,8 @@ export function createInteractionEventHandler(questionObj, config, customHandler
|
|
|
195
195
|
if (!config.controlled) {
|
|
196
196
|
recordInteractionResult(config, evaluation);
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
dispatchInteractionChecked(event.currentTarget, evaluation);
|
|
198
200
|
break;
|
|
199
201
|
|
|
200
202
|
case 'reset':
|
|
@@ -231,7 +233,11 @@ export function createInteractionEventHandler(questionObj, config, customHandler
|
|
|
231
233
|
*/
|
|
232
234
|
export function recordInteractionResult(config, evaluation) {
|
|
233
235
|
// Mark as submitted in state (for restoration purposes)
|
|
234
|
-
saveInteractionState(
|
|
236
|
+
saveInteractionState(
|
|
237
|
+
config.id,
|
|
238
|
+
normalizeInteractionResponseForPersistence(config.scormType, evaluation.response),
|
|
239
|
+
true
|
|
240
|
+
);
|
|
235
241
|
|
|
236
242
|
// Format the response according to SCORM 2004 requirements
|
|
237
243
|
const scormType = config.scormType || 'other';
|
|
@@ -266,6 +272,43 @@ export function recordInteractionResult(config, evaluation) {
|
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Converts structured interaction responses back from their evaluation/SCORM
|
|
277
|
+
* JSON representation before storing them for UI restoration.
|
|
278
|
+
*/
|
|
279
|
+
export function normalizeInteractionResponseForPersistence(scormType, response) {
|
|
280
|
+
if (typeof response !== 'string' || !['choice', 'matching', 'sequencing', 'other'].includes(scormType)) {
|
|
281
|
+
return response;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const trimmed = response.trim();
|
|
285
|
+
if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) {
|
|
286
|
+
return response;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
return JSON.parse(trimmed);
|
|
291
|
+
} catch {
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Emits the documented interaction completion event from a rendered
|
|
298
|
+
* interaction or its author-provided container.
|
|
299
|
+
*/
|
|
300
|
+
export function dispatchInteractionChecked(target, evaluation) {
|
|
301
|
+
if (!target || !evaluation) return;
|
|
302
|
+
|
|
303
|
+
target.dispatchEvent(new CustomEvent('interaction-checked', {
|
|
304
|
+
bubbles: true,
|
|
305
|
+
detail: {
|
|
306
|
+
isCorrect: evaluation.correct,
|
|
307
|
+
evaluation
|
|
308
|
+
}
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
269
312
|
/**
|
|
270
313
|
* Creates standard interaction controls HTML.
|
|
271
314
|
* Uses utility classes for layout: .flex .flex-wrap .justify-center .gap-3
|
|
@@ -430,14 +473,34 @@ const _responseDebounceTimers = new Map();
|
|
|
430
473
|
* @param {object} questionObj - The live interaction instance.
|
|
431
474
|
*/
|
|
432
475
|
export function registerCoreInteraction(config, questionObj) {
|
|
476
|
+
const scormTypeByInteractionType = {
|
|
477
|
+
'multiple-choice-single': 'choice',
|
|
478
|
+
'multiple-choice-multiple': 'choice',
|
|
479
|
+
'drag-drop': 'other',
|
|
480
|
+
'fill-in': 'fill-in',
|
|
481
|
+
'hotspot': 'other',
|
|
482
|
+
'likert': 'likert',
|
|
483
|
+
'matching': 'matching',
|
|
484
|
+
'numeric': 'numeric',
|
|
485
|
+
'sequencing': 'sequencing',
|
|
486
|
+
'true-false': 'true-false'
|
|
487
|
+
};
|
|
488
|
+
const registryConfig = config.scormType
|
|
489
|
+
? config
|
|
490
|
+
: { ...config, scormType: scormTypeByInteractionType[questionObj.type] || 'other' };
|
|
491
|
+
|
|
433
492
|
// Delegate to the InteractionRegistry (separate from persistence manager)
|
|
434
|
-
interactionRegistry.register(
|
|
493
|
+
interactionRegistry.register(registryConfig, questionObj);
|
|
494
|
+
|
|
495
|
+
// Snapshot restoration state before deferring DOM work. An automated check can
|
|
496
|
+
// run before the next animation frame; reading state inside the callback would
|
|
497
|
+
// then mistake that new answer for pre-existing state and restore it twice.
|
|
498
|
+
const savedState = getInteractionState(config.id);
|
|
435
499
|
|
|
436
500
|
// Defer state restoration and auto-save setup to next frame
|
|
437
501
|
// This ensures the DOM container exists after render() completes
|
|
438
502
|
requestAnimationFrame(() => {
|
|
439
503
|
// Restore previously saved response state if it exists
|
|
440
|
-
const savedState = getInteractionState(config.id);
|
|
441
504
|
if (savedState && savedState.response !== null && savedState.response !== undefined) {
|
|
442
505
|
try {
|
|
443
506
|
if (typeof questionObj.setResponse === 'function') {
|
|
@@ -446,7 +509,9 @@ export function registerCoreInteraction(config, questionObj) {
|
|
|
446
509
|
|
|
447
510
|
// If it was previously submitted, also restore the feedback state
|
|
448
511
|
if (savedState.submitted && typeof questionObj.checkAnswer === 'function') {
|
|
449
|
-
questionObj.checkAnswer();
|
|
512
|
+
const evaluation = questionObj.checkAnswer();
|
|
513
|
+
const interactionElement = document.querySelector(`[data-interaction-id="${CSS.escape(config.id)}"]`);
|
|
514
|
+
dispatchInteractionChecked(interactionElement, evaluation);
|
|
450
515
|
}
|
|
451
516
|
}
|
|
452
517
|
} catch (error) {
|
|
@@ -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: {
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
export const schema = {
|
|
8
8
|
type: 'interactive-image',
|
|
9
9
|
description: 'Image with clickable hotspots for modals or accordion integration',
|
|
10
|
-
example: `<div data-component="interactive-image" class="interactive-image
|
|
11
|
-
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='250' fill='%23f1f5f9'%3E%3Crect width='400' height='250' rx='8'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2394a3b8' font-family='system-ui' font-size='14'%3EInteractive Diagram%3C/text%3E%3C/svg%3E" alt="Interactive diagram">
|
|
10
|
+
example: `<div data-component="interactive-image" class="interactive-image-container">
|
|
11
|
+
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='250' fill='%23f1f5f9'%3E%3Crect width='400' height='250' rx='8'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2394a3b8' font-family='system-ui' font-size='14'%3EInteractive Diagram%3C/text%3E%3C/svg%3E" alt="Interactive diagram" class="interactive-image-img">
|
|
12
12
|
<button data-hotspot-id="feature-a" data-title="Feature A" data-body="Details about Feature A" class="hotspot" style="position: absolute; top: 30%; left: 25%; width: 24px; height: 24px; border-radius: 50%; background: #3b82f6; border: 2px solid white; cursor: pointer;" aria-label="Feature A"></button>
|
|
13
13
|
<button data-hotspot-id="feature-b" data-title="Feature B" data-body="Details about Feature B" class="hotspot" style="position: absolute; top: 60%; left: 65%; width: 24px; height: 24px; border-radius: 50%; background: #f59e0b; border: 2px solid white; cursor: pointer;" aria-label="Feature B"></button>
|
|
14
14
|
</div>`,
|
|
@@ -45,6 +45,12 @@ export function init(root, _options = {}) {
|
|
|
45
45
|
const container = typeof root === 'string' ? document.querySelector(root) : root;
|
|
46
46
|
if (!container) return;
|
|
47
47
|
|
|
48
|
+
// The data-component attribute is the author-facing contract. Normalize
|
|
49
|
+
// the internal CSS hooks so documented minimal markup positions hotspots
|
|
50
|
+
// against the image instead of an unrelated ancestor.
|
|
51
|
+
container.classList.add('interactive-image-container');
|
|
52
|
+
container.querySelector(':scope > img')?.classList.add('interactive-image-img');
|
|
53
|
+
|
|
48
54
|
const hotspots = Array.from(container.querySelectorAll('[data-hotspot-id]'));
|
|
49
55
|
if (!hotspots.length) return;
|
|
50
56
|
|
|
@@ -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
|
]);
|
|
@@ -781,6 +781,28 @@ function getEffectiveBackgroundColor(element) {
|
|
|
781
781
|
return [result[0], result[1], result[2]];
|
|
782
782
|
}
|
|
783
783
|
|
|
784
|
+
/**
|
|
785
|
+
* Detects computed gradient backgrounds on an element or any ancestor.
|
|
786
|
+
* Course themes commonly use custom classes, so class-name allowlists cannot
|
|
787
|
+
* reliably identify every gradient that makes flat-color contrast calculation
|
|
788
|
+
* invalid.
|
|
789
|
+
* @param {HTMLElement} element
|
|
790
|
+
* @returns {boolean}
|
|
791
|
+
*/
|
|
792
|
+
function hasComputedGradientBackground(element) {
|
|
793
|
+
let current = element;
|
|
794
|
+
|
|
795
|
+
while (current && current.tagName !== 'HTML') {
|
|
796
|
+
const backgroundImage = window.getComputedStyle(current).backgroundImage;
|
|
797
|
+
if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient')) {
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
current = current.parentElement;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
|
|
784
806
|
|
|
785
807
|
/**
|
|
786
808
|
* Builds a compact description of an element and its ancestry for lint messages.
|
|
@@ -833,6 +855,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
|
|
|
833
855
|
const textElements = renderedContent.querySelectorAll('p, span:not(.accordion-icon), li, a, h1, h2, h3, h4, h5, h6, button');
|
|
834
856
|
for (const el of textElements) {
|
|
835
857
|
if (isLintIgnored(el, 'contrast')) continue;
|
|
858
|
+
if (el.classList.contains('sr-only') || el.closest('.sr-only')) continue;
|
|
836
859
|
// Skip elements that are not visible or have no text
|
|
837
860
|
if (el.offsetParent === null || el.textContent.trim() === '') continue;
|
|
838
861
|
|
|
@@ -872,18 +895,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
|
|
|
872
895
|
if (el.classList.contains('badge') || el.closest('.badge') !== null) continue;
|
|
873
896
|
|
|
874
897
|
// Skip contrast check for elements on gradient backgrounds (can't reliably compute)
|
|
875
|
-
|
|
876
|
-
el.closest('.gradient-light') !== null ||
|
|
877
|
-
el.closest('.hero-gradient') !== null ||
|
|
878
|
-
el.closest('.btn-gradient') !== null ||
|
|
879
|
-
el.closest('[class*="bg-gradient-dark"]') !== null ||
|
|
880
|
-
el.closest('[class*="gradient-header"]') !== null ||
|
|
881
|
-
el.closest('[class*="gradient-success"]') !== null ||
|
|
882
|
-
el.closest('[class*="gradient-progress"]') !== null ||
|
|
883
|
-
el.closest('[style*="linear-gradient"]') !== null ||
|
|
884
|
-
el.closest('[style*="radial-gradient"]') !== null ||
|
|
885
|
-
style.backgroundImage.includes('gradient');
|
|
886
|
-
if (hasGradientBackground) continue;
|
|
898
|
+
if (hasComputedGradientBackground(el)) continue;
|
|
887
899
|
|
|
888
900
|
const textColorStr = style.color;
|
|
889
901
|
const bgColorRgb = getEffectiveBackgroundColor(el);
|
|
@@ -1042,7 +1054,8 @@ function validateTextProximityToVisualElements(slideId, renderedContent, warning
|
|
|
1042
1054
|
'dropdown', 'dropdown-menu', 'dropdown-item',
|
|
1043
1055
|
'table', // Tables manage their own cell spacing
|
|
1044
1056
|
'step-number', 'step-content', 'step', // Pattern-steps elements have intentional circular styling
|
|
1045
|
-
'btn-link' // Link-styled button intentionally has minimal padding
|
|
1057
|
+
'btn-link', // Link-styled button intentionally has minimal padding
|
|
1058
|
+
'hotspot-area', 'interactive-image-hotspot'
|
|
1046
1059
|
]);
|
|
1047
1060
|
|
|
1048
1061
|
// HTML elements that manage their own spacing (exclude from checks)
|
|
@@ -1171,6 +1184,8 @@ function validateElementOverlap(slideId, renderedContent, warnings) {
|
|
|
1171
1184
|
const absoluteElements = renderedContent.querySelectorAll('[style*="position:absolute"], [style*="position: absolute"]');
|
|
1172
1185
|
for (const el of absoluteElements) {
|
|
1173
1186
|
if (el.offsetParent === null) continue;
|
|
1187
|
+
// Hotspots intentionally overlap their background image and sometimes each other.
|
|
1188
|
+
if (el.matches('[data-hotspot-id], .hotspot-area, .interactive-image-hotspot')) continue;
|
|
1174
1189
|
|
|
1175
1190
|
const rect = el.getBoundingClientRect();
|
|
1176
1191
|
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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.60",
|
|
3
3
|
"name": "CourseCode Framework",
|
|
4
4
|
"description": "Multi-format course authoring and learner runtime framework",
|
|
5
|
-
"released": "2026-07-
|
|
5
|
+
"released": "2026-07-14",
|
|
6
6
|
"formats": [
|
|
7
7
|
"SCORM 2004 4th Edition",
|
|
8
8
|
"SCORM 1.2",
|
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/lib/headless-browser.js
CHANGED
|
@@ -410,7 +410,8 @@ class HeadlessBrowser {
|
|
|
410
410
|
if (scrollY !== undefined && scrollY > 0) {
|
|
411
411
|
await this._ensureCourseFrame();
|
|
412
412
|
await this.courseFrame.evaluate((y) => {
|
|
413
|
-
const container = document.querySelector('
|
|
413
|
+
const container = document.querySelector('#content')
|
|
414
|
+
|| document.querySelector('.slide-content')
|
|
414
415
|
|| document.querySelector('[class*="slide"]')
|
|
415
416
|
|| document.documentElement;
|
|
416
417
|
container.scrollTop = y;
|
|
@@ -425,17 +426,21 @@ class HeadlessBrowser {
|
|
|
425
426
|
// temporarily expand the viewport so nothing is clipped, then screenshot.
|
|
426
427
|
await this._ensureCourseFrame();
|
|
427
428
|
const contentHeight = await this.courseFrame.evaluate(() => {
|
|
429
|
+
const content = document.querySelector('#content');
|
|
428
430
|
return Math.max(
|
|
431
|
+
content?.scrollHeight || 0,
|
|
429
432
|
document.body.scrollHeight,
|
|
430
433
|
document.documentElement.scrollHeight
|
|
431
434
|
);
|
|
432
435
|
});
|
|
433
436
|
|
|
434
|
-
const
|
|
437
|
+
const iframeElement = await this.page.$('iframe');
|
|
438
|
+
const iframeBox = await iframeElement?.boundingBox();
|
|
439
|
+
const playerChromeHeight = Math.max(0, this._viewport.height - (iframeBox?.height || this._viewport.height));
|
|
440
|
+
const fullHeight = Math.max(contentHeight + playerChromeHeight, this._viewport.height);
|
|
435
441
|
await this.page.setViewport({ width: this._viewport.width, height: fullHeight });
|
|
436
442
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
437
443
|
|
|
438
|
-
const iframeElement = await this.page.$('iframe');
|
|
439
444
|
if (iframeElement) {
|
|
440
445
|
screenshotBuffer = await iframeElement.screenshot({
|
|
441
446
|
type: 'jpeg',
|
package/lib/info.js
CHANGED
|
@@ -9,6 +9,18 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
11
11
|
|
|
12
|
+
export function findLatestCoursePackage(directory) {
|
|
13
|
+
const packages = fs.readdirSync(directory, { withFileTypes: true })
|
|
14
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.zip'))
|
|
15
|
+
.map(entry => ({
|
|
16
|
+
name: entry.name,
|
|
17
|
+
modifiedAt: fs.statSync(path.join(directory, entry.name)).mtimeMs
|
|
18
|
+
}))
|
|
19
|
+
.sort((a, b) => b.modifiedAt - a.modifiedAt || a.name.localeCompare(b.name));
|
|
20
|
+
|
|
21
|
+
return packages[0]?.name || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export async function info() {
|
|
13
25
|
const cwd = process.cwd();
|
|
14
26
|
|
|
@@ -70,9 +82,9 @@ export async function info() {
|
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// Check for ZIP
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
console.log(` Course package: ${
|
|
85
|
+
const latestPackage = findLatestCoursePackage(cwd);
|
|
86
|
+
if (latestPackage) {
|
|
87
|
+
console.log(` Course package: ${latestPackage}`);
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
console.log('');
|
package/lib/upgrade.js
CHANGED
|
@@ -113,6 +113,39 @@ export function addMissingRuntimeDependencies(projectPkg, templatePkg) {
|
|
|
113
113
|
return added;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Keep the project-local authoring/build CLI aligned with the framework that
|
|
118
|
+
* was just installed. Generated projects rely on this dependency so an
|
|
119
|
+
* isolated checkout can run CourseCode commands without a global install.
|
|
120
|
+
*/
|
|
121
|
+
export function syncCoursecodeCliDependency(projectPkg, targetVersion) {
|
|
122
|
+
const targetRange = `^${targetVersion}`;
|
|
123
|
+
const currentDevRange = projectPkg.devDependencies?.coursecode;
|
|
124
|
+
const currentRuntimeRange = projectPkg.dependencies?.coursecode;
|
|
125
|
+
const updated = currentDevRange !== targetRange || Boolean(currentRuntimeRange);
|
|
126
|
+
|
|
127
|
+
if (!updated) {
|
|
128
|
+
return { updated: false, from: currentDevRange, to: targetRange };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
projectPkg.devDependencies = Object.fromEntries(
|
|
132
|
+
Object.entries({
|
|
133
|
+
...(projectPkg.devDependencies || {}),
|
|
134
|
+
coursecode: targetRange
|
|
135
|
+
}).sort(([a], [b]) => a.localeCompare(b))
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (currentRuntimeRange) {
|
|
139
|
+
delete projectPkg.dependencies.coursecode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
updated: true,
|
|
144
|
+
from: currentDevRange || currentRuntimeRange || null,
|
|
145
|
+
to: targetRange
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
const OBSOLETE_MANAGED_DEV_DEPENDENCIES = ['@vitejs/plugin-legacy'];
|
|
117
150
|
|
|
118
151
|
/**
|
|
@@ -209,7 +242,7 @@ export async function upgrade(options = {}) {
|
|
|
209
242
|
- framework/ (replace entirely)
|
|
210
243
|
- schemas/ (replace entirely)
|
|
211
244
|
- lib/manifest/ (replace entirely)
|
|
212
|
-
- package.json (add missing runtime dependencies)
|
|
245
|
+
- package.json (sync CourseCode CLI and add missing runtime dependencies)
|
|
213
246
|
- .coursecoderc.json (update version)`;
|
|
214
247
|
|
|
215
248
|
if (options.configs) {
|
|
@@ -307,14 +340,17 @@ export async function upgrade(options = {}) {
|
|
|
307
340
|
// the project's existing version ranges.
|
|
308
341
|
const pkgPath = path.join(cwd, 'package.json');
|
|
309
342
|
let addedRuntimeDeps = [];
|
|
343
|
+
let cliDependencyChange = null;
|
|
310
344
|
let toolingChanges = null;
|
|
311
345
|
if (fs.existsSync(pkgPath)) {
|
|
312
346
|
const projectPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
313
347
|
addedRuntimeDeps = addMissingRuntimeDependencies(projectPkg, templatePkg);
|
|
348
|
+
cliDependencyChange = syncCoursecodeCliDependency(projectPkg, targetVersion);
|
|
314
349
|
if (options.configs) {
|
|
315
350
|
toolingChanges = syncManagedBuildTooling(projectPkg, templatePkg);
|
|
316
351
|
}
|
|
317
|
-
const packageChanged =
|
|
352
|
+
const packageChanged = cliDependencyChange.updated ||
|
|
353
|
+
addedRuntimeDeps.length > 0 ||
|
|
318
354
|
toolingChanges?.updated.length > 0 ||
|
|
319
355
|
toolingChanges?.removed.length > 0 ||
|
|
320
356
|
toolingChanges?.engineUpdated;
|
|
@@ -330,13 +366,19 @@ export async function upgrade(options = {}) {
|
|
|
330
366
|
|
|
331
367
|
Your course/ directory was not modified.`;
|
|
332
368
|
|
|
369
|
+
if (cliDependencyChange?.updated) {
|
|
370
|
+
const previousRange = cliDependencyChange.from || 'missing';
|
|
371
|
+
successMsg += `
|
|
372
|
+
|
|
373
|
+
CourseCode CLI dependency updated:
|
|
374
|
+
- coursecode: ${previousRange} → ${cliDependencyChange.to}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
333
377
|
if (addedRuntimeDeps.length > 0) {
|
|
334
378
|
successMsg += `
|
|
335
379
|
|
|
336
380
|
Runtime dependencies added to package.json:
|
|
337
|
-
- ${addedRuntimeDeps.join('\n - ')}
|
|
338
|
-
|
|
339
|
-
Run npm install to update node_modules and your lockfile.`;
|
|
381
|
+
- ${addedRuntimeDeps.join('\n - ')}`;
|
|
340
382
|
}
|
|
341
383
|
|
|
342
384
|
if (toolingChanges && (
|
|
@@ -359,9 +401,6 @@ export async function upgrade(options = {}) {
|
|
|
359
401
|
successMsg += `
|
|
360
402
|
- Updated Node engine requirement to ${templatePkg.engines.node}`;
|
|
361
403
|
}
|
|
362
|
-
successMsg += `
|
|
363
|
-
|
|
364
|
-
Run npm install to update node_modules and your lockfile.`;
|
|
365
404
|
}
|
|
366
405
|
|
|
367
406
|
if (configUpdates.length > 0) {
|
|
@@ -373,6 +412,18 @@ ${configUpdates.join('\n')}
|
|
|
373
412
|
Review the changes and delete .backup files when satisfied.`;
|
|
374
413
|
}
|
|
375
414
|
|
|
415
|
+
if (
|
|
416
|
+
cliDependencyChange?.updated ||
|
|
417
|
+
addedRuntimeDeps.length > 0 ||
|
|
418
|
+
toolingChanges?.updated.length > 0 ||
|
|
419
|
+
toolingChanges?.removed.length > 0 ||
|
|
420
|
+
toolingChanges?.engineUpdated
|
|
421
|
+
) {
|
|
422
|
+
successMsg += `
|
|
423
|
+
|
|
424
|
+
Run npm install to update node_modules and your lockfile.`;
|
|
425
|
+
}
|
|
426
|
+
|
|
376
427
|
successMsg += `
|
|
377
428
|
|
|
378
429
|
Review the changelog for breaking changes:
|
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
|
|