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.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- 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
|
+
}
|