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.
@@ -247,8 +247,10 @@
247
247
  ACCORDION STYLE VARIANTS
248
248
  ============================================================================
249
249
 
250
- Apply these data attributes to the .accordion container to change
251
- the visual appearance.
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
- [data-accordion-style="flush"].accordion {
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
- [data-accordion-style="flush"] .accordion-item {
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
- [data-accordion-style="flush"] .accordion-item:last-child {
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
- [data-accordion-style="flush"] .accordion-button {
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
- [data-accordion-style="flush"] .accordion-body {
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
- [data-accordion-style="flush"] .accordion-content {
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
- [data-accordion-style="separated"].accordion {
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
- [data-accordion-style="separated"] .accordion-item {
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
- [data-accordion-style="separated"] .accordion-item:last-child {
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
- [data-accordion-style="separated"] .accordion-button:not(.collapsed) {
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
- [data-accordion-style="minimal"].accordion {
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
- [data-accordion-style="minimal"] .accordion-item {
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
- [data-accordion-style="minimal"] .accordion-button {
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
- [data-accordion-style="minimal"] .accordion-button:hover {
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
- [data-accordion-style="minimal"] .accordion-button:not(.collapsed) {
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
- [data-accordion-style="minimal"] .accordion-content {
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
- [data-accordion-style="minimal"] .accordion-body {
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
- [data-theme="dark"] [data-accordion-style="minimal"] .accordion-button:not(.collapsed) {
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
- [data-accordion-style="boxed"].accordion {
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
- [data-accordion-style="boxed"] .accordion-item {
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
- [data-accordion-style="boxed"] .accordion-item:last-child {
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
- [data-accordion-style="boxed"] .accordion-button {
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
- [data-accordion-style="boxed"] .accordion-button:hover {
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
- [data-accordion-style="boxed"] .accordion-button:not(.collapsed) {
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
- [data-accordion-style="boxed"] .accordion-button:not(.collapsed) .accordion-icon {
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
- [data-accordion-style="boxed"] .accordion-content {
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
- [data-theme="dark"] [data-accordion-style="boxed"] .accordion-item {
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
- [data-theme="dark"] [data-accordion-style="boxed"] .accordion-content {
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
- text: { type: 'string', required: true },
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
- valueSchema: {
141
- correct: { type: ['string', 'array'], required: true },
142
- typoTolerance: { type: 'number', default: 0 },
143
- hint: { type: 'string' }
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: 'Source-target pairs to match',
97
+ description: 'Items and their matching target text',
98
98
  itemSchema: {
99
- source: { type: 'string', required: true },
100
- target: { type: 'string', required: true }
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.58",
2
+ "version": "0.1.59",
3
3
  "name": "CourseCode Framework",
4
4
  "description": "Multi-format course authoring and learner runtime framework",
5
5
  "released": "2026-07-13",
@@ -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
- // Check for output
68
- const zipFiles = fs.readdirSync(process.cwd()).filter(f => f.endsWith('.zip'));
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
- Output:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "description": "Multi-format course authoring framework with CLI tools (SCORM 2004, SCORM 1.2, cmi5, LTI 1.3)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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/example_pdf_1.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>
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