coursecode 0.1.38 → 0.1.40

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/bin/cli.js CHANGED
@@ -141,6 +141,7 @@ program
141
141
  .option('-f, --force', 'Regenerate all narration (ignore cache)')
142
142
  .option('-s, --slide <id>', 'Generate narration for a specific slide only')
143
143
  .option('--dry-run', 'Preview what would be generated')
144
+ .option('--rebuild-cache', 'Rebuild .narration-cache.json from existing audio (no TTS calls)')
144
145
  .action(async (options) => {
145
146
  const { narration } = await import('../lib/narration.js');
146
147
  await narration(options);
@@ -205,6 +205,23 @@ export async function lintCourse(courseConfig) {
205
205
  // Cleanup offscreen container
206
206
  cleanupOffscreenContainer();
207
207
 
208
+ // --- 4. Filesystem-backed checks via preview server ---
209
+ // The browser can't read .narration-cache.json or hash slide source files,
210
+ // so the preview server exposes /__lint/narration to do that work and
211
+ // return any stale-narration warnings. Silently ignored if the endpoint
212
+ // is unavailable (e.g. running outside the preview server).
213
+ try {
214
+ const resp = await fetch('/__lint/narration', { cache: 'no-store' });
215
+ if (resp.ok) {
216
+ const data = await resp.json();
217
+ if (Array.isArray(data.warnings)) {
218
+ warnings.push(...data.warnings);
219
+ }
220
+ }
221
+ } catch {
222
+ // Endpoint absent (e.g. SCORM package preview) — skip silently.
223
+ }
224
+
208
225
  // Display warnings individually so each appears as a separate entry in the debug panel
209
226
  if (warnings.length > 0) {
210
227
  for (const w of warnings) {
@@ -40,7 +40,7 @@ import {
40
40
  loadNarrationCache,
41
41
  narrationCacheKey,
42
42
  VOICE_SETTING_KEYS as _SHARED_VOICE_KEYS
43
- } from '../../lib/narration-parser.js';
43
+ } from './narration-parser.js';
44
44
 
45
45
  const __filename = fileURLToPath(import.meta.url);
46
46
  const __dirname = path.dirname(__filename);
@@ -65,6 +65,10 @@ const VERBOSE = args.includes('--verbose') || args.includes('-v');
65
65
  const SLIDE_FILTER = args.includes('--slide') ? args[args.indexOf('--slide') + 1] : null;
66
66
  const SHOW_PROVIDERS = args.includes('--providers') || args.includes('--provider');
67
67
  const SHOW_HELP = args.includes('--help') || args.includes('-h');
68
+ // Rebuild .narration-cache.json from current slide text without calling any
69
+ // TTS provider. Useful when audio is committed but the cache is missing
70
+ // (e.g. after a fresh clone or migrating a project).
71
+ const REBUILD_CACHE = args.includes('--rebuild-cache');
68
72
 
69
73
  /**
70
74
  * Load environment variables from .env file
@@ -224,7 +228,7 @@ function parseSlideNarration(filePath, baseName) {
224
228
  }
225
229
 
226
230
  // Removed: original inline parseSlideNarration / parseNarrationObject implementations
227
- // now live in lib/narration-parser.js so the build linter can reuse them.
231
+ // now live in framework/scripts/narration-parser.js so the build linter can reuse them.
228
232
 
229
233
  /**
230
234
  * Main execution
@@ -244,11 +248,12 @@ async function main() {
244
248
 
245
249
  Usage:
246
250
  npm run narration Generate all changed narration
247
- npm run narration -- --force Regenerate all (ignore cache)
248
- npm run narration -- --dry-run Preview without generating
249
- npm run narration -- --slide <id> Generate specific slide only
250
- npm run narration -- --providers List available TTS providers
251
- npm run narration -- --verbose Show detailed output
251
+ npm run narration -- --force Regenerate all (ignore cache)
252
+ npm run narration -- --dry-run Preview without generating
253
+ npm run narration -- --slide <id> Generate specific slide only
254
+ npm run narration -- --rebuild-cache Rebuild cache from existing audio (no TTS calls)
255
+ npm run narration -- --providers List available TTS providers
256
+ npm run narration -- --verbose Show detailed output
252
257
 
253
258
  Provider Selection:
254
259
  Set TTS_PROVIDER env var to: elevenlabs, openai, or azure
@@ -266,17 +271,22 @@ Examples:
266
271
  // Load environment variables
267
272
  loadEnv();
268
273
 
269
- // Initialize TTS provider
274
+ // Initialize TTS provider — skipped in --rebuild-cache mode since we
275
+ // never call the provider when only refreshing the cache file.
270
276
  let provider;
271
- try {
272
- provider = getActiveProvider();
273
- provider.validateConfig();
274
- const defaultVoice = provider.getDefaultVoiceId();
275
- console.log(`🔊 Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
276
- } catch (error) {
277
- console.error(`❌ Provider error: ${error.message}\n`);
278
- printProviderHelp();
279
- process.exit(1);
277
+ if (REBUILD_CACHE) {
278
+ console.log('🔁 Rebuild-cache mode: hashing existing audio, no TTS calls.\n');
279
+ } else {
280
+ try {
281
+ provider = getActiveProvider();
282
+ provider.validateConfig();
283
+ const defaultVoice = provider.getDefaultVoiceId();
284
+ console.log(`🔊 Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
285
+ } catch (error) {
286
+ console.error(`❌ Provider error: ${error.message}\n`);
287
+ printProviderHelp();
288
+ process.exit(1);
289
+ }
280
290
  }
281
291
 
282
292
  // Load course config
@@ -375,7 +385,24 @@ Examples:
375
385
  const cacheKey = key === 'slide' ? source.src : `${source.src}#${key}`;
376
386
  const cachedHash = cache[cacheKey];
377
387
  const outputExists = fs.existsSync(outputPath);
378
-
388
+
389
+ // --rebuild-cache: hash text+settings for existing audio and move on.
390
+ // Never call TTS, never write or delete audio files.
391
+ if (REBUILD_CACHE) {
392
+ if (outputExists) {
393
+ newCache[cacheKey] = contentHash;
394
+ skipped++;
395
+ if (VERBOSE) {
396
+ const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
397
+ console.log(` ✅ ${label}: cached (${path.relative(ROOT_DIR, outputPath)})`);
398
+ }
399
+ } else if (VERBOSE) {
400
+ const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
401
+ console.log(` ⏭️ ${label}: no audio yet, skipping`);
402
+ }
403
+ continue;
404
+ }
405
+
379
406
  if (cachedHash === contentHash && outputExists && !FORCE_REGENERATE) {
380
407
  if (VERBOSE) {
381
408
  const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
@@ -1117,13 +1117,14 @@ export async function buildCourse(options = {}) {
1117
1117
  * picks up the same .env, course-config, and slide files a manual run would.
1118
1118
  *
1119
1119
  * @param {object} options
1120
- * @param {boolean} [options.dryRun=false] - Show what would be generated without calling TTS
1121
- * @param {boolean} [options.force=false] - Regenerate everything, ignoring the cache
1122
- * @param {string} [options.slide] - Limit to a single slide ID
1120
+ * @param {boolean} [options.dryRun=false] - Show what would be generated without calling TTS
1121
+ * @param {boolean} [options.force=false] - Regenerate everything, ignoring the cache
1122
+ * @param {string} [options.slide] - Limit to a single slide ID
1123
+ * @param {boolean} [options.rebuildCache=false] - Rebuild .narration-cache.json from existing audio without calling TTS
1123
1124
  * @returns {Promise<{success, dryRun, summary, generated, skipped, errors, warnings, output, duration}>}
1124
1125
  */
1125
1126
  export async function generateNarration(options = {}) {
1126
- const { dryRun = false, force = false, slide } = options;
1127
+ const { dryRun = false, force = false, slide, rebuildCache = false } = options;
1127
1128
  const startTime = Date.now();
1128
1129
 
1129
1130
  let courseRoot;
@@ -1157,6 +1158,7 @@ export async function generateNarration(options = {}) {
1157
1158
  if (force) args.push('--force');
1158
1159
  if (dryRun) args.push('--dry-run');
1159
1160
  if (slide) args.push('--slide', slide);
1161
+ if (rebuildCache) args.push('--rebuild-cache');
1160
1162
 
1161
1163
  return new Promise((resolve) => {
1162
1164
  const child = spawn('node', args, {
@@ -19,7 +19,7 @@ import {
19
19
  loadNarrationCache,
20
20
  narrationCacheKey,
21
21
  classifyNarrationFreshness
22
- } from './narration-parser.js';
22
+ } from '../framework/scripts/narration-parser.js';
23
23
  import { fileURLToPath, pathToFileURL } from 'url';
24
24
  import {
25
25
  flattenStructure,
@@ -448,6 +448,10 @@ NOT idempotent: real runs call paid TTS APIs and write files. Prefer dryRun firs
448
448
  slide: {
449
449
  type: 'string',
450
450
  description: 'Optional slide ID filter (e.g. "intro"). Only narration for this slide is processed.'
451
+ },
452
+ rebuildCache: {
453
+ type: 'boolean',
454
+ description: 'Rebuild .narration-cache.json from existing audio files without calling TTS (default: false). Use after a fresh clone or when the cache is missing but audio is committed. Free; no API calls.'
451
455
  }
452
456
  },
453
457
  required: []
package/lib/mcp-server.js CHANGED
@@ -287,7 +287,8 @@ export async function startMcpServer(options = {}) {
287
287
  result = await generateNarration({
288
288
  dryRun: args?.dryRun === true,
289
289
  force: args?.force === true,
290
- slide: args?.slide
290
+ slide: args?.slide,
291
+ rebuildCache: args?.rebuildCache === true
291
292
  });
292
293
  break;
293
294
 
package/lib/narration.js CHANGED
@@ -32,6 +32,7 @@ export async function narration(options = {}) {
32
32
  if (options.force) args.push('--force');
33
33
  if (options.slide) args.push('--slide', options.slide);
34
34
  if (options.dryRun) args.push('--dry-run');
35
+ if (options.rebuildCache) args.push('--rebuild-cache');
35
36
 
36
37
  return new Promise((resolve, reject) => {
37
38
  const child = spawn('node', args, {
@@ -67,6 +67,47 @@ export async function handleApiRoutes(ctx, req, res, url) {
67
67
  return true;
68
68
  }
69
69
 
70
+ // Narration freshness check — runtime linter fetches this since it can't
71
+ // read the filesystem directly. Returns warnings for stale .mp3 files
72
+ // (audio exists but cached hash differs from current text+settings).
73
+ // Silent when audio is absent or .narration-cache.json is missing.
74
+ if (url === '/__lint/narration') {
75
+ try {
76
+ const { checkNarrationFreshness } = await import('./build-linter.js');
77
+ const { flattenStructure } = await import('./validation-rules.js');
78
+ const configPath = path.join(paths.coursePath, 'course-config.js');
79
+ if (!fs.existsSync(configPath)) {
80
+ res.writeHead(200, { 'Content-Type': 'application/json' });
81
+ res.end(JSON.stringify({ warnings: [] }));
82
+ return true;
83
+ }
84
+ // Cache-bust import so edits to course-config.js are picked up
85
+ const configUrl = `${pathToFileURL(configPath).href}?t=${Date.now()}`;
86
+ const configModule = await import(configUrl);
87
+ const courseConfig = configModule.courseConfig || configModule.default;
88
+ if (!courseConfig?.structure) {
89
+ res.writeHead(200, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify({ warnings: [] }));
91
+ return true;
92
+ }
93
+ // Respect the same opt-out as the build linter
94
+ if (courseConfig.lint?.narrationFreshness === false) {
95
+ res.writeHead(200, { 'Content-Type': 'application/json' });
96
+ res.end(JSON.stringify({ warnings: [], disabled: true }));
97
+ return true;
98
+ }
99
+ const slides = flattenStructure(courseConfig.structure);
100
+ const warnings = [];
101
+ checkNarrationFreshness(slides, paths.coursePath, warnings);
102
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
103
+ res.end(JSON.stringify({ warnings }));
104
+ } catch (err) {
105
+ res.writeHead(500, { 'Content-Type': 'application/json' });
106
+ res.end(JSON.stringify({ warnings: [], error: err.message }));
107
+ }
108
+ return true;
109
+ }
110
+
70
111
  // Refresh course content HTML
71
112
  if (url === '/__content') {
72
113
  generateContentHtml({ coursePath: paths.coursePath, includeNarration: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
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": {
File without changes