@lessonkit/cli 1.3.1 → 1.5.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/README.md +26 -5
- package/dist/bin.js +101 -33
- package/dist/index.d.ts +7 -0
- package/dist/index.js +101 -33
- package/package.json +11 -8
- package/template/vite-react/.env.example +8 -0
- package/template/vite-react/README.md +32 -1
- package/template/vite-react/package.json +18 -14
- package/template/vite-react/src/App.test.tsx +3 -3
- package/template/vite-react/src/App.tsx +3 -15
- package/template/vite-react/src/courseConfig.test.ts +93 -0
- package/template/vite-react/src/courseConfig.ts +127 -0
- package/template/vite-react/src/vite-env.d.ts +3 -0
- package/template/vite-react/vitest.config.ts +1 -1
package/README.md
CHANGED
|
@@ -4,25 +4,37 @@
|
|
|
4
4
|
[](https://lessonkit.readthedocs.io/en/latest/reference/cli.html)
|
|
5
5
|
[](https://github.com/eddiethedean/lessonkit/blob/main/LICENSE)
|
|
6
6
|
|
|
7
|
-
Scaffold, develop, build, and package LessonKit courses.
|
|
7
|
+
Scaffold, develop, build, and package LessonKit courses.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g @lessonkit/cli
|
|
13
|
-
# or one-shot:
|
|
13
|
+
# or one-shot (recommended for new courses):
|
|
14
14
|
npx @lessonkit/cli init my-course
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
**Node.js:** **20.19+** recommended for `init` (Vite 8 scaffold). **18+** minimum for `dev`, `build`, and `package`.
|
|
18
|
+
|
|
17
19
|
## Commands
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
lessonkit init my-course # scaffold Vite + React project
|
|
22
|
+
lessonkit init my-course # scaffold Vite + React project (runs npm install)
|
|
21
23
|
lessonkit dev # Vite dev server
|
|
22
24
|
lessonkit build # production build → dist/
|
|
23
25
|
lessonkit package --target scorm12 # LMS artifact
|
|
24
26
|
```
|
|
25
27
|
|
|
28
|
+
### Init flags
|
|
29
|
+
|
|
30
|
+
| Flag | Purpose |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `--here` | Scaffold in the current directory |
|
|
33
|
+
| `--force` | Overwrite existing files in the target directory |
|
|
34
|
+
| `--skip-install` | Skip `npm install` after copying the template |
|
|
35
|
+
|
|
36
|
+
### Package targets
|
|
37
|
+
|
|
26
38
|
| Target | Output |
|
|
27
39
|
| --- | --- |
|
|
28
40
|
| `react-vite` | Vite build only |
|
|
@@ -32,11 +44,20 @@ lessonkit package --target scorm12 # LMS artifact
|
|
|
32
44
|
|
|
33
45
|
Every project includes a root `lessonkit.json` manifest (`schemaVersion: 1`).
|
|
34
46
|
|
|
35
|
-
Subprocess timeout defaults to **30 minutes** (`LESSONKIT_CMD_TIMEOUT_MS`).
|
|
47
|
+
Subprocess timeout defaults to **30 minutes** (`LESSONKIT_CMD_TIMEOUT_MS`).
|
|
48
|
+
|
|
49
|
+
## Common issues
|
|
50
|
+
|
|
51
|
+
| Symptom | Fix |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `lessonkit: command not found` | Use `npx @lessonkit/cli` or `npm run dev` in a scaffolded project |
|
|
54
|
+
| `init` fails on Node version | Use Node **20.19+** for Vite 8 scaffold |
|
|
55
|
+
| SCORM zip not at project root | Default path: `.lxpack/course/.lxpack/out/course-scorm12.zip` — trust CLI stdout |
|
|
56
|
+
| ID parity errors on `package` | Align `courseId`, `lessonId`, `checkId` between React and `lessonkit.json` |
|
|
36
57
|
|
|
37
58
|
## Docs
|
|
38
59
|
|
|
39
|
-
[CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html) · [
|
|
60
|
+
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Ship to LMS checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/ship-to-lms.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html) · [Published template](https://github.com/eddiethedean/lessonkit/tree/main/packages/cli/template/vite-react) (monorepo source: [`templates/vite-react`](https://github.com/eddiethedean/lessonkit/tree/main/templates/vite-react))
|
|
40
61
|
|
|
41
62
|
## License
|
|
42
63
|
|
package/dist/bin.js
CHANGED
|
@@ -6,8 +6,9 @@ import { Command } from "commander";
|
|
|
6
6
|
|
|
7
7
|
// src/commands/init.ts
|
|
8
8
|
import { slugifyId } from "@lessonkit/core";
|
|
9
|
-
import { cp, mkdir, readdir, readFile, writeFile } from "fs/promises";
|
|
9
|
+
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
10
10
|
import { existsSync } from "fs";
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
11
12
|
import { basename, dirname, join, resolve } from "path";
|
|
12
13
|
import { fileURLToPath } from "url";
|
|
13
14
|
|
|
@@ -124,7 +125,7 @@ async function runNpmInstall(cwd) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
// src/commands/init.ts
|
|
127
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git"]);
|
|
128
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
|
|
128
129
|
var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
|
|
129
130
|
function getTemplateDir() {
|
|
130
131
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -137,18 +138,13 @@ function getTemplateDir() {
|
|
|
137
138
|
}
|
|
138
139
|
return candidates[0];
|
|
139
140
|
}
|
|
140
|
-
async function isDirEmpty(dir) {
|
|
141
|
-
if (!existsSync(dir)) return true;
|
|
142
|
-
const entries = await readdir(dir);
|
|
143
|
-
return entries.length === 0;
|
|
144
|
-
}
|
|
145
141
|
async function isDirEmptyOrDotfilesOnly(dir) {
|
|
146
142
|
if (!existsSync(dir)) return true;
|
|
147
143
|
const entries = await readdir(dir);
|
|
148
144
|
return entries.every((name) => name.startsWith("."));
|
|
149
145
|
}
|
|
150
146
|
function escapeJsxString(value) {
|
|
151
|
-
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\r\n|\n|\r/g, "\\n");
|
|
147
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029").replace(/\r\n|\n|\r/g, "\\n");
|
|
152
148
|
}
|
|
153
149
|
async function copyTemplate(src, dest) {
|
|
154
150
|
await mkdir(dest, { recursive: true });
|
|
@@ -169,7 +165,7 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
|
|
|
169
165
|
const pkgPath = join(projectDir, "package.json");
|
|
170
166
|
const lessonkitPath = join(projectDir, "lessonkit.json");
|
|
171
167
|
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
172
|
-
pkg.name =
|
|
168
|
+
pkg.name = slug;
|
|
173
169
|
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
174
170
|
`, "utf8");
|
|
175
171
|
const lessonkit = JSON.parse(await readFile(lessonkitPath, "utf8"));
|
|
@@ -177,14 +173,37 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
|
|
|
177
173
|
const course = lessonkit.course;
|
|
178
174
|
course.courseId = slug;
|
|
179
175
|
course.title = projectName;
|
|
176
|
+
const courseTracking = course.tracking ?? {};
|
|
177
|
+
const courseXapi = courseTracking.xapi ?? {};
|
|
178
|
+
courseXapi.activityIri = `https://example.com/courses/${slug}`;
|
|
179
|
+
courseTracking.xapi = courseXapi;
|
|
180
|
+
course.tracking = courseTracking;
|
|
180
181
|
await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
|
|
181
182
|
`, "utf8");
|
|
183
|
+
const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
|
|
184
|
+
let courseConfigSource = await readFile(courseConfigPath, "utf8");
|
|
185
|
+
courseConfigSource = courseConfigSource.replace(/courseId: "my-course"/g, `courseId: "${slug}"`);
|
|
186
|
+
await writeFile(courseConfigPath, courseConfigSource, "utf8");
|
|
182
187
|
const appPath = join(projectDir, "src", "App.tsx");
|
|
183
188
|
let appSource = await readFile(appPath, "utf8");
|
|
184
189
|
appSource = appSource.replace(/courseId="my-course"/g, `courseId="${slug}"`);
|
|
185
190
|
appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
|
|
186
191
|
await writeFile(appPath, appSource, "utf8");
|
|
187
192
|
}
|
|
193
|
+
async function promoteStagingToProjectDir(stagingDir, projectDir) {
|
|
194
|
+
await mkdir(projectDir, { recursive: true });
|
|
195
|
+
const entries = await readdir(stagingDir, { withFileTypes: true });
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const srcPath = join(stagingDir, entry.name);
|
|
198
|
+
const destPath = join(projectDir, entry.name);
|
|
199
|
+
if (entry.isDirectory()) {
|
|
200
|
+
await cp(srcPath, destPath, { recursive: true });
|
|
201
|
+
} else if (entry.isFile()) {
|
|
202
|
+
await cp(srcPath, destPath);
|
|
203
|
+
} else {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
188
207
|
async function runInit(opts, logger) {
|
|
189
208
|
const cwd = process.cwd();
|
|
190
209
|
const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
|
|
@@ -212,7 +231,7 @@ async function runInit(opts, logger) {
|
|
|
212
231
|
}
|
|
213
232
|
);
|
|
214
233
|
}
|
|
215
|
-
if (opts.here && !await
|
|
234
|
+
if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
|
|
216
235
|
throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
|
|
217
236
|
code: "INVALID_PROJECT",
|
|
218
237
|
exitCode: EXIT_INVALID_PROJECT
|
|
@@ -234,11 +253,26 @@ async function runInit(opts, logger) {
|
|
|
234
253
|
exitCode: EXIT_INVALID_PROJECT
|
|
235
254
|
});
|
|
236
255
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
256
|
+
const stagingDir = opts.here ? join(cwd, `.lessonkit-init-${randomUUID()}`) : join(cwd, `.${slug}-init-${randomUUID()}`);
|
|
257
|
+
try {
|
|
258
|
+
await copyTemplate(templateDir, stagingDir);
|
|
259
|
+
await applyTemplateSubstitutions(stagingDir, projectName, slug);
|
|
260
|
+
if (!opts.skipInstall) {
|
|
261
|
+
if (!opts.json) logger.log(`Installing dependencies in ${stagingDir}\u2026`);
|
|
262
|
+
await runNpmInstall(stagingDir);
|
|
263
|
+
}
|
|
264
|
+
if (opts.here) {
|
|
265
|
+
await promoteStagingToProjectDir(stagingDir, projectDir);
|
|
266
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
267
|
+
} else {
|
|
268
|
+
await rename(stagingDir, projectDir);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(
|
|
272
|
+
/* v8 ignore next */
|
|
273
|
+
() => void 0
|
|
274
|
+
);
|
|
275
|
+
throw err;
|
|
242
276
|
}
|
|
243
277
|
if (!opts.json) {
|
|
244
278
|
logger.log(`Created LessonKit project at ${projectDir}`);
|
|
@@ -247,6 +281,10 @@ async function runInit(opts, logger) {
|
|
|
247
281
|
return { ok: true, command: "init", projectRoot: projectDir };
|
|
248
282
|
}
|
|
249
283
|
|
|
284
|
+
// src/commands/dev.ts
|
|
285
|
+
import { existsSync as existsSync3 } from "fs";
|
|
286
|
+
import { join as join3 } from "path";
|
|
287
|
+
|
|
250
288
|
// src/lib/project.ts
|
|
251
289
|
import { readFileSync, existsSync as existsSync2 } from "fs";
|
|
252
290
|
import { readFile as readFile2 } from "fs/promises";
|
|
@@ -442,13 +480,24 @@ function resolvePackageOutput(project, target, override) {
|
|
|
442
480
|
}
|
|
443
481
|
return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
|
|
444
482
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
483
|
+
function stripOutDirFromViteArgs(viteArgs) {
|
|
484
|
+
const stripped = [];
|
|
485
|
+
for (let i = 0; i < viteArgs.length; i++) {
|
|
486
|
+
const arg = viteArgs[i];
|
|
487
|
+
if (arg === "--outDir" || arg === "-o") {
|
|
488
|
+
i++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (arg.startsWith("--outDir=")) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
stripped.push(arg);
|
|
450
495
|
}
|
|
451
|
-
return
|
|
496
|
+
return stripped;
|
|
497
|
+
}
|
|
498
|
+
function resolveViteBuildArgv(project, viteArgs = []) {
|
|
499
|
+
const passthrough = stripOutDirFromViteArgs(viteArgs);
|
|
500
|
+
return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
|
|
452
501
|
}
|
|
453
502
|
function parsePackageTarget(value) {
|
|
454
503
|
if (!value) {
|
|
@@ -477,15 +526,24 @@ async function runBuild(opts) {
|
|
|
477
526
|
const pkg = await readPackageJson(project.root);
|
|
478
527
|
assertViteProject(pkg, project.root);
|
|
479
528
|
const viteJs = resolveViteJs(project.root);
|
|
480
|
-
const buildArgs =
|
|
481
|
-
await runCommand(process.execPath, [viteJs, ...buildArgs
|
|
529
|
+
const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
|
|
530
|
+
await runCommand(process.execPath, [viteJs, ...buildArgs], {
|
|
482
531
|
cwd: project.root
|
|
483
532
|
});
|
|
533
|
+
const distDir = resolveDistDir(project);
|
|
534
|
+
const indexHtml = join3(distDir, "index.html");
|
|
535
|
+
if (!existsSync3(indexHtml)) {
|
|
536
|
+
throw new CliError(
|
|
537
|
+
`Build did not produce index.html at ${indexHtml}. Check paths.spaDistDir in lessonkit.json.`,
|
|
538
|
+
{ code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
|
|
539
|
+
);
|
|
540
|
+
}
|
|
484
541
|
return { ok: true, command: "build", projectRoot: project.root };
|
|
485
542
|
}
|
|
486
543
|
|
|
487
544
|
// src/commands/package.ts
|
|
488
|
-
import { existsSync as
|
|
545
|
+
import { existsSync as existsSync4 } from "fs";
|
|
546
|
+
import { isAbsolute } from "path";
|
|
489
547
|
import { packageLessonkitCourse } from "@lessonkit/lxpack";
|
|
490
548
|
async function runPackage(opts) {
|
|
491
549
|
let target;
|
|
@@ -503,7 +561,7 @@ async function runPackage(opts) {
|
|
|
503
561
|
}
|
|
504
562
|
const project = await loadProject(opts.cwd ?? process.cwd());
|
|
505
563
|
const distDir = resolveDistDir(project);
|
|
506
|
-
if (opts.noBuild && !
|
|
564
|
+
if (opts.noBuild && !existsSync4(distDir)) {
|
|
507
565
|
throw new CliError(
|
|
508
566
|
`dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
|
|
509
567
|
{
|
|
@@ -516,7 +574,7 @@ async function runPackage(opts) {
|
|
|
516
574
|
if (!opts.noBuild) {
|
|
517
575
|
await runBuild({ cwd: project.root, json: opts.json });
|
|
518
576
|
}
|
|
519
|
-
if (!
|
|
577
|
+
if (!existsSync4(distDir)) {
|
|
520
578
|
throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
|
|
521
579
|
code: "INVALID_PROJECT",
|
|
522
580
|
exitCode: EXIT_INVALID_PROJECT
|
|
@@ -528,14 +586,20 @@ async function runPackage(opts) {
|
|
|
528
586
|
if (!opts.noBuild) {
|
|
529
587
|
await runBuild({ cwd: project.root, json: opts.json });
|
|
530
588
|
}
|
|
531
|
-
if (!
|
|
589
|
+
if (!existsSync4(distDir)) {
|
|
532
590
|
throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
|
|
533
591
|
code: "INVALID_PROJECT",
|
|
534
592
|
exitCode: EXIT_INVALID_PROJECT
|
|
535
593
|
});
|
|
536
594
|
}
|
|
537
595
|
const outDir = resolveLxpackOutDir(project);
|
|
538
|
-
const { output, dir, outputBaseDir } = resolvePackageOutput(
|
|
596
|
+
const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
|
|
597
|
+
project,
|
|
598
|
+
target,
|
|
599
|
+
opts.out
|
|
600
|
+
);
|
|
601
|
+
const trimmedOut = opts.out?.trim();
|
|
602
|
+
const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
|
|
539
603
|
const result = await packageLessonkitCourse({
|
|
540
604
|
descriptor: project.course,
|
|
541
605
|
outDir,
|
|
@@ -544,7 +608,8 @@ async function runPackage(opts) {
|
|
|
544
608
|
target,
|
|
545
609
|
output,
|
|
546
610
|
dir,
|
|
547
|
-
outputBaseDir
|
|
611
|
+
outputBaseDir,
|
|
612
|
+
strictParity: opts.strictParity
|
|
548
613
|
});
|
|
549
614
|
if (!result.ok) {
|
|
550
615
|
throw new CliError("Packaging failed.", {
|
|
@@ -647,7 +712,7 @@ function createProgram(baseLogger = console) {
|
|
|
647
712
|
);
|
|
648
713
|
}
|
|
649
714
|
);
|
|
650
|
-
program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--json", "Emit structured JSON result").action(async (opts) => {
|
|
715
|
+
program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--json", "Emit structured JSON result").action(async (opts) => {
|
|
651
716
|
const logger = createLogger({ json: opts.json });
|
|
652
717
|
await handleCommand(
|
|
653
718
|
async () => {
|
|
@@ -656,7 +721,8 @@ function createProgram(baseLogger = console) {
|
|
|
656
721
|
cwd: opts.cwd,
|
|
657
722
|
noBuild: opts.build === false,
|
|
658
723
|
out: opts.out,
|
|
659
|
-
json: opts.json
|
|
724
|
+
json: opts.json,
|
|
725
|
+
strictParity: opts.strictParity
|
|
660
726
|
});
|
|
661
727
|
if (!opts.json && result.ok && result.command === "package") {
|
|
662
728
|
if (result.target === "react-vite") {
|
|
@@ -674,8 +740,10 @@ function createProgram(baseLogger = console) {
|
|
|
674
740
|
Boolean(opts.json)
|
|
675
741
|
);
|
|
676
742
|
});
|
|
677
|
-
program.command("publish").description("
|
|
678
|
-
baseLogger.log(
|
|
743
|
+
program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
|
|
744
|
+
baseLogger.log(
|
|
745
|
+
"lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
|
|
746
|
+
);
|
|
679
747
|
});
|
|
680
748
|
return program;
|
|
681
749
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,14 @@ type CliLogger = {
|
|
|
5
5
|
error: (...args: unknown[]) => void;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Build the Commander program used by the `lessonkit` CLI binary.
|
|
10
|
+
* Useful for embedding init/build/package in Node scripts and tests.
|
|
11
|
+
*/
|
|
8
12
|
declare function createProgram(baseLogger?: CliLogger): Command;
|
|
13
|
+
/**
|
|
14
|
+
* Parse argv and run the LessonKit CLI (same as the `lessonkit` binary entrypoint).
|
|
15
|
+
*/
|
|
9
16
|
declare function run(argv?: string[], logger?: CliLogger): Promise<void>;
|
|
10
17
|
|
|
11
18
|
export { type CliLogger, createProgram, run };
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,9 @@ import { Command } from "commander";
|
|
|
4
4
|
|
|
5
5
|
// src/commands/init.ts
|
|
6
6
|
import { slugifyId } from "@lessonkit/core";
|
|
7
|
-
import { cp, mkdir, readdir, readFile, writeFile } from "fs/promises";
|
|
7
|
+
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
8
8
|
import { existsSync } from "fs";
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
9
10
|
import { basename, dirname, join, resolve } from "path";
|
|
10
11
|
import { fileURLToPath } from "url";
|
|
11
12
|
|
|
@@ -122,7 +123,7 @@ async function runNpmInstall(cwd) {
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
// src/commands/init.ts
|
|
125
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git"]);
|
|
126
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
|
|
126
127
|
var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
|
|
127
128
|
function getTemplateDir() {
|
|
128
129
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -135,18 +136,13 @@ function getTemplateDir() {
|
|
|
135
136
|
}
|
|
136
137
|
return candidates[0];
|
|
137
138
|
}
|
|
138
|
-
async function isDirEmpty(dir) {
|
|
139
|
-
if (!existsSync(dir)) return true;
|
|
140
|
-
const entries = await readdir(dir);
|
|
141
|
-
return entries.length === 0;
|
|
142
|
-
}
|
|
143
139
|
async function isDirEmptyOrDotfilesOnly(dir) {
|
|
144
140
|
if (!existsSync(dir)) return true;
|
|
145
141
|
const entries = await readdir(dir);
|
|
146
142
|
return entries.every((name) => name.startsWith("."));
|
|
147
143
|
}
|
|
148
144
|
function escapeJsxString(value) {
|
|
149
|
-
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\r\n|\n|\r/g, "\\n");
|
|
145
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029").replace(/\r\n|\n|\r/g, "\\n");
|
|
150
146
|
}
|
|
151
147
|
async function copyTemplate(src, dest) {
|
|
152
148
|
await mkdir(dest, { recursive: true });
|
|
@@ -167,7 +163,7 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
|
|
|
167
163
|
const pkgPath = join(projectDir, "package.json");
|
|
168
164
|
const lessonkitPath = join(projectDir, "lessonkit.json");
|
|
169
165
|
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
170
|
-
pkg.name =
|
|
166
|
+
pkg.name = slug;
|
|
171
167
|
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
172
168
|
`, "utf8");
|
|
173
169
|
const lessonkit = JSON.parse(await readFile(lessonkitPath, "utf8"));
|
|
@@ -175,14 +171,37 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
|
|
|
175
171
|
const course = lessonkit.course;
|
|
176
172
|
course.courseId = slug;
|
|
177
173
|
course.title = projectName;
|
|
174
|
+
const courseTracking = course.tracking ?? {};
|
|
175
|
+
const courseXapi = courseTracking.xapi ?? {};
|
|
176
|
+
courseXapi.activityIri = `https://example.com/courses/${slug}`;
|
|
177
|
+
courseTracking.xapi = courseXapi;
|
|
178
|
+
course.tracking = courseTracking;
|
|
178
179
|
await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
|
|
179
180
|
`, "utf8");
|
|
181
|
+
const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
|
|
182
|
+
let courseConfigSource = await readFile(courseConfigPath, "utf8");
|
|
183
|
+
courseConfigSource = courseConfigSource.replace(/courseId: "my-course"/g, `courseId: "${slug}"`);
|
|
184
|
+
await writeFile(courseConfigPath, courseConfigSource, "utf8");
|
|
180
185
|
const appPath = join(projectDir, "src", "App.tsx");
|
|
181
186
|
let appSource = await readFile(appPath, "utf8");
|
|
182
187
|
appSource = appSource.replace(/courseId="my-course"/g, `courseId="${slug}"`);
|
|
183
188
|
appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
|
|
184
189
|
await writeFile(appPath, appSource, "utf8");
|
|
185
190
|
}
|
|
191
|
+
async function promoteStagingToProjectDir(stagingDir, projectDir) {
|
|
192
|
+
await mkdir(projectDir, { recursive: true });
|
|
193
|
+
const entries = await readdir(stagingDir, { withFileTypes: true });
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const srcPath = join(stagingDir, entry.name);
|
|
196
|
+
const destPath = join(projectDir, entry.name);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
await cp(srcPath, destPath, { recursive: true });
|
|
199
|
+
} else if (entry.isFile()) {
|
|
200
|
+
await cp(srcPath, destPath);
|
|
201
|
+
} else {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
186
205
|
async function runInit(opts, logger) {
|
|
187
206
|
const cwd = process.cwd();
|
|
188
207
|
const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
|
|
@@ -210,7 +229,7 @@ async function runInit(opts, logger) {
|
|
|
210
229
|
}
|
|
211
230
|
);
|
|
212
231
|
}
|
|
213
|
-
if (opts.here && !await
|
|
232
|
+
if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
|
|
214
233
|
throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
|
|
215
234
|
code: "INVALID_PROJECT",
|
|
216
235
|
exitCode: EXIT_INVALID_PROJECT
|
|
@@ -232,11 +251,26 @@ async function runInit(opts, logger) {
|
|
|
232
251
|
exitCode: EXIT_INVALID_PROJECT
|
|
233
252
|
});
|
|
234
253
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
const stagingDir = opts.here ? join(cwd, `.lessonkit-init-${randomUUID()}`) : join(cwd, `.${slug}-init-${randomUUID()}`);
|
|
255
|
+
try {
|
|
256
|
+
await copyTemplate(templateDir, stagingDir);
|
|
257
|
+
await applyTemplateSubstitutions(stagingDir, projectName, slug);
|
|
258
|
+
if (!opts.skipInstall) {
|
|
259
|
+
if (!opts.json) logger.log(`Installing dependencies in ${stagingDir}\u2026`);
|
|
260
|
+
await runNpmInstall(stagingDir);
|
|
261
|
+
}
|
|
262
|
+
if (opts.here) {
|
|
263
|
+
await promoteStagingToProjectDir(stagingDir, projectDir);
|
|
264
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
265
|
+
} else {
|
|
266
|
+
await rename(stagingDir, projectDir);
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(
|
|
270
|
+
/* v8 ignore next */
|
|
271
|
+
() => void 0
|
|
272
|
+
);
|
|
273
|
+
throw err;
|
|
240
274
|
}
|
|
241
275
|
if (!opts.json) {
|
|
242
276
|
logger.log(`Created LessonKit project at ${projectDir}`);
|
|
@@ -245,6 +279,10 @@ async function runInit(opts, logger) {
|
|
|
245
279
|
return { ok: true, command: "init", projectRoot: projectDir };
|
|
246
280
|
}
|
|
247
281
|
|
|
282
|
+
// src/commands/dev.ts
|
|
283
|
+
import { existsSync as existsSync3 } from "fs";
|
|
284
|
+
import { join as join3 } from "path";
|
|
285
|
+
|
|
248
286
|
// src/lib/project.ts
|
|
249
287
|
import { readFileSync, existsSync as existsSync2 } from "fs";
|
|
250
288
|
import { readFile as readFile2 } from "fs/promises";
|
|
@@ -440,13 +478,24 @@ function resolvePackageOutput(project, target, override) {
|
|
|
440
478
|
}
|
|
441
479
|
return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
|
|
442
480
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
481
|
+
function stripOutDirFromViteArgs(viteArgs) {
|
|
482
|
+
const stripped = [];
|
|
483
|
+
for (let i = 0; i < viteArgs.length; i++) {
|
|
484
|
+
const arg = viteArgs[i];
|
|
485
|
+
if (arg === "--outDir" || arg === "-o") {
|
|
486
|
+
i++;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (arg.startsWith("--outDir=")) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
stripped.push(arg);
|
|
448
493
|
}
|
|
449
|
-
return
|
|
494
|
+
return stripped;
|
|
495
|
+
}
|
|
496
|
+
function resolveViteBuildArgv(project, viteArgs = []) {
|
|
497
|
+
const passthrough = stripOutDirFromViteArgs(viteArgs);
|
|
498
|
+
return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
|
|
450
499
|
}
|
|
451
500
|
function parsePackageTarget(value) {
|
|
452
501
|
if (!value) {
|
|
@@ -475,15 +524,24 @@ async function runBuild(opts) {
|
|
|
475
524
|
const pkg = await readPackageJson(project.root);
|
|
476
525
|
assertViteProject(pkg, project.root);
|
|
477
526
|
const viteJs = resolveViteJs(project.root);
|
|
478
|
-
const buildArgs =
|
|
479
|
-
await runCommand(process.execPath, [viteJs, ...buildArgs
|
|
527
|
+
const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
|
|
528
|
+
await runCommand(process.execPath, [viteJs, ...buildArgs], {
|
|
480
529
|
cwd: project.root
|
|
481
530
|
});
|
|
531
|
+
const distDir = resolveDistDir(project);
|
|
532
|
+
const indexHtml = join3(distDir, "index.html");
|
|
533
|
+
if (!existsSync3(indexHtml)) {
|
|
534
|
+
throw new CliError(
|
|
535
|
+
`Build did not produce index.html at ${indexHtml}. Check paths.spaDistDir in lessonkit.json.`,
|
|
536
|
+
{ code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
|
|
537
|
+
);
|
|
538
|
+
}
|
|
482
539
|
return { ok: true, command: "build", projectRoot: project.root };
|
|
483
540
|
}
|
|
484
541
|
|
|
485
542
|
// src/commands/package.ts
|
|
486
|
-
import { existsSync as
|
|
543
|
+
import { existsSync as existsSync4 } from "fs";
|
|
544
|
+
import { isAbsolute } from "path";
|
|
487
545
|
import { packageLessonkitCourse } from "@lessonkit/lxpack";
|
|
488
546
|
async function runPackage(opts) {
|
|
489
547
|
let target;
|
|
@@ -501,7 +559,7 @@ async function runPackage(opts) {
|
|
|
501
559
|
}
|
|
502
560
|
const project = await loadProject(opts.cwd ?? process.cwd());
|
|
503
561
|
const distDir = resolveDistDir(project);
|
|
504
|
-
if (opts.noBuild && !
|
|
562
|
+
if (opts.noBuild && !existsSync4(distDir)) {
|
|
505
563
|
throw new CliError(
|
|
506
564
|
`dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
|
|
507
565
|
{
|
|
@@ -514,7 +572,7 @@ async function runPackage(opts) {
|
|
|
514
572
|
if (!opts.noBuild) {
|
|
515
573
|
await runBuild({ cwd: project.root, json: opts.json });
|
|
516
574
|
}
|
|
517
|
-
if (!
|
|
575
|
+
if (!existsSync4(distDir)) {
|
|
518
576
|
throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
|
|
519
577
|
code: "INVALID_PROJECT",
|
|
520
578
|
exitCode: EXIT_INVALID_PROJECT
|
|
@@ -526,14 +584,20 @@ async function runPackage(opts) {
|
|
|
526
584
|
if (!opts.noBuild) {
|
|
527
585
|
await runBuild({ cwd: project.root, json: opts.json });
|
|
528
586
|
}
|
|
529
|
-
if (!
|
|
587
|
+
if (!existsSync4(distDir)) {
|
|
530
588
|
throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
|
|
531
589
|
code: "INVALID_PROJECT",
|
|
532
590
|
exitCode: EXIT_INVALID_PROJECT
|
|
533
591
|
});
|
|
534
592
|
}
|
|
535
593
|
const outDir = resolveLxpackOutDir(project);
|
|
536
|
-
const { output, dir, outputBaseDir } = resolvePackageOutput(
|
|
594
|
+
const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
|
|
595
|
+
project,
|
|
596
|
+
target,
|
|
597
|
+
opts.out
|
|
598
|
+
);
|
|
599
|
+
const trimmedOut = opts.out?.trim();
|
|
600
|
+
const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
|
|
537
601
|
const result = await packageLessonkitCourse({
|
|
538
602
|
descriptor: project.course,
|
|
539
603
|
outDir,
|
|
@@ -542,7 +606,8 @@ async function runPackage(opts) {
|
|
|
542
606
|
target,
|
|
543
607
|
output,
|
|
544
608
|
dir,
|
|
545
|
-
outputBaseDir
|
|
609
|
+
outputBaseDir,
|
|
610
|
+
strictParity: opts.strictParity
|
|
546
611
|
});
|
|
547
612
|
if (!result.ok) {
|
|
548
613
|
throw new CliError("Packaging failed.", {
|
|
@@ -645,7 +710,7 @@ function createProgram(baseLogger = console) {
|
|
|
645
710
|
);
|
|
646
711
|
}
|
|
647
712
|
);
|
|
648
|
-
program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--json", "Emit structured JSON result").action(async (opts) => {
|
|
713
|
+
program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--json", "Emit structured JSON result").action(async (opts) => {
|
|
649
714
|
const logger = createLogger({ json: opts.json });
|
|
650
715
|
await handleCommand(
|
|
651
716
|
async () => {
|
|
@@ -654,7 +719,8 @@ function createProgram(baseLogger = console) {
|
|
|
654
719
|
cwd: opts.cwd,
|
|
655
720
|
noBuild: opts.build === false,
|
|
656
721
|
out: opts.out,
|
|
657
|
-
json: opts.json
|
|
722
|
+
json: opts.json,
|
|
723
|
+
strictParity: opts.strictParity
|
|
658
724
|
});
|
|
659
725
|
if (!opts.json && result.ok && result.command === "package") {
|
|
660
726
|
if (result.target === "react-vite") {
|
|
@@ -672,8 +738,10 @@ function createProgram(baseLogger = console) {
|
|
|
672
738
|
Boolean(opts.json)
|
|
673
739
|
);
|
|
674
740
|
});
|
|
675
|
-
program.command("publish").description("
|
|
676
|
-
baseLogger.log(
|
|
741
|
+
program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
|
|
742
|
+
baseLogger.log(
|
|
743
|
+
"lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
|
|
744
|
+
);
|
|
677
745
|
});
|
|
678
746
|
return program;
|
|
679
747
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "LessonKit CLI — init, dev, build, and package learning experiences.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
"lessonkit": "./dist/bin.js"
|
|
26
26
|
},
|
|
27
27
|
"exports": {
|
|
28
|
-
".":
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
29
32
|
},
|
|
30
33
|
"files": [
|
|
31
34
|
"dist",
|
|
@@ -39,20 +42,20 @@
|
|
|
39
42
|
"typecheck": "tsc -p tsconfig.json",
|
|
40
43
|
"test": "vitest run --passWithNoTests",
|
|
41
44
|
"test:coverage": "vitest run --coverage --passWithNoTests=false",
|
|
42
|
-
"lint": "
|
|
45
|
+
"lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
45
|
-
"@lessonkit/core": "1.
|
|
46
|
-
"@lessonkit/lxpack": "1.
|
|
47
|
-
"commander": "^
|
|
48
|
+
"@lessonkit/core": "1.5.0",
|
|
49
|
+
"@lessonkit/lxpack": "1.5.0",
|
|
50
|
+
"commander": "^15.0.0"
|
|
48
51
|
},
|
|
49
52
|
"engines": {
|
|
50
53
|
"node": ">=18"
|
|
51
54
|
},
|
|
52
55
|
"devDependencies": {
|
|
53
|
-
"@types/node": "^
|
|
56
|
+
"@types/node": "^25.9.2",
|
|
54
57
|
"tsup": "^8.5.0",
|
|
55
|
-
"typescript": "^
|
|
58
|
+
"typescript": "^6.0.3",
|
|
56
59
|
"vitest": "^4.1.8"
|
|
57
60
|
}
|
|
58
61
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Copy to `.env` for production LMS export (see courseConfig.ts).
|
|
2
|
+
# Never commit real secrets — use short-lived tokens from your backend.
|
|
3
|
+
|
|
4
|
+
# Analytics batch ingest proxy (createFetchBatchSink)
|
|
5
|
+
VITE_ANALYTICS_URL=
|
|
6
|
+
|
|
7
|
+
# xAPI statement proxy (createFetchTransport)
|
|
8
|
+
VITE_XAPI_PROXY_URL=
|
|
@@ -14,8 +14,39 @@ npm run package:scorm12
|
|
|
14
14
|
## Files
|
|
15
15
|
|
|
16
16
|
- `src/App.tsx` — course UI (IDs match `lessonkit.json`)
|
|
17
|
+
- `src/courseConfig.ts` — production transports, observability hooks, and LMS bridge config
|
|
18
|
+
- `.env.example` — `VITE_XAPI_PROXY_URL` and `VITE_ANALYTICS_URL` for production builds
|
|
17
19
|
- `lessonkit.json` — manifest for CLI and LXPack packaging
|
|
18
20
|
|
|
21
|
+
## Before LMS packaging
|
|
22
|
+
|
|
23
|
+
1. **LMS bridge** — In `src/courseConfig.ts`, enable the bridge for SCORM/xAPI/cmi5 export (template defaults to `"off"` for local preview):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
lxpack: {
|
|
27
|
+
bridge: "auto",
|
|
28
|
+
allowedParentOrigins: ["https://your-lms.example"], // required in production builds
|
|
29
|
+
},
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Development builds allow `bridge: "auto"` without an allowlist; **production builds do not**. Discover your LMS origin from the SCORM preview URL or browser devtools (`document.referrer`).
|
|
33
|
+
|
|
34
|
+
2. **Production runtime** — Copy `.env.example` to `.env`, set `VITE_ANALYTICS_URL` and `VITE_XAPI_PROXY_URL`, and wire the observability hooks in `courseConfig.ts` (see comments there). Rebuild with `npm run build` before packaging. Alternatively, disable `tracking` and `xapi` for a first test export only.
|
|
35
|
+
|
|
36
|
+
3. **Activity IRI** — Replace the `example.com` placeholder in `lessonkit.json` → `course.tracking.xapi.activityIri` before xAPI/cmi5 export (must be HTTPS).
|
|
37
|
+
|
|
38
|
+
## SCORM output path
|
|
39
|
+
|
|
40
|
+
After `npm run package:scorm12`, the CLI prints the resolved ZIP path. Default:
|
|
41
|
+
|
|
42
|
+
**`.lxpack/course/.lxpack/out/course-scorm12.zip`**
|
|
43
|
+
|
|
44
|
+
(`paths.outputBaseDir` is resolved inside `paths.lxpackOutDir`, not at the project root.)
|
|
45
|
+
|
|
46
|
+
## Production
|
|
47
|
+
|
|
48
|
+
Copy `.env.example` to `.env` and set your LRS/analytics proxy URLs before `npm run build`. See the [production checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html) and [Ship to LMS checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/ship-to-lms.html).
|
|
49
|
+
|
|
19
50
|
## Docs
|
|
20
51
|
|
|
21
|
-
[
|
|
52
|
+
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [First LMS export](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/first-lms-export.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html)
|
|
@@ -7,29 +7,33 @@
|
|
|
7
7
|
"build": "lessonkit build",
|
|
8
8
|
"preview": "vite preview",
|
|
9
9
|
"package:scorm12": "lessonkit package --target scorm12",
|
|
10
|
+
"package:scorm2004": "lessonkit package --target scorm2004",
|
|
10
11
|
"package:standalone": "lessonkit package --target standalone",
|
|
12
|
+
"package:xapi": "lessonkit package --target xapi",
|
|
13
|
+
"package:cmi5": "lessonkit package --target cmi5",
|
|
11
14
|
"typecheck": "tsc -p tsconfig.json",
|
|
12
15
|
"test": "vitest run --passWithNoTests",
|
|
13
16
|
"test:coverage": "vitest run --coverage --passWithNoTests=false"
|
|
14
17
|
},
|
|
15
18
|
"dependencies": {
|
|
16
|
-
"@lessonkit/core": "^1.
|
|
17
|
-
"@lessonkit/react": "^1.
|
|
18
|
-
"@lessonkit/themes": "^1.
|
|
19
|
-
"@lessonkit/xapi": "^1.
|
|
20
|
-
"react": "^
|
|
21
|
-
"react-dom": "^
|
|
19
|
+
"@lessonkit/core": "^1.5.0",
|
|
20
|
+
"@lessonkit/react": "^1.5.0",
|
|
21
|
+
"@lessonkit/themes": "^1.5.0",
|
|
22
|
+
"@lessonkit/xapi": "^1.5.0",
|
|
23
|
+
"react": "^19.2.7",
|
|
24
|
+
"react-dom": "^19.2.7"
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
|
-
"@lessonkit/cli": "^1.
|
|
25
|
-
"@lessonkit/lxpack": "^1.
|
|
27
|
+
"@lessonkit/cli": "^1.5.0",
|
|
28
|
+
"@lessonkit/lxpack": "^1.5.0",
|
|
26
29
|
"@testing-library/react": "^16.3.0",
|
|
27
|
-
"@
|
|
28
|
-
"@types/react
|
|
29
|
-
"@
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
30
|
+
"@testing-library/dom": "^10.4.1",
|
|
31
|
+
"@types/react": "^19.2.17",
|
|
32
|
+
"@types/react-dom": "^19.2.3",
|
|
33
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
34
|
+
"jsdom": "^29.1.1",
|
|
35
|
+
"typescript": "^6.0.3",
|
|
36
|
+
"vite": "^8.0.11",
|
|
33
37
|
"vitest": "^4.1.8"
|
|
34
38
|
}
|
|
35
39
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { render } from "@testing-library/react";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
4
|
import App from "./App";
|
|
5
5
|
|
|
6
6
|
describe("template App", () => {
|
|
7
|
-
it("renders
|
|
7
|
+
it("renders the starter quiz question", () => {
|
|
8
8
|
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
9
9
|
render(<App />);
|
|
10
|
+
expect(screen.getByText("Ready to build?")).toBeDefined();
|
|
10
11
|
spy.mockRestore();
|
|
11
|
-
expect(true).toBe(true);
|
|
12
12
|
});
|
|
13
13
|
});
|
|
14
14
|
|
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Course, Lesson, Quiz, Scenario, ThemeProvider } from "@lessonkit/react";
|
|
3
|
-
import
|
|
4
|
-
import type { XAPIStatement } from "@lessonkit/xapi";
|
|
3
|
+
import { createCourseConfig, COURSE_THEME_PRESET } from "./courseConfig";
|
|
5
4
|
|
|
6
|
-
const courseConfig =
|
|
7
|
-
tracking: {
|
|
8
|
-
sink: (event: TelemetryEvent) => {
|
|
9
|
-
console.log("[telemetry]", event);
|
|
10
|
-
},
|
|
11
|
-
},
|
|
12
|
-
xapi: {
|
|
13
|
-
transport: (statement: XAPIStatement) => {
|
|
14
|
-
console.log("[xapi]", statement);
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
} as const;
|
|
5
|
+
const courseConfig = createCourseConfig();
|
|
18
6
|
|
|
19
7
|
export default function App() {
|
|
20
8
|
return (
|
|
21
|
-
<ThemeProvider preset=
|
|
9
|
+
<ThemeProvider preset={COURSE_THEME_PRESET} mode="light">
|
|
22
10
|
<div className="app-shell">
|
|
23
11
|
<Course title="{{courseTitle}}" courseId="my-course" config={courseConfig}>
|
|
24
12
|
<Lesson title="My first lesson" lessonId="lesson-1">
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TelemetryEvent } from "@lessonkit/core";
|
|
3
|
+
import { createCourseConfig } from "./courseConfig";
|
|
4
|
+
|
|
5
|
+
describe("createCourseConfig", () => {
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllEnvs();
|
|
10
|
+
globalThis.fetch = originalFetch;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns dev console sinks when proxy URLs are unset", () => {
|
|
14
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
15
|
+
const config = createCourseConfig();
|
|
16
|
+
|
|
17
|
+
expect(config.courseId).toBe("my-course");
|
|
18
|
+
expect(config.lxpack?.bridge).toBe("off");
|
|
19
|
+
expect(config.observability?.onTelemetrySinkError).toBeTypeOf("function");
|
|
20
|
+
expect(config.observability?.onLxpackBridgeMiss).toBeTypeOf("function");
|
|
21
|
+
|
|
22
|
+
config.tracking?.sink?.({
|
|
23
|
+
name: "interaction",
|
|
24
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
25
|
+
courseId: "my-course",
|
|
26
|
+
} as TelemetryEvent);
|
|
27
|
+
expect(log).toHaveBeenCalledWith("[telemetry]", expect.any(Object));
|
|
28
|
+
|
|
29
|
+
log.mockRestore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("invokes observability hooks without throwing", () => {
|
|
33
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
34
|
+
const config = createCourseConfig();
|
|
35
|
+
|
|
36
|
+
config.observability?.onTelemetrySinkError?.(new Error("sink"), { sinkId: "tracking" });
|
|
37
|
+
config.observability?.onTelemetryBufferDrop?.();
|
|
38
|
+
config.observability?.onXapiQueueDepth?.(10);
|
|
39
|
+
config.observability?.onXapiQueueDepth?.(60);
|
|
40
|
+
config.observability?.onXapiQueueCap?.();
|
|
41
|
+
config.observability?.onLxpackBridgeMiss?.({
|
|
42
|
+
name: "course_completed",
|
|
43
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
44
|
+
courseId: "my-course",
|
|
45
|
+
} as TelemetryEvent);
|
|
46
|
+
config.observability?.onXapiTransportError?.(new Error("transport"));
|
|
47
|
+
config.observability?.onXapiMappingError?.(new Error("mapping"));
|
|
48
|
+
config.observability?.onLxpackBridgeError?.(new Error("bridge"));
|
|
49
|
+
|
|
50
|
+
expect(warn).toHaveBeenCalled();
|
|
51
|
+
warn.mockRestore();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("uses fetch transports when proxy URLs are set", async () => {
|
|
55
|
+
vi.stubEnv("VITE_XAPI_PROXY_URL", "https://lrs.example/statements");
|
|
56
|
+
vi.stubEnv("VITE_ANALYTICS_URL", "https://analytics.example/events");
|
|
57
|
+
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
|
58
|
+
globalThis.fetch = fetchMock as typeof fetch;
|
|
59
|
+
|
|
60
|
+
const config = createCourseConfig();
|
|
61
|
+
|
|
62
|
+
expect(config.tracking?.batchSink).toBeTypeOf("function");
|
|
63
|
+
expect(config.xapi?.transport).toBeTypeOf("function");
|
|
64
|
+
|
|
65
|
+
await config.tracking?.batchSink?.([
|
|
66
|
+
{ name: "course_started", timestamp: "t", courseId: "my-course" } as TelemetryEvent,
|
|
67
|
+
]);
|
|
68
|
+
await config.xapi?.transport?.({
|
|
69
|
+
id: "s1",
|
|
70
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
71
|
+
verb: "http://adlnet.gov/expapi/verbs/completed",
|
|
72
|
+
object: { id: "https://example.com/a" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("skips production guard when MODE is test even with proxy URLs set", () => {
|
|
79
|
+
vi.stubEnv("NODE_ENV", "production");
|
|
80
|
+
vi.stubEnv("MODE", "test");
|
|
81
|
+
vi.stubEnv("VITE_XAPI_PROXY_URL", "https://lrs.example/statements");
|
|
82
|
+
vi.stubEnv("VITE_ANALYTICS_URL", "https://analytics.example/events");
|
|
83
|
+
|
|
84
|
+
expect(() => createCourseConfig()).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("throws when production guard is enforced with console sinks", () => {
|
|
88
|
+
vi.stubEnv("NODE_ENV", "production");
|
|
89
|
+
vi.stubEnv("MODE", "production");
|
|
90
|
+
|
|
91
|
+
expect(() => createCourseConfig()).toThrow(/console telemetry sinks|observability hooks/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { TelemetryEvent } from "@lessonkit/core";
|
|
2
|
+
import type { LessonkitConfig } from "@lessonkit/react";
|
|
3
|
+
import { assertProductionCourseConfig, shouldEnforceProductionGuard } from "@lessonkit/react";
|
|
4
|
+
import { createFetchBatchSink, createFetchTransport } from "@lessonkit/xapi";
|
|
5
|
+
import type { XAPIStatement } from "@lessonkit/xapi";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Replace with your backend token proxy. Never ship static LRS passwords in the bundle.
|
|
9
|
+
* Example: fetch("/api/lrs-token") and return { Authorization: `Bearer ${token}` }.
|
|
10
|
+
*/
|
|
11
|
+
function lrsAuthHeaders(): Record<string, string> {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createObservability(): NonNullable<LessonkitConfig["observability"]> {
|
|
16
|
+
const report = (channel: string, detail: unknown) => {
|
|
17
|
+
if (import.meta.env.DEV) {
|
|
18
|
+
console.warn(`[lessonkit:${channel}]`, detail);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Wire to your monitoring stack (Sentry, Datadog, etc.).
|
|
22
|
+
/* v8 ignore next -- production-only; swap for your APM in go-live builds */
|
|
23
|
+
console.error(`[lessonkit:${channel}]`, detail);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
onTelemetrySinkError: (err, ctx) => report("telemetry-sink", { err, ...ctx }),
|
|
28
|
+
onTelemetryBufferDrop: () => report("telemetry-buffer-cap", {}),
|
|
29
|
+
onXapiQueueDepth: (depth) => {
|
|
30
|
+
if (depth > 50) report("xapi-queue-depth", { depth });
|
|
31
|
+
},
|
|
32
|
+
onXapiQueueCap: () => report("xapi-queue-cap", {}),
|
|
33
|
+
onLxpackBridgeMiss: (event) => report("lxpack-bridge-miss", { event: event.name }),
|
|
34
|
+
onLxpackBridgeError: (err) => report("lxpack-bridge-error", { err }),
|
|
35
|
+
onXapiTransportError: (err) => report("xapi-transport", { err }),
|
|
36
|
+
onXapiMappingError: (err) => report("xapi-mapping", { err }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readProxyUrls(): { xapiProxyUrl?: string; analyticsUrl?: string } {
|
|
41
|
+
return {
|
|
42
|
+
xapiProxyUrl: import.meta.env.VITE_XAPI_PROXY_URL as string | undefined,
|
|
43
|
+
analyticsUrl: import.meta.env.VITE_ANALYTICS_URL as string | undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function devConsoleTracking(): LessonkitConfig["tracking"] {
|
|
48
|
+
return {
|
|
49
|
+
sink: (event: TelemetryEvent) => {
|
|
50
|
+
console.log("[telemetry]", event);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function devConsoleXapi(): LessonkitConfig["xapi"] {
|
|
56
|
+
return {
|
|
57
|
+
enabled: true,
|
|
58
|
+
transport: (statement: XAPIStatement) => {
|
|
59
|
+
console.log("[xapi]", statement);
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function productionTracking(analyticsUrl: string | undefined): LessonkitConfig["tracking"] {
|
|
66
|
+
if (!analyticsUrl) {
|
|
67
|
+
/* v8 ignore next */
|
|
68
|
+
throw new Error(
|
|
69
|
+
"VITE_ANALYTICS_URL is required in production. Point it at your analytics ingest proxy.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const { batchSink, exitBatchSink } = createFetchBatchSink({
|
|
73
|
+
url: analyticsUrl,
|
|
74
|
+
headers: lrsAuthHeaders,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
enabled: true,
|
|
78
|
+
batchSink,
|
|
79
|
+
exitBatchSink,
|
|
80
|
+
batch: { enabled: true },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function productionXapi(xapiProxyUrl: string | undefined): LessonkitConfig["xapi"] {
|
|
85
|
+
if (!xapiProxyUrl) {
|
|
86
|
+
/* v8 ignore next */
|
|
87
|
+
throw new Error(
|
|
88
|
+
"VITE_XAPI_PROXY_URL is required in production. Point it at your LRS proxy (never the raw LRS with embedded secrets).",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const { transport, exitTransport, abortInFlight } = createFetchTransport({
|
|
92
|
+
url: xapiProxyUrl,
|
|
93
|
+
headers: lrsAuthHeaders,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
enabled: true,
|
|
97
|
+
transport,
|
|
98
|
+
exitTransport,
|
|
99
|
+
abortInFlight,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Theme preset — keep in sync with lessonkit.json `course.theme.preset`. */
|
|
104
|
+
export const COURSE_THEME_PRESET = "default" as const;
|
|
105
|
+
|
|
106
|
+
export function createCourseConfig(): LessonkitConfig {
|
|
107
|
+
const { xapiProxyUrl, analyticsUrl } = readProxyUrls();
|
|
108
|
+
const useProductionTransports = import.meta.env.PROD || (xapiProxyUrl && analyticsUrl);
|
|
109
|
+
|
|
110
|
+
const config: LessonkitConfig = {
|
|
111
|
+
courseId: "my-course",
|
|
112
|
+
lxpack: {
|
|
113
|
+
// Set bridge: "auto" when packaging for LMS (SCORM/xAPI/cmi5). In production, allowedParentOrigins
|
|
114
|
+
// is required — see https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html
|
|
115
|
+
bridge: "off",
|
|
116
|
+
// allowedParentOrigins: ["https://your-lms.example"],
|
|
117
|
+
},
|
|
118
|
+
observability: createObservability(),
|
|
119
|
+
tracking: useProductionTransports ? productionTracking(analyticsUrl) : devConsoleTracking(),
|
|
120
|
+
xapi: useProductionTransports ? productionXapi(xapiProxyUrl) : devConsoleXapi(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (shouldEnforceProductionGuard()) {
|
|
124
|
+
assertProductionCourseConfig(config);
|
|
125
|
+
}
|
|
126
|
+
return config;
|
|
127
|
+
}
|
|
@@ -8,7 +8,7 @@ export default defineConfig({
|
|
|
8
8
|
provider: "v8",
|
|
9
9
|
include: ["src/**/*.{ts,tsx}"],
|
|
10
10
|
exclude: ["dist/**", "node_modules/**", "**/*.d.ts", "**/*.d.cts"],
|
|
11
|
-
thresholds: { statements: 85, branches:
|
|
11
|
+
thresholds: { statements: 85, branches: 80, functions: 85, lines: 85 },
|
|
12
12
|
},
|
|
13
13
|
},
|
|
14
14
|
});
|