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,586 @@
1
+ /**
2
+ * Slide Source Editor
3
+ *
4
+ * Pure functions for editing content inside slide .js source files and theme.css.
5
+ * Operates at the source-text level using regex and template parsing.
6
+ * No HTTP concerns — takes inputs, returns results or throws.
7
+ *
8
+ * For config-object edits (course-config.js), use course-writer.js instead.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+
14
+ // =============================================================================
15
+ // THEME EDITING
16
+ // =============================================================================
17
+
18
+ /**
19
+ * Edit or reset a CSS custom property in theme.css
20
+ * @param {string} coursePath - Path to course directory
21
+ * @param {string} token - CSS custom property name (e.g., '--color-primary')
22
+ * @param {string|null} value - New value, or null/empty to reset (remove)
23
+ * @returns {{ action: 'updated'|'reset' }}
24
+ */
25
+ export function editThemeToken(coursePath, token, value) {
26
+ if (!token) throw new Error('Missing required field: token');
27
+
28
+ const themePath = path.join(coursePath, 'theme.css');
29
+ let content = fs.existsSync(themePath) ? fs.readFileSync(themePath, 'utf-8') : '';
30
+
31
+ if (!value) {
32
+ const removeRegex = new RegExp(`\\s*${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:[^;]+;`, 'g');
33
+ content = content.replace(removeRegex, '');
34
+ fs.writeFileSync(themePath, content, 'utf-8');
35
+ return { action: 'reset' };
36
+ }
37
+
38
+ const tokenRegex = new RegExp(`(${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*)([^;]+)(;)`);
39
+ const match = content.match(tokenRegex);
40
+
41
+ if (match) {
42
+ content = content.replace(tokenRegex, `$1${value}$3`);
43
+ } else {
44
+ const rootMatch = content.match(/:root\s*\{/);
45
+ if (rootMatch) {
46
+ const insertPos = rootMatch.index + rootMatch[0].length;
47
+ const newLine = `\n ${token}: ${value};`;
48
+ content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
49
+ } else {
50
+ const insertion = `\n:root {\n ${token}: ${value};\n}\n`;
51
+ const headerEnd = content.indexOf('============ */');
52
+ if (headerEnd !== -1) {
53
+ const insertPos = content.indexOf('\n', headerEnd) + 1;
54
+ content = content.slice(0, insertPos) + insertion + content.slice(insertPos);
55
+ } else {
56
+ content = insertion + content;
57
+ }
58
+ }
59
+ }
60
+
61
+ fs.writeFileSync(themePath, content, 'utf-8');
62
+ return { action: 'updated' };
63
+ }
64
+
65
+ // =============================================================================
66
+ // ASSESSMENT EDITING
67
+ // =============================================================================
68
+
69
+ /**
70
+ * Edit a field in an assessment's settings block
71
+ * @param {string} coursePath - Path to course directory
72
+ * @param {string} assessmentId - Assessment slide ID
73
+ * @param {string} field - Settings field name
74
+ * @param {*} value - New value
75
+ */
76
+ export function editAssessmentSetting(coursePath, assessmentId, field, value) {
77
+ if (!assessmentId || !field || value === undefined) {
78
+ throw new Error('Missing required fields: assessmentId, field, value');
79
+ }
80
+
81
+ const filePath = path.join(coursePath, 'slides', `${assessmentId}.js`);
82
+ if (!fs.existsSync(filePath)) {
83
+ throw new FileNotFoundError(`Assessment file not found: ${assessmentId}.js`);
84
+ }
85
+
86
+ let content = fs.readFileSync(filePath, 'utf-8');
87
+ const settingsMatch = /settings:\s*\{/.exec(content);
88
+ if (!settingsMatch) {
89
+ throw new Error('No settings block found in assessment config');
90
+ }
91
+
92
+ let settingsStart = settingsMatch.index + settingsMatch[0].length - 1;
93
+ let settingsEnd = settingsStart;
94
+ let depth = 1;
95
+ while (settingsEnd < content.length && depth > 0) {
96
+ settingsEnd++;
97
+ if (content[settingsEnd] === '{') depth++;
98
+ if (content[settingsEnd] === '}') depth--;
99
+ }
100
+
101
+ const settingsBlock = content.slice(settingsStart, settingsEnd + 1);
102
+ let newSettingsBlock = settingsBlock;
103
+
104
+ const formatValue = (val) => {
105
+ if (val === null || val === 'null') return 'null';
106
+ if (typeof val === 'boolean') return String(val);
107
+ if (val === 'true' || val === 'false') return val;
108
+ if (typeof val === 'number') return String(val);
109
+ if (!isNaN(val) && val !== '') return String(val);
110
+ if (Array.isArray(val)) return JSON.stringify(val).replace(/"/g, "'");
111
+ return `'${val}'`;
112
+ };
113
+
114
+ const valueStr = formatValue(value);
115
+ const esc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ const boolRegex = new RegExp(`(${esc}:\\s*)(true|false)`);
117
+ const numRegex = new RegExp(`(${esc}:\\s*)(\\d+(?:\\.\\d+)?|null)`);
118
+ const stringRegex = new RegExp(`(${esc}:\\s*)(['"\`])([^'"\`]*?)\\2`);
119
+ const arrayRegex = new RegExp(`(${esc}:\\s*)\\[[^\\]]*\\]`);
120
+
121
+ let updated = false;
122
+
123
+ if (typeof value === 'boolean' || value === 'true' || value === 'false') {
124
+ if (boolRegex.test(settingsBlock)) {
125
+ newSettingsBlock = settingsBlock.replace(boolRegex, `$1${valueStr}`);
126
+ updated = true;
127
+ }
128
+ } else if (value === null || value === 'null' || typeof value === 'number' || (!isNaN(value) && value !== '')) {
129
+ if (numRegex.test(settingsBlock)) {
130
+ newSettingsBlock = settingsBlock.replace(numRegex, `$1${valueStr}`);
131
+ updated = true;
132
+ }
133
+ } else if (Array.isArray(value)) {
134
+ if (arrayRegex.test(settingsBlock)) {
135
+ newSettingsBlock = settingsBlock.replace(arrayRegex, `$1${valueStr}`);
136
+ updated = true;
137
+ }
138
+ } else {
139
+ if (stringRegex.test(settingsBlock)) {
140
+ newSettingsBlock = settingsBlock.replace(stringRegex, `$1'${value}'`);
141
+ updated = true;
142
+ }
143
+ }
144
+
145
+ if (!updated) {
146
+ throw new Error(`Could not find property: ${field} in settings block`);
147
+ }
148
+
149
+ content = content.slice(0, settingsStart) + newSettingsBlock + content.slice(settingsEnd + 1);
150
+ fs.writeFileSync(filePath, content, 'utf-8');
151
+ }
152
+
153
+ // =============================================================================
154
+ // INTERACTION EDITING
155
+ // =============================================================================
156
+
157
+ /**
158
+ * Edit a field in an interaction config block within a slide file
159
+ * @param {string} coursePath - Path to course directory
160
+ * @param {string} slideId - Slide ID
161
+ * @param {string} interactionId - Interaction ID
162
+ * @param {string} field - Field name (supports 'choices[N].prop' syntax)
163
+ * @param {*} value - New value
164
+ */
165
+ export function editInteractionField(coursePath, slideId, interactionId, field, value) {
166
+ if (!slideId || !interactionId || !field || value === undefined) {
167
+ throw new Error('Missing required fields: slideId, interactionId, field, value');
168
+ }
169
+
170
+ const filePath = path.join(coursePath, 'slides', `${slideId}.js`);
171
+ if (!fs.existsSync(filePath)) {
172
+ throw new FileNotFoundError(`Slide file not found: ${slideId}.js`);
173
+ }
174
+
175
+ let content = fs.readFileSync(filePath, 'utf-8');
176
+
177
+ const idPattern = new RegExp(`id:\\s*['"]${interactionId}['"]`);
178
+ const idMatch = idPattern.exec(content);
179
+ if (!idMatch) {
180
+ throw new FileNotFoundError(`Interaction not found: ${interactionId}`);
181
+ }
182
+
183
+ let startBrace = idMatch.index;
184
+ while (startBrace > 0 && content[startBrace] !== '{') startBrace--;
185
+
186
+ let endBrace = idMatch.index;
187
+ let depth = 1;
188
+ while (endBrace < content.length && depth > 0) {
189
+ endBrace++;
190
+ if (content[endBrace] === '{') depth++;
191
+ if (content[endBrace] === '}') depth--;
192
+ }
193
+
194
+ const configStr = content.slice(startBrace, endBrace + 1);
195
+ let newConfigStr = configStr;
196
+ const stringFields = ['prompt', 'label', 'template'];
197
+ const valueFields = ['correctAnswer', 'tolerance', 'correctValue'];
198
+
199
+ const arrayFieldMatch = field.match(/^choices\[(\d+)\]\.(\w+)$/);
200
+
201
+ if (arrayFieldMatch) {
202
+ newConfigStr = editChoiceField(configStr, arrayFieldMatch, value);
203
+ } else if (stringFields.includes(field)) {
204
+ newConfigStr = editStringField(configStr, field, value);
205
+ } else if (valueFields.includes(field)) {
206
+ newConfigStr = editValueField(configStr, field, value);
207
+ } else {
208
+ throw new Error(`Unrecognized interaction field: ${field}`);
209
+ }
210
+
211
+ const newContent = content.slice(0, startBrace) + newConfigStr + content.slice(endBrace + 1);
212
+ fs.writeFileSync(filePath, newContent, 'utf-8');
213
+ }
214
+
215
+ /** Edit a choice[N].prop field within an interaction config */
216
+ function editChoiceField(configStr, match, value) {
217
+ const choiceIndex = parseInt(match[1], 10);
218
+ const choiceProp = match[2];
219
+
220
+ const choicesStart = configStr.indexOf('choices:');
221
+ if (choicesStart === -1) {
222
+ throw new Error('No choices array found in interaction config');
223
+ }
224
+
225
+ let bracketStart = configStr.indexOf('[', choicesStart);
226
+ let bracketEnd = bracketStart;
227
+ let d = 1;
228
+ while (bracketEnd < configStr.length && d > 0) {
229
+ bracketEnd++;
230
+ if (configStr[bracketEnd] === '[') d++;
231
+ if (configStr[bracketEnd] === ']') d--;
232
+ }
233
+
234
+ const choicesStr = configStr.slice(bracketStart, bracketEnd + 1);
235
+
236
+ let choiceCount = 0;
237
+ let choiceObjStart = -1;
238
+ let choiceObjEnd = -1;
239
+ let i = 1;
240
+ while (i < choicesStr.length && choiceCount <= choiceIndex) {
241
+ if (choicesStr[i] === '{') {
242
+ if (choiceCount === choiceIndex) {
243
+ choiceObjStart = i;
244
+ let objDepth = 1;
245
+ choiceObjEnd = i;
246
+ while (choiceObjEnd < choicesStr.length && objDepth > 0) {
247
+ choiceObjEnd++;
248
+ if (choicesStr[choiceObjEnd] === '{') objDepth++;
249
+ if (choicesStr[choiceObjEnd] === '}') objDepth--;
250
+ }
251
+ break;
252
+ }
253
+ choiceCount++;
254
+ }
255
+ i++;
256
+ }
257
+
258
+ if (choiceObjStart === -1) {
259
+ throw new Error(`Choice index ${choiceIndex} not found`);
260
+ }
261
+
262
+ const choiceObjStr = choicesStr.slice(choiceObjStart, choiceObjEnd + 1);
263
+ const propPattern = new RegExp(`(${choiceProp}:\\s*)(['"\`])([^'"\`]*?)\\2`);
264
+ const propMatch = choiceObjStr.match(propPattern);
265
+
266
+ if (!propMatch) {
267
+ throw new Error(`Property ${choiceProp} not found in choice ${choiceIndex}`);
268
+ }
269
+
270
+ const quote = propMatch[2];
271
+ const escaped = value.replace(/\\/g, '\\\\').replace(new RegExp(quote, 'g'), '\\' + quote);
272
+ const newChoiceObjStr = choiceObjStr.replace(propPattern, `$1${quote}${escaped}${quote}`);
273
+ const newChoicesStr = choicesStr.slice(0, choiceObjStart) + newChoiceObjStr + choicesStr.slice(choiceObjEnd + 1);
274
+ return configStr.slice(0, bracketStart) + newChoicesStr + configStr.slice(bracketEnd + 1);
275
+ }
276
+
277
+ /** Edit a string field (prompt, label, template) */
278
+ function editStringField(configStr, field, value) {
279
+ const esc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
280
+ const fieldPattern = new RegExp(`(${esc}:\\s*)(['"\`])([^'"\`]*?)\\2`);
281
+ const fieldMatch = configStr.match(fieldPattern);
282
+ if (fieldMatch) {
283
+ const quote = fieldMatch[2];
284
+ const escaped = value.replace(/\\/g, '\\\\').replace(new RegExp(quote, 'g'), '\\' + quote);
285
+ return configStr.replace(fieldPattern, `$1${quote}${escaped}${quote}`);
286
+ }
287
+ // Field doesn't exist yet — insert after id
288
+ const idPart = configStr.match(/id:\s*['"][^'"]+['"]/);
289
+ if (idPart) {
290
+ return configStr.replace(idPart[0], `${idPart[0]},\n ${field}: '${value}'`);
291
+ }
292
+ return configStr;
293
+ }
294
+
295
+ /** Edit a value field (correctAnswer, tolerance, correctValue) */
296
+ function editValueField(configStr, field, value) {
297
+ const esc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
298
+ const fieldPattern = new RegExp(`(${esc}:\\s*)([^,}\\n]+)`);
299
+ const fieldMatch = configStr.match(fieldPattern);
300
+
301
+ let replacement = value;
302
+ if (typeof value === 'string' && value !== 'true' && value !== 'false' && isNaN(value)) {
303
+ replacement = `'${value}'`;
304
+ }
305
+
306
+ if (fieldMatch) {
307
+ return configStr.replace(fieldPattern, `$1${replacement}`);
308
+ }
309
+ return configStr;
310
+ }
311
+
312
+ // =============================================================================
313
+ // TEMPLATE CONTENT EDITING
314
+ // =============================================================================
315
+
316
+ /**
317
+ * Edit inner text content of an element within a slide's innerHTML template
318
+ * @param {string} coursePath - Path to course directory
319
+ * @param {string} slideFile - Slide filename (e.g., 'intro.js')
320
+ * @param {string} editPath - Element path within HTML
321
+ * @param {string} newText - New inner text
322
+ * @param {Function} findElementByPath - HTML element path resolver
323
+ * @returns {{ file: string }}
324
+ */
325
+ export function editContent(coursePath, slideFile, editPath, newText, findElementByPath) {
326
+ if (!slideFile || !editPath || newText === undefined) {
327
+ throw new Error('Missing required fields: slideFile, editPath, newText');
328
+ }
329
+
330
+ const sourceFilePath = path.join(coursePath, 'slides', slideFile);
331
+ if (!fs.existsSync(sourceFilePath)) {
332
+ throw new FileNotFoundError(`Slide file not found: ${slideFile}`);
333
+ }
334
+
335
+ let sourceContent = fs.readFileSync(sourceFilePath, 'utf-8');
336
+ const { templateStart, templateContent } = findTemplate(sourceContent);
337
+
338
+ if (templateStart === -1) {
339
+ throw new Error('No innerHTML template found in file');
340
+ }
341
+
342
+ const element = findElementByPath(templateContent, editPath);
343
+ if (!element) {
344
+ throw new FileNotFoundError(`Element not found for path: ${editPath}`);
345
+ }
346
+
347
+ const absStart = templateStart + element.innerStart;
348
+ const absEnd = templateStart + element.innerEnd;
349
+ const originalContent = sourceContent.slice(absStart, absEnd);
350
+
351
+ let leadingExpressions = extractLeadingExpressions(originalContent);
352
+ let trailingExpressions = extractTrailingExpressions(originalContent);
353
+
354
+ // Reconcile rendered SVG output from iconManager.getIcon() expressions.
355
+ // If the SVG is present in newText → strip it (the source ${...} is preserved).
356
+ // If the SVG is absent → user deleted the icon, so remove the source ${...} too.
357
+ // Only applies to getIcon expressions — literal <svg> in source is left untouched.
358
+ let cleanedText = newText;
359
+ const sourceTextPortion = originalContent.slice(
360
+ leadingExpressions.length,
361
+ originalContent.length - trailingExpressions.length
362
+ );
363
+ const hasLiteralSvg = /<svg\b/i.test(sourceTextPortion);
364
+
365
+ if (!hasLiteralSvg && leadingExpressions && /\bgetIcon\b/.test(leadingExpressions)) {
366
+ const svgMatch = cleanedText.match(/^(\s*<svg\b[^>]*>[\s\S]*?<\/svg>\s*)+/i);
367
+ if (svgMatch) {
368
+ cleanedText = cleanedText.slice(svgMatch[0].length);
369
+ } else {
370
+ leadingExpressions = '';
371
+ }
372
+ }
373
+ if (!hasLiteralSvg && trailingExpressions && /\bgetIcon\b/.test(trailingExpressions)) {
374
+ const svgMatch = cleanedText.match(/(\s*<svg\b[^>]*>[\s\S]*?<\/svg>\s*)+$/i);
375
+ if (svgMatch) {
376
+ cleanedText = cleanedText.slice(0, -svgMatch[0].length);
377
+ } else {
378
+ trailingExpressions = '';
379
+ }
380
+ }
381
+
382
+ const finalContent = leadingExpressions + cleanedText + trailingExpressions;
383
+ sourceContent = sourceContent.slice(0, absStart) + finalContent + sourceContent.slice(absEnd);
384
+ fs.writeFileSync(sourceFilePath, sourceContent, 'utf-8');
385
+
386
+ return { file: sourceFilePath };
387
+ }
388
+
389
+ /**
390
+ * Edit an element's tag and classes within a slide's innerHTML template
391
+ * @param {string} coursePath - Path to course directory
392
+ * @param {string} slideFile - Slide filename
393
+ * @param {string} editPath - Element path within HTML
394
+ * @param {string} newTag - New HTML tag name
395
+ * @param {string} [newClasses] - New CSS classes
396
+ * @param {Function} findElementByPath - HTML element path resolver
397
+ * @returns {{ file: string, undo: object }}
398
+ */
399
+ export function editTag(coursePath, slideFile, editPath, newTag, newClasses, findElementByPath) {
400
+ if (!slideFile || !editPath || !newTag) {
401
+ throw new Error('Missing required fields: slideFile, editPath, newTag');
402
+ }
403
+
404
+ const VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr']);
405
+ if (VOID_ELEMENTS.has(newTag.toLowerCase())) {
406
+ throw new Error(`Cannot change to void element <${newTag}>. Void elements cannot contain content.`);
407
+ }
408
+
409
+ const sourceFilePath = path.join(coursePath, 'slides', slideFile);
410
+ if (!fs.existsSync(sourceFilePath)) {
411
+ throw new FileNotFoundError(`Source file not found: ${slideFile}`);
412
+ }
413
+
414
+ let sourceContent = fs.readFileSync(sourceFilePath, 'utf-8');
415
+ const { templateStart, templateContent } = findTemplate(sourceContent);
416
+
417
+ if (templateStart === -1) {
418
+ throw new Error('Could not find innerHTML template literal');
419
+ }
420
+
421
+ const element = findElementByPath(templateContent, editPath);
422
+ if (!element) {
423
+ throw new FileNotFoundError(`Element not found for path: ${editPath}`);
424
+ }
425
+
426
+ const origOpeningTag = templateContent.slice(element.startOffset, element.innerStart);
427
+ const origClosingTag = templateContent.slice(element.innerEnd, element.endOffset);
428
+ const tagMatch = origOpeningTag.match(/^<(\w+)([^>]*)>/);
429
+ const origAttrsString = tagMatch ? tagMatch[2] : '';
430
+
431
+ const attrMatches = origAttrsString.matchAll(/(\w+(?:-\w+)*)(?:="([^"]*)"|='([^']*)')?/g);
432
+ const preservedAttrs = [];
433
+ for (const match of attrMatches) {
434
+ const attrName = match[1];
435
+ const attrValue = match[2] || match[3] || '';
436
+ if (attrName !== 'class' && attrName) {
437
+ preservedAttrs.push(attrValue ? `${attrName}="${attrValue}"` : attrName);
438
+ }
439
+ }
440
+
441
+ let newOpeningTag = `<${newTag}`;
442
+ if (newClasses && newClasses.trim()) {
443
+ newOpeningTag += ` class="${newClasses.trim()}"`;
444
+ }
445
+ if (preservedAttrs.length > 0) {
446
+ newOpeningTag += ' ' + preservedAttrs.join(' ');
447
+ }
448
+ newOpeningTag += '>';
449
+ const newClosingTag = `</${newTag}>`;
450
+ const innerContent = templateContent.slice(element.innerStart, element.innerEnd);
451
+ const newElement = newOpeningTag + innerContent + newClosingTag;
452
+ const _originalElement = origOpeningTag + innerContent + origClosingTag;
453
+
454
+ const absStart = templateStart + element.startOffset;
455
+ const absEnd = templateStart + element.endOffset;
456
+ sourceContent = sourceContent.slice(0, absStart) + newElement + sourceContent.slice(absEnd);
457
+ fs.writeFileSync(sourceFilePath, sourceContent, 'utf-8');
458
+
459
+ return { file: sourceFilePath };
460
+ }
461
+
462
+
463
+ // =============================================================================
464
+ // TEMPLATE EXPRESSION EXTRACTION (brace-balanced)
465
+ // =============================================================================
466
+
467
+ /**
468
+ * Extract consecutive leading ${...} expressions (with surrounding whitespace).
469
+ * Uses brace-balanced parsing to handle nested braces inside expressions
470
+ * like ${fn({ key: 'val' })}.
471
+ */
472
+ function extractLeadingExpressions(content) {
473
+ let end = 0;
474
+ let i = 0;
475
+
476
+ // Skip whitespace then try to match ${...}
477
+ while (i < content.length) {
478
+ // Skip whitespace
479
+ while (i < content.length && /\s/.test(content[i])) i++;
480
+ // Check for ${
481
+ if (i + 1 < content.length && content[i] === '$' && content[i + 1] === '{') {
482
+ i += 2; // skip past ${
483
+ let depth = 1;
484
+ while (i < content.length && depth > 0) {
485
+ if (content[i] === '{') depth++;
486
+ else if (content[i] === '}') depth--;
487
+ i++;
488
+ }
489
+ // Skip trailing whitespace after the expression
490
+ while (i < content.length && /\s/.test(content[i])) i++;
491
+ end = i;
492
+ } else {
493
+ break;
494
+ }
495
+ }
496
+
497
+ return content.slice(0, end);
498
+ }
499
+
500
+ /**
501
+ * Extract consecutive trailing ${...} expressions (with surrounding whitespace).
502
+ * Scans backward from the end using brace-balanced parsing.
503
+ */
504
+ function extractTrailingExpressions(content) {
505
+ let start = content.length;
506
+ let i = content.length - 1;
507
+
508
+ while (i >= 0) {
509
+ // Skip trailing whitespace
510
+ while (i >= 0 && /\s/.test(content[i])) i--;
511
+ // Check for closing } of a ${...} expression
512
+ if (i >= 0 && content[i] === '}') {
513
+ // Walk backward with brace balancing to find the matching ${
514
+ let depth = 1;
515
+ i--;
516
+ while (i >= 0 && depth > 0) {
517
+ if (content[i] === '}') depth++;
518
+ else if (content[i] === '{') depth--;
519
+ i--;
520
+ }
521
+ // i now points one before the '{'. Check for '$'
522
+ if (i >= 0 && content[i] === '$') {
523
+ // Skip preceding whitespace
524
+ i--;
525
+ while (i >= 0 && /\s/.test(content[i])) i--;
526
+ start = i + 1;
527
+ } else {
528
+ break; // Not a template expression
529
+ }
530
+ } else {
531
+ break;
532
+ }
533
+ }
534
+
535
+ return content.slice(start);
536
+ }
537
+
538
+ // =============================================================================
539
+ // HELPERS
540
+ // =============================================================================
541
+
542
+ /**
543
+ * Find innerHTML template literal boundaries in source content.
544
+ * @param {string} sourceContent - Full file content
545
+ * @returns {{ templateStart: number, templateEnd: number, templateContent: string }}
546
+ */
547
+ function findTemplate(sourceContent) {
548
+ const innerHtmlMatch = sourceContent.match(/innerHTML\s*(?::|=)\s*`/);
549
+ if (!innerHtmlMatch) {
550
+ return { templateStart: -1, templateEnd: -1, templateContent: '' };
551
+ }
552
+
553
+ const templateStart = innerHtmlMatch.index + innerHtmlMatch[0].length;
554
+
555
+ let depth = 0;
556
+ let templateEnd = -1;
557
+ for (let i = templateStart; i < sourceContent.length; i++) {
558
+ const char = sourceContent[i];
559
+ if (char === '\\') { i++; continue; }
560
+ if (depth === 0 && char === '`') { templateEnd = i; break; }
561
+ if (char === '$' && sourceContent[i + 1] === '{') { depth++; i++; continue; }
562
+ if (depth > 0 && char === '{') depth++;
563
+ else if (depth > 0 && char === '}') depth--;
564
+ }
565
+
566
+ if (templateEnd === -1) {
567
+ return { templateStart: -1, templateEnd: -1, templateContent: '' };
568
+ }
569
+
570
+ return {
571
+ templateStart,
572
+ templateEnd,
573
+ templateContent: sourceContent.slice(templateStart, templateEnd)
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Custom error for 404-style "not found" cases.
579
+ * Route handlers check `instanceof FileNotFoundError` to send 404 vs 400.
580
+ */
581
+ export class FileNotFoundError extends Error {
582
+ constructor(message) {
583
+ super(message);
584
+ this.name = 'FileNotFoundError';
585
+ }
586
+ }