coursecode 0.1.59 → 0.1.61
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/buttons.css +2 -2
- package/framework/css/design-tokens.css +1 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +2 -0
- package/framework/js/automation/api-interactions.js +4 -1
- package/framework/js/components/interactions/hotspot.js +5 -13
- package/framework/js/components/interactions/interaction-base.js +69 -4
- package/framework/js/components/ui-components/interactive-image.js +8 -2
- package/framework/js/dev/runtime-linter.js +23 -12
- package/framework/version.json +2 -2
- package/lib/headless-browser.js +11 -4
- package/lib/info.js +15 -3
- package/lib/upgrade.js +59 -8
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -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
|
```
|
|
@@ -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 });
|
|
@@ -6,7 +6,6 @@ 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';
|
|
@@ -330,6 +329,7 @@ export function createHotspotQuestion(config) {
|
|
|
330
329
|
if (!selections.length) {
|
|
331
330
|
displayFeedback(
|
|
332
331
|
targetContainer,
|
|
332
|
+
id,
|
|
333
333
|
'Select at least one hotspot before checking your answer.',
|
|
334
334
|
'error'
|
|
335
335
|
);
|
|
@@ -341,7 +341,8 @@ export function createHotspotQuestion(config) {
|
|
|
341
341
|
if (evaluation.correct) {
|
|
342
342
|
displayFeedback(
|
|
343
343
|
targetContainer,
|
|
344
|
-
|
|
344
|
+
id,
|
|
345
|
+
feedback?.correct || '✓ Excellent! You found all the correct areas.',
|
|
345
346
|
'correct'
|
|
346
347
|
);
|
|
347
348
|
} else {
|
|
@@ -349,21 +350,12 @@ export function createHotspotQuestion(config) {
|
|
|
349
350
|
const selectedCount = selections.length;
|
|
350
351
|
displayFeedback(
|
|
351
352
|
targetContainer,
|
|
352
|
-
|
|
353
|
+
id,
|
|
354
|
+
feedback?.incorrect || `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
|
|
353
355
|
'incorrect'
|
|
354
356
|
);
|
|
355
357
|
}
|
|
356
358
|
|
|
357
|
-
recordInteractionResult(
|
|
358
|
-
id,
|
|
359
|
-
'other',
|
|
360
|
-
evaluation.response,
|
|
361
|
-
evaluation.correct,
|
|
362
|
-
JSON.stringify(normalizedHotspots.filter(h => h.correct).map(h => h.id)),
|
|
363
|
-
prompt,
|
|
364
|
-
controlled
|
|
365
|
-
);
|
|
366
|
-
|
|
367
359
|
return evaluation;
|
|
368
360
|
},
|
|
369
361
|
|
|
@@ -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) {
|
|
@@ -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
|
|
|
@@ -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.
|
|
@@ -873,18 +895,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
|
|
|
873
895
|
if (el.classList.contains('badge') || el.closest('.badge') !== null) continue;
|
|
874
896
|
|
|
875
897
|
// Skip contrast check for elements on gradient backgrounds (can't reliably compute)
|
|
876
|
-
|
|
877
|
-
el.closest('.gradient-light') !== null ||
|
|
878
|
-
el.closest('.hero-gradient') !== null ||
|
|
879
|
-
el.closest('.btn-gradient') !== null ||
|
|
880
|
-
el.closest('[class*="bg-gradient-dark"]') !== null ||
|
|
881
|
-
el.closest('[class*="gradient-header"]') !== null ||
|
|
882
|
-
el.closest('[class*="gradient-success"]') !== null ||
|
|
883
|
-
el.closest('[class*="gradient-progress"]') !== null ||
|
|
884
|
-
el.closest('[style*="linear-gradient"]') !== null ||
|
|
885
|
-
el.closest('[style*="radial-gradient"]') !== null ||
|
|
886
|
-
style.backgroundImage.includes('gradient');
|
|
887
|
-
if (hasGradientBackground) continue;
|
|
898
|
+
if (hasComputedGradientBackground(el)) continue;
|
|
888
899
|
|
|
889
900
|
const textColorStr = style.color;
|
|
890
901
|
const bgColorRgb = getEffectiveBackgroundColor(el);
|
package/framework/version.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.61",
|
|
3
3
|
"name": "CourseCode Framework",
|
|
4
4
|
"description": "Multi-format course authoring and learner runtime framework",
|
|
5
|
-
"released": "2026-07-
|
|
5
|
+
"released": "2026-07-20",
|
|
6
6
|
"formats": [
|
|
7
7
|
"SCORM 2004 4th Edition",
|
|
8
8
|
"SCORM 1.2",
|
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',
|
|
@@ -459,7 +464,9 @@ class HeadlessBrowser {
|
|
|
459
464
|
}
|
|
460
465
|
|
|
461
466
|
return {
|
|
462
|
-
|
|
467
|
+
// Puppeteer >=22 returns Uint8Array, whose toString() ignores the
|
|
468
|
+
// 'base64' argument — wrap in Buffer so encoding applies.
|
|
469
|
+
data: Buffer.from(screenshotBuffer).toString('base64'),
|
|
463
470
|
mimeType: 'image/jpeg'
|
|
464
471
|
};
|
|
465
472
|
}
|
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:
|