coursecode 0.1.47 → 0.1.49

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/README.md CHANGED
@@ -6,6 +6,16 @@ CourseCode creates real project files you can inspect, version, and edit directl
6
6
 
7
7
  Bring your own PDFs, Word docs, or PowerPoints, use AI to accelerate authoring, and deploy to any LMS format without vendor lock-in or subscriptions.
8
8
 
9
+ ## View the demo
10
+
11
+ - [View the live CourseCode demo course](https://preview.coursecodecloud.com/preview/coursecode-demo/nTfJU2qvp23P0mguxrrGFZr4FlXfM03N)
12
+
13
+ ## Explore the ecosystem
14
+
15
+ - [Build SCORM courses with the framework](https://coursecodeframework.com/scorm/)
16
+ - [Use CourseCode Desktop if you prefer a GUI](https://coursecodedesktop.com/scorm/)
17
+ - [Use CourseCode Cloud for hosted delivery](https://coursecodecloud.com)
18
+
9
19
  ## Features
10
20
 
11
21
  - **MCP integration**: Works with Claude Code, Codex, Cursor, CourseCode Desktop, and any MCP-capable AI tool — previews, screenshots, linting, and testing without manual file sharing
package/lib/create.js CHANGED
@@ -10,6 +10,44 @@ import { spawn } from 'child_process';
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const PACKAGE_ROOT = path.join(__dirname, '..');
12
12
 
13
+ export function toProjectDirectoryName(name) {
14
+ return String(name || '')
15
+ .trim()
16
+ .normalize('NFKD')
17
+ .replace(/[\u0300-\u036f]/g, '')
18
+ .toLowerCase()
19
+ .replace(/['’]/g, '')
20
+ .replace(/[^a-z0-9]+/g, '_')
21
+ .replace(/^_+|_+$/g, '');
22
+ }
23
+
24
+ function escapeSingleQuotedValue(value) {
25
+ return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
26
+ }
27
+
28
+ export function stampCourseTitle(configContent, title) {
29
+ const titleValue = escapeSingleQuotedValue(title);
30
+ let nextContent = configContent.replace(
31
+ /^(\s*)title\s*:\s*['"`][^'"`]*['"`](\s*,?)/m,
32
+ `$1title: '${titleValue}'$2`
33
+ );
34
+
35
+ nextContent = nextContent.replace(
36
+ /^(\s*)courseTitle\s*:\s*['"`][^'"`]*['"`](\s*,?)/m,
37
+ `$1courseTitle: '${titleValue}'$2`
38
+ );
39
+
40
+ return nextContent;
41
+ }
42
+
43
+ function writeCourseTitle(projectDir, title) {
44
+ const configPath = path.join(projectDir, 'course', 'course-config.js');
45
+ if (!fs.existsSync(configPath)) return;
46
+
47
+ const current = fs.readFileSync(configPath, 'utf-8');
48
+ fs.writeFileSync(configPath, stampCourseTitle(current, title), 'utf-8');
49
+ }
50
+
13
51
  /**
14
52
  * Copy directory recursively
15
53
  */
@@ -85,15 +123,31 @@ function gitInit(cwd) {
85
123
  }
86
124
 
87
125
  export async function create(name, options = {}) {
88
- const targetDir = path.resolve(process.cwd(), name);
126
+ const displayName = String(name || '').trim();
127
+ const directoryName = toProjectDirectoryName(displayName);
128
+
129
+ if (!displayName) {
130
+ console.error('\n❌ Course name is required.\n');
131
+ process.exit(1);
132
+ }
133
+
134
+ if (!directoryName) {
135
+ console.error('\n❌ Course name must include letters or numbers.\n');
136
+ process.exit(1);
137
+ }
138
+
139
+ const targetDir = path.resolve(process.cwd(), directoryName);
89
140
 
90
141
  // Check if directory already exists
91
142
  if (fs.existsSync(targetDir)) {
92
- console.error(`\n❌ Directory "${name}" already exists.\n`);
143
+ console.error(`\n❌ Directory "${directoryName}" already exists.\n`);
93
144
  process.exit(1);
94
145
  }
95
146
 
96
- console.log(`\n🚀 Creating CourseCode project: ${name}\n`);
147
+ console.log(`\n🚀 Creating CourseCode project: ${displayName}\n`);
148
+ if (directoryName !== displayName) {
149
+ console.log(` Project folder: ${directoryName}`);
150
+ }
97
151
 
98
152
  // Create project directory
99
153
  fs.mkdirSync(targetDir, { recursive: true });
@@ -138,7 +192,7 @@ export async function create(name, options = {}) {
138
192
  // Read and customize package.json
139
193
  const pkgPath = path.join(targetDir, 'package.json');
140
194
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
141
- pkg.name = name;
195
+ pkg.name = directoryName;
142
196
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
143
197
 
144
198
  // Create .coursecoderc.json to track framework version
@@ -161,6 +215,8 @@ export async function create(name, options = {}) {
161
215
  clean({ basePath: targetDir });
162
216
  }
163
217
 
218
+ writeCourseTitle(targetDir, displayName);
219
+
164
220
  // Install dependencies
165
221
  if (options.install !== false) {
166
222
  console.log('\n Installing dependencies...\n');
@@ -184,7 +240,7 @@ export async function create(name, options = {}) {
184
240
  // Print success message
185
241
  if (options.blank) {
186
242
  console.log(`
187
- ✅ CourseCode project "${name}" created (blank starter)!
243
+ ✅ CourseCode project "${displayName}" created (blank starter)!
188
244
 
189
245
  Course files:
190
246
  - course/course-config.js - Course metadata & structure (minimal)
@@ -199,7 +255,7 @@ export async function create(name, options = {}) {
199
255
  `);
200
256
  } else {
201
257
  console.log(`
202
- ✅ CourseCode project "${name}" created successfully!
258
+ ✅ CourseCode project "${displayName}" created successfully!
203
259
 
204
260
  Course files:
205
261
  - course/course-config.js - Course metadata & structure
@@ -228,9 +284,15 @@ export async function create(name, options = {}) {
228
284
 
229
285
  child.on('error', () => {
230
286
  console.warn(' ⚠️ Failed to start dev server. Run manually:');
231
- console.log(` cd ${name} && coursecode dev\n`);
287
+ console.log(` cd ${directoryName} && coursecode dev\n`);
232
288
  });
233
289
  } else {
234
- console.log(`\n To start developing:\n\n cd ${name}\n coursecode dev\n`);
290
+ console.log(`\n To start developing:\n\n cd ${directoryName}\n coursecode dev\n`);
235
291
  }
292
+
293
+ return {
294
+ displayName,
295
+ directoryName,
296
+ targetDir
297
+ };
236
298
  }
package/lib/import.js CHANGED
@@ -179,9 +179,9 @@ export async function importPresentation(source, options = {}) {
179
179
 
180
180
  // Create blank project (no example slides — they'd just be deleted)
181
181
  console.log(' ⏳ Creating course project...\n');
182
- await create(name, { blank: true, install: options.install });
182
+ const createdProject = await create(name, { blank: true, install: options.install });
183
183
 
184
- const targetDir = path.resolve(process.cwd(), name);
184
+ const targetDir = createdProject?.targetDir || path.resolve(process.cwd(), name);
185
185
  const courseDir = path.join(targetDir, 'course');
186
186
 
187
187
  // Import presentation in-place
@@ -20,6 +20,9 @@ import { createLoginHandlers } from './login-screen.js';
20
20
  const config = window.STUB_CONFIG || {};
21
21
  const LAUNCH_URL = config.launchUrl || '/';
22
22
  const START_SLIDE = config.startSlide || null;
23
+ const QUERY = new URLSearchParams(window.location.search);
24
+ const SHOW_HEADER = resolveShowHeader();
25
+ const INITIAL_SKIP_GATING = resolveInitialSkipGating();
23
26
 
24
27
  // State
25
28
  let isInitialized = false;
@@ -44,27 +47,38 @@ function init() {
44
47
  ? createContentViewerHandlers({ initialContent: config.courseContent || null })
45
48
  : null;
46
49
 
50
+ document.body.classList.toggle('stub-player-header-hidden', !SHOW_HEADER);
51
+
47
52
  // Header Bar — viewer mode: Review + More menu (skip gating, reset)
48
- createHeaderBarHandlers({
49
- onToggle: () => {},
50
- onContent: () => {
51
- if (contentPanel) {
52
- contentPanel.classList.toggle('visible');
53
- if (contentPanel.classList.contains('visible') && !contentLoaded && contentHandlers) {
54
- contentHandlers.loadContent();
55
- contentLoaded = true;
53
+ if (SHOW_HEADER) {
54
+ createHeaderBarHandlers({
55
+ onToggle: () => {},
56
+ onContent: () => {
57
+ if (contentPanel) {
58
+ contentPanel.classList.toggle('visible');
59
+ if (contentPanel.classList.contains('visible') && !contentLoaded && contentHandlers) {
60
+ contentHandlers.loadContent();
61
+ contentLoaded = true;
62
+ }
56
63
  }
57
- }
58
- },
59
- onReset: () => doReset(),
60
- onSkipGating: (enabled) => {
61
- if (enabled) {
62
- loadCourse();
63
- } else {
64
- doReset();
65
- }
64
+ },
65
+ onReset: () => doReset(),
66
+ onSkipGating: (enabled) => {
67
+ if (enabled) {
68
+ loadCourse();
69
+ } else {
70
+ doReset();
71
+ }
72
+ },
73
+ initialSkipGating: INITIAL_SKIP_GATING
74
+ });
75
+ } else if (INITIAL_SKIP_GATING !== null) {
76
+ try {
77
+ localStorage.setItem('coursecode-skipGating', INITIAL_SKIP_GATING ? 'true' : 'false');
78
+ } catch {
79
+ // ignore storage failures
66
80
  }
67
- });
81
+ }
68
82
 
69
83
  // Load course if no login screen active
70
84
  if (!document.getElementById('stub-player-login-screen')?.classList.contains('visible')) {
@@ -75,13 +89,39 @@ function init() {
75
89
  setupOutsideClickListener(contentPanel);
76
90
  }
77
91
 
92
+ function resolveShowHeader() {
93
+ const previewHeader = QUERY.get('previewHeader');
94
+ if (previewHeader === 'hidden') return false;
95
+ if (previewHeader === 'visible') return true;
96
+
97
+ const hideHeader = resolveBooleanQuery('hideHeader');
98
+ if (hideHeader !== null) return !hideHeader;
99
+
100
+ return config.showHeader !== false;
101
+ }
102
+
103
+ function resolveInitialSkipGating() {
104
+ const querySkipGating = resolveBooleanQuery('skipGating');
105
+ if (querySkipGating !== null) return querySkipGating;
106
+ return typeof config.skipGating === 'boolean' ? config.skipGating : null;
107
+ }
108
+
109
+ function resolveBooleanQuery(name) {
110
+ const value = QUERY.get(name);
111
+ if (value === null) return null;
112
+ const normalized = value.toLowerCase();
113
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
114
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
115
+ return null;
116
+ }
117
+
78
118
  // =============================================================================
79
119
  // COURSE LOADING & NAVIGATION
80
120
  // =============================================================================
81
121
 
82
122
  function loadCourse() {
83
123
  const frame = document.getElementById('stub-player-course-frame');
84
- const skipGating = document.getElementById('stub-player-skip-gating')?.checked;
124
+ const skipGating = resolveSkipGating();
85
125
  let url = LAUNCH_URL;
86
126
  if (skipGating) url += (url.includes('?') ? '&' : '?') + 'skipGating=true';
87
127
  frame.src = url;
@@ -99,7 +139,7 @@ function handleFrameLoad() {
99
139
  win.cmi5 = window.cmi5;
100
140
  win.lti = window.lti;
101
141
 
102
- if (document.getElementById('stub-player-skip-gating')?.checked) {
142
+ if (resolveSkipGating()) {
103
143
  win.__SCORM_PREVIEW_SKIP_GATING = true;
104
144
  }
105
145
 
@@ -115,6 +155,11 @@ function handleFrameLoad() {
115
155
  }
116
156
  }
117
157
 
158
+ function resolveSkipGating() {
159
+ if (INITIAL_SKIP_GATING !== null) return INITIAL_SKIP_GATING;
160
+ return document.getElementById('stub-player-skip-gating')?.checked === true;
161
+ }
162
+
118
163
  function navigateToSlide(contentWindow, slideIdOrIndex) {
119
164
  const maxAttempts = 50;
120
165
  let attempts = 0;
@@ -158,10 +203,13 @@ function resolveSlideId(contentWindow, slideIdOrIndex) {
158
203
  // =============================================================================
159
204
 
160
205
  function doReset() {
161
- // Static/cloud export: clear LMS localStorage and reload (no server route)
162
- const storageKey = config.storageKey;
163
- if (storageKey) {
164
- try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
206
+ // Viewer previews share an origin between the stub player and course iframe.
207
+ // Match the local /__reset route by clearing course/framework progress too.
208
+ let skipGating = null;
209
+ try { skipGating = localStorage.getItem('coursecode-skipGating'); } catch { /* ignore */ }
210
+ try { localStorage.clear(); } catch { /* ignore */ }
211
+ if (skipGating !== null) {
212
+ try { localStorage.setItem('coursecode-skipGating', skipGating); } catch { /* ignore */ }
165
213
  }
166
214
  window.location.reload();
167
215
  }
@@ -102,9 +102,10 @@ export function generateHeaderBar({ isLive, hasContent }) {
102
102
  * @param {Function} callbacks.onReset - () => void
103
103
  * @param {Function} callbacks.onSkipGating - (enabled) => void
104
104
  * @param {Function} callbacks.onStatus - () => void
105
+ * @param {boolean|null} callbacks.initialSkipGating - query/config override for skip-gating state
105
106
  */
106
107
  export function createHeaderBarHandlers(callbacks) {
107
- const { onToggle, onDebug, onConfig, onContent, onInteract, onCatalog, onEdit, onReset, onSkipGating, onStatus } = callbacks;
108
+ const { onToggle, onDebug, onConfig, onContent, onInteract, onCatalog, onEdit, onReset, onSkipGating, onStatus, initialSkipGating = null } = callbacks;
108
109
  const closeMoreMenu = () => {
109
110
  document.getElementById('stub-player-more-menu')?.classList.remove('visible');
110
111
  };
@@ -211,6 +212,7 @@ export function createHeaderBarHandlers(callbacks) {
211
212
  if (skipGatingCheckbox) {
212
213
  // Restore persisted state (defaults to true / skip on)
213
214
  const persistedSkip = (() => {
215
+ if (initialSkipGating !== null) return initialSkipGating;
214
216
  try {
215
217
  const stored = localStorage.getItem(SKIP_GATING_STORAGE_KEY);
216
218
  return stored !== 'false';
@@ -333,6 +333,15 @@
333
333
  height: 100%;
334
334
  }
335
335
 
336
+ body.stub-player-header-hidden #stub-player-header {
337
+ display: none;
338
+ }
339
+
340
+ body.stub-player-header-hidden #stub-player-course-frame {
341
+ top: 0;
342
+ height: 100%;
343
+ }
344
+
336
345
  /* Responsive: icon-only mode on narrow viewports */
337
346
  @media (max-width: 820px) {
338
347
  #stub-player-header .btn-label {
@@ -7,6 +7,8 @@ export interface StubPlayerConfig {
7
7
  liveReload?: boolean
8
8
  courseContent?: string
9
9
  startSlide?: string | number
10
+ showHeader?: boolean
11
+ skipGating?: boolean | null
10
12
  isDesktop?: boolean
11
13
  moduleBasePath?: string
12
14
  }
@@ -53,12 +53,15 @@ export { escapeHtml };
53
53
  * @param {boolean} [config.liveReload] - True to enable live reload via SSE
54
54
  * @param {string} [config.courseContent] - Markdown/HTML content for the content viewer
55
55
  * @param {string|number} [config.startSlide] - Slide ID or index to navigate to on load
56
+ * @param {boolean} [config.showHeader] - False to hide the preview player header
57
+ * @param {boolean} [config.skipGating] - True to bypass navigation locks
56
58
  * @returns {string} - Complete HTML for the player page
57
59
  */
58
60
  export function generateStubPlayer(config) {
59
- const { title, launchUrl, storageKey, passwordHash, isLive, liveReload, courseContent, startSlide, isDesktop, moduleBasePath = '/__stub-player' } = config;
61
+ const { title, launchUrl, storageKey, passwordHash, isLive, liveReload, courseContent, startSlide, isDesktop, showHeader = true, skipGating = null, moduleBasePath = '/__stub-player' } = config;
60
62
  const hasPassword = !!passwordHash;
61
63
  const hasContent = !!courseContent;
64
+ const headerVisible = showHeader !== false;
62
65
 
63
66
  const stubPlayerStyles = loadStyles(isLive);
64
67
 
@@ -73,7 +76,7 @@ export function generateStubPlayer(config) {
73
76
  ${stubPlayerStyles}
74
77
  </style>
75
78
  </head>
76
- <body>
79
+ <body class="${headerVisible ? '' : 'stub-player-header-hidden'}">
77
80
  ${hasPassword ? generateLoginScreen({ title: escapeHtml(title) }) : ''}
78
81
 
79
82
  <iframe id="stub-player-course-frame" name="stub-player-course-frame"></iframe>
@@ -109,6 +112,8 @@ ${stubPlayerStyles}
109
112
  liveReload: ${liveReload || false},
110
113
  startSlide: ${startSlide !== undefined ? JSON.stringify(startSlide) : 'null'},
111
114
  courseContent: ${hasContent ? JSON.stringify(courseContent) : 'null'},
115
+ showHeader: ${headerVisible},
116
+ skipGating: ${typeof skipGating === 'boolean' ? skipGating : 'null'},
112
117
  isDesktop: ${isDesktop || false},
113
118
  isCI: ${!!process.env.CI}
114
119
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
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": {