@lxpack/scorm 0.1.1 → 0.2.1

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/README.md CHANGED
@@ -5,15 +5,16 @@
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
7
 
8
- SCORM 1.2 and standalone HTML export for LXPack courses.
8
+ SCORM 1.2, SCORM 2004 (multi-SCO), and standalone HTML export for LXPack courses.
9
9
 
10
- Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
10
+ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime (**v0.2.0**).
11
11
 
12
12
  | Related | Package |
13
13
  |---------|---------|
14
14
  | CLI | [`@lxpack/cli`](../cli/README.md) |
15
15
  | Manifest validation | [`@lxpack/validators`](../validators/README.md) |
16
16
  | Embedded client | [`@lxpack/runtime`](../runtime/README.md) |
17
+ | Components bundle | [`@lxpack/components`](../components/README.md) |
17
18
 
18
19
  ## Install
19
20
 
@@ -25,6 +26,8 @@ Requires Node.js 20+.
25
26
 
26
27
  ## Usage
27
28
 
29
+ ### SCORM 1.2 or standalone ZIP
30
+
28
31
  ```ts
29
32
  import { readFile } from "node:fs/promises";
30
33
  import type { CourseManifest } from "@lxpack/validators";
@@ -44,6 +47,7 @@ const assessmentBundle = await buildRuntimeAssessmentBundle(courseDir, manifest)
44
47
 
45
48
  const runtimeClientJs = await readFile("path/to/client.js", "utf8");
46
49
  const runtimeCss = await readFile("path/to/styles.css", "utf8");
50
+ const componentsJs = await readFile("path/to/bundle.js", "utf8"); // optional
47
51
 
48
52
  await packageCourse({
49
53
  courseDir,
@@ -52,10 +56,31 @@ await packageCourse({
52
56
  outputPath: "/path/to/output/course-scorm12.zip",
53
57
  runtimeClientJs,
54
58
  runtimeCss,
59
+ componentsJs,
60
+ assessmentBundle,
61
+ });
62
+ ```
63
+
64
+ ### SCORM 2004 multi-SCO
65
+
66
+ ```ts
67
+ import { packageScorm2004, buildScoIndexHtml, listCourseActivities } from "@lxpack/scorm";
68
+
69
+ const activities = listCourseActivities(manifest);
70
+
71
+ await packageScorm2004({
72
+ courseDir,
73
+ manifest,
74
+ outputPath: "/path/to/output/course-scorm2004.zip",
75
+ runtimeClientJs,
76
+ runtimeCss,
77
+ componentsJs,
55
78
  assessmentBundle,
56
79
  });
57
80
  ```
58
81
 
82
+ Each activity gets `sco/<activityId>/index.html` (via `buildScoIndexHtml`). Shared assets include `lxpack-runtime.js` and `lxpack-components.js`. `generateScorm2004Manifest` emits `imsmanifest.xml` with IMS Simple Sequencing metadata.
83
+
59
84
  ### Standalone directory
60
85
 
61
86
  ```ts
@@ -66,6 +91,7 @@ await packageStandaloneDir({
66
91
  outputDir: "/path/to/output/standalone",
67
92
  runtimeClientJs,
68
93
  runtimeCss,
94
+ componentsJs,
69
95
  assessmentBundle,
70
96
  });
71
97
  ```
@@ -77,16 +103,21 @@ Most users should use `lxpack build` from [`@lxpack/cli`](../cli/README.md) inst
77
103
  | Export | Description |
78
104
  |--------|-------------|
79
105
  | `packageCourse(options)` | Build a SCORM 1.2 or standalone ZIP |
80
- | `packageStandaloneDir(options)` | Write an unpacked directory (ZIP or SCORM layout) |
106
+ | `packageScorm2004(options)` | Build a SCORM 2004 multi-SCO ZIP |
107
+ | `packageStandaloneDir(options)` | Write an unpacked directory |
108
+ | `listCourseActivities(manifest)` | Ordered activities for multi-SCO layout |
81
109
  | `generateImsManifest(manifest, files)` | SCORM 1.2 `imsmanifest.xml` |
