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,535 @@
1
+ import {
2
+ createInteractionEventHandler,
3
+ renderInteractionControls,
4
+ normalizeInitialResponse,
5
+ validateContainer,
6
+ parseResponse,
7
+ registerCoreInteraction
8
+ } from './interaction-base.js';
9
+ import { escapeHTML } from '../../utilities/utilities.js';
10
+
11
+ /**
12
+ * Normalizes text for comparison: trims and collapses whitespace
13
+ * @param {string} text - The text to normalize
14
+ * @returns {string} Normalized text
15
+ */
16
+ function normalizeText(text) {
17
+ return (text || '').trim().replace(/\s+/g, ' ');
18
+ }
19
+
20
+ /**
21
+ * Calculates Levenshtein distance between two strings
22
+ * @param {string} a - First string
23
+ * @param {string} b - Second string
24
+ * @returns {number} Number of edits (insertions, deletions, substitutions)
25
+ */
26
+ function levenshteinDistance(a, b) {
27
+ if (a.length === 0) return b.length;
28
+ if (b.length === 0) return a.length;
29
+
30
+ const matrix = Array(b.length + 1).fill(null)
31
+ .map(() => Array(a.length + 1).fill(null));
32
+
33
+ for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
34
+ for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
35
+
36
+ for (let j = 1; j <= b.length; j++) {
37
+ for (let i = 1; i <= a.length; i++) {
38
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
39
+ matrix[j][i] = Math.min(
40
+ matrix[j][i - 1] + 1, // deletion
41
+ matrix[j - 1][i] + 1, // insertion
42
+ matrix[j - 1][i - 1] + cost // substitution
43
+ );
44
+ }
45
+ }
46
+ return matrix[b.length][a.length];
47
+ }
48
+
49
+ /**
50
+ * Checks if a response matches any correct answer
51
+ * @param {string} response - User's response
52
+ * @param {string|string[]} correct - Correct answer(s)
53
+ * @param {boolean} caseSensitive - Whether to match case
54
+ * @param {number} typoTolerance - Max Levenshtein distance to accept (0 = exact match)
55
+ * @returns {boolean} True if response matches any correct answer
56
+ */
57
+ function matchesAnswer(response, correct, caseSensitive, typoTolerance = 0) {
58
+ const normalizedResponse = normalizeText(response);
59
+ const correctAnswers = Array.isArray(correct) ? correct : [correct];
60
+
61
+ return correctAnswers.some(answer => {
62
+ const normalizedAnswer = normalizeText(answer);
63
+
64
+ // Apply case normalization if not case sensitive
65
+ const compareResponse = caseSensitive ? normalizedResponse : normalizedResponse.toLowerCase();
66
+ const compareAnswer = caseSensitive ? normalizedAnswer : normalizedAnswer.toLowerCase();
67
+
68
+ // Exact match check first
69
+ if (compareResponse === compareAnswer) {
70
+ return true;
71
+ }
72
+
73
+ // If typo tolerance is set, check Levenshtein distance
74
+ if (typoTolerance > 0) {
75
+ const distance = levenshteinDistance(compareResponse, compareAnswer);
76
+ return distance <= typoTolerance;
77
+ }
78
+
79
+ return false;
80
+ });
81
+ }
82
+
83
+ // Metadata for fill-in interaction type
84
+ export const metadata = {
85
+ creator: 'createFillInQuestion',
86
+ scormType: 'fill-in',
87
+ showCheckAnswer: true,
88
+ isAnswered: (response) => {
89
+ if (!response || typeof response !== 'object') return false;
90
+ return Object.values(response).some(val => val && String(val).trim().length > 0);
91
+ },
92
+ getCorrectAnswer: (config) => {
93
+ if (!config.blanks || typeof config.blanks !== 'object') {
94
+ return '';
95
+ }
96
+ return JSON.stringify(Object.fromEntries(
97
+ Object.entries(config.blanks).map(([key, blank]) => [
98
+ `${config.id}_${key}`,
99
+ Array.isArray(blank.correct) ? blank.correct[0] : blank.correct
100
+ ])
101
+ ));
102
+ },
103
+ formatCorrectAnswer: (question, _correctAnswer) => {
104
+ let html = '<ul class="list-disc pl-4 m-0">';
105
+ if (question.blanks && typeof question.blanks === 'object') {
106
+ Object.entries(question.blanks).forEach(([key, blank]) => {
107
+ html += `<li class="correct-item"><strong>${key}:</strong> ${blank.correct}</li>`;
108
+ });
109
+ }
110
+ html += '</ul>';
111
+ return html;
112
+ },
113
+ formatUserResponse: (question, response) => {
114
+ let html = '<ul class="list-disc pl-4 m-0">';
115
+ const responseObj = parseResponse(response, 'object') || {};
116
+ Object.entries(responseObj).forEach(([key, value]) => {
117
+ html += `<li class="response-item"><strong>${key}:</strong> ${value}</li>`;
118
+ });
119
+ html += '</ul>';
120
+ return html;
121
+ }
122
+ };
123
+
124
+ // Schema for validation, linting, and AI-assisted authoring
125
+ export const schema = {
126
+ type: 'fill-in',
127
+ description: 'Text input with fill-in-the-blank support',
128
+ scormType: 'fill-in',
129
+ example: `<div class="interaction fill-in fill-in-inline" data-interaction-id="demo-fi">
130
+ <div class="fill-in-template">
131
+ <p>The capital of France is <input type="text" class="fill-in-inline-input" placeholder="..." style="min-width:80px;"> and the capital of Japan is <input type="text" class="fill-in-inline-input" placeholder="..." style="min-width:80px;">.</p>
132
+ </div>
133
+ <div class="interaction-controls"><button class="btn btn-primary" disabled>Check Answer</button></div>
134
+ </div>`,
135
+ properties: {
136
+ blanks: {
137
+ type: 'object',
138
+ required: true,
139
+ description: 'Map of blank IDs to their correct answers',
140
+ valueSchema: {
141
+ correct: { type: ['string', 'array'], required: true },
142
+ typoTolerance: { type: 'number', default: 0 },
143
+ hint: { type: 'string' }
144
+ }
145
+ },
146
+ template: {
147
+ type: 'string',
148
+ description: 'HTML template with {{blankId}} placeholders (inline mode)'
149
+ },
150
+ caseSensitive: {
151
+ type: 'boolean',
152
+ default: false,
153
+ description: 'Require exact case match'
154
+ }
155
+ },
156
+ notes: 'Requires either template (inline) or just blanks (stacked mode)'
157
+ };
158
+
159
+ /**
160
+ * Creates a fill-in-the-blank question with inline inputs
161
+ * @param {Object} config - Configuration object
162
+ * @param {string} config.id - Unique identifier
163
+ * @param {string} config.template - Template string with {{blankId}} placeholders
164
+ * @param {Object} config.blanks - Object mapping blankId to { correct, placeholder? }
165
+ * @param {boolean} config.caseSensitive - Whether matching is case-sensitive (default: false)
166
+ * @param {string} config.feedback - Optional hint text
167
+ * @param {boolean} config.controlled - Whether to use controlled mode
168
+ * @returns {Object} Question object with render, evaluate, checkAnswer, reset, getResponse, setResponse methods
169
+ *
170
+ * @example
171
+ * createFillInQuestion({
172
+ * id: 'capitals',
173
+ * template: 'The capital of {{country1}} is Paris and the capital of {{country2}} is Tokyo.',
174
+ * blanks: {
175
+ * country1: { correct: 'France', placeholder: 'country' },
176
+ * country2: { correct: 'Japan' }
177
+ * }
178
+ * });
179
+ */
180
+ export function createFillInQuestion(config) {
181
+ // Custom validation for fill-in (supports both template and prompt modes)
182
+ if (!config || typeof config !== 'object') {
183
+ throw new Error('Interaction config must be an object');
184
+ }
185
+ if (!config.id || typeof config.id !== 'string') {
186
+ throw new Error('Interaction must have a valid string id');
187
+ }
188
+ if (!config.blanks || typeof config.blanks !== 'object') {
189
+ throw new Error(`Fill-in question "${config.id}" must have a blanks object`);
190
+ }
191
+
192
+ // Determine mode: inline (template) vs stacked (prompt)
193
+ const isInlineMode = !!config.template;
194
+ const isStackedMode = !!config.prompt && !config.template;
195
+
196
+ if (!isInlineMode && !isStackedMode) {
197
+ throw new Error(`Fill-in question "${config.id}" must have either a 'template' (inline mode) or 'prompt' (stacked mode)`);
198
+ }
199
+
200
+ const { id, template, prompt, blanks, feedback, caseSensitive = false, controlled = false } = config;
201
+
202
+ // Validate blanks object
203
+ if (!blanks || typeof blanks !== 'object' || Object.keys(blanks).length === 0) {
204
+ throw new Error(`Fill-in question "${id}" must have at least one blank in the blanks object`);
205
+ }
206
+
207
+ let blankIds;
208
+
209
+ if (isInlineMode) {
210
+ // Validate that all template placeholders have corresponding blanks
211
+ const placeholderRegex = /\{\{(\w+)\}\}/g;
212
+ const placeholders = [...template.matchAll(placeholderRegex)].map(m => m[1]);
213
+
214
+ if (placeholders.length === 0) {
215
+ throw new Error(`Fill-in question "${id}" template must contain at least one {{blankId}} placeholder`);
216
+ }
217
+
218
+ placeholders.forEach(placeholder => {
219
+ if (!blanks[placeholder]) {
220
+ throw new Error(`Fill-in question "${id}" is missing blank definition for placeholder "{{${placeholder}}}"`);
221
+ }
222
+ });
223
+
224
+ // Store blank IDs in order they appear in template
225
+ blankIds = placeholders;
226
+ } else {
227
+ // Stacked mode: use blanks object keys in definition order
228
+ blankIds = Object.keys(blanks);
229
+ }
230
+
231
+ let _container = null;
232
+
233
+ const questionObj = {
234
+ id,
235
+ type: 'fill-in',
236
+ blanks,
237
+ blankIds,
238
+
239
+ render: (container, initialResponse = null) => {
240
+ validateContainer(container, id);
241
+ _container = container;
242
+
243
+ // Parse initial response as object
244
+ const initialValues = normalizeInitialResponse(initialResponse);
245
+ const initialObj = initialValues && typeof initialValues === 'object' ? initialValues : {};
246
+
247
+ let html;
248
+
249
+ if (isInlineMode) {
250
+ // INLINE MODE: Replace {{placeholders}} with inputs in flowing text
251
+ let templateHtml = template;
252
+
253
+ blankIds.forEach((blankId, index) => {
254
+ const blank = blanks[blankId];
255
+ const inputName = `${id}_${blankId}`;
256
+ const initialValue = initialObj[inputName] || '';
257
+ const placeholder = blank.placeholder || '...';
258
+
259
+ // Calculate approximate width based on correct answer length (use first if array)
260
+ const correctLength = Array.isArray(blank.correct)
261
+ ? blank.correct[0].length
262
+ : blank.correct.length;
263
+ const minWidth = Math.max(60, Math.min(200, correctLength * 10 + 20));
264
+
265
+ const inputHtml = `<input
266
+ type="text"
267
+ class="fill-in-inline-input"
268
+ id="${id}_${blankId}"
269
+ name="${inputName}"
270
+ placeholder="${placeholder}"
271
+ value="${initialValue}"
272
+ data-blank-id="${blankId}"
273
+ data-case-sensitive="${caseSensitive}"
274
+ data-testid="${id}-blank-${index}"
275
+ style="min-width: ${minWidth}px;"
276
+ aria-label="${blankId}"
277
+ />`;
278
+
279
+ templateHtml = templateHtml.replace(`{{${blankId}}}`, inputHtml);
280
+ });
281
+
282
+ html = `
283
+ <div class="interaction fill-in fill-in-inline" data-interaction-id="${id}">
284
+ <div class="fill-in-template">
285
+ ${templateHtml}
286
+ </div>
287
+ ${renderInteractionControls(id, controlled, feedback ? [
288
+ `<button type="button" class="btn btn-info" data-action="show-hint" data-interaction="${id}">Show Hint</button>`
289
+ ] : [])}
290
+ <div class="overall-feedback" id="${id}_overall_feedback" aria-live="polite"></div>
291
+ </div>
292
+ `;
293
+ } else {
294
+ // STACKED MODE: Question prompt followed by stacked input fields
295
+ let blanksHtml = '';
296
+
297
+ blankIds.forEach((blankId, index) => {
298
+ const blank = blanks[blankId];
299
+ const inputName = `${id}_${blankId}`;
300
+ const initialValue = initialObj[inputName] || '';
301
+ const placeholder = blank.placeholder || '';
302
+ const label = blank.label; // Optional - only show if explicitly provided
303
+
304
+ blanksHtml += `
305
+ <div class="fill-in-item">
306
+ ${label ? `<label for="${id}_${blankId}">${label}</label>` : ''}
307
+ <input
308
+ type="text"
309
+ class="fill-in-stacked-input"
310
+ id="${id}_${blankId}"
311
+ name="${inputName}"
312
+ placeholder="${placeholder}"
313
+ value="${initialValue}"
314
+ data-blank-id="${blankId}"
315
+ data-case-sensitive="${caseSensitive}"
316
+ data-testid="${id}-blank-${index}"
317
+ aria-describedby="${id}_feedback_${index}"
318
+ ${label ? '' : `aria-label="${blankId}"`}
319
+ />
320
+ <div id="${id}_feedback_${index}" class="feedback" aria-live="polite"></div>
321
+ </div>
322
+ `;
323
+ });
324
+
325
+ html = `
326
+ <div class="interaction fill-in fill-in-stacked" data-interaction-id="${id}">
327
+ <div class="question-prompt">
328
+ <h3>${prompt}</h3>
329
+ </div>
330
+ <div class="fill-in-container">
331
+ ${blanksHtml}
332
+ </div>
333
+ ${renderInteractionControls(id, controlled, feedback ? [
334
+ `<button type="button" class="btn btn-info" data-action="show-hint" data-interaction="${id}">Show Hint</button>`
335
+ ] : [])}
336
+ <div class="overall-feedback" id="${id}_overall_feedback" aria-live="polite"></div>
337
+ </div>
338
+ `;
339
+ }
340
+
341
+ container.innerHTML = html;
342
+
343
+ // Attach event handler only in uncontrolled mode
344
+ if (!controlled) {
345
+ const correctPattern = JSON.stringify(
346
+ Object.fromEntries(Object.entries(blanks).map(([k, v]) => [k, v.correct]))
347
+ );
348
+
349
+ container.addEventListener('click', createInteractionEventHandler(questionObj, {
350
+ ...config,
351
+ scormType: 'fill-in',
352
+ correctPattern
353
+ }, {
354
+ 'show-hint': () => questionObj.showHint()
355
+ }));
356
+ }
357
+ },
358
+
359
+ evaluate: (responses) => {
360
+ if (!responses || typeof responses !== 'object') {
361
+ return {
362
+ score: 0,
363
+ correct: false,
364
+ results: blankIds.map(blankId => ({
365
+ blankId,
366
+ response: '',
367
+ correct: false,
368
+ expected: blanks[blankId].correct
369
+ })),
370
+ response: JSON.stringify({}),
371
+ error: 'Invalid response format'
372
+ };
373
+ }
374
+
375
+ let correctCount = 0;
376
+ const results = blankIds.map(blankId => {
377
+ const key = `${id}_${blankId}`;
378
+ const response = responses[key] || '';
379
+ const blank = blanks[blankId];
380
+ const correctAnswers = blank.correct;
381
+ const typoTolerance = blank.typoTolerance || 0;
382
+
383
+ const isCorrect = matchesAnswer(response, correctAnswers, caseSensitive, typoTolerance);
384
+
385
+ if (isCorrect) correctCount++;
386
+
387
+ // For display, show first correct answer
388
+ const displayAnswer = Array.isArray(correctAnswers) ? correctAnswers[0] : correctAnswers;
389
+ return { blankId, response, correct: isCorrect, expected: displayAnswer };
390
+ });
391
+
392
+ return {
393
+ score: correctCount / blankIds.length,
394
+ correct: correctCount === blankIds.length,
395
+ results,
396
+ response: JSON.stringify(responses)
397
+ };
398
+ },
399
+
400
+ checkAnswer: () => {
401
+ validateContainer(_container, id);
402
+
403
+ const inputs = _container.querySelectorAll('input[type="text"]');
404
+ if (inputs.length === 0) {
405
+ throw new Error(`No input elements found for fill-in question "${id}"`);
406
+ }
407
+
408
+ const responses = {};
409
+
410
+ inputs.forEach(input => {
411
+ responses[input.name] = input.value.trim();
412
+ const blankId = input.dataset.blankId;
413
+ const blank = blanks[blankId];
414
+ const correctAnswers = blank.correct;
415
+ const typoTolerance = blank.typoTolerance || 0;
416
+ const isCaseSensitive = input.dataset.caseSensitive === 'true';
417
+
418
+ const isCorrect = matchesAnswer(input.value, correctAnswers, isCaseSensitive, typoTolerance);
419
+
420
+ if (isCorrect) {
421
+ input.classList.add('correct');
422
+ input.classList.remove('incorrect');
423
+ } else {
424
+ input.classList.add('incorrect');
425
+ input.classList.remove('correct');
426
+ }
427
+ });
428
+
429
+ const evaluation = questionObj.evaluate(responses);
430
+
431
+ // Show overall feedback
432
+ const overallFeedback = _container.querySelector('.overall-feedback');
433
+ if (overallFeedback) {
434
+ if (evaluation.correct) {
435
+ overallFeedback.innerHTML = '<div class="feedback correct">✓ All answers correct!</div>';
436
+ } else {
437
+ const incorrectBlanks = evaluation.results
438
+ .filter(r => !r.correct)
439
+ .map(r => `<strong>${escapeHTML(r.blankId)}</strong>: "${escapeHTML(String(r.expected))}"`)
440
+ .join(', ');
441
+ overallFeedback.innerHTML = `<div class="feedback incorrect">✗ Incorrect. Expected: ${incorrectBlanks}</div>`;
442
+ }
443
+ }
444
+
445
+ return evaluation;
446
+ },
447
+
448
+ reset: () => {
449
+ validateContainer(_container, id);
450
+
451
+ const inputs = _container.querySelectorAll('input[type="text"]');
452
+ inputs.forEach(input => {
453
+ input.value = '';
454
+ input.classList.remove('correct', 'incorrect');
455
+ });
456
+
457
+ const overallFeedback = _container.querySelector('.overall-feedback');
458
+ if (overallFeedback) {
459
+ overallFeedback.innerHTML = '';
460
+ }
461
+ },
462
+
463
+ showHint: () => {
464
+ validateContainer(_container, id);
465
+
466
+ if (!feedback) return;
467
+
468
+ const overallFeedback = _container.querySelector('.overall-feedback');
469
+ if (!overallFeedback) {
470
+ throw new Error(`Overall feedback element not found for fill-in question "${id}"`);
471
+ }
472
+
473
+ overallFeedback.innerHTML = `<div class="hint">${escapeHTML(feedback)}</div>`;
474
+ },
475
+
476
+ setResponse: (response) => {
477
+ validateContainer(_container, id);
478
+
479
+ const responseObj = parseResponse(response, 'object');
480
+ if (!responseObj) return;
481
+
482
+ const inputs = _container.querySelectorAll('input[type="text"]');
483
+ if (inputs.length === 0) {
484
+ throw new Error(`No input elements found for fill-in question "${id}"`);
485
+ }
486
+
487
+ Object.keys(responseObj).forEach(name => {
488
+ const input = _container.querySelector(`input[name="${CSS.escape(name)}"]`);
489
+ if (input) {
490
+ input.value = responseObj[name];
491
+ }
492
+ });
493
+ },
494
+
495
+ getResponse: () => {
496
+ // Return empty response if not yet rendered (for automation API compatibility)
497
+ if (!_container) {
498
+ const emptyResponse = {};
499
+ blankIds.forEach(blankId => {
500
+ emptyResponse[`${id}_${blankId}`] = '';
501
+ });
502
+ return emptyResponse;
503
+ }
504
+
505
+ const responses = {};
506
+ const inputs = _container.querySelectorAll('input[type="text"]');
507
+
508
+ if (inputs.length === 0) {
509
+ throw new Error(`No input elements found for fill-in question "${id}"`);
510
+ }
511
+
512
+ inputs.forEach(input => {
513
+ responses[input.name] = input.value.trim();
514
+ });
515
+
516
+ return responses;
517
+ },
518
+
519
+ getCorrectAnswer: () => {
520
+ return Object.fromEntries(
521
+ Object.entries(blanks).map(([key, blank]) => [
522
+ `${id}_${key}`,
523
+ Array.isArray(blank.correct) ? blank.correct[0] : blank.correct
524
+ ])
525
+ );
526
+ }
527
+ };
528
+
529
+ // For uncontrolled interactions, register with the central registry for lifecycle mgmt
530
+ if (!controlled) {
531
+ registerCoreInteraction(config, questionObj);
532
+ }
533
+
534
+ return questionObj;
535
+ }