coursecode 0.1.56 → 0.1.58

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.
Files changed (86) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +49 -5
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +28 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +6 -2
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/package.json +1 -1
  86. package/template/vite.config.js +50 -22
@@ -44,10 +44,15 @@ export function deepClone(obj) {
44
44
  }
45
45
 
46
46
  const clonedObj = {};
47
- for (const key in obj) {
48
- if (obj.hasOwnProperty(key)) {
49
- clonedObj[key] = deepClone(obj[key]);
50
- }
47
+ for (const key of Object.keys(obj)) {
48
+ // defineProperty avoids invoking Object.prototype.__proto__ when state
49
+ // originated from JSON containing that literal key.
50
+ Object.defineProperty(clonedObj, key, {
51
+ value: deepClone(obj[key]),
52
+ enumerable: true,
53
+ configurable: true,
54
+ writable: true
55
+ });
51
56
  }
52
57
  return clonedObj;
53
58
  }
@@ -241,7 +246,7 @@ export function waitFor(condition, timeout = 5000, interval = 100) {
241
246
  * @returns {Object} The merged target object
242
247
  */
243
248
  export function deepMerge(target, ...sources) {
244
- if (!target || typeof target !== 'object' || Array.isArray(target)) {
249
+ if (!isPlainObject(target)) {
245
250
  throw new Error('deepMerge: target must be a plain object');
246
251
  }
247
252
 
@@ -251,22 +256,23 @@ export function deepMerge(target, ...sources) {
251
256
 
252
257
  const source = sources.shift();
253
258
 
254
- if (source && typeof source === 'object' && !Array.isArray(source)) {
255
- for (const key in source) {
256
- if (source.hasOwnProperty(key)) {
257
- const sourceValue = source[key];
258
- const targetValue = target[key];
259
-
260
- if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
261
- // Recursive merge for plain objects
262
- if (!targetValue || typeof targetValue !== 'object' || Array.isArray(targetValue)) {
263
- target[key] = {};
264
- }
265
- deepMerge(target[key], sourceValue);
266
- } else {
267
- // Direct assignment for primitives, arrays, and other types
268
- target[key] = sourceValue;
259
+ if (isPlainObject(source)) {
260
+ for (const key of Object.keys(source)) {
261
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
262
+ throw new Error(`deepMerge: unsafe key "${key}" is not allowed`);
263
+ }
264
+
265
+ const sourceValue = source[key];
266
+ const targetValue = target[key];
267
+
268
+ if (isPlainObject(sourceValue)) {
269
+ if (!isPlainObject(targetValue)) {
270
+ target[key] = {};
269
271
  }
272
+ deepMerge(target[key], sourceValue);
273
+ } else {
274
+ // Direct assignment for primitives, arrays, and non-plain objects
275
+ target[key] = sourceValue;
270
276
  }
271
277
  }
272
278
  }
@@ -274,6 +280,12 @@ export function deepMerge(target, ...sources) {
274
280
  return deepMerge(target, ...sources);
275
281
  }
276
282
 
283
+ function isPlainObject(value) {
284
+ if (value === null || typeof value !== 'object') return false;
285
+ const prototype = Object.getPrototypeOf(value);
286
+ return prototype === Object.prototype || prototype === null;
287
+ }
288
+
277
289
  /**
278
290
  * Shuffle an array using Fisher-Yates algorithm (creates new array).
279
291
  * @param {Array} array - Array to shuffle
@@ -1,8 +1,9 @@
1
1
  import { eventBus } from '../core/event-bus.js';
2
2
  import { logger } from './logger.js';
3
3
  import { validateRenderedHTML } from '../validation/html-validators.js';
4
- import { initializeDeclarativeComponents } from './ui-initializer.js';
4
+ import { cleanupDeclarativeComponents, initializeDeclarativeComponents } from './ui-initializer.js';
5
5
  import { courseConfig } from '../../../course/course-config.js';
6
+ import { rewritePortableAssetAttributes } from './portable-assets.js';
6
7
 
7
8
  /**
8
9
  * Creates a view manager for a given container element.
@@ -78,9 +79,20 @@ export function createViewManager(container, scope = 'local') {
78
79
  // Auto-wrap content with content-width class if configured and not already wrapped
79
80
  newElement = autoWrapContentIfNeeded(newElement, name);
80
81
 
82
+ // Convert copied course assets to embedded data URLs before components
83
+ // initialize and begin loading media in a portable HTML export.
84
+ rewritePortableAssetAttributes(newElement);
85
+
81
86
  // Validate rendered HTML for common issues BEFORE adding to DOM
82
87
  validateRenderedContent(newElement, name);
83
88
 
89
+ // Release component resources before removing the old view. Some
90
+ // components subscribe to document-level events or own media managers,
91
+ // so removing their DOM alone is not sufficient cleanup.
92
+ if (oldElement) {
93
+ cleanupDeclarativeComponents(oldElement);
94
+ }
95
+
84
96
  // Clear container and add new view
85
97
  container.innerHTML = '';
86
98
  container.appendChild(newElement);
@@ -243,6 +255,9 @@ export function createViewManager(container, scope = 'local') {
243
255
  if (currentElement && typeof currentView.onHide === 'function') {
244
256
  currentView.onHide(currentElement);
245
257
  }
258
+ if (currentElement) {
259
+ cleanupDeclarativeComponents(currentElement);
260
+ }
246
261
  }
247
262
  container.innerHTML = '';
248
263
  currentViewName = null;
@@ -229,6 +229,75 @@ export function formatLearnerResponseForScorm(interactionType, response) {
229
229
  }
230
230
  }
231
231
 
232
+ /** SCORM 1.2 interaction vocabulary (RTE 1.2.7). */
233
+ export const SCORM_12_INTERACTION_TYPES = [
234
+ 'true-false',
235
+ 'choice',
236
+ 'fill-in',
237
+ 'matching',
238
+ 'performance',
239
+ 'likert',
240
+ 'sequencing',
241
+ 'numeric'
242
+ ];
243
+
244
+ /**
245
+ * Converts framework/SCORM 2004 interaction data to the materially different
246
+ * SCORM 1.2 vocabulary and response syntax.
247
+ *
248
+ * The framework keeps one lossless internal representation. Unsupported 2004
249
+ * types are represented as fill-in rows in the optional native 1.2 interaction
250
+ * array; the original record remains intact in suspend_data.
251
+ */
252
+ export function serializeInteractionForScorm12(interaction) {
253
+ const type = SCORM_12_INTERACTION_TYPES.includes(interaction.type)
254
+ ? interaction.type
255
+ : 'fill-in';
256
+
257
+ const convertResponse = value => {
258
+ if (value === null || value === undefined) return '';
259
+ let serialized = String(value);
260
+
261
+ if (interaction.type === 'true-false') {
262
+ const normalized = serialized.toLowerCase().trim();
263
+ if (normalized === 'true' || normalized === 't' || normalized === '1') return 't';
264
+ if (normalized === 'false' || normalized === 'f' || normalized === '0') return 'f';
265
+ return '';
266
+ }
267
+
268
+ if (interaction.type === 'choice' || interaction.type === 'sequencing') {
269
+ serialized = serialized.replaceAll('[,]', ',');
270
+ } else if (interaction.type === 'matching') {
271
+ serialized = serialized.replaceAll('[.]', '.').replaceAll('[,]', ',');
272
+ }
273
+
274
+ // CMIString values in SCORM 1.2 are ASCII. Escape non-ASCII code units
275
+ // instead of submitting an invalid value that strict LMSs reject.
276
+ return serialized.replace(/[^\x00-\x7F]/g, char =>
277
+ `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
278
+ );
279
+ };
280
+
281
+ const result = interaction.result === 'incorrect'
282
+ ? 'wrong'
283
+ : interaction.result;
284
+
285
+ return {
286
+ ...interaction,
287
+ id: convertResponse(interaction.id),
288
+ type,
289
+ learner_response: convertResponse(interaction.learner_response),
290
+ result,
291
+ correct_responses: interaction.correct_responses?.map(item => {
292
+ if (typeof item === 'object' && item !== null && 'pattern' in item) {
293
+ return { ...item, pattern: convertResponse(item.pattern) };
294
+ }
295
+ return convertResponse(item);
296
+ }),
297
+ objectives: interaction.objectives?.map(convertResponse)
298
+ };
299
+ }
300
+
232
301
  /**
233
302
  * SCORM 2004 4th Edition valid completion status values.
234
303
  * @constant {string[]}
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.1.56",
2
+ "version": "0.1.58",
3
3
  "name": "CourseCode Framework",
4
4
  "description": "Multi-format course authoring and learner runtime framework",
5
- "released": "2026-07-11",
5
+ "released": "2026-07-13",
6
6
  "formats": [
7
7
  "SCORM 2004 4th Edition",
8
8
  "SCORM 1.2",
@@ -965,7 +965,32 @@ export async function getWorkflowStatus(port = 4173) {
965
965
  // Infer stage
966
966
  let stage, stageNumber, nextAction, recommendedTool;
967
967
 
968
- if (checklist.hasSlides && checklist.hasCourseConfig) {
968
+ const isPowerPointImport = checklist.source === 'powerpoint-import';
969
+ const needsReferenceConversion = checklist.hasRawRefs && !checklist.hasConvertedRefs && !isPowerPointImport;
970
+ const needsOutline = checklist.hasConvertedRefs && !checklist.hasOutline && !isPowerPointImport;
971
+ const hasNoAuthoringInputs = !checklist.hasRawRefs && !checklist.hasConvertedRefs && !checklist.hasOutline && !isPowerPointImport;
972
+
973
+ if (needsReferenceConversion) {
974
+ stage = 'source-ingestion';
975
+ stageNumber = 1;
976
+ nextAction = 'Convert reference files to markdown: coursecode convert';
977
+ recommendedTool = 'coursecode_workflow_status';
978
+ } else if (needsOutline) {
979
+ stage = 'outline-creation';
980
+ stageNumber = 2;
981
+ nextAction = 'Create course outline from reference materials. Stage instructions have all file paths.';
982
+ recommendedTool = 'coursecode_workflow_status';
983
+ } else if (hasNoAuthoringInputs) {
984
+ stage = 'source-ingestion';
985
+ stageNumber = 1;
986
+ nextAction = 'Add reference files (PDF, DOCX, PPTX, MD) to course/references/ and run coursecode convert';
987
+ recommendedTool = 'coursecode_workflow_status';
988
+ } else if (checklist.hasOutline && (!checklist.hasSlides || !checklist.hasCourseConfig)) {
989
+ stage = 'course-building';
990
+ stageNumber = 3;
991
+ nextAction = 'Build slide files and course-config.js based on the outline. Stage instructions have all file paths.';
992
+ recommendedTool = 'coursecode_workflow_status';
993
+ } else if (checklist.hasSlides && checklist.hasCourseConfig) {
969
994
  // Course is built — run lint to decide polish vs export
970
995
  let lintPassed = false;
971
996
  try {
@@ -993,21 +1018,6 @@ export async function getWorkflowStatus(port = 4173) {
993
1018
  }
994
1019
  recommendedTool = 'coursecode_lint';
995
1020
  }
996
- } else if (!checklist.hasRawRefs && !checklist.hasConvertedRefs) {
997
- stage = 'source-ingestion';
998
- stageNumber = 1;
999
- nextAction = 'Add reference files (PDF, DOCX, PPTX, MD) to course/references/ and run coursecode convert';
1000
- recommendedTool = 'coursecode_workflow_status';
1001
- } else if (checklist.hasRawRefs && !checklist.hasConvertedRefs) {
1002
- stage = 'source-ingestion';
1003
- stageNumber = 1;
1004
- nextAction = 'Convert reference files to markdown: coursecode convert';
1005
- recommendedTool = 'coursecode_workflow_status';
1006
- } else if (!checklist.hasOutline) {
1007
- stage = 'outline-creation';
1008
- stageNumber = 2;
1009
- nextAction = 'Create course outline from reference materials. Stage instructions have all file paths.';
1010
- recommendedTool = 'coursecode_workflow_status';
1011
1021
  } else {
1012
1022
  stage = 'course-building';
1013
1023
  stageNumber = 3;
@@ -1050,7 +1060,7 @@ export async function buildCourse(options = {}) {
1050
1060
  const child = spawn('npx', ['vite', 'build'], {
1051
1061
  cwd: courseRoot,
1052
1062
  env,
1053
- shell: true,
1063
+ shell: process.platform === 'win32',
1054
1064
  stdio: ['ignore', 'pipe', 'pipe']
1055
1065
  });
1056
1066
 
@@ -1059,6 +1069,16 @@ export async function buildCourse(options = {}) {
1059
1069
 
1060
1070
  child.stdout.on('data', (data) => { _stdout += data.toString(); });
1061
1071
  child.stderr.on('data', (data) => { stderr += data.toString(); });
1072
+ child.on('error', (error) => {
1073
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
1074
+ resolve({
1075
+ success: false,
1076
+ error: error.message,
1077
+ errors: [error.message],
1078
+ warnings: [],
1079
+ duration
1080
+ });
1081
+ });
1062
1082
 
1063
1083
  child.on('close', (code) => {
1064
1084
  const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
@@ -1235,4 +1255,3 @@ export async function generateNarration(options = {}) {
1235
1255
  });
1236
1256
  });
1237
1257
  }
1238
-
@@ -9,7 +9,7 @@
9
9
 
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
- import archiver from 'archiver';
12
+ import { ZipArchive } from 'archiver';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { generateManifest } from './manifest/manifest-factory.js';
15
15
 
@@ -28,14 +28,36 @@ function sanitizeTitle(title) {
28
28
 
29
29
  function withClientCredentials(externalUrl, clientId, token) {
30
30
  if (!clientId || !token) return externalUrl;
31
- const separator = externalUrl.includes('?') ? '&' : '?';
32
- return `${externalUrl}${separator}clientId=${encodeURIComponent(clientId)}&token=${encodeURIComponent(token)}`;
31
+ const url = new URL(externalUrl);
32
+ url.searchParams.set('clientId', clientId);
33
+ url.searchParams.set('token', token);
34
+ return url.toString();
35
+ }
36
+
37
+ function validateExternalUrl(value) {
38
+ let url;
39
+ try {
40
+ url = new URL(value);
41
+ } catch {
42
+ throw new Error(`externalUrl must be an absolute URL, received: ${value}`);
43
+ }
44
+ const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
45
+ if (url.protocol !== 'https:' && !localHttp) {
46
+ throw new Error('externalUrl must use HTTPS (HTTP is allowed only for local development)');
47
+ }
48
+ if (url.hash) {
49
+ throw new Error('externalUrl must not contain a URL fragment because launch credentials would not reach the server');
50
+ }
51
+ if (url.username || url.password) {
52
+ throw new Error('externalUrl must not embed HTTP credentials');
53
+ }
54
+ return url;
33
55
  }
34
56
 
35
57
  function zipDirectory(sourceDir, zipFilePath) {
36
58
  return new Promise((resolve, reject) => {
37
59
  const output = fs.createWriteStream(zipFilePath);
38
- const archive = archiver('zip', { zlib: { level: 9 } });
60
+ const archive = new ZipArchive({ zlib: { level: 9 } });
39
61
 
40
62
  output.on('close', () => resolve(archive.pointer()));
41
63
  archive.on('error', reject);
@@ -55,6 +77,7 @@ export function validateExternalHostingConfig(config) {
55
77
  if (!config.externalUrl) {
56
78
  throw new Error(`${config.lmsFormat} format requires 'externalUrl' in course-config.js`);
57
79
  }
80
+ validateExternalUrl(config.externalUrl);
58
81
 
59
82
  if (config.accessControl?.enforcement !== 'server') {
60
83
  throw new Error(`${config.lmsFormat} requires accessControl.enforcement = 'server'. Browser-only token checks are not secure.`);
@@ -164,6 +187,10 @@ export async function createProxyPackage({ rootDir, config, clientId = null, tok
164
187
 
165
188
  let proxyHtml = fs.readFileSync(path.join(templatesDir, 'proxy.html'), 'utf-8');
166
189
  proxyHtml = proxyHtml.replace('{{EXTERNAL_URL_JSON}}', JSON.stringify(externalUrl));
190
+ proxyHtml = proxyHtml.replace(
191
+ '{{BASE_FORMAT_JSON}}',
192
+ JSON.stringify(config.lmsFormat === 'scorm1.2-proxy' ? 'scorm1.2' : 'scorm2004')
193
+ );
167
194
  fs.writeFileSync(path.join(proxyDir, 'proxy.html'), proxyHtml);
168
195
 
169
196
  fs.copyFileSync(path.join(templatesDir, 'scorm-bridge.js'), path.join(proxyDir, 'scorm-bridge.js'));
@@ -172,6 +199,22 @@ export async function createProxyPackage({ rootDir, config, clientId = null, tok
172
199
  const { filename, content } = generateManifest(config.lmsFormat, config, [], { externalUrl: config.externalUrl });
173
200
  fs.writeFileSync(path.join(proxyDir, filename), content);
174
201
 
202
+ const schemasDir = path.join(PACKAGE_ROOT, 'schemas');
203
+ for (const entry of fs.readdirSync(schemasDir, { withFileTypes: true })) {
204
+ if (entry.isFile() && /\.(?:xsd|dtd|xml)$/i.test(entry.name)) {
205
+ fs.copyFileSync(path.join(schemasDir, entry.name), path.join(proxyDir, entry.name));
206
+ }
207
+ }
208
+ fs.cpSync(path.join(schemasDir, 'common'), path.join(proxyDir, 'common'), { recursive: true });
209
+
210
+ const required = config.lmsFormat === 'scorm1.2-proxy'
211
+ ? ['imsmanifest.xml', 'proxy.html', 'scorm-bridge.js', 'pipwerks.js', 'imscp_rootv1p1p2.xsd', 'adlcp_rootv1p2.xsd', 'ims_xml.xsd']
212
+ : ['imsmanifest.xml', 'proxy.html', 'scorm-bridge.js', 'pipwerks.js', 'imscp_v1p1.xsd', 'adlcp_v1p3.xsd', 'imsss_v1p0.xsd'];
213
+ const missing = required.filter(file => !fs.existsSync(path.join(proxyDir, file)));
214
+ if (missing.length > 0 || proxyHtml.includes('{{')) {
215
+ throw new Error(`Proxy package validation failed: ${missing.length ? `missing ${missing.join(', ')}` : 'unresolved template placeholder'}`);
216
+ }
217
+
175
218
  if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
176
219
  const bytes = await zipDirectory(proxyDir, zipFilePath);
177
220
  const sizeKB = (bytes / 1024).toFixed(1);
@@ -206,7 +249,8 @@ export async function createRemotePackage({ rootDir, config, clientId = null, to
206
249
  const bytes = await zipDirectory(remoteDir, zipFilePath);
207
250
  const sizeKB = (bytes / 1024).toFixed(1);
208
251
  console.warn(`📦 Created ${zipFileName} (${sizeKB} KB) - Upload to LMS`);
209
- console.warn(` AU URL points to: ${externalUrl.replace(/\/$/, '')}/index.html`);
252
+ const auUrl = content.match(/<url>([^<]+)<\/url>/)?.[1] || externalUrl;
253
+ console.warn(` AU URL points to: ${auUrl}`);
210
254
  return zipFilePath;
211
255
  } finally {
212
256
  if (fs.existsSync(remoteDir)) fs.rmSync(remoteDir, { recursive: true });
package/lib/build.js CHANGED
@@ -17,7 +17,7 @@ function runCommand(cmd, args, options = {}) {
17
17
  const child = spawn(cmd, args, {
18
18
  cwd: process.cwd(),
19
19
  stdio: 'inherit',
20
- shell: true,
20
+ shell: process.platform === 'win32',
21
21
  env: options.env || process.env,
22
22
  ...options
23
23
  });
package/lib/cloud.js CHANGED
@@ -11,7 +11,7 @@ import crypto from 'crypto';
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import os from 'os';
14
- import { exec } from 'child_process';
14
+ import { execFile } from 'child_process';
15
15
  import readline from 'readline';
16
16
  import { fileURLToPath } from 'url';
17
17
 
@@ -427,10 +427,23 @@ function handleResponseError(status, body) {
427
427
  */
428
428
  function openBrowser(url) {
429
429
  const platform = process.platform;
430
- const cmd = platform === 'darwin' ? 'open'
431
- : platform === 'win32' ? 'start'
432
- : 'xdg-open';
433
- exec(`${cmd} "${url}"`);
430
+ let command;
431
+ let args;
432
+ if (platform === 'darwin') {
433
+ command = 'open';
434
+ args = [url];
435
+ } else if (platform === 'win32') {
436
+ command = 'rundll32';
437
+ args = ['url.dll,FileProtocolHandler', url];
438
+ } else {
439
+ command = 'xdg-open';
440
+ args = [url];
441
+ }
442
+
443
+ const child = execFile(command, args);
444
+ child.on('error', (error) => {
445
+ console.warn(` ⚠ Could not open the browser automatically: ${error.message}`);
446
+ });
434
447
  }
435
448
 
436
449
  /**
@@ -987,9 +1000,17 @@ export async function deploy(options = {}) {
987
1000
  // Reconcile local sourceType with cloud truth (handles unlink-via-dashboard)
988
1001
  try {
989
1002
  const statusData = JSON.parse(await statusRes.text());
990
- const serverSourceType = statusData.source?.type || statusData.source_type;
1003
+ const serverGithubRepo = statusData.source?.githubRepo || statusData.github_repo;
991
1004
  const localRc = readRcConfig();
992
- if (localRc?.sourceType === 'github' && serverSourceType !== 'github') {
1005
+ if (serverGithubRepo && (
1006
+ localRc?.sourceType !== 'github' || localRc?.githubRepo !== serverGithubRepo
1007
+ )) {
1008
+ updateRcConfig((rc) => {
1009
+ rc.sourceType = 'github';
1010
+ rc.githubRepo = serverGithubRepo;
1011
+ return rc;
1012
+ });
1013
+ } else if (localRc?.sourceType === 'github' && !serverGithubRepo) {
993
1014
  updateRcConfig((rc) => {
994
1015
  delete rc.sourceType;
995
1016
  delete rc.githubRepo;
@@ -1508,8 +1529,16 @@ export async function status(options = {}) {
1508
1529
 
1509
1530
  // Reconcile local sourceType with cloud truth (handles unlink-via-dashboard)
1510
1531
  const localRc = readRcConfig();
1511
- const serverSourceType = data.source?.type || data.source_type;
1512
- if (localRc?.sourceType === 'github' && serverSourceType !== 'github') {
1532
+ const serverGithubRepo = data.source?.githubRepo || data.github_repo;
1533
+ if (serverGithubRepo && (
1534
+ localRc?.sourceType !== 'github' || localRc?.githubRepo !== serverGithubRepo
1535
+ )) {
1536
+ updateRcConfig((rc) => {
1537
+ rc.sourceType = 'github';
1538
+ rc.githubRepo = serverGithubRepo;
1539
+ return rc;
1540
+ });
1541
+ } else if (localRc?.sourceType === 'github' && !serverGithubRepo) {
1513
1542
  updateRcConfig((rc) => {
1514
1543
  delete rc.sourceType;
1515
1544
  delete rc.githubRepo;
@@ -1527,9 +1556,9 @@ export async function status(options = {}) {
1527
1556
 
1528
1557
  console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
1529
1558
 
1530
- const sourceType = data.source?.type || data.source_type;
1531
1559
  const githubRepo = data.source?.githubRepo || data.github_repo;
1532
- if (sourceType === 'github' && githubRepo) {
1560
+ const sourceType = data.source?.type || data.source_type;
1561
+ if (githubRepo) {
1533
1562
  console.log(`Source: GitHub — ${githubRepo}`);
1534
1563
  console.log(' production=github | preview=cli+github');
1535
1564
  } else if (sourceType) {
package/lib/convert.js CHANGED
@@ -238,13 +238,41 @@ async function convertFile(filePath) {
238
238
  async function convertDocx(filePath) {
239
239
  const mammoth = await import('mammoth');
240
240
  const result = await mammoth.convertToMarkdown({ path: filePath });
241
+ const sanitized = replaceEmbeddedDataImages(result.value);
242
+ const warnings = result.messages.filter(m => m.type === 'warning');
243
+
244
+ if (sanitized.omittedImageCount > 0) {
245
+ warnings.push({
246
+ type: 'warning',
247
+ message: `Omitted ${sanitized.omittedImageCount} embedded image payload(s). Review the source document for visual content.`
248
+ });
249
+ }
241
250
 
242
251
  return {
243
- markdown: result.value,
244
- warnings: result.messages.filter(m => m.type === 'warning')
252
+ markdown: sanitized.markdown,
253
+ warnings
245
254
  };
246
255
  }
247
256
 
257
+ /**
258
+ * Replace mammoth's inline base64 image output with compact review markers.
259
+ * Reference Markdown is consumed by AI authoring tools, where multi-megabyte
260
+ * data URIs waste context and do not preserve useful visual meaning.
261
+ */
262
+ export function replaceEmbeddedDataImages(markdown) {
263
+ let omittedImageCount = 0;
264
+ const sanitized = markdown.replace(
265
+ /!\[([^\]]*)\]\(data:image\/[^;]+;base64,[^)]+\)/gi,
266
+ (_match, altText) => {
267
+ omittedImageCount++;
268
+ const label = altText?.trim() ? `: ${altText.trim()}` : '';
269
+ return `*[Embedded image omitted${label}. Review the source document for visual content.]*`;
270
+ }
271
+ );
272
+
273
+ return { markdown: sanitized, omittedImageCount };
274
+ }
275
+
248
276
  /**
249
277
  * Convert PPTX to markdown using node-pptx-parser
250
278
  */
package/lib/create.js CHANGED
@@ -9,6 +9,16 @@ import { spawn } from 'child_process';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const PACKAGE_ROOT = path.join(__dirname, '..');
12
+ const REQUIRED_PROJECT_DIRECTORIES = [
13
+ {
14
+ path: path.join('course', 'references'),
15
+ placeholder: '# Place source documents here (docx, pptx, pdf)\n# Run `coursecode convert` to convert them to markdown\n'
16
+ },
17
+ {
18
+ path: path.join('course', 'references', 'converted'),
19
+ placeholder: '# Converted markdown files will be placed here\n# These can be used as source material for AI-assisted course creation\n'
20
+ }
21
+ ];
12
22
 
13
23
  export function toProjectDirectoryName(name) {
14
24
  return String(name || '')
@@ -103,6 +113,18 @@ function copyDir(src, dest, options = {}) {
103
113
  }
104
114
  }
105
115
 
116
+ function ensureRequiredProjectDirectories(projectDir) {
117
+ for (const directory of REQUIRED_PROJECT_DIRECTORIES) {
118
+ const directoryPath = path.join(projectDir, directory.path);
119
+ const placeholderPath = path.join(directoryPath, '.gitkeep');
120
+ fs.mkdirSync(directoryPath, { recursive: true });
121
+
122
+ if (!fs.existsSync(placeholderPath)) {
123
+ fs.writeFileSync(placeholderPath, directory.placeholder, 'utf-8');
124
+ }
125
+ }
126
+ }
127
+
106
128
  function mergeProjectControlFile(sourcePath, destinationPath) {
107
129
  const source = fs.readFileSync(sourcePath, 'utf-8').trimEnd();
108
130
  if (!fs.existsSync(destinationPath)) {
@@ -150,7 +172,7 @@ function npmInstall(cwd) {
150
172
  const child = spawn(command, args, {
151
173
  cwd,
152
174
  stdio: 'inherit',
153
- shell: !npmCli
175
+ shell: !npmCli && process.platform === 'win32'
154
176
  });
155
177
 
156
178
  child.on('close', (code) => {
@@ -160,6 +182,7 @@ function npmInstall(cwd) {
160
182
  reject(new Error(`npm install failed with code ${code}`));
161
183
  }
162
184
  });
185
+ child.on('error', reject);
163
186
  });
164
187
  }
165
188
 
@@ -171,7 +194,7 @@ function gitInit(cwd) {
171
194
  const child = spawn('git', ['init'], {
172
195
  cwd,
173
196
  stdio: 'pipe',
174
- shell: true
197
+ shell: false
175
198
  });
176
199
 
177
200
  child.on('close', (code) => {
@@ -181,6 +204,7 @@ function gitInit(cwd) {
181
204
  reject(new Error(`git init failed with code ${code}`));
182
205
  }
183
206
  });
207
+ child.on('error', reject);
184
208
  });
185
209
  }
186
210
 
@@ -231,6 +255,7 @@ export async function create(name, options = {}) {
231
255
  });
232
256
  mergeProjectControlFile(path.join(templateDir, 'gitignore'), path.join(targetDir, '.gitignore'));
233
257
  mergeProjectControlFile(path.join(templateDir, 'gitattributes'), path.join(targetDir, '.gitattributes'));
258
+ ensureRequiredProjectDirectories(targetDir);
234
259
 
235
260
  // Copy framework
236
261
  const frameworkSrc = path.join(PACKAGE_ROOT, 'framework');
@@ -361,7 +386,7 @@ export async function create(name, options = {}) {
361
386
  const child = spawn(command, args, {
362
387
  cwd: targetDir,
363
388
  stdio: 'inherit',
364
- shell: !npxCli
389
+ shell: !npxCli && process.platform === 'win32'
365
390
  });
366
391
 
367
392
  child.on('error', () => {
package/lib/dev.js CHANGED
@@ -33,7 +33,7 @@ export async function dev(options = {}) {
33
33
  const viteBuild = spawn('npx', ['vite', 'build', '--mode', 'development', '--watch'], {
34
34
  cwd: process.cwd(),
35
35
  stdio: 'inherit',
36
- shell: true,
36
+ shell: process.platform === 'win32',
37
37
  env
38
38
  });
39
39