82
- | `buildIndexHtml(options)` | Course shell HTML with embedded config |
110
+ | `generateScorm2004Manifest(...)` | SCORM 2004 manifest with sequencing |
111
+ | `buildIndexHtml(options)` | Single-SCO course shell HTML |
112
+ | `buildScoIndexHtml(options)` | Per-activity launch HTML for SCORM 2004 |
113
+ | `scoLaunchPath(activityId)` | Relative launch path (`sco/<id>/index.html`) |
83
114
  | `buildRuntimeConfig(options)` | Config object passed to `safeJsonForHtml` |
84
115
  | `collectFiles(courseDir, baseDir)` | Course assets for packaging (respects skip rules) |
85
116
  | `shouldSkipCourseFile(rel)` | Whether a relative path is omitted from exports |
86
117
  | `buildManifestFileList(courseFiles)` | File list for `imsmanifest.xml` |
87
118
  | `safeJsonForHtml(value)` | JSON safe for `<script type="application/json">` blocks |
88
119
  | `courseSlug(manifest)` | Stable slug for ZIP names and manifest identifiers |
89
- | `ExportTarget` | `"scorm12"` \| `"standalone"` |
120
+ | `ExportTarget` | `"scorm12"` \| `"scorm2004"` \| `"standalone"` |
90
121
 
91
122
  ### `PackageOptions`
92
123
 
@@ -94,28 +125,30 @@ Most users should use `lxpack build` from [`@lxpack/cli`](../cli/README.md) inst
94
125
  |-------|-------------|
95
126
  | `courseDir` | Course root directory |
96
127
  | `manifest` | Parsed `course.yaml` |
97
- | `outputPath` | Destination ZIP path (`packageCourse`) |
98
- | `target` | `scorm12` or `standalone` |
128
+ | `outputPath` | Destination ZIP path |
129
+ | `target` | `scorm12`, `scorm2004`, or `standalone` |
99
130
  | `runtimeClientJs` | Contents of `@lxpack/runtime/client` bundle |
100
131
  | `runtimeCss` | Runtime stylesheet |
101
- | `assessmentBundle` | Optional; from `buildRuntimeAssessmentBundle()` |
132
+ | `componentsJs` | Optional `@lxpack/components/bundle` for component lessons |
133
+ | `assessmentBundle` | From `buildRuntimeAssessmentBundle()` (assessments, keys, configs, feedback) |
102
134
 
103
135
  ## What gets packaged
104
136
 
105
137
  **Included**
106
138
 
107
- - Lessons, interactions, and assets referenced by the manifest
108
- - `index.html` — shell with embedded manifest + assessment config
139
+ - Lessons, interactions, assets, and component overrides referenced by the manifest
109
140
  - `lxpack-runtime.js` — browser client bundle
110
- - `imsmanifest.xml` — SCORM 1.2 target only
141
+ - `lxpack-components.js` — when component lessons are used
142
+ - **SCORM 1.2 / standalone:** root `index.html` with embedded config
143
+ - **SCORM 2004:** `sco/<activityId>/index.html` per activity + `imsmanifest.xml`
111
144
 
112
145
  **Excluded** (`shouldSkipCourseFile`)
113
146
 
114
147
  - `course.yaml`, `lxpack.config.json`, `.lxpack/`
115
148
  - `assessments/**` — author YAML; assessments are embedded in config instead
116
- - Root `index.html` if present (packager generates the entry page)
149
+ - Root `index.html` if present (packager generates entry pages)
117
150
 
118
- Answer keys are only present inside the JSON config block (with `<` escaped via `safeJsonForHtml`), not as downloadable files.
151
+ Answer keys and feedback text are only present inside the JSON config block (with `<` escaped via `safeJsonForHtml`), not as downloadable files.
119
152
 
120
153
  ## Development
121
154
 
@@ -130,6 +163,8 @@ pnpm --filter @lxpack/scorm typecheck
130
163
  ## Links
131
164
 
