coursecode 0.1.54 → 0.1.56

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
@@ -235,7 +235,8 @@ coursecode preview --export
235
235
 
236
236
  | Command | Description |
237
237
  |---------|-------------|
238
- | `coursecode create <name>` | Create a new course project |
238
+ | `coursecode create <name>` | Create a new course project; `create .` derives a title-cased course title and hyphenated npm name from the current folder |
239
+ | `coursecode init [name]` | Initialize the current directory, optionally with a course title |
239
240
  | `coursecode preview` | Preview your course locally |
240
241
  | `coursecode convert` | Convert PDFs, Word, PowerPoint to markdown |
241
242
  | `coursecode mcp` | Start the MCP server for AI integration |
package/bin/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Commands:
7
7
  * coursecode create <name> - Create a new course project
8
+ * coursecode init [name] - Initialize the current directory
8
9
  * coursecode dev - Start development server
9
10
  * coursecode build - Build course package
10
11
  * coursecode upgrade - Upgrade framework in current project
@@ -79,7 +80,7 @@ program
79
80
  // Create command
80
81
  program
81
82
  .command('create <name>')
82
- .description('Create a new course project')
83
+ .description('Create a new course project (use "." for the current directory)')
83
84
  .option('--blank', 'Create without example slides (clean starter)')
84
85
  .option('--no-install', 'Skip npm install')
85
86
  .option('--start', 'Auto-start dev server after creation (skip prompt)')
@@ -88,6 +89,17 @@ program
88
89
  await create(name, options);
89
90
  });
90
91
 
92
+ program
93
+ .command('init [name]')
94
+ .description('Initialize the current directory as a CourseCode project')
95
+ .option('--blank', 'Create without example content (clean starter)')
96
+ .option('--no-install', 'Skip npm install')
97
+ .option('--start', 'Auto-start dev server after creation (skip prompt)')
98
+ .action(async (name, options) => {
99
+ const { create } = await import('../lib/create.js');
100
+ await create(name || '.', { ...options, currentDirectory: true });
101
+ });
102
+
91
103
  // Dev command
92
104
  program
93
105
  .command('dev')
@@ -16,6 +16,8 @@
16
16
  |---------|-------------|
17
17
  | `coursecode create <name>` | Create a new course project (includes example slides) |
18
18
  | `coursecode create <name> --blank` | Create a blank project (no example content) |
19
+ | `coursecode create .` | Initialize the current directory; `client-manager-course` becomes title `Client Manager Course` and package name `client-manager-course` |
20
+ | `coursecode init [name]` | Initialize the current directory, optionally with a specific course title |
19
21
  | `coursecode clean` | Remove example files and reset config to minimal starter |
20
22
  | `coursecode new slide <id>` | Create a new slide file in `course/slides/` |
21
23
  | `coursecode new assessment <id>` | Create a new assessment file in `course/slides/` |
@@ -253,7 +255,7 @@ coursecode export-content --format json -o content.json
253
255
  3. **Create slide** in `course/slides/intro.js`:
254
256
  ```javascript
255
257
  export const slide = {
256
- render(root, context) {
258
+ render(_root, _context) {
257
259
  const container = document.createElement('div');
258
260
  container.innerHTML = `<h1>Welcome</h1><p>Content here</p>`;
259
261
  return container; // Must return a DOM element
@@ -261,6 +263,8 @@ coursecode export-content --format json -o content.json
261
263
  };
262
264
  ```
263
265
 
266
+ The first render argument is reserved and currently receives `null`. Keep it as `_root` for API compatibility. Create and return a new root `HTMLElement`; use the second argument only when the slide needs navigation or assessment context.
267
+
264
268
  > **⚠️ No import statements.** Components, interactions, CSS classes, and icons are all globally available at runtime. The only valid `import` is for local assets (images, SVGs). Access framework APIs via `const { createXxxQuestion } = CourseCode;` (destructure from global, **not** an import).
265
269
 
266
270
  4. **Add styles** to `course/theme.css` (only for custom branding - use framework utility classes first, see CSS Quick Reference section below)
@@ -310,7 +310,7 @@ Separates data, presentation, logic (used in `app/`, `navigation/`):
310
310
 
311
311
  - **Single ViewManager** in `main.js` controls slide navigation
312
312
  - **No caching** - views render fresh each `showView()` to prevent stale data
313
- - Slide signature: `render(root, context)` where `root` is framework-provided container
313
+ - Slide signature: `render(_root, context)`. The first argument is reserved and currently `null`; slides create and return their own root `HTMLElement`. The second argument carries navigation/render context.
314
314
  - Components with sub-views (assessments) create own ViewManager
