coursecode 0.1.0

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 (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. package/template/vite.config.js +339 -0
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+
3
+ /* eslint-disable no-console */
4
+
5
+ /**
6
+ * Narration Generator Script
7
+ *
8
+ * Generates audio narration from text sources via configurable TTS providers.
9
+ * Supports ElevenLabs, OpenAI, and Azure Cognitive Services.
10
+ *
11
+ * Narration source: `export const narration` in slide JS files
12
+ * - Simple: export const narration = `text`;
13
+ * - Multi-key: export const narration = { slide: `...`, 'modal-id': `...`, 'tab-id': `...` };
14
+ * - Generates: audio/intro.mp3, audio/intro--modal-id.mp3, audio/intro--tab-id.mp3
15
+ *
16
+ * Usage:
17
+ * npm run narration # Generate all changed narration
18
+ * npm run narration -- --force # Regenerate all narration (ignore cache)
19
+ * npm run narration -- --dry-run # Show what would be generated
20
+ * npm run narration -- --providers # List available TTS providers
21
+ *
22
+ * Provider Selection (in priority order):
23
+ * 1. TTS_PROVIDER env var (explicit: elevenlabs, openai, azure)
24
+ * 2. Auto-detect based on available API keys
25
+ * 3. Default to deepgram
26
+ *
27
+ * Provider Setup:
28
+ * ElevenLabs: ELEVENLABS_API_KEY (optional: ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL_ID)
29
+ * OpenAI: OPENAI_API_KEY (optional: OPENAI_VOICE, OPENAI_MODEL)
30
+ * Azure: AZURE_SPEECH_KEY + AZURE_SPEECH_REGION (optional: AZURE_VOICE)
31
+ */
32
+
33
+ import fs from 'fs';
34
+ import path from 'path';
35
+ import crypto from 'crypto';
36
+ import { fileURLToPath } from 'url';
37
+ import { getActiveProvider, printProviderHelp, listProviders as _listProviders } from './tts-providers/index.js';
38
+
39
+ const __filename = fileURLToPath(import.meta.url);
40
+ const __dirname = path.dirname(__filename);
41
+ // __dirname = framework/scripts, go up two levels to reach scorm_template
42
+ const SCORM_TEMPLATE_DIR = path.resolve(__dirname, '../..');
43
+ const ROOT_DIR = path.resolve(SCORM_TEMPLATE_DIR, '..');
44
+ const COURSE_DIR = path.join(SCORM_TEMPLATE_DIR, 'course');
45
+ const ASSETS_DIR = path.join(COURSE_DIR, 'assets');
46
+ const AUDIO_DIR = path.join(ASSETS_DIR, 'audio');
47
+
48
+ const SLIDES_DIR = path.join(COURSE_DIR, 'slides');
49
+ const CACHE_FILE = path.join(SCORM_TEMPLATE_DIR, '.narration-cache.json');
50
+
51
+ // Reserved keys for voice settings (not narration content)
52
+ const VOICE_SETTING_KEYS = ['voice_id', 'model_id', 'stability', 'similarity_boost', 'voice', 'model', 'speed', 'rate', 'pitch', 'style'];
53
+
54
+ // Parse command line arguments
55
+ const args = process.argv.slice(2);
56
+ const FORCE_REGENERATE = args.includes('--force');
57
+ const DRY_RUN = args.includes('--dry-run');
58
+ const VERBOSE = args.includes('--verbose') || args.includes('-v');
59
+ const SLIDE_FILTER = args.includes('--slide') ? args[args.indexOf('--slide') + 1] : null;
60
+ const SHOW_PROVIDERS = args.includes('--providers') || args.includes('--provider');
61
+ const SHOW_HELP = args.includes('--help') || args.includes('-h');
62
+
63
+ /**
64
+ * Load environment variables from .env file
65
+ * Searches in multiple locations: CWD, SCORM_TEMPLATE_DIR, ROOT_DIR
66
+ */
67
+ function loadEnv() {
68
+ const searchPaths = [
69
+ path.join(process.cwd(), '.env'), // Current working directory (most common)
70
+ path.join(SCORM_TEMPLATE_DIR, '.env'), // Template directory
71
+ path.join(ROOT_DIR, '.env') // Root directory
72
+ ];
73
+
74
+ for (const envPath of searchPaths) {
75
+ if (fs.existsSync(envPath)) {
76
+ const envContent = fs.readFileSync(envPath, 'utf-8');
77
+ for (const line of envContent.split('\n')) {
78
+ const trimmed = line.trim();
79
+ if (trimmed && !trimmed.startsWith('#')) {
80
+ const [key, ...valueParts] = trimmed.split('=');
81
+ const value = valueParts.join('=').replace(/^["']|["']$/g, '');
82
+ process.env[key.trim()] = value;
83
+ }
84
+ }
85
+ if (VERBOSE) {
86
+ console.log(` Loaded .env from: ${envPath}`);
87
+ }
88
+ return;
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Load and parse course-config.js to extract audio sources
95
+ */
96
+ async function loadCourseConfig() {
97
+ const configPath = path.join(COURSE_DIR, 'course-config.js');
98
+
99
+ if (!fs.existsSync(configPath)) {
100
+ throw new Error(`Course config not found: ${configPath}`);
101
+ }
102
+
103
+ // Dynamic import of the ES module
104
+ const configModule = await import(`file://${configPath}`);
105
+ return configModule.courseConfig;
106
+ }
107
+
108
+ /**
109
+ * Recursively find all audio sources in the course structure
110
+ */
111
+ function findAudioSources(structure, sources = []) {
112
+ for (const item of structure) {
113
+ // Check slide-level audio
114
+ if (item.audio?.src) {
115
+ sources.push({
116
+ slideId: item.id,
117
+ src: item.audio.src,
118
+ component: item.component,
119
+ type: 'slide'
120
+ });
121
+ }
122
+
123
+ // Recurse into sections
124
+ if (item.children) {
125
+ findAudioSources(item.children, sources);
126
+ }
127
+ }
128
+ return sources;
129
+ }
130
+
131
+ /**
132
+ * Scan all slide files for component-level narration exports (modal/tab audio).
133
+ * These are narration exports with multi-key format that are NOT referenced in course config.
134
+ */
135
+ function findComponentNarrationSources() {
136
+ const sources = [];
137
+
138
+ if (!fs.existsSync(SLIDES_DIR)) {
139
+ return sources;
140
+ }
141
+
142
+ const slideFiles = fs.readdirSync(SLIDES_DIR).filter(f => f.endsWith('.js'));
143
+
144
+ for (const slideFile of slideFiles) {
145
+ const filePath = path.join(SLIDES_DIR, slideFile);
146
+ let content = fs.readFileSync(filePath, 'utf-8');
147
+
148
+ // Quick check: does the file have a narration export?
149
+ if (!content.includes('export const narration')) {
150
+ continue;
151
+ }
152
+
153
+ // Remove block comments to avoid matching examples in JSDoc
154
+ content = content.replace(/\/\*[\s\S]*?\*\//g, '');
155
+
156
+ // Check for narration export
157
+ const exportMatch = content.match(/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/);
158
+
159
+ if (exportMatch) {
160
+ const baseName = slideFile.replace('.js', '');
161
+ sources.push({
162
+ slideId: baseName,
163
+ src: `@slides/${slideFile}`,
164
+ component: `@slides/${slideFile}`,
165
+ type: 'component',
166
+ sourceType: 'slide',
167
+ sourcePath: filePath,
168
+ baseName
169
+ });
170
+ }
171
+ }
172
+
173
+ return sources;
174
+ }
175
+
176
+ /**
177
+ * Determine source type and filter to only generatable sources
178
+ *
179
+ * Source types:
180
+ * @slides/file.js → course/slides/file.js → course/assets/audio/file.mp3 (+ keyed variants)
181
+ */
182
+ function categorizeAndFilterSources(sources) {
183
+ const result = [];
184
+
185
+ for (const source of sources) {
186
+ const src = source.src;
187
+
188
+ // Slide file reference (@slides/...)
189
+ if (src.startsWith('@slides/') && src.endsWith('.js')) {
190
+ const slideFile = src.replace('@slides/', '');
191
+ const baseName = slideFile.replace('.js', '');
192
+ result.push({
193
+ ...source,
194
+ sourceType: 'slide',
195
+ sourcePath: path.join(SLIDES_DIR, slideFile),
196
+ baseName
197
+ });
198
+ }
199
+ // Skip other sources (direct .mp3 files, URLs, etc.)
200
+ }
201
+
202
+ return result;
203
+ }
204
+
205
+ /**
206
+ * Load the narration cache
207
+ */
208
+ function loadCache() {
209
+ if (fs.existsSync(CACHE_FILE)) {
210
+ try {
211
+ return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+ return {};
217
+ }
218
+
219
+ /**
220
+ * Save the narration cache
221
+ */
222
+ function saveCache(cache) {
223
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
224
+ }
225
+
226
+ /**
227
+ * Calculate MD5 hash of content
228
+ */
229
+ function hashContent(content) {
230
+ return crypto.createHash('md5').update(content).digest('hex');
231
+ }
232
+
233
+
234
+
235
+ /**
236
+ * Extract narration export from a slide file using static analysis.
237
+ * This avoids importing the module (which would fail due to browser-only dependencies).
238
+ *
239
+ * Supports:
240
+ * Simple string: export const narration = `text`;
241
+ * Object with text: export const narration = { text: `...`, voice_id: '...' };
242
+ * Multi-key object: export const narration = { slide: `...`, 'modal-id': `...`, 'tab-id': `...` };
243
+ *
244
+ * Returns array of narration items with key, text, settings, outputPath
245
+ */
246
+ function parseSlideNarration(filePath, baseName) {
247
+ let content = fs.readFileSync(filePath, 'utf-8');
248
+
249
+ // Remove block comments (/* ... */) to avoid matching examples in JSDoc
250
+ content = content.replace(/\/\*[\s\S]*?\*\//g, '');
251
+
252
+ // Remove single-line comments (// ...)
253
+ content = content.replace(/\/\/.*$/gm, '');
254
+
255
+ // Try to match the full narration export - look for the complete object/value
256
+ // This regex matches from 'export const narration =' until we hit another export, async, function, etc.
257
+ const exportMatch = content.match(/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/);
258
+
259
+ if (!exportMatch) {
260
+ return null;
261
+ }
262
+
263
+ const exportValue = exportMatch[1].trim();
264
+
265
+ // Case 1: Simple template literal - export const narration = `text`;
266
+ if (exportValue.startsWith('`') && exportValue.endsWith('`')) {
267
+ const text = exportValue.slice(1, -1).trim();
268
+ return [{
269
+ key: 'slide',
270
+ text,
271
+ settings: {},
272
+ outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
273
+ }];
274
+ }
275
+
276
+ // Case 2: Simple quoted string - export const narration = "text" or 'text'
277
+ if ((exportValue.startsWith('"') && exportValue.endsWith('"')) ||
278
+ (exportValue.startsWith("'") && exportValue.endsWith("'"))) {
279
+ const text = exportValue.slice(1, -1).trim();
280
+ return [{
281
+ key: 'slide',
282
+ text,
283
+ settings: {},
284
+ outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
285
+ }];
286
+ }
287
+
288
+ // Case 3: Object - parse keys and values
289
+ if (exportValue.startsWith('{')) {
290
+ return parseNarrationObject(exportValue, baseName);
291
+ }
292
+
293
+ return null;
294
+ }
295
+
296
+ /**
297
+ * Parse a narration object with multiple keys
298
+ * Handles: { slide: `...`, 'modal-id': `...`, text: `...`, voice_id: '...' }
299
+ */
300
+ function parseNarrationObject(objectStr, baseName) {
301
+ const results = [];
302
+ let globalSettings = {};
303
+
304
+ // Extract voice settings (support both ElevenLabs and common formats)
305
+ const settingPatterns = [
306
+ { key: 'voice_id', regex: /voice_id\s*:\s*['"]([^'"]+)['"]/ },
307
+ { key: 'voice', regex: /voice\s*:\s*['"]([^'"]+)['"]/ },
308
+ { key: 'model_id', regex: /model_id\s*:\s*['"]([^'"]+)['"]/ },
309
+ { key: 'model', regex: /model\s*:\s*['"]([^'"]+)['"]/ },
310
+ { key: 'stability', regex: /stability\s*:\s*([\d.]+)/ },
311
+ { key: 'similarity_boost', regex: /similarity_boost\s*:\s*([\d.]+)/ },
312
+ { key: 'speed', regex: /speed\s*:\s*([\d.]+)/ },
313
+ { key: 'rate', regex: /rate\s*:\s*['"]([^'"]+)['"]/ },
314
+ { key: 'pitch', regex: /pitch\s*:\s*['"]([^'"]+)['"]/ },
315
+ { key: 'style', regex: /style\s*:\s*['"]([^'"]+)['"]/ }
316
+ ];
317
+
318
+ for (const { key, regex } of settingPatterns) {
319
+ const match = objectStr.match(regex);
320
+ if (match) globalSettings[key] = match[1];
321
+ }
322
+
323
+ // Check for old format: { text: `...` } (single narration with settings)
324
+ const singleTextMatch = objectStr.match(/^\s*\{\s*text\s*:\s*`([\s\S]*?)`/);
325
+ if (singleTextMatch && !objectStr.match(/slide\s*:/)) {
326
+ return [{
327
+ key: 'slide',
328
+ text: singleTextMatch[1].trim(),
329
+ settings: globalSettings,
330
+ outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
331
+ }];
332
+ }
333
+
334
+ // Multi-key format: { slide: `...`, 'key': `...` }
335
+ // Match patterns like: slide: `text` or 'modal-id': `text` or "tab-id": `text`
336
+ const keyValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*`([\s\S]*?)`/g;
337
+ let match;
338
+
339
+ while ((match = keyValueRegex.exec(objectStr)) !== null) {
340
+ const key = match[2] || match[3]; // Quoted key or unquoted key
341
+ const text = match[4].trim();
342
+
343
+ // Skip voice setting keys
344
+ if (VOICE_SETTING_KEYS.includes(key)) continue;
345
+
346
+ // Skip 'text' key in old format (already handled above)
347
+ if (key === 'text') continue;
348
+
349
+ // Determine output filename
350
+ let outputPath;
351
+ if (key === 'slide') {
352
+ outputPath = path.join(AUDIO_DIR, `${baseName}.mp3`);
353
+ } else {
354
+ outputPath = path.join(AUDIO_DIR, `${baseName}--${key}.mp3`);
355
+ }
356
+
357
+ results.push({
358
+ key,
359
+ text,
360
+ settings: { ...globalSettings },
361
+ outputPath
362
+ });
363
+ }
364
+
365
+ // Also match quoted string values: 'key': "text" or 'key': 'text'
366
+ const quotedValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*(['"])([\s\S]*?)\4/g;
367
+ while ((match = quotedValueRegex.exec(objectStr)) !== null) {
368
+ const key = match[2] || match[3];
369
+ const text = match[5].trim();
370
+
371
+ if (VOICE_SETTING_KEYS.includes(key)) continue;
372
+ if (key === 'text') continue;
373
+
374
+ // Check if we already have this key (from template literal match)
375
+ if (results.some(r => r.key === key)) continue;
376
+
377
+ let outputPath;
378
+ if (key === 'slide') {
379
+ outputPath = path.join(AUDIO_DIR, `${baseName}.mp3`);
380
+ } else {
381
+ outputPath = path.join(AUDIO_DIR, `${baseName}--${key}.mp3`);
382
+ }
383
+
384
+ results.push({
385
+ key,
386
+ text,
387
+ settings: { ...globalSettings },
388
+ outputPath
389
+ });
390
+ }
391
+
392
+ return results.length > 0 ? results : null;
393
+ }
394
+
395
+ /**
396
+ * Main execution
397
+ */
398
+ async function main() {
399
+ // Handle --providers flag
400
+ if (SHOW_PROVIDERS) {
401
+ loadEnv();
402
+ printProviderHelp(VERBOSE);
403
+ return;
404
+ }
405
+
406
+ // Handle --help flag
407
+ if (SHOW_HELP) {
408
+ console.log(`
409
+ šŸŽ™ļø Narration Generator
410
+
411
+ Usage:
412
+ npm run narration Generate all changed narration
413
+ npm run narration -- --force Regenerate all (ignore cache)
414
+ npm run narration -- --dry-run Preview without generating
415
+ npm run narration -- --slide <id> Generate specific slide only
416
+ npm run narration -- --providers List available TTS providers
417
+ npm run narration -- --verbose Show detailed output
418
+
419
+ Provider Selection:
420
+ Set TTS_PROVIDER env var to: elevenlabs, openai, or azure
421
+ Or configure API keys and provider will be auto-detected.
422
+
423
+ Examples:
424
+ TTS_PROVIDER=openai npm run narration
425
+ OPENAI_API_KEY=sk-xxx npm run narration
426
+ `);
427
+ return;
428
+ }
429
+
430
+ console.log('šŸŽ™ļø Narration Generator\n');
431
+
432
+ // Load environment variables
433
+ loadEnv();
434
+
435
+ // Initialize TTS provider
436
+ let provider;
437
+ try {
438
+ provider = getActiveProvider();
439
+ provider.validateConfig();
440
+ const defaultVoice = provider.getDefaultVoiceId();
441
+ console.log(`šŸ”Š Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
442
+ } catch (error) {
443
+ console.error(`āŒ Provider error: ${error.message}\n`);
444
+ printProviderHelp();
445
+ process.exit(1);
446
+ }
447
+
448
+ // Load course config
449
+ let config;
450
+ try {
451
+ config = await loadCourseConfig();
452
+ console.log(`šŸ“š Loaded course: ${config.metadata?.title || 'Untitled'}\n`);
453
+ } catch (error) {
454
+ console.error(`āŒ Failed to load course config: ${error.message}`);
455
+ process.exit(1);
456
+ }
457
+
458
+ // Find all audio sources from course config and categorize them
459
+ const configSources = findAudioSources(config.structure);
460
+ const generatableSources = categorizeAndFilterSources(configSources);
461
+
462
+ // Also scan slide files for component-level narration (modal/tab audio)
463
+ const componentSources = findComponentNarrationSources();
464
+
465
+ // Merge sources, avoiding duplicates (config sources take precedence)
466
+ const configSrcSet = new Set(generatableSources.map(s => s.src));
467
+ for (const compSource of componentSources) {
468
+ if (!configSrcSet.has(compSource.src)) {
469
+ generatableSources.push(compSource);
470
+ }
471
+ }
472
+
473
+ if (generatableSources.length === 0) {
474
+ console.log('ā„¹ļø No narration sources found.');
475
+ console.log(' Options:');
476
+ console.log(' • Slide-level: audio: { src: "@slides/intro.js" } in course config');
477
+ console.log(' • Component-level: export const narration = {...} in slide file');
478
+ return;
479
+ }
480
+
481
+ // Filter by slide ID if specified
482
+ if (SLIDE_FILTER) {
483
+ const beforeCount = generatableSources.length;
484
+ const filtered = generatableSources.filter(s => s.slideId === SLIDE_FILTER || s.baseName === SLIDE_FILTER);
485
+ if (filtered.length === 0) {
486
+ console.log(`āŒ No narration source found for slide: ${SLIDE_FILTER}`);
487
+ console.log(` Available slides: ${generatableSources.map(s => s.slideId || s.baseName).join(', ')}`);
488
+ process.exit(1);
489
+ }
490
+ generatableSources.length = 0;
491
+ generatableSources.push(...filtered);
492
+ console.log(`šŸŽÆ Filtered to slide: ${SLIDE_FILTER} (${filtered.length} of ${beforeCount} sources)\n`);
493
+ }
494
+
495
+ console.log(`šŸ“ Found ${generatableSources.length} narration source(s)\n`);
496
+
497
+ // Load cache
498
+ const cache = FORCE_REGENERATE ? {} : loadCache();
499
+ const newCache = {};
500
+
501
+ let generated = 0;
502
+ let skipped = 0;
503
+ let noNarration = 0;
504
+ let errors = 0;
505
+
506
+ for (const source of generatableSources) {
507
+ const relativeSrcPath = path.relative(ROOT_DIR, source.sourcePath);
508
+
509
+ // Check if source file exists
510
+ if (!fs.existsSync(source.sourcePath)) {
511
+ console.log(` āš ļø ${source.slideId}: Source not found: ${relativeSrcPath}`);
512
+ errors++;
513
+ continue;
514
+ }
515
+
516
+ // Parse narration based on source type - returns array of items
517
+ let narrationItems;
518
+ try {
519
+ narrationItems = parseSlideNarration(source.sourcePath, source.baseName);
520
+
521
+ if (!narrationItems) {
522
+ if (VERBOSE) {
523
+ console.log(` ā­ļø ${source.slideId}: No narration export in slide`);
524
+ }
525
+ noNarration++;
526
+ continue;
527
+ }
528
+ } catch (error) {
529
+ console.log(` āŒ ${source.slideId}: ${error.message}`);
530
+ errors++;
531
+ continue;
532
+ }
533
+
534
+ // Process each narration item (slide, modals, tabs)
535
+ for (const item of narrationItems) {
536
+ const { key, text, settings, outputPath } = item;
537
+ const relativeOutPath = path.relative(ROOT_DIR, outputPath);
538
+ const contentHash = hashContent(text + JSON.stringify(settings));
539
+
540
+ // Cache key includes the item key for multi-key narration
541
+ const cacheKey = key === 'slide' ? source.src : `${source.src}#${key}`;
542
+ const cachedHash = cache[cacheKey];
543
+ const outputExists = fs.existsSync(outputPath);
544
+
545
+ if (cachedHash === contentHash && outputExists && !FORCE_REGENERATE) {
546
+ if (VERBOSE) {
547
+ const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
548
+ console.log(` ā­ļø ${label}: Unchanged, skipping`);
549
+ }
550
+ newCache[cacheKey] = contentHash;
551
+ skipped++;
552
+ continue;
553
+ }
554
+
555
+ // Generate audio
556
+ const sourceLabel = '(slide)';
557
+ const keyLabel = key === 'slide' ? '' : ` [${key}]`;
558
+ console.log(` šŸ”„ ${source.slideId}${keyLabel} ${sourceLabel}`);
559
+ console.log(` → ${relativeOutPath}`);
560
+
561
+ if (VERBOSE) {
562
+ console.log(` Text: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"`);
563
+ if (Object.keys(settings).length > 0) {
564
+ console.log(` Settings: ${JSON.stringify(settings)}`);
565
+ }
566
+ }
567
+
568
+ if (DRY_RUN) {
569
+ console.log(' (dry run - skipping generation)');
570
+ generated++;
571
+ continue;
572
+ }
573
+
574
+ try {
575
+ const audioBuffer = await provider.generateAudio(text, settings);
576
+
577
+ // Ensure output directory exists
578
+ const outputDir = path.dirname(outputPath);
579
+ if (!fs.existsSync(outputDir)) {
580
+ fs.mkdirSync(outputDir, { recursive: true });
581
+ }
582
+
583
+ // Write audio file
584
+ fs.writeFileSync(outputPath, audioBuffer);
585
+
586
+ // Update cache
587
+ newCache[cacheKey] = contentHash;
588
+ generated++;
589
+
590
+ console.log(` āœ… Generated (${(audioBuffer.length / 1024).toFixed(1)} KB)`);
591
+
592
+ } catch (error) {
593
+ console.log(` āŒ Error: ${error.message}`);
594
+ errors++;
595
+ }
596
+ }
597
+ }
598
+
599
+ // Save cache
600
+ if (!DRY_RUN) {
601
+ // Preserve unchanged entries from old cache
602
+ for (const [key, hash] of Object.entries(cache)) {
603
+ if (!(key in newCache)) {
604
+ newCache[key] = hash;
605
+ }
606
+ }
607
+ saveCache(newCache);
608
+ }
609
+
610
+ // Summary
611
+ console.log('\n' + '─'.repeat(50));
612
+ const parts = [`${generated} generated`, `${skipped} unchanged`];
613
+ if (noNarration > 0) parts.push(`${noNarration} no export`);
614
+ if (errors > 0) parts.push(`${errors} errors`);
615
+ console.log(`✨ Complete: ${parts.join(', ')}`);
616
+
617
+ if (DRY_RUN) {
618
+ console.log('\n (This was a dry run. No files were modified.)');
619
+ }
620
+
621
+ if (errors > 0) {
622
+ process.exit(1);
623
+ }
624
+ }
625
+
626
+ main().catch(error => {
627
+ console.error(`\nāŒ Fatal error: ${error.message}`);
628
+ process.exit(1);
629
+ });