132
165
  - [LXPack repository](https://github.com/eddiethedean/lxpack)
166
+ - [Documentation index](https://github.com/eddiethedean/lxpack/blob/main/docs/README.md)
167
+ - [Roadmap & phases](https://github.com/eddiethedean/lxpack/blob/main/docs/ROADMAP.md)
133
168
  - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
134
169
 
135
170
  ## License
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CourseManifest, RuntimeAssessmentBundle } from '@lxpack/validators';
2
+ export { CourseActivity, enumerateActivities as listCourseActivities } from '@lxpack/validators';
2
3
 
3
4
  declare function manifestIdentifier(manifest: CourseManifest): string;
4
5
  declare function generateImsManifest(manifest: CourseManifest, files: string[], launchUrl?: string): string;
@@ -6,19 +7,27 @@ declare function generateImsManifest(manifest: CourseManifest, files: string[],
6
7
  interface BuildHtmlOptions {
7
8
  manifest: CourseManifest;
8
9
  runtimeCss: string;
9
- mode: "standalone" | "scorm12";
10
+ mode: "standalone" | "scorm12" | "scorm2004";
11
+ activityId?: string;
10
12
  assessmentBundle?: RuntimeAssessmentBundle;
13
+ componentsScript?: string;
11
14
  }
12
- declare function buildRuntimeConfig(options: BuildHtmlOptions): {
13
- manifest: CourseManifest;
14
- baseUrl: string;
15
- mode: BuildHtmlOptions["mode"];
16
- assessments?: RuntimeAssessmentBundle["assessments"];
17
- answerKeys?: RuntimeAssessmentBundle["answerKeys"];
18
- };
15
+ declare function buildRuntimeConfig(options: BuildHtmlOptions): Record<string, unknown>;
16
+ declare function buildScoIndexHtml(options: BuildHtmlOptions & {
17
+ activityId: string;
18
+ }): string;
19
19
  declare function buildIndexHtml(options: BuildHtmlOptions): string;
20
20
 
21
- type ExportTarget = "scorm12" | "standalone";
21
+ interface PageTemplateOptions {
22
+ title: string;
23
+ runtimeCss: string;
24
+ configJson: string;
25
+ runtimeScript: string;
26
+ componentsScript?: string;
27
+ }
28
+ declare function buildLearnerPageHtml(options: PageTemplateOptions): string;
29
+
30
+ type ExportTarget = "scorm12" | "scorm2004" | "standalone";
22
31
  interface PackageOptions {
23
32
  courseDir: string;
24
33
  manifest: CourseManifest;
@@ -26,6 +35,7 @@ interface PackageOptions {
26
35
  target: ExportTarget;
27
36
  runtimeClientJs: string;
28
37
  runtimeCss: string;
38
+ componentsBundleJs?: string;
29
39
  assessmentBundle?: RuntimeAssessmentBundle;
30
40
  }
31
41
  declare function shouldSkipCourseFile(rel: string): boolean;
@@ -36,6 +46,22 @@ declare function collectFiles(dir: string, baseDir: string): Promise<Array<{
36
46
  declare function buildManifestFileList(courseFiles: Array<{
37
47
  path: string;
38
48
  }>): string[];
49
+ interface PackageSink {
50
+ writeFile(relPath: string, content: string | Buffer): Promise<void>;
51
+ }
52
+ declare function assemblePackage(options: Omit<PackageOptions, "outputPath">, sink: PackageSink): Promise<{
53
+ fileCount: number;
54
+ }>;
55
+ declare function packageScorm2004(options: PackageOptions): Promise<{
56
+ outputPath: string;
57
+ fileCount: number;
58
+ }>;
59
+ declare function packageScorm2004Dir(options: Omit<PackageOptions, "outputPath"> & {
60
+ outputDir: string;
61
+ }): Promise<{
62
+ outputDir: string;
63
+ fileCount: number;
64
+ }>;
39
65
  declare function packageCourse(options: PackageOptions): Promise<{
40
66
  outputPath: string;
41
67
  fileCount: number;
@@ -47,10 +73,19 @@ declare function packageStandaloneDir(options: Omit<PackageOptions, "outputPath"
47
73
  fileCount: number;
48
74
  }>;
49
75
 
76
+ /** Omit embedded quiz data from lesson SCOs; limit assessment SCOs to one quiz. */
77
+ declare function sliceAssessmentBundleForActivity(bundle: RuntimeAssessmentBundle, activityId: string, activityKind: "lesson" | "assessment"): RuntimeAssessmentBundle;
78
+
50
79
  /** JSON safe to embed in HTML script blocks (prevents `</script>` breakout). */
51
80
  declare function safeJsonForHtml(value: unknown): string;
52
81
 
53
82
  /** Stable slug for ZIP filenames and SCORM identifiers. */
54
83
  declare function courseSlug(manifest: CourseManifest): string;
55
84
 
56
- export { type BuildHtmlOptions, type ExportTarget, type PackageOptions, buildIndexHtml, buildManifestFileList, buildRuntimeConfig, collectFiles, courseSlug, generateImsManifest, manifestIdentifier, packageCourse, packageStandaloneDir, safeJsonForHtml, shouldSkipCourseFile };
85
+ declare function scoLaunchPath(activityId: string): string;
86
+ declare function buildScorm2004ManifestFiles(manifest: CourseManifest, courseFiles: string[], hasComponentsBundle?: boolean): string[];
87
+ declare function generateScorm2004Manifest(manifest: CourseManifest, courseFiles: string[], options?: {
88
+ hasComponentsBundle?: boolean;
89
+ }): string;
90
+
91
+ export { type BuildHtmlOptions, type ExportTarget, type PackageOptions, type PackageSink, type PageTemplateOptions, assemblePackage, buildIndexHtml, buildLearnerPageHtml, buildManifestFileList, buildRuntimeConfig, buildScoIndexHtml, buildScorm2004ManifestFiles, collectFiles, courseSlug, generateImsManifest, generateScorm2004Manifest, manifestIdentifier, packageCourse, packageScorm2004, packageScorm2004Dir, packageStandaloneDir, safeJsonForHtml, scoLaunchPath, shouldSkipCourseFile, sliceAssessmentBundleForActivity };
package/dist/index.js CHANGED
@@ -57,49 +57,183 @@ function safeJsonForHtml(value) {
57
57
  return JSON.stringify(value).replace(/</g, "\\u003c");
58
58
  }
59
59
 
60
- // src/build-html.ts
61
- function buildRuntimeConfig(options) {
62
- const { manifest, mode, assessmentBundle } = options;
63
- return {
64
- manifest,
65
- baseUrl: ".",
66
- mode,
67
- ...assessmentBundle ? {
68
- assessments: assessmentBundle.assessments,
69
- answerKeys: assessmentBundle.answerKeys
70
- } : {}
71
- };
72
- }
73
- function buildIndexHtml(options) {
74
- const { manifest, runtimeCss } = options;
75
- const config = safeJsonForHtml(buildRuntimeConfig(options));
60
+ // src/page-template.ts
61
+ import { escapeHtml } from "@lxpack/validators";
62
+ function buildLearnerPageHtml(options) {
63
+ const componentsTag = options.componentsScript ? `<script type="module" src="${escapeHtml(options.componentsScript)}"></script>` : "";
76
64
  return `<!DOCTYPE html>
77
65
  <html lang="en">
78
66
  <head>
79
67
  <meta charset="UTF-8">
80
68
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
81
- <title>${escapeHtml(manifest.title)}</title>
82
- <style>${runtimeCss}</style>
69
+ <title>${escapeHtml(options.title)}</title>
70
+ <style>${options.runtimeCss}</style>
83
71
  </head>
84
72
  <body>
85
73
  <div id="lxpack-app"></div>
86
- <script type="application/json" id="lxpack-config">${config}</script>
74
+ <script type="application/json" id="lxpack-config">${options.configJson}</script>
87
75
  <script>
88
76
  window.__LXPACK_CONFIG__ = JSON.parse(document.getElementById('lxpack-config').textContent);
89
77
  </script>
90
- <script type="module" src="./lxpack-runtime.js"></script>
78
+ ${componentsTag}
79
+ <script type="module" src="${escapeHtml(options.runtimeScript)}"></script>
91
80
  </body>
92
81
  </html>`;
93
82
  }
94
- function escapeHtml(text) {
95
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
83
+
84
+ // src/build-html.ts
85
+ function buildRuntimeConfig(options) {
86
+ const { manifest, mode, assessmentBundle, activityId } = options;
87
+ return {
88
+ manifest,
89
+ baseUrl: activityId ? "../.." : ".",
90
+ mode,
91
+ ...activityId ? { activityId } : {},
92
+ ...assessmentBundle ? {
93
+ ...Object.keys(assessmentBundle.assessments).length ? { assessments: assessmentBundle.assessments } : {},
94
+ ...Object.keys(assessmentBundle.answerKeys).length ? { answerKeys: assessmentBundle.answerKeys } : {},
95
+ ...Object.keys(assessmentBundle.configs ?? {}).length ? { assessmentConfigs: assessmentBundle.configs } : {},
96
+ ...Object.keys(assessmentBundle.feedback ?? {}).length ? { assessmentFeedback: assessmentBundle.feedback } : {}
97
+ } : {}
98
+ };
99
+ }
100
+ function buildScoIndexHtml(options) {
101
+ const { manifest, runtimeCss, activityId, componentsScript } = options;
102
+ const config = safeJsonForHtml(buildRuntimeConfig({ ...options, activityId }));
103
+ return buildLearnerPageHtml({
104
+ title: `${manifest.title} \u2014 ${activityId}`,
105
+ runtimeCss,
106
+ configJson: config,
107
+ runtimeScript: "../../lxpack-runtime.js",
108
+ componentsScript
109
+ });
110
+ }
111
+ function buildIndexHtml(options) {
112
+ const { manifest, runtimeCss, componentsScript } = options;
113
+ const config = safeJsonForHtml(buildRuntimeConfig(options));
114
+ return buildLearnerPageHtml({
115
+ title: manifest.title,
116
+ runtimeCss,
117
+ configJson: config,
118
+ runtimeScript: "./lxpack-runtime.js",
119
+ componentsScript
120
+ });
96
121
  }
97
122
 
98
123
  // src/package.ts
99
- import { readFile, readdir, writeFile, mkdir, cp } from "fs/promises";
124
+ import { readFile, readdir, writeFile, mkdir } from "fs/promises";
100
125
  import { existsSync } from "fs";
101
126
  import { dirname, join, relative } from "path";
102
127
  import JSZip from "jszip";
128
+
129
+ // src/assessment-slice.ts
130
+ function sliceAssessmentBundleForActivity(bundle, activityId, activityKind) {
131
+ if (activityKind === "assessment") {
132
+ const assessment = bundle.assessments[activityId];
133
+ if (!assessment) {
134
+ return { assessments: {}, answerKeys: {}, configs: {}, feedback: {} };
135
+ }
136
+ return {
137
+ assessments: { [activityId]: assessment },
138
+ answerKeys: bundle.answerKeys[activityId] ? { [activityId]: bundle.answerKeys[activityId] } : {},
139
+ configs: bundle.configs[activityId] ? { [activityId]: bundle.configs[activityId] } : {},
140
+ feedback: bundle.feedback[activityId] ? { [activityId]: bundle.feedback[activityId] } : {}
141
+ };
142
+ }
143
+ return {
144
+ assessments: {},
145
+ answerKeys: {},
146
+ configs: {},
147
+ feedback: {}
148
+ };
149
+ }
150
+
151
+ // src/activities.ts
152
+ import {
153
+ enumerateActivities
154
+ } from "@lxpack/validators";
155
+
156
+ // src/scorm2004-manifest.ts
157
+ function escapeXml2(text) {
158
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
159
+ }
160
+ function scoLaunchPath(activityId) {
161
+ return `sco/${activityId}/index.html`;
162
+ }
163
+ function buildScorm2004ManifestFiles(manifest, courseFiles, hasComponentsBundle = false) {
164
+ const activities = enumerateActivities(manifest);
165
+ const scoFiles = activities.map((a) => scoLaunchPath(a.id));
166
+ return [
167
+ "lxpack-runtime.js",
168
+ ...hasComponentsBundle ? ["lxpack-components.js"] : [],
169
+ ...scoFiles,
170
+ ...courseFiles
171
+ ];
172
+ }
173
+ function generateScorm2004Manifest(manifest, courseFiles, options) {
174
+ const hasComponentsBundle = options?.hasComponentsBundle ?? false;
175
+ const identifier = manifestIdentifier(manifest);
176
+ const orgId = `${identifier}-org`;
177
+ const activities = enumerateActivities(manifest);
178
+ const itemEntries = activities.map((activity) => {
179
+ const itemId = `item_${activity.id}`;
180
+ const resourceId = `res_${activity.id}`;
181
+ const sequencing = activity.kind === "assessment" ? `
182
+ <imsss:sequencing>
183
+ <imsss:deliveryControls tracked="true" completionSetByContent="true" objectiveSetByContent="true"/>
184
+ </imsss:sequencing>` : `
185
+ <imsss:sequencing>
186
+ <imsss:deliveryControls tracked="true" completionSetByContent="true"/>
187
+ </imsss:sequencing>`;
188
+ return ` <item identifier="${escapeXml2(itemId)}" identifierref="${escapeXml2(resourceId)}">
189
+ <title>${escapeXml2(activity.title)}</title>${sequencing}
190
+ </item>`;
191
+ }).join("\n");
192
+ const resources = activities.map((activity) => {
193
+ const resourceId = `res_${activity.id}`;
194
+ const href = scoLaunchPath(activity.id);
195
+ const componentFile = hasComponentsBundle ? `
196
+ <file href="lxpack-components.js"/>` : "";
197
+ return ` <resource identifier="${escapeXml2(resourceId)}" type="webcontent" adlcp:scormType="sco" href="${escapeXml2(href)}">
198
+ <file href="${escapeXml2(href)}"/>
199
+ <file href="lxpack-runtime.js"/>${componentFile}
200
+ </resource>`;
201
+ }).join("\n");
202
+ const orgSequencing = `
203
+ <imsss:sequencing>
204
+ <imsss:controlMode choice="true" flow="true"/>
205
+ </imsss:sequencing>`;
206
+ const uniqueCourseFiles = [...new Set(courseFiles)].filter((f) => !f.startsWith("sco/")).map(
207
+ (href) => ` <file href="${escapeXml2(href)}"/>`
208
+ ).join("\n");
209
+ return `<?xml version="1.0" encoding="UTF-8"?>
210
+ <manifest identifier="${escapeXml2(identifier)}" version="${escapeXml2(manifest.version)}"
211
+ xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
212
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3"
213
+ xmlns:adlseq="http://www.adlnet.org/xsd/adlseq_v1p3"
214
+ xmlns:imsss="http://www.imsglobal.org/xsd/imsss"
215
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
216
+ <metadata>
217
+ <schema>ADL SCORM</schema>
218
+ <schemaversion>2004 4th Edition</schemaversion>
219
+ </metadata>
220
+ <organizations default="${escapeXml2(orgId)}">
221
+ <organization identifier="${escapeXml2(orgId)}">
222
+ <title>${escapeXml2(manifest.title)}</title>${orgSequencing}
223
+ ${itemEntries}
224
+ </organization>
225
+ </organizations>
226
+ <resources>
227
+ ${resources}
228
+ <resource identifier="shared_assets" type="webcontent" adlcp:scormType="asset">
229
+ ${uniqueCourseFiles}
230
+ <file href="lxpack-runtime.js"/>${hasComponentsBundle ? '\n <file href="lxpack-components.js"/>' : ""}
231
+ </resource>
232
+ </resources>
233
+ </manifest>`;
234
+ }
235
+
236
+ // src/package.ts
103
237
  var SKIP_FILES = /* @__PURE__ */ new Set([
104
238
  "course.yaml",
105
239
  "lxpack.config.ts",
@@ -140,38 +274,119 @@ async function collectFiles(dir, baseDir) {
140
274
  function buildManifestFileList(courseFiles) {
141
275
  return ["index.html", "lxpack-runtime.js", ...courseFiles.map((f) => f.path)];
142
276
  }
143
- async function packageCourse(options) {
277
+ async function writeSingleScoArtifacts(options) {
144
278
  const {
145
279
  courseDir,
146
280
  manifest,
147
- outputPath,
148
281
  target,
149
282
  runtimeClientJs,
150
283
  runtimeCss,
151
- assessmentBundle
284
+ assessmentBundle,
285
+ componentsBundleJs,
286
+ writeFile: writeArtifact
152
287
  } = options;
153
- const zip = new JSZip();
154
288
  const mode = target === "scorm12" ? "scorm12" : "standalone";
155
289
  const courseFiles = await collectFiles(courseDir, courseDir);
290
+ let fileCount = 0;
156
291
  for (const file of courseFiles) {
157
292
  const content = await readFile(file.fullPath);
158
- zip.file(file.path, content);
293
+ await writeArtifact(file.path, content);
294
+ fileCount++;
159
295
  }
160
296
  const indexHtml = buildIndexHtml({
161
297
  manifest,
162
298
  runtimeCss,
163
299
  mode,
164
- assessmentBundle
300
+ assessmentBundle,
301
+ componentsScript: componentsBundleJs ? "./lxpack-components.js" : void 0
165
302
  });
166
- zip.file("index.html", indexHtml);
167
- zip.file("lxpack-runtime.js", runtimeClientJs);
303
+ await writeArtifact("index.html", indexHtml);
304
+ await writeArtifact("lxpack-runtime.js", runtimeClientJs);
305
+ fileCount += 2;
306
+ if (componentsBundleJs) {
307
+ await writeArtifact("lxpack-components.js", componentsBundleJs);
308
+ fileCount++;
309
+ }
168
310
  if (target === "scorm12") {
169
311
  const manifestFiles = buildManifestFileList(courseFiles);
170
- zip.file(
312
+ await writeArtifact(
171
313
  "imsmanifest.xml",
172
314
  generateImsManifest(manifest, manifestFiles)
173
315
  );
316
+ fileCount++;
317
+ }
318
+ return { fileCount };
319
+ }
320
+ async function assemblePackage(options, sink) {
321
+ if (options.target === "scorm2004") {
322
+ return writeScorm2004Artifacts({ ...options, writeFile: sink.writeFile });
174
323
  }
324
+ return writeSingleScoArtifacts({ ...options, writeFile: sink.writeFile });
325
+ }
326
+ async function writeScorm2004Artifacts(options) {
327
+ const {
328
+ courseDir,
329
+ manifest,
330
+ runtimeClientJs,
331
+ runtimeCss,
332
+ componentsBundleJs,
333
+ assessmentBundle,
334
+ writeFile: writeArtifact
335
+ } = options;
336
+ const courseFiles = await collectFiles(courseDir, courseDir);
337
+ let fileCount = 0;
338
+ for (const file of courseFiles) {
339
+ const content = await readFile(file.fullPath);
340
+ await writeArtifact(file.path, content);
341
+ fileCount++;
342
+ }
343
+ await writeArtifact("lxpack-runtime.js", runtimeClientJs);
344
+ fileCount++;
345
+ if (componentsBundleJs) {
346
+ await writeArtifact("lxpack-components.js", componentsBundleJs);
347
+ fileCount++;
348
+ }
349
+ const activities = enumerateActivities(manifest);
350
+ for (const activity of activities) {
351
+ const scoBundle = assessmentBundle != null ? sliceAssessmentBundleForActivity(
352
+ assessmentBundle,
353
+ activity.id,
354
+ activity.kind
355
+ ) : void 0;
356
+ const html = buildScoIndexHtml({
357
+ manifest,
358
+ runtimeCss,
359
+ mode: "scorm2004",
360
+ activityId: activity.id,
361
+ assessmentBundle: scoBundle,
362
+ componentsScript: componentsBundleJs ? "../../lxpack-components.js" : void 0
363
+ });
364
+ await writeArtifact(`sco/${activity.id}/index.html`, html);
365
+ fileCount++;
366
+ }
367
+ const manifestFiles = buildScorm2004ManifestFiles(
368
+ manifest,
369
+ courseFiles.map((f) => f.path),
370
+ Boolean(componentsBundleJs)
371
+ );
372
+ await writeArtifact(
373
+ "imsmanifest.xml",
374
+ generateScorm2004Manifest(manifest, manifestFiles, {
375
+ hasComponentsBundle: Boolean(componentsBundleJs)
376
+ })
377
+ );
378
+ fileCount++;
379
+ return { fileCount };
380
+ }
381
+ async function packageScorm2004(options) {
382
+ const { outputPath, ...rest } = options;
383
+ const zip = new JSZip();
384
+ const { fileCount } = await writeScorm2004Artifacts({
385
+ ...rest,
386
+ writeFile: async (relPath, content) => {
387
+ zip.file(relPath, content);
388
+ }
389
+ });
175
390
  const buffer = await zip.generateAsync({
176
391
  type: "nodebuffer",
177
392
  compression: "DEFLATE"
@@ -179,7 +394,44 @@ async function packageCourse(options) {
179
394
  await writeFile(outputPath, buffer);
180
395
  return {
181
396
  outputPath,
182
- fileCount: Object.keys(zip.files).length
397
+ fileCount: Object.keys(zip.files).length || fileCount
398
+ };
399
+ }
400
+ async function packageScorm2004Dir(options) {
401
+ const { outputDir, ...rest } = options;
402
+ await mkdir(outputDir, { recursive: true });
403
+ const { fileCount } = await writeScorm2004Artifacts({
404
+ ...rest,
405
+ writeFile: async (relPath, content) => {
406
+ const dest = join(outputDir, relPath);
407
+ const destDir = dirname(dest);
408
+ if (!existsSync(destDir)) {
409
+ await mkdir(destDir, { recursive: true });
410
+ }
411
+ await writeFile(dest, content);
412
+ }
413
+ });
414
+ return { outputDir, fileCount };
415
+ }
416
+ async function packageCourse(options) {
417
+ if (options.target === "scorm2004") {
418
+ return packageScorm2004(options);
419
+ }
420
+ const { outputPath, ...rest } = options;
421
+ const zip = new JSZip();
422
+ const { fileCount } = await assemblePackage(rest, {
423
+ writeFile: async (relPath, content) => {
424
+ zip.file(relPath, content);
425
+ }
426
+ });
427
+ const buffer = await zip.generateAsync({
428
+ type: "nodebuffer",
429
+ compression: "DEFLATE"
430
+ });
431
+ await writeFile(outputPath, buffer);
432
+ return {
433
+ outputPath,
434
+ fileCount: Object.keys(zip.files).length || fileCount
183
435
  };
184
436
  }
185
437
  async function packageStandaloneDir(options) {
@@ -190,50 +442,57 @@ async function packageStandaloneDir(options) {
190
442
  target,
191
443
  runtimeClientJs,
192
444
  runtimeCss,
193
- assessmentBundle
445
+ assessmentBundle,
446
+ componentsBundleJs
194
447
  } = options;
195
448
  await mkdir(outputDir, { recursive: true });
196
- const mode = target === "scorm12" ? "scorm12" : "standalone";
197
- const courseFiles = await collectFiles(courseDir, courseDir);
198
- let fileCount = 0;
199
- for (const file of courseFiles) {
200
- const dest = join(outputDir, file.path);
201
- const destDir = dirname(dest);
202
- if (!existsSync(destDir)) {
203
- await mkdir(destDir, { recursive: true });
449
+ const { fileCount } = await assemblePackage(
450
+ {
451
+ courseDir,
452
+ manifest,
453
+ target,
454
+ runtimeClientJs,
455
+ runtimeCss,
456
+ assessmentBundle,
457
+ componentsBundleJs
458
+ },
459
+ {
460
+ writeFile: async (relPath, content) => {
461
+ const dest = join(outputDir, relPath);
462
+ const destDir = dirname(dest);
463
+ if (!existsSync(destDir)) {
464
+ await mkdir(destDir, { recursive: true });
465
+ }
466
+ if (typeof content === "string") {
467
+ await writeFile(dest, content);
468
+ } else {
469
+ await writeFile(dest, content);
470
+ }
471
+ }
204
472
  }
205
- await cp(file.fullPath, dest);
206
- fileCount++;
207
- }
208
- const indexHtml = buildIndexHtml({
209
- manifest,
210
- runtimeCss,
211
- mode,
212
- assessmentBundle
213
- });
214
- await writeFile(join(outputDir, "index.html"), indexHtml);
215
- await writeFile(join(outputDir, "lxpack-runtime.js"), runtimeClientJs);
216
- fileCount += 2;
217
- if (target === "scorm12") {
218
- const manifestFiles = buildManifestFileList(courseFiles);
219
- await writeFile(
220
- join(outputDir, "imsmanifest.xml"),
221
- generateImsManifest(manifest, manifestFiles)
222
- );
223
- fileCount++;
224
- }
473
+ );
225
474
  return { outputDir, fileCount };
226
475
  }
227
476
  export {
477
+ assemblePackage,
228
478
  buildIndexHtml,
479
+ buildLearnerPageHtml,
229
480
  buildManifestFileList,
230
481
  buildRuntimeConfig,
482
+ buildScoIndexHtml,
483
+ buildScorm2004ManifestFiles,
231
484
  collectFiles,
232
485
  courseSlug,
233
486
  generateImsManifest,
487
+ generateScorm2004Manifest,
488
+ enumerateActivities as listCourseActivities,
234
489
  manifestIdentifier,
235
490
  packageCourse,
491
+ packageScorm2004,
492
+ packageScorm2004Dir,
236
493
  packageStandaloneDir,
237
494
  safeJsonForHtml,
238
- shouldSkipCourseFile
495
+ scoLaunchPath,
496
+ shouldSkipCourseFile,
497
+ sliceAssessmentBundleForActivity
239
498
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/scorm",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "SCORM and standalone HTML export for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "jszip": "^3.10.1",
42
- "@lxpack/validators": "0.1.1"
42
+ "@lxpack/validators": "0.2.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.13.10",