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,450 @@
1
+ /**
2
+ * Course Linter - Node.js version
3
+ *
4
+ * Validates course configuration and structure at build time.
5
+ * Uses shared validation rules from validation-rules.js.
6
+ *
7
+ * Used by:
8
+ * - `coursecode lint` CLI command
9
+ * - CourseCode Studio for server-side validation
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { parseSlideSource, extractAssessment } from './course-parser.js';
15
+ import { getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
16
+ import { getValidCssClasses, lintCssSelectors } from './css-index.js';
17
+ import { fileURLToPath, pathToFileURL } from 'url';
18
+ import {
19
+ flattenStructure,
20
+ registerInteractionId,
21
+ validateGlobalConfig,
22
+ validateAssessmentConfig,
23
+ validateEngagement,
24
+ validateRequirementConfig,
25
+ validateGatingConditions,
26
+ formatLintResults
27
+ } from './validation-rules.js';
28
+
29
+ // Re-export shared rules for external use
30
+ export {
31
+ flattenStructure,
32
+ validateAssessmentConfig,
33
+ validateQuestionConfig,
34
+ formatLintResults
35
+ } from './validation-rules.js';
36
+
37
+ // Dynamic class patterns that are valid even if not in stylesheets
38
+ const DYNAMIC_CLASS_PREFIXES = ['js-', 'is-', 'animate-', 'delay-', 'icon-'];
39
+ const DYNAMIC_CLASSES = new Set([
40
+ 'active', 'open', 'closed', 'hidden', 'visible', 'disabled', 'loading',
41
+ 'collapsed', 'expanded', 'selected', 'checked', 'focused', 'hover',
42
+ 'entering', 'leaving', 'mounted',
43
+ // JS-functional selectors — queried by JS components, no CSS rules needed
44
+ 'dropdown-text', 'tabs',
45
+ // Component-internal classes — styled via [data-component] selectors in individual component CSS files
46
+ 'intro-card', 'card-icon',
47
+ // Interaction-internal classes — used by interaction JS for DOM structure
48
+ 'drag-drop', 'matching-items', 'matching-targets',
49
+ // Slide-specific JS selectors — queried by slide scripts for event binding
50
+ 'resources', 'complete-remedial-btn',
51
+ ]);
52
+
53
+ /**
54
+ * Lint a course configuration and slide files.
55
+ *
56
+ * @param {object} courseConfig - The course configuration object
57
+ * @param {string} coursePath - Path to the course directory containing slides/
58
+ * @returns {{ errors: string[], warnings: string[] }} Validation results
59
+ */
60
+ export async function lintCourse(courseConfig, coursePath) {
61
+ const errors = [];
62
+ const warnings = [];
63
+ const interactionIdRegistry = new Map();
64
+
65
+ // Validate config structure
66
+ if (!courseConfig || !courseConfig.structure) {
67
+ errors.push('FATAL: courseConfig.structure is required');
68
+ return { errors, warnings };
69
+ }
70
+
71
+ // Flatten structure to get all slides
72
+ const slides = flattenStructure(courseConfig.structure);
73
+
74
+ // Collect slide files on disk
75
+ const slidesDir = path.join(coursePath, 'slides');
76
+ const slideFilesOnDisk = new Set();
77
+
78
+ if (fs.existsSync(slidesDir)) {
79
+ const files = fs.readdirSync(slidesDir).filter(f => f.endsWith('.js'));
80
+ files.forEach(f => slideFilesOnDisk.add(`@slides/${f}`));
81
+ }
82
+
83
+ // Global config validation (uses shared rules)
84
+ const { warnings: globalWarnings, objectiveIds } = validateGlobalConfig(
85
+ courseConfig,
86
+ slides,
87
+ slideFilesOnDisk
88
+ );
89
+ warnings.push(...globalWarnings);
90
+
91
+ // Build valid CSS class index once for all slides
92
+ const validCssIndex = getValidCssClasses();
93
+
94
+ // Lint framework CSS selectors for global pollution
95
+ const cssLint = lintCssSelectors();
96
+ warnings.push(...cssLint.warnings);
97
+
98
+ // Lint framework JS for banned logging/error patterns
99
+ const jsLint = lintFrameworkJs();
100
+ warnings.push(...jsLint.warnings);
101
+
102
+ // Validate each slide
103
+ for (const slide of slides) {
104
+ await validateSlide(slide, coursePath, objectiveIds, errors, warnings, interactionIdRegistry, validCssIndex);
105
+ }
106
+
107
+ return { errors, warnings };
108
+ }
109
+
110
+ /**
111
+ * Validates a single slide's configuration using source parsing.
112
+ */
113
+ async function validateSlide(slide, coursePath, objectiveIds, errors, warnings, interactionIdRegistry, validCssIndex) {
114
+ // Use shared engagement validation
115
+ if (!validateEngagement(slide, errors, warnings)) {
116
+ return;
117
+ }
118
+
119
+ const engagement = slide.engagement;
120
+ const isAssessment = slide.type === 'assessment';
121
+
122
+ // Resolve slide file path
123
+ const slideFileName = slide.component.replace('@slides/', '');
124
+ const slideFilePath = path.join(coursePath, 'slides', slideFileName);
125
+
126
+ if (!fs.existsSync(slideFilePath)) {
127
+ errors.push(`Slide "${slide.id}" references non-existent file: ${slide.component}`);
128
+ return;
129
+ }
130
+
131
+ // Convention: slide ID should match component filename (minus @slides/ and .js)
132
+ const expectedId = slideFileName.replace('.js', '');
133
+ if (slide.id !== expectedId) {
134
+ warnings.push(`Slide "${slide.id}" has component "${slide.component}" — slide ID should match filename. Expected id="${expectedId}".`);
135
+ }
136
+
137
+ // Read and parse slide source
138
+ const source = fs.readFileSync(slideFilePath, 'utf-8');
139
+
140
+ if (isAssessment) {
141
+ // Parse assessment source using unified parser
142
+ const assessmentData = extractAssessment(source, slide.id);
143
+
144
+ if (assessmentData) {
145
+ // Validate assessment ID matches slide ID
146
+ if (assessmentData.id && assessmentData.id !== slide.id) {
147
+ errors.push(`Assessment ID mismatch: course-config.js declares slide id="${slide.id}" but ${slide.component} exports config.id="${assessmentData.id}". These must match for proper SCORM tracking.`);
148
+ }
149
+
150
+ // Build config for validation
151
+ const hasQuestions = assessmentData.questions?.length > 0;
152
+ const hasBanks = assessmentData.questionBanks?.length > 0;
153
+
154
+ const configForValidation = {
155
+ id: assessmentData.id,
156
+ title: assessmentData.title,
157
+ ...assessmentData.settings,
158
+ questions: assessmentData.questions || [],
159
+ questionBanks: assessmentData.questionBanks || [],
160
+ _hasRuntimeQuestions: hasQuestions,
161
+ _hasRuntimeQuestionBanks: hasBanks
162
+ };
163
+
164
+ // Use shared assessment validation
165
+ validateAssessmentConfig(configForValidation, slide.id, objectiveIds, errors, warnings, interactionIdRegistry);
166
+ } else {
167
+ errors.push(`Slide "${slide.id}" is marked as type='assessment' but does not export a 'config' object.`);
168
+ }
169
+ return;
170
+ }
171
+
172
+ // Parse slide content using unified parser
173
+ const slideData = parseSlideSource(source, slide.id);
174
+
175
+ // Schema-driven: get tracking map and registered component types
176
+ const engagementTrackingMap = getEngagementTrackingMap();
177
+ const registeredComponentTypes = new Set(getRegisteredComponentTypes());
178
+
179
+ // Validate unknown data-component types
180
+ // Sub-components are handled by their parent component (e.g. modal-trigger → modal)
181
+ const SUB_COMPONENT_TYPES = new Set(['modal-trigger']);
182
+ for (const el of slideData.elements || []) {
183
+ const componentType = el.attributes?.['data-component'];
184
+ if (componentType && !registeredComponentTypes.has(componentType) && !SUB_COMPONENT_TYPES.has(componentType)) {
185
+ warnings.push(`Slide "${slide.id}" uses unknown component type: "${componentType}". No schema found.`);
186
+ }
187
+ }
188
+
189
+ // Validate gating conditions if present
190
+ if (slide.navigation?.gating) {
191
+ validateGatingConditions(slide.id, slide.navigation.gating, objectiveIds, errors);
192
+ }
193
+
194
+ // Non-assessment slide validation
195
+ if (engagement.required && engagement.requirements) {
196
+ for (const req of engagement.requirements) {
197
+ // Config-only validation (shared rules, schema-driven)
198
+ validateRequirementConfig(slide.id, req, errors, warnings, engagementTrackingMap);
199
+
200
+ // Content validation — auto-checks component-linked requirements
201
+ validateRequirementContent(slide.id, req, slideData, engagementTrackingMap, errors);
202
+ }
203
+ }
204
+
205
+ // Register interaction IDs from parsed source
206
+ for (const interaction of slideData.interactions || []) {
207
+ if (interaction.id) {
208
+ registerInteractionId(interaction.id, slide.id, 'DOM Interaction', interactionIdRegistry, errors);
209
+ }
210
+ }
211
+
212
+ // Static CSS class validation — checks class attributes in source template
213
+ validateCssClassesStatic(slide.id, source, validCssIndex, warnings);
214
+
215
+ // Button variant validation — btn must always have a color variant
216
+ validateButtonVariants(slide.id, source, warnings);
217
+ }
218
+
219
+ /**
220
+ * Schema-driven content validation for component-linked requirement types.
221
+ * Uses the engagement tracking map to find which component a requirement expects.
222
+ */
223
+ function validateRequirementContent(slideId, requirement, slideData, engagementTrackingMap, errors) {
224
+ const componentType = engagementTrackingMap[requirement.type];
225
+ if (!componentType) return; // Not a component-linked requirement
226
+
227
+ const elements = slideData.elements || [];
228
+ const hasComponent = elements.some(el => el.attributes?.['data-component'] === componentType);
229
+
230
+ if (!hasComponent) {
231
+ errors.push(`Slide "${slideId}" has '${requirement.type}' requirement but no ${componentType} component found in source. Add data-component="${componentType}" or remove this requirement.`);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Static CSS class validation — extracts class="..." values from slide source
237
+ * and checks them against the valid CSS class index built from PostCSS.
238
+ */
239
+ function validateCssClassesStatic(slideId, source, validCssIndex, warnings) {
240
+ const validSet = new Set(validCssIndex.classes);
241
+ const undefinedClasses = new Map(); // className -> count
242
+
243
+ // Extract all class="..." attributes from HTML template strings
244
+ const classAttrRegex = /class="([^"]+)"/g;
245
+ let match;
246
+ while ((match = classAttrRegex.exec(source)) !== null) {
247
+ const classNames = match[1].split(/\s+/).filter(Boolean);
248
+ for (const cls of classNames) {
249
+ // Skip template expressions like ${...}
250
+ if (cls.includes('${') || cls.includes('}')) continue;
251
+ if (validSet.has(cls)) continue;
252
+ if (DYNAMIC_CLASSES.has(cls)) continue;
253
+ if (DYNAMIC_CLASS_PREFIXES.some(p => cls.startsWith(p))) continue;
254
+ undefinedClasses.set(cls, (undefinedClasses.get(cls) || 0) + 1);
255
+ }
256
+ }
257
+
258
+ for (const [cls, count] of undefinedClasses) {
259
+ const suffix = count > 1 ? ` (used ${count} times)` : '';
260
+ warnings.push(`Slide "${slideId}": CSS class "${cls}" is not defined in any stylesheet${suffix}. This may be a hallucinated or outdated class name.`);
261
+ }
262
+ }
263
+
264
+ /** Color variant classes that satisfy the btn variant requirement */
265
+ export const BTN_COLOR_VARIANTS = new Set([
266
+ 'btn-primary', 'btn-secondary', 'btn-success', 'btn-info',
267
+ 'btn-warning', 'btn-danger', 'btn-reset', 'btn-gradient', 'btn-hint',
268
+ 'btn-outline-primary', 'btn-outline-secondary',
269
+ ]);
270
+
271
+ /**
272
+ * Validates that .btn always appears alongside a color variant class.
273
+ * Size modifiers (btn-sm, btn-lg) and functional aliases (btn-submit, btn-check, btn-nav)
274
+ * do NOT satisfy this requirement — a color variant is always needed.
275
+ */
276
+ export function validateButtonVariants(slideId, source, warnings) {
277
+ const classAttrRegex = /class="([^"]+)"/g;
278
+ let match;
279
+ while ((match = classAttrRegex.exec(source)) !== null) {
280
+ const classNames = match[1].split(/\s+/).filter(Boolean);
281
+ // Skip template expressions
282
+ if (classNames.some(c => c.includes('${') || c.includes('}'))) continue;
283
+
284
+ const hasBtn = classNames.includes('btn');
285
+ if (!hasBtn) continue;
286
+
287
+ const hasColorVariant = classNames.some(c => BTN_COLOR_VARIANTS.has(c));
288
+ if (!hasColorVariant) {
289
+ warnings.push(
290
+ `Slide "${slideId}": Button has "btn" class without a color variant. ` +
291
+ 'Add a variant like btn-primary, btn-secondary, btn-success, etc.'
292
+ );
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * CLI entry point for linting a course.
299
+ * @param {object} options - CLI options
300
+ */
301
+ export async function lint(options = {}) {
302
+ const coursePath = options.coursePath || './course';
303
+ const configPath = path.join(coursePath, 'course-config.js');
304
+
305
+ console.log('\n🔍 Linting course...\n');
306
+
307
+ if (!fs.existsSync(configPath)) {
308
+ console.error(`❌ Course config not found: ${configPath}`);
309
+ console.error(' Run this command from a course project root.');
310
+ process.exit(1);
311
+ }
312
+
313
+ try {
314
+ // Dynamic import of course config
315
+ const configUrl = pathToFileURL(path.resolve(configPath)).href;
316
+ const configModule = await import(configUrl);
317
+ const courseConfig = configModule.default || configModule.courseConfig;
318
+
319
+ if (!courseConfig) {
320
+ console.error('❌ Course config does not export default or courseConfig');
321
+ process.exit(1);
322
+ }
323
+
324
+ const { errors, warnings } = await lintCourse(courseConfig, coursePath);
325
+
326
+ console.log(formatLintResults({ errors, warnings }));
327
+
328
+ if (errors.length > 0) {
329
+ process.exit(1);
330
+ }
331
+
332
+ } catch (error) {
333
+ console.error(`❌ Failed to lint course: ${error.message}`);
334
+ if (options.verbose) {
335
+ console.error(error.stack);
336
+ }
337
+ process.exit(1);
338
+ }
339
+ }
340
+
341
+ // === Framework JS Lint Rules ===
342
+
343
+ /**
344
+ * Banned patterns in framework JS source files.
345
+ * Each rule has a regex, a message, and optional file-level exemptions.
346
+ */
347
+ const BANNED_JS_PATTERNS = [
348
+ {
349
+ id: 'manual-error-emission',
350
+ pattern: /eventBus\.emit\(['"][a-z]+:error['"]/,
351
+ message: 'Manual error event emission. Use logger.error(msg, ctx) instead — it auto-emits to eventBus.',
352
+ exempt: [],
353
+ },
354
+ {
355
+ id: 'framework-error-import',
356
+ pattern: /import.*framework-error/,
357
+ message: 'Importing deleted module. Use logger.fatal() instead of frameworkError().',
358
+ exempt: [],
359
+ },
360
+ {
361
+ id: 'framework-error-call',
362
+ pattern: /frameworkError\s*\(/,
363
+ message: 'frameworkError() is removed. Use logger.fatal(msg, ctx) instead.',
364
+ exempt: [],
365
+ },
366
+ {
367
+ id: 'direct-console-usage',
368
+ pattern: /\bconsole\.(log|warn|error|info|debug)\s*\(/,
369
+ message: 'Direct console usage. Use logger.debug/info/warn/error instead.',
370
+ exempt: ['logger.js', 'icons.js'], // logger.js IS the console wrapper; icons.js is zero-dependency
371
+ },
372
+ {
373
+ id: 'unsafe-innerhtml',
374
+ pattern: /\.innerHTML\s*=\s*`[^`]*\$\{(?!(?:icon|escapeHTML))/,
375
+ message: 'Unsafe innerHTML with unescaped interpolation. Use escapeHTML() for user-facing text or textContent for plain text.',
376
+ exempt: [
377
+ 'access-control.js', // Static markup only, no user input
378
+ 'lightbox.js', // Uses icons/escapeHTML — regex can't distinguish all safe patterns
379
+ 'interaction-base.js', // ${type} is framework-controlled; message uses escapeHTML
380
+ 'fill-in.js', // All dynamic values escaped via escapeHTML; regex can't detect pre-escaped vars
381
+ 'AssessmentUI.js', // Interpolates config titles, icon output, CSS classes — all author-controlled
382
+ 'NavigationUI.js', // Interpolates menu labels, icon output — all author-controlled
383
+ ],
384
+ },
385
+ ];
386
+
387
+ /**
388
+ * Recursively collect .js files from a directory.
389
+ */
390
+ function collectJsFiles(dir, result) {
391
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
392
+ const fullPath = path.join(dir, entry.name);
393
+ if (entry.isDirectory()) {
394
+ collectJsFiles(fullPath, result);
395
+ } else if (entry.name.endsWith('.js')) {
396
+ result.push(fullPath);
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Lint framework JS source files for banned logging/error patterns.
403
+ * Prevents regression to pre-unified-logger patterns.
404
+ *
405
+ * @returns {{ warnings: string[] }} Lint warnings
406
+ */
407
+ export function lintFrameworkJs() {
408
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
409
+ const frameworkRoot = path.dirname(__dirname);
410
+ const jsDir = path.join(frameworkRoot, 'framework', 'js');
411
+ const warnings = [];
412
+
413
+ if (!fs.existsSync(jsDir)) return { warnings };
414
+
415
+ const jsFiles = [];
416
+ collectJsFiles(jsDir, jsFiles);
417
+
418
+ for (const file of jsFiles) {
419
+ // Skip vendor files entirely
420
+ if (file.includes(`${path.sep}vendor${path.sep}`)) continue;
421
+
422
+ const basename = path.basename(file);
423
+ const relPath = path.relative(jsDir, file);
424
+
425
+ try {
426
+ const source = fs.readFileSync(file, 'utf-8');
427
+ const lines = source.split('\n');
428
+
429
+ for (let i = 0; i < lines.length; i++) {
430
+ const line = lines[i];
431
+ // Skip comments
432
+ const trimmed = line.trimStart();
433
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
434
+
435
+ for (const rule of BANNED_JS_PATTERNS) {
436
+ if (rule.exempt.includes(basename)) continue;
437
+ if (rule.pattern.test(line)) {
438
+ warnings.push(
439
+ `[${rule.id}] ${relPath}:${i + 1} — ${rule.message}`
440
+ );
441
+ }
442
+ }
443
+ }
444
+ } catch {
445
+ // Skip unreadable files
446
+ }
447
+ }
448
+
449
+ return { warnings };
450
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Shared LMS packaging helpers for Vite build configs.
3
+ *
4
+ * Supports:
5
+ * - Standard package ZIP (scorm2004, scorm1.2, cmi5, lti)
6
+ * - SCORM proxy package ZIPs (scorm1.2-proxy, scorm2004-proxy)
7
+ * - cmi5 remote manifest-only ZIPs (cmi5-remote)
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import archiver from 'archiver';
13
+ import { fileURLToPath } from 'url';
14
+ import { generateManifest } from './manifest/manifest-factory.js';
15
+
16
+ // Resolve package root for template access
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
20
+
21
+ function sanitizeTitle(title) {
22
+ return String(title || 'course')
23
+ .replace(/[<>:"/\\|?*]/g, '-')
24
+ .replace(/\s+/g, '_')
25
+ .toLowerCase();
26
+ }
27
+
28
+ function withClientCredentials(externalUrl, clientId, token) {
29
+ if (!clientId || !token) return externalUrl;
30
+ const separator = externalUrl.includes('?') ? '&' : '?';
31
+ return `${externalUrl}${separator}clientId=${encodeURIComponent(clientId)}&token=${encodeURIComponent(token)}`;
32
+ }
33
+
34
+ function zipDirectory(sourceDir, zipFilePath) {
35
+ return new Promise((resolve, reject) => {
36
+ const output = fs.createWriteStream(zipFilePath);
37
+ const archive = archiver('zip', { zlib: { level: 9 } });
38
+
39
+ output.on('close', () => resolve(archive.pointer()));
40
+ archive.on('error', reject);
41
+ archive.pipe(output);
42
+ archive.directory(sourceDir, false);
43
+ archive.finalize();
44
+ });
45
+ }
46
+
47
+ export function validateExternalHostingConfig(config) {
48
+ const isProxyFormat = config.lmsFormat.endsWith('-proxy');
49
+ const isRemoteFormat = config.lmsFormat.endsWith('-remote');
50
+ const isExternalFormat = isProxyFormat || isRemoteFormat;
51
+
52
+ if (!isExternalFormat) return;
53
+
54
+ if (!config.externalUrl) {
55
+ throw new Error(`${config.lmsFormat} format requires 'externalUrl' in course-config.js`);
56
+ }
57
+
58
+ if (!config.accessControl?.clients || Object.keys(config.accessControl.clients).length === 0) {
59
+ throw new Error(`${config.lmsFormat} format requires 'accessControl.clients' in course-config.js. Use 'coursecode token --add <client>' to add clients.`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Re-stamp the lms-format meta tag in an index.html file.
65
+ * Used when creating format-specific ZIPs from a universal dist/.
66
+ * @param {string} htmlPath - Absolute path to the index.html to modify
67
+ * @param {string} format - The LMS format to stamp (e.g., 'scorm2004', 'cmi5')
68
+ */
69
+ export function stampFormatInHtml(htmlPath, format) {
70
+ let html = fs.readFileSync(htmlPath, 'utf-8');
71
+ // Replace existing meta tag or insert after charset
72
+ const existingMeta = /<meta\s+name="lms-format"\s+content="[^"]*"\s*\/?>/;
73
+ if (existingMeta.test(html)) {
74
+ html = html.replace(existingMeta, `<meta name="lms-format" content="${format}" />`);
75
+ } else {
76
+ html = html.replace(
77
+ '<meta charset="UTF-8" />',
78
+ `<meta charset="UTF-8" />\n <meta name="lms-format" content="${format}" />`
79
+ );
80
+ }
81
+ fs.writeFileSync(htmlPath, html, 'utf-8');
82
+ }
83
+
84
+ export async function createStandardPackage({ rootDir, distDir, config, outputDir }) {
85
+ // outputDir defaults to rootDir for backward compatibility
86
+ const targetDir = outputDir || rootDir;
87
+
88
+ // Determine zip filename
89
+ const zipFileName = `${sanitizeTitle(config.title)}_v${config.version}_${config.lmsFormat}.zip`;
90
+ const zipFilePath = path.join(targetDir, zipFileName);
91
+
92
+ if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
93
+ const bytes = await zipDirectory(distDir, zipFilePath);
94
+ const sizeInMB = (bytes / 1024 / 1024).toFixed(2);
95
+ console.warn(`📦 Created ${zipFileName} (${sizeInMB} MB)`);
96
+ return zipFilePath;
97
+ }
98
+
99
+ export async function createProxyPackage({ rootDir, config, clientId = null, token = null, outputDir }) {
100
+ // outputDir defaults to rootDir for backward compatibility
101
+ const targetDir = outputDir || rootDir;
102
+
103
+ const suffix = clientId ? `_${clientId}` : '';
104
+ const zipFileName = `${sanitizeTitle(config.title)}${suffix}_proxy.zip`;
105
+ const zipFilePath = path.join(targetDir, zipFileName);
106
+
107
+ // Use a temp dir inside the target dir to ensure we can move/zip easily, or system temp
108
+ // For now, keep it in rootDir/.proxy-temp to avoid cross-device link errors,
109
+ // unless outputDir is provided, then use outputDir/.proxy-temp
110
+ const tempBase = outputDir || rootDir;
111
+ const proxyDir = path.join(tempBase, '.proxy-temp');
112
+
113
+ if (fs.existsSync(proxyDir)) fs.rmSync(proxyDir, { recursive: true });
114
+ fs.mkdirSync(proxyDir, { recursive: true });
115
+
116
+ try {
117
+ // Resolve templates from PACKAGE_ROOT, not rootDir
118
+ const templatesDir = path.join(PACKAGE_ROOT, 'lib', 'proxy-templates');
119
+ const externalUrl = withClientCredentials(config.externalUrl, clientId, token);
120
+
121
+ let proxyHtml = fs.readFileSync(path.join(templatesDir, 'proxy.html'), 'utf-8');
122
+ proxyHtml = proxyHtml.replace('{{EXTERNAL_URL}}', externalUrl);
123
+ fs.writeFileSync(path.join(proxyDir, 'proxy.html'), proxyHtml);
124
+
125
+ fs.copyFileSync(path.join(templatesDir, 'scorm-bridge.js'), path.join(proxyDir, 'scorm-bridge.js'));
126
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'framework', 'js', 'vendor', 'pipwerks.js'), path.join(proxyDir, 'pipwerks.js'));
127
+
128
+ const { filename, content } = generateManifest(config.lmsFormat, config, [], { externalUrl: config.externalUrl });
129
+ fs.writeFileSync(path.join(proxyDir, filename), content);
130
+
131
+ if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
132
+ const bytes = await zipDirectory(proxyDir, zipFilePath);
133
+ const sizeKB = (bytes / 1024).toFixed(1);
134
+ console.warn(`📦 Created ${zipFileName} (${sizeKB} KB) - Upload to LMS`);
135
+ console.warn(` Course URL: ${config.externalUrl}`);
136
+ return zipFilePath;
137
+ } finally {
138
+ if (fs.existsSync(proxyDir)) fs.rmSync(proxyDir, { recursive: true });
139
+ }
140
+ }
141
+
142
+ export async function createRemotePackage({ rootDir, config, clientId = null, token = null, outputDir }) {
143
+ // outputDir defaults to rootDir for backward compatibility
144
+ const targetDir = outputDir || rootDir;
145
+
146
+ const suffix = clientId ? `_${clientId}` : '';
147
+ const zipFileName = `${sanitizeTitle(config.title)}${suffix}_cmi5-remote.zip`;
148
+ const zipFilePath = path.join(targetDir, zipFileName);
149
+
150
+ const tempBase = outputDir || rootDir;
151
+ const remoteDir = path.join(tempBase, '.remote-temp');
152
+
153
+ if (fs.existsSync(remoteDir)) fs.rmSync(remoteDir, { recursive: true });
154
+ fs.mkdirSync(remoteDir, { recursive: true });
155
+
156
+ try {
157
+ const externalUrl = withClientCredentials(config.externalUrl, clientId, token);
158
+ const { filename, content } = generateManifest(config.lmsFormat, config, [], { externalUrl });
159
+ fs.writeFileSync(path.join(remoteDir, filename), content);
160
+
161
+ if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
162
+ const bytes = await zipDirectory(remoteDir, zipFilePath);
163
+ const sizeKB = (bytes / 1024).toFixed(1);
164
+ console.warn(`📦 Created ${zipFileName} (${sizeKB} KB) - Upload to LMS`);
165
+ console.warn(` AU URL points to: ${externalUrl.replace(/\/$/, '')}/index.html`);
166
+ return zipFilePath;
167
+ } finally {
168
+ if (fs.existsSync(remoteDir)) fs.rmSync(remoteDir, { recursive: true });
169
+ }
170
+ }
171
+
172
+ export async function createExternalPackagesForClients({ rootDir, config, outputDir }) {
173
+ validateExternalHostingConfig(config);
174
+
175
+ const entries = Object.entries(config.accessControl.clients);
176
+ const isProxyFormat = config.lmsFormat.endsWith('-proxy');
177
+ const isRemoteFormat = config.lmsFormat.endsWith('-remote');
178
+
179
+ for (const [clientId, clientConfig] of entries) {
180
+ if (isProxyFormat) {
181
+ await createProxyPackage({ rootDir, config, clientId, token: clientConfig.token, outputDir });
182
+ } else if (isRemoteFormat) {
183
+ await createRemotePackage({ rootDir, config, clientId, token: clientConfig.token, outputDir });
184
+ }
185
+ }
186
+ }