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 +1 -0
- package/framework/js/dev/runtime-linter.js +17 -0
- package/framework/scripts/generate-narration.js +45 -18
- package/lib/authoring-api.js +6 -4
- package/lib/build-linter.js +1 -1
- package/lib/mcp-prompts.js +4 -0
- package/lib/mcp-server.js +2 -1
- package/lib/narration.js +1 -0
- package/lib/preview-routes-api.js +41 -0
- package/package.json +1 -1
- /package/{lib → framework/scripts}/narration-parser.js +0 -0
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 '
|
|
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
|
|
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
|
|
248
|
-
npm run narration -- --dry-run
|
|
249
|
-
npm run narration -- --slide <id>
|
|
250
|
-
npm run narration -- --
|
|
251
|
-
npm run narration -- --
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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}`;
|
package/lib/authoring-api.js
CHANGED
|
@@ -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]
|
|
1121
|
-
* @param {boolean} [options.force=false]
|
|
1122
|
-
* @param {string} [options.slide]
|
|
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, {
|
package/lib/build-linter.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
loadNarrationCache,
|
|
20
20
|
narrationCacheKey,
|
|
21
21
|
classifyNarrationFreshness
|
|
22
|
-
} from '
|
|
22
|
+
} from '../framework/scripts/narration-parser.js';
|
|
23
23
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
24
24
|
import {
|
|
25
25
|
flattenStructure,
|
package/lib/mcp-prompts.js
CHANGED
|
@@ -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
|
File without changes
|