@lxpack/scorm 0.1.0 → 0.1.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 ADDED
@@ -0,0 +1,137 @@
1
+ # @lxpack/scorm
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@lxpack/scorm)](https://www.npmjs.com/package/@lxpack/scorm)
4
+ [![CI](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
+
8
+ SCORM 1.2 and standalone HTML export for LXPack courses.
9
+
10
+ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
+
12
+ | Related | Package |
13
+ |---------|---------|
14
+ | CLI | [`@lxpack/cli`](../cli/README.md) |
15
+ | Manifest validation | [`@lxpack/validators`](../validators/README.md) |
16
+ | Embedded client | [`@lxpack/runtime`](../runtime/README.md) |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @lxpack/scorm
22
+ ```
23
+
24
+ Requires Node.js 20+.
25
+
26
+ ## Usage
27
+
28
+ ```ts
29
+ import { readFile } from "node:fs/promises";
30
+ import type { CourseManifest } from "@lxpack/validators";
31
+ import { buildRuntimeAssessmentBundle } from "@lxpack/validators";
32
+ import {
33
+ packageCourse,
34
+ packageStandaloneDir,
35
+ buildIndexHtml,
36
+ collectFiles,
37
+ safeJsonForHtml,
38
+ courseSlug,
39
+ } from "@lxpack/scorm";
40
+
41
+ const courseDir = "/path/to/my-course";
42
+ const manifest: CourseManifest = /* from validateCourse */;
43
+ const assessmentBundle = await buildRuntimeAssessmentBundle(courseDir, manifest);
44
+
45
+ const runtimeClientJs = await readFile("path/to/client.js", "utf8");
46
+ const runtimeCss = await readFile("path/to/styles.css", "utf8");
47
+
48
+ await packageCourse({
49
+ courseDir,
50
+ manifest,
51
+ target: "scorm12",
52
+ outputPath: "/path/to/output/course-scorm12.zip",
53
+ runtimeClientJs,
54
+ runtimeCss,
55
+ assessmentBundle,
56
+ });
57
+ ```
58
+
59
+ ### Standalone directory
60
+
61
+ ```ts
62
+ await packageStandaloneDir({
63
+ courseDir,
64
+ manifest,
65
+ target: "standalone",
66
+ outputDir: "/path/to/output/standalone",
67
+ runtimeClientJs,
68
+ runtimeCss,
69
+ assessmentBundle,
70
+ });
71
+ ```
72
+
73
+ Most users should use `lxpack build` from [`@lxpack/cli`](../cli/README.md) instead of calling these APIs directly.
74
+
75
+ ## Exports
76
+
77
+ | Export | Description |
78
+ |--------|-------------|
79
+ | `packageCourse(options)` | Build a SCORM 1.2 or standalone ZIP |
80
+ | `packageStandaloneDir(options)` | Write an unpacked directory (ZIP or SCORM layout) |
81
+ | `generateImsManifest(manifest, files)` | SCORM 1.2 `imsmanifest.xml` |
82
+ | `buildIndexHtml(options)` | Course shell HTML with embedded config |
83
+ | `buildRuntimeConfig(options)` | Config object passed to `safeJsonForHtml` |
84
+ | `collectFiles(courseDir, baseDir)` | Course assets for packaging (respects skip rules) |
85
+ | `shouldSkipCourseFile(rel)` | Whether a relative path is omitted from exports |
86
+ | `buildManifestFileList(courseFiles)` | File list for `imsmanifest.xml` |
87
+ | `safeJsonForHtml(value)` | JSON safe for `<script type="application/json">` blocks |
88
+ | `courseSlug(manifest)` | Stable slug for ZIP names and manifest identifiers |
89
+ | `ExportTarget` | `"scorm12"` \| `"standalone"` |
90
+
91
+ ### `PackageOptions`
92
+
93
+ | Field | Description |
94
+ |-------|-------------|
95
+ | `courseDir` | Course root directory |
96
+ | `manifest` | Parsed `course.yaml` |
97
+ | `outputPath` | Destination ZIP path (`packageCourse`) |
98
+ | `target` | `scorm12` or `standalone` |
99
+ | `runtimeClientJs` | Contents of `@lxpack/runtime/client` bundle |
100
+ | `runtimeCss` | Runtime stylesheet |
101
+ | `assessmentBundle` | Optional; from `buildRuntimeAssessmentBundle()` |
102
+
103
+ ## What gets packaged
104
+
105
+ **Included**
106
+
107
+ - Lessons, interactions, and assets referenced by the manifest
108
+ - `index.html` — shell with embedded manifest + assessment config
109
+ - `lxpack-runtime.js` — browser client bundle
110
+ - `imsmanifest.xml` — SCORM 1.2 target only
111
+
112
+ **Excluded** (`shouldSkipCourseFile`)
113
+
114
+ - `course.yaml`, `lxpack.config.json`, `.lxpack/`
115
+ - `assessments/**` — author YAML; assessments are embedded in config instead
116
+ - Root `index.html` if present (packager generates the entry page)
117
+
118
+ Answer keys are only present inside the JSON config block (with `<` escaped via `safeJsonForHtml`), not as downloadable files.
119
+
120
+ ## Development
121
+
122
+ From the monorepo root:
123
+
124
+ ```bash
125
+ pnpm --filter @lxpack/scorm build
126
+ pnpm --filter @lxpack/scorm test
127
+ pnpm --filter @lxpack/scorm typecheck
128
+ ```
129
+
130
+ ## Links
131
+
132
+ - [LXPack repository](https://github.com/eddiethedean/lxpack)
133
+ - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
134
+
135
+ ## License
136
+
137
+ Apache-2.0
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CourseManifest } from '@lxpack/validators';
1
+ import { CourseManifest, RuntimeAssessmentBundle } from '@lxpack/validators';
2
2
 
3
3
  declare function manifestIdentifier(manifest: CourseManifest): string;
4
4
  declare function generateImsManifest(manifest: CourseManifest, files: string[], launchUrl?: string): string;
@@ -7,7 +7,15 @@ interface BuildHtmlOptions {
7
7
  manifest: CourseManifest;
8
8
  runtimeCss: string;
9
9
  mode: "standalone" | "scorm12";
10
+ assessmentBundle?: RuntimeAssessmentBundle;
10
11
  }
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
+ };
11
19
  declare function buildIndexHtml(options: BuildHtmlOptions): string;
12
20
 
13
21
  type ExportTarget = "scorm12" | "standalone";
@@ -18,7 +26,9 @@ interface PackageOptions {
18
26
  target: ExportTarget;
19
27
  runtimeClientJs: string;
20
28
  runtimeCss: string;
29
+ assessmentBundle?: RuntimeAssessmentBundle;
21
30
  }
31
+ declare function shouldSkipCourseFile(rel: string): boolean;
22
32
  declare function collectFiles(dir: string, baseDir: string): Promise<Array<{
23
33
  path: string;
24
34
  fullPath: string;
@@ -37,4 +47,10 @@ declare function packageStandaloneDir(options: Omit<PackageOptions, "outputPath"
37
47
  fileCount: number;
38
48
  }>;
39
49
 
40
- export { type BuildHtmlOptions, type ExportTarget, type PackageOptions, buildIndexHtml, buildManifestFileList, collectFiles, generateImsManifest, manifestIdentifier, packageCourse, packageStandaloneDir };
50
+ /** JSON safe to embed in HTML script blocks (prevents `</script>` breakout). */
51
+ declare function safeJsonForHtml(value: unknown): string;
52
+
53
+ /** Stable slug for ZIP filenames and SCORM identifiers. */
54
+ declare function courseSlug(manifest: CourseManifest): string;
55
+
56
+ export { type BuildHtmlOptions, type ExportTarget, type PackageOptions, buildIndexHtml, buildManifestFileList, buildRuntimeConfig, collectFiles, courseSlug, generateImsManifest, manifestIdentifier, packageCourse, packageStandaloneDir, safeJsonForHtml, shouldSkipCourseFile };
package/dist/index.js CHANGED
@@ -1,13 +1,19 @@
1
- // src/manifest.ts
2
- function manifestIdentifier(manifest) {
1
+ // src/slug.ts
2
+ function courseSlug(manifest) {
3
3
  const slug = manifest.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4
4
  if (slug) return slug;
5
5
  let hash = 0;
6
- for (let i = 0; i < manifest.title.length; i++) {
7
- hash = (hash << 5) - hash + manifest.title.charCodeAt(i);
6
+ const key = `${manifest.title}::${manifest.version}`;
7
+ for (let i = 0; i < key.length; i++) {
8
+ hash = (hash << 5) - hash + key.charCodeAt(i);
8
9
  hash |= 0;
9
10
  }
10
- return `course-${Math.abs(hash)}`;
11
+ return `course-${Math.abs(hash).toString(36)}`;
12
+ }
13
+
14
+ // src/manifest.ts
15
+ function manifestIdentifier(manifest) {
16
+ return courseSlug(manifest);
11
17
  }
12
18
  function generateImsManifest(manifest, files, launchUrl = "index.html") {
13
19
  const identifier = manifestIdentifier(manifest);
@@ -46,14 +52,27 @@ function escapeXml(text) {
46
52
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
47
53
  }
48
54
 
55
+ // src/safe-json.ts
56
+ function safeJsonForHtml(value) {
57
+ return JSON.stringify(value).replace(/</g, "\\u003c");
58
+ }
59
+
49
60
  // src/build-html.ts
50
- function buildIndexHtml(options) {
51
- const { manifest, runtimeCss, mode } = options;
52
- const config = JSON.stringify({
61
+ function buildRuntimeConfig(options) {
62
+ const { manifest, mode, assessmentBundle } = options;
63
+ return {
53
64
  manifest,
54
65
  baseUrl: ".",
55
- mode
56
- });
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));
57
76
  return `<!DOCTYPE html>
58
77
  <html lang="en">
59
78
  <head>
@@ -77,7 +96,8 @@ function escapeHtml(text) {
77
96
  }
78
97
 
79
98
  // src/package.ts
80
- import { readFile, readdir } from "fs/promises";
99
+ import { readFile, readdir, writeFile, mkdir, cp } from "fs/promises";
100
+ import { existsSync } from "fs";
81
101
  import { dirname, join, relative } from "path";
82
102
  import JSZip from "jszip";
83
103
  var SKIP_FILES = /* @__PURE__ */ new Set([
@@ -85,6 +105,18 @@ var SKIP_FILES = /* @__PURE__ */ new Set([
85
105
  "lxpack.config.ts",
86
106
  "lxpack.config.json"
87
107
  ]);
108
+ function shouldSkipCourseFile(rel) {
109
+ if (SKIP_FILES.has(rel) || rel.startsWith(".lxpack")) {
110
+ return true;
111
+ }
112
+ if (rel === "index.html") {
113
+ return true;
114
+ }
115
+ if (rel.startsWith("assessments/")) {
116
+ return true;
117
+ }
118
+ return false;
119
+ }
88
120
  async function collectFiles(dir, baseDir) {
89
121
  const entries = await readdir(dir, { withFileTypes: true });
90
122
  const files = [];
@@ -97,10 +129,7 @@ async function collectFiles(dir, baseDir) {
97
129
  files.push(...await collectFiles(fullPath, baseDir));
98
130
  } else if (entry.isFile()) {
99
131
  const rel = relative(baseDir, fullPath).replace(/\\/g, "/");
100
- if (SKIP_FILES.has(rel) || rel.startsWith(".lxpack")) {
101
- continue;
102
- }
103
- if (rel === "index.html") {
132
+ if (shouldSkipCourseFile(rel)) {
104
133
  continue;
105
134
  }
106
135
  files.push({ path: rel, fullPath });
@@ -112,7 +141,15 @@ function buildManifestFileList(courseFiles) {
112
141
  return ["index.html", "lxpack-runtime.js", ...courseFiles.map((f) => f.path)];
113
142
  }
114
143
  async function packageCourse(options) {
115
- const { courseDir, manifest, outputPath, target, runtimeClientJs, runtimeCss } = options;
144
+ const {
145
+ courseDir,
146
+ manifest,
147
+ outputPath,
148
+ target,
149
+ runtimeClientJs,
150
+ runtimeCss,
151
+ assessmentBundle
152
+ } = options;
116
153
  const zip = new JSZip();
117
154
  const mode = target === "scorm12" ? "scorm12" : "standalone";
118
155
  const courseFiles = await collectFiles(courseDir, courseDir);
@@ -120,7 +157,12 @@ async function packageCourse(options) {
120
157
  const content = await readFile(file.fullPath);
121
158
  zip.file(file.path, content);
122
159
  }
123
- const indexHtml = buildIndexHtml({ manifest, runtimeCss, mode });
160
+ const indexHtml = buildIndexHtml({
161
+ manifest,
162
+ runtimeCss,
163
+ mode,
164
+ assessmentBundle
165
+ });
124
166
  zip.file("index.html", indexHtml);
125
167
  zip.file("lxpack-runtime.js", runtimeClientJs);
126
168
  if (target === "scorm12") {
@@ -134,7 +176,6 @@ async function packageCourse(options) {
134
176
  type: "nodebuffer",
135
177
  compression: "DEFLATE"
136
178
  });
137
- const { writeFile } = await import("fs/promises");
138
179
  await writeFile(outputPath, buffer);
139
180
  return {
140
181
  outputPath,
@@ -142,9 +183,15 @@ async function packageCourse(options) {
142
183
  };
143
184
  }
144
185
  async function packageStandaloneDir(options) {
145
- const { courseDir, manifest, outputDir, target, runtimeClientJs, runtimeCss } = options;
146
- const { mkdir, writeFile, cp } = await import("fs/promises");
147
- const { existsSync } = await import("fs");
186
+ const {
187
+ courseDir,
188
+ manifest,
189
+ outputDir,
190
+ target,
191
+ runtimeClientJs,
192
+ runtimeCss,
193
+ assessmentBundle
194
+ } = options;
148
195
  await mkdir(outputDir, { recursive: true });
149
196
  const mode = target === "scorm12" ? "scorm12" : "standalone";
150
197
  const courseFiles = await collectFiles(courseDir, courseDir);
@@ -158,7 +205,12 @@ async function packageStandaloneDir(options) {
158
205
  await cp(file.fullPath, dest);
159
206
  fileCount++;
160
207
  }
161
- const indexHtml = buildIndexHtml({ manifest, runtimeCss, mode });
208
+ const indexHtml = buildIndexHtml({
209
+ manifest,
210
+ runtimeCss,
211
+ mode,
212
+ assessmentBundle
213
+ });
162
214
  await writeFile(join(outputDir, "index.html"), indexHtml);
163
215
  await writeFile(join(outputDir, "lxpack-runtime.js"), runtimeClientJs);
164
216
  fileCount += 2;
@@ -175,9 +227,13 @@ async function packageStandaloneDir(options) {
175
227
  export {
176
228
  buildIndexHtml,
177
229
  buildManifestFileList,
230
+ buildRuntimeConfig,
178
231
  collectFiles,
232
+ courseSlug,
179
233
  generateImsManifest,
180
234
  manifestIdentifier,
181
235
  packageCourse,
182
- packageStandaloneDir
236
+ packageStandaloneDir,
237
+ safeJsonForHtml,
238
+ shouldSkipCourseFile
183
239
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/scorm",
3
- "version": "0.1.0",
3
+ "version": "0.1.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.0"
42
+ "@lxpack/validators": "0.1.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.13.10",