315
315
 
316
316
  ### Event Delegation
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.1.54",
2
+ "version": "0.1.56",
3
3
  "name": "CourseCode Framework",
4
4
  "description": "Multi-format course authoring and learner runtime framework",
5
- "released": "2026-07-10",
5
+ "released": "2026-07-11",
6
6
  "formats": [
7
7
  "SCORM 2004 4th Edition",
8
8
  "SCORM 1.2",
package/lib/create.js CHANGED
@@ -21,6 +21,32 @@ export function toProjectDirectoryName(name) {
21
21
  .replace(/^_+|_+$/g, '');
22
22
  }
23
23
 
24
+ function directoryNameWords(name) {
25
+ return String(name || '')
26
+ .trim()
27
+ .normalize('NFKD')
28
+ .replace(/[\u0300-\u036f]/g, '')
29
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
30
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
31
+ .replace(/['’]/g, '')
32
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
33
+ .trim()
34
+ .split(/\s+/)
35
+ .filter(Boolean);
36
+ }
37
+
38
+ export function toCurrentDirectoryCourseTitle(name) {
39
+ return directoryNameWords(name)
40
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
41
+ .join(' ');
42
+ }
43
+
44
+ export function toCurrentDirectoryPackageName(name) {
45
+ return directoryNameWords(name)
46
+ .map(word => word.toLowerCase())
47
+ .join('-');
48
+ }
49
+
24
50
  function escapeSingleQuotedValue(value) {
25
51
  return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
26
52
  }
@@ -77,6 +103,42 @@ function copyDir(src, dest, options = {}) {
77
103
  }
78
104
  }
79
105
 
106
+ function mergeProjectControlFile(sourcePath, destinationPath) {
107
+ const source = fs.readFileSync(sourcePath, 'utf-8').trimEnd();
108
+ if (!fs.existsSync(destinationPath)) {
109
+ fs.writeFileSync(destinationPath, `${source}\n`, 'utf-8');
110
+ return;
111
+ }
112
+
113
+ const current = fs.readFileSync(destinationPath, 'utf-8');
114
+ const existingLines = new Set(current.split(/\r?\n/).map(line => line.trim()));
115
+ const missingRules = source
116
+ .split(/\r?\n/)
117
+ .filter(line => line.trim() && !line.trim().startsWith('#') && !existingLines.has(line.trim()));
118
+
119
+ if (missingRules.length === 0) return;
120
+
121
+ const separator = current.endsWith('\n') ? '\n' : '\n\n';
122
+ fs.appendFileSync(
123
+ destinationPath,
124
+ `${separator}# CourseCode project defaults\n${missingRules.join('\n')}\n`,
125
+ 'utf-8'
126
+ );
127
+ }
128
+
129
+ function assertCurrentDirectoryCanBeInitialized(targetDir, templateDir) {
130
+ const managedPaths = fs.readdirSync(templateDir)
131
+ .filter(name => !['.DS_Store', 'gitignore', 'gitattributes'].includes(name))
132
+ .concat(['framework', 'schemas', 'lib', '.coursecoderc.json']);
133
+ const conflicts = managedPaths.filter(name => fs.existsSync(path.join(targetDir, name)));
134
+
135
+ if (conflicts.length > 0) {
136
+ throw new Error(
137
+ `Cannot initialize the current directory because CourseCode-managed paths already exist: ${conflicts.join(', ')}`
138
+ );
139
+ }
140
+ }
141
+
80
142
  /**
81
143
  * Run npm install in directory
82
144
  */
@@ -123,8 +185,16 @@ function gitInit(cwd) {
123
185
  }
124
186
 
125
187
  export async function create(name, options = {}) {
126
- const displayName = String(name || '').trim();
127
- const directoryName = toProjectDirectoryName(displayName);
188
+ const requestedName = String(name || '').trim();
189
+ const currentDirectory = options.currentDirectory === true || requestedName === '.';
190
+ const inferFromCurrentDirectory = currentDirectory && requestedName === '.';
191
+ const currentDirectoryName = path.basename(process.cwd());
192
+ const displayName = inferFromCurrentDirectory
193
+ ? toCurrentDirectoryCourseTitle(currentDirectoryName)
194
+ : requestedName;
195
+ const directoryName = inferFromCurrentDirectory
196
+ ? toCurrentDirectoryPackageName(currentDirectoryName)
197
+ : toProjectDirectoryName(displayName);
128
198
 
129
199
  if (!displayName) {
130
200
  console.error('\n❌ Course name is required.\n');
@@ -136,16 +206,18 @@ export async function create(name, options = {}) {
136
206
  process.exit(1);
137
207
  }
138
208
 
139
- const targetDir = path.resolve(process.cwd(), directoryName);
209
+ const targetDir = currentDirectory ? path.resolve(process.cwd()) : path.resolve(process.cwd(), directoryName);
210
+ const templateDir = path.join(PACKAGE_ROOT, 'template');
140
211
 
141
- // Check if directory already exists
142
- if (fs.existsSync(targetDir)) {
212
+ if (currentDirectory) {
213
+ assertCurrentDirectoryCanBeInitialized(targetDir, templateDir);
214
+ } else if (fs.existsSync(targetDir)) {
143
215
  console.error(`\n❌ Directory "${directoryName}" already exists.\n`);
144
216
  process.exit(1);
145
217
  }
146
218
 
147
219
  console.log(`\n🚀 Creating CourseCode project: ${displayName}\n`);
148
- if (directoryName !== displayName) {
220
+ if (!currentDirectory && directoryName !== displayName) {
149
221
  console.log(` Project folder: ${directoryName}`);
150
222
  }
151
223
 
@@ -153,11 +225,12 @@ export async function create(name, options = {}) {
153
225
  fs.mkdirSync(targetDir, { recursive: true });
154
226
 
155
227
  // Copy template files (course directory and vite config)
156
- const templateDir = path.join(PACKAGE_ROOT, 'template');
157
228
  console.log(' Copying template files...');
158
229
  copyDir(templateDir, targetDir, {
159
- exclude: [/^\.DS_Store$/, /^node_modules$/]
230
+ exclude: [/^\.DS_Store$/, /^node_modules$/, /^gitignore$/, /^gitattributes$/]
160
231
  });
232
+ mergeProjectControlFile(path.join(templateDir, 'gitignore'), path.join(targetDir, '.gitignore'));
233
+ mergeProjectControlFile(path.join(templateDir, 'gitattributes'), path.join(targetDir, '.gitattributes'));
161
234
 
162
235
  // Copy framework
163
236
  const frameworkSrc = path.join(PACKAGE_ROOT, 'framework');
@@ -189,14 +262,19 @@ export async function create(name, options = {}) {
189
262
  console.log(' Copying proxy packaging templates...');
190
263
  copyDir(proxyTemplatesSrc, proxyTemplatesDest);
191
264
 
192
- // Read and customize package.json
265
+ // Read and customize package.json. Pin the authoring/build CLI to the
266
+ // version that created the project so an isolated checkout can build.
267
+ const frameworkPkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
193
268
  const pkgPath = path.join(targetDir, 'package.json');
194
269
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
195
270
  pkg.name = directoryName;
271
+ pkg.devDependencies = {
272
+ ...pkg.devDependencies,
273
+ coursecode: `^${frameworkPkg.version}`
274
+ };
196
275
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
197
276
 
198
277
  // Create .coursecoderc.json to track framework version
199
- const frameworkPkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
200
278
  const coursecoderc = {
201
279
  frameworkVersion: frameworkPkg.version,
202
280
  createdAt: new Date().toISOString(),
@@ -212,7 +290,7 @@ export async function create(name, options = {}) {
212
290
  // If --blank, remove example files and reset config
213
291
  if (options.blank) {
214
292
  const { clean } = await import('./scaffold.js');
215
- clean({ basePath: targetDir });
293
+ clean({ basePath: targetDir, blank: true });
216
294
  }
217
295
 
218
296
  writeCourseTitle(targetDir, displayName);
@@ -229,12 +307,16 @@ export async function create(name, options = {}) {
229
307
  }
230
308
 
231
309
  // Initialize git repository
232
- console.log('\n Initializing git repository...');
233
- try {
234
- await gitInit(targetDir);
235
- console.log(' Git repository initialized');
236
- } catch (_error) {
237
- console.warn(' ⚠️ Git init failed. You can run "git init" manually.');
310
+ if (fs.existsSync(path.join(targetDir, '.git'))) {
311
+ console.log('\n ✅ Existing Git repository preserved');
312
+ } else {
313
+ console.log('\n Initializing git repository...');
314
+ try {
315
+ await gitInit(targetDir);
316
+ console.log(' ✅ Git repository initialized');
317
+ } catch (_error) {
318
+ console.warn(' ⚠️ Git init failed. You can run "git init" manually.');
319
+ }
238
320
  }
239
321
 
240
322
  // Print success message
@@ -287,12 +369,14 @@ export async function create(name, options = {}) {
287
369
  console.log(` cd ${directoryName} && coursecode dev\n`);
288
370
  });
289
371
  } else {
290
- console.log(`\n To start developing:\n\n cd ${directoryName}\n coursecode dev\n`);
372
+ const changeDirectory = currentDirectory ? '' : `cd ${directoryName}\n `;
373
+ console.log(`\n To start developing:\n\n ${changeDirectory}coursecode dev\n`);
291
374
  }
292
375
 
293
376
  return {
294
377
  displayName,
295
378
  directoryName,
296
- targetDir
379
+ targetDir,
380
+ currentDirectory
297
381
  };
298
382
  }
package/lib/scaffold.js CHANGED
@@ -34,13 +34,24 @@ const MINIMAL_CONFIG = `export const courseConfig = {
34
34
  };
35
35
  `;
36
36
 
37
+ const DEMO_ASSETS = [
38
+ 'docs/example_md_1.md',
39
+ 'docs/example_md_2.md',
40
+ 'docs/example_pdf_1_thumbnail.png',
41
+ 'docs/example_pdf_2.pdf',
42
+ 'images/course-architecture.svg',
43
+ 'images/logo.svg',
44
+ 'widgets/counter-demo.html',
45
+ 'widgets/gravity-painter.html'
46
+ ];
47
+
37
48
  /**
38
49
  * Minimal slide template
39
50
  */
40
51
  function slideTemplate(id) {
41
52
  const title = id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
42
53
  return `export const slide = {
43
- render(root, context) {
54
+ render(_root, _context) {
44
55
  const container = document.createElement('div');
45
56
  container.innerHTML = \`
46
57
  <h1>${title}</h1>
@@ -83,9 +94,11 @@ const questions = [
83
94
  ];
84
95
 
85
96
  export const slide = {
86
- render(root) {
87
- const assessment = AssessmentManager.createAssessment(config, questions);
88
- return assessment.render(root);
97
+ render(_root, context = {}) {
98
+ const container = document.createElement('div');
99
+ const assessment = AssessmentManager.createAssessment({ ...config, questions });
100
+ assessment.render(container, context);
101
+ return container;
89
102
  }
90
103
  };
91
104
  `;
@@ -128,6 +141,22 @@ export function clean(options = {}) {
128
141
  }
129
142
  }
130
143
 
144
+ // Remove the known demo documents, images, and widgets without touching
145
+ // any author-created assets that may already exist in the project.
146
+ const assetsDir = path.join(coursePath, 'assets');
147
+ for (const relativePath of DEMO_ASSETS) {
148
+ const assetPath = path.join(assetsDir, relativePath);
149
+ if (fs.existsSync(assetPath)) {
150
+ fs.rmSync(assetPath, { force: true });
151
+ removed++;
152
+ }
153
+ }
154
+
155
+ // A freshly blank scaffold must not inherit the example narration cache.
156
+ if (options.blank) {
157
+ fs.rmSync(path.join(basePath, '.narration-cache.json'), { force: true });
158
+ }
159
+
131
160
  // Rewrite course-config.js to minimal starter
132
161
  const configPath = path.join(coursePath, 'course-config.js');
133
162
  if (fs.existsSync(configPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
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": {
@@ -14,7 +14,6 @@
14
14
  "!framework/dist/**",
15
15
  "schemas",
16
16
  "template",
17
- "!template/.gitattributes",
18
17
  "!template/**/.gitkeep",
19
18
  "README.md",
20
19
  "THIRD_PARTY_NOTICES.md"
@@ -51,6 +50,7 @@
51
50
  "lint:responsive:structure": "node scripts/check-responsive-structure-scoping.mjs",
52
51
  "lint:fix": "eslint . --fix",
53
52
  "smoke:responsive": "node scripts/responsive-visual-smoke.mjs",
53
+ "smoke:packed-create": "node scripts/smoke-packed-create.mjs",
54
54
  "build": "npx vite build --config vite.framework-dev.config.js",
55
55
  "build:dev": "npx vite build --config vite.framework-dev.config.js",
56
56
  "build:scorm2004": "LMS_FORMAT=scorm2004 npx vite build --config vite.framework-dev.config.js",
@@ -69,7 +69,7 @@
69
69
  "test:cloud": "vitest run --config tests/vitest.cloud.config.js",
70
70
  "test:watch": "vitest --config tests/vitest.config.js",
71
71
  "test:coverage": "vitest run --config tests/vitest.config.js --coverage",
72
- "prerelease:check": "npm run lint && npm run lint:responsive && npm run lint:responsive:structure && npm run test:coverage && npm run build",
72
+ "prerelease:check": "npm run lint && npm run lint:responsive && npm run lint:responsive:structure && npm run test:coverage && npm run build && npm run smoke:packed-create",
73
73
  "prerelease:check:full": "npm run prerelease:check && npm run test:e2e && npm run test:drivers && npm run smoke:responsive -- --profile=expanded",
74
74
  "prepublishOnly": "npm run prerelease:check"
75
75
  },
@@ -0,0 +1,44 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
3
+
4
+ # Force LF line endings for text files
5
+ *.js text eol=lf
6
+ *.mjs text eol=lf
7
+ *.cjs text eol=lf
8
+ *.json text eol=lf
9
+ *.css text eol=lf
10
+ *.html text eol=lf
11
+ *.htm text eol=lf
12
+ *.xml text eol=lf
13
+ *.xsd text eol=lf
14
+ *.dtd text eol=lf
15
+ *.md text eol=lf
16
+ *.txt text eol=lf
17
+ *.yml text eol=lf
18
+ *.yaml text eol=lf
19
+ *.env text eol=lf
20
+
21
+ # Binary files
22
+ *.png binary
23
+ *.jpg binary
24
+ *.jpeg binary
25
+ *.gif binary
26
+ *.ico binary
27
+ *.webp binary
28
+ *.svg binary
29
+ *.mp3 binary
30
+ *.mp4 binary
31
+ *.wav binary
32
+ *.ogg binary
33
+ *.webm binary
34
+ *.woff binary
35
+ *.woff2 binary
36
+ *.ttf binary
37
+ *.eot binary
38
+ *.otf binary
39
+ *.pdf binary
40
+ *.zip binary
41
+
42
+ # Linguist overrides for GitHub stats
43
+ *.css linguist-detectable=false
44
+ *.xsd linguist-detectable=false
@@ -0,0 +1,38 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+ *.zip
7
+
8
+ # Preview export (comment this line to include in repo for GitHub Pages deployment)
9
+ course-preview/
10
+
11
+ # Environment
12
+ .env
13
+ .env.local
14
+
15
+ # Google Cloud service account credentials (NEVER commit these!)
16
+ **/service-account*.json
17
+ **/*-credentials.json
18
+ **/*-keyfile.json
19
+
20
+ # System files
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # IDE
25
+ .idea/
26
+ .vscode/
27
+ *.swp
28
+ *.swo
29
+
30
+ # CourseCode Cloud (project binding)
31
+ .coursecode/
32
+
33
+ # Narration cache
34
+ .narration-cache.json
35
+
36
+ # Logs
37
+ *.log
38
+ npm-debug.log*
@@ -16,6 +16,13 @@ const ROOT_DIR = path.resolve(__dirname);
16
16
  const DIST_DIR = path.join(ROOT_DIR, 'dist');
17
17
  const SUPPORTED_BROWSER_TARGETS = ['chrome111', 'edge111', 'firefox114', 'safari16.4'];
18
18
 
19
+ function directoryContainsFiles(directory) {
20
+ if (!fs.existsSync(directory)) return false;
21
+ return fs.readdirSync(directory, { withFileTypes: true }).some(entry => (
22
+ entry.isFile() || (entry.isDirectory() && directoryContainsFiles(path.join(directory, entry.name)))
23
+ ));
24
+ }
25
+
19
26
  async function loadBuildUtils() {
20
27
  if (
21
28
  generateManifest &&
@@ -382,7 +389,10 @@ export default defineConfig(async ({ mode }) => {
382
389
  { src: 'schemas/common/*', dest: 'common', rename: { stripBase: 2 } },
383
390
  // Publish runtime assets only. Never ship authoring references,
384
391
  // configuration source, assessment source, or hidden project files.
385
- { src: 'course/assets', dest: 'course', rename: { stripBase: 1 } },
392
+ // A blank course is valid and may not contain any assets yet.
393
+ ...(directoryContainsFiles(path.join(ROOT_DIR, 'course', 'assets'))
394
+ ? [{ src: 'course/assets', dest: 'course', rename: { stripBase: 1 } }]
395
+ : []),
386
396
  { src: 'framework/js/vendor/**/*', dest: 'js/vendor', rename: { stripBase: 3 } }
387
397
  ],
388
398
  watch: isDev ? { rerun: true } : undefined