@lessonkit/cli 1.3.1 → 1.4.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/dist/bin.js CHANGED
@@ -177,8 +177,17 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
177
177
  const course = lessonkit.course;
178
178
  course.courseId = slug;
179
179
  course.title = projectName;
180
+ const tracking = lessonkit.tracking ?? {};
181
+ const xapi = tracking.xapi ?? {};
182
+ xapi.activityIri = `https://example.com/courses/${slug}`;
183
+ tracking.xapi = xapi;
184
+ lessonkit.tracking = tracking;
180
185
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
181
186
  `, "utf8");
187
+ const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
188
+ let courseConfigSource = await readFile(courseConfigPath, "utf8");
189
+ courseConfigSource = courseConfigSource.replace(/courseId: "my-course"/g, `courseId: "${slug}"`);
190
+ await writeFile(courseConfigPath, courseConfigSource, "utf8");
182
191
  const appPath = join(projectDir, "src", "App.tsx");
183
192
  let appSource = await readFile(appPath, "utf8");
184
193
  appSource = appSource.replace(/courseId="my-course"/g, `courseId="${slug}"`);
package/dist/index.js CHANGED
@@ -175,8 +175,17 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
175
175
  const course = lessonkit.course;
176
176
  course.courseId = slug;
177
177
  course.title = projectName;
178
+ const tracking = lessonkit.tracking ?? {};
179
+ const xapi = tracking.xapi ?? {};
180
+ xapi.activityIri = `https://example.com/courses/${slug}`;
181
+ tracking.xapi = xapi;
182
+ lessonkit.tracking = tracking;
178
183
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
179
184
  `, "utf8");
185
+ const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
186
+ let courseConfigSource = await readFile(courseConfigPath, "utf8");
187
+ courseConfigSource = courseConfigSource.replace(/courseId: "my-course"/g, `courseId: "${slug}"`);
188
+ await writeFile(courseConfigPath, courseConfigSource, "utf8");
180
189
  const appPath = join(projectDir, "src", "App.tsx");
181
190
  let appSource = await readFile(appPath, "utf8");
182
191
  appSource = appSource.replace(/courseId="my-course"/g, `courseId="${slug}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.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
- ".": "./dist/index.js"
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
29
32
  },
30
33
  "files": [
31
34
  "dist",
@@ -42,15 +45,15 @@
42
45
  "lint": "echo \"(no lint configured yet)\""
43
46
  },
44
47
  "dependencies": {
45
- "@lessonkit/core": "1.3.1",
46
- "@lessonkit/lxpack": "1.3.1",
47
- "commander": "^14.0.1"
48
+ "@lessonkit/core": "1.4.0",
49
+ "@lessonkit/lxpack": "1.4.0",
50
+ "commander": "^15.0.0"
48
51
  },
49
52
  "engines": {
50
53
  "node": ">=18"
51
54
  },
52
55
  "devDependencies": {
53
- "@types/node": "^24.0.0",
56
+ "@types/node": "^25.9.2",
54
57
  "tsup": "^8.5.0",
55
58
  "typescript": "^5.8.3",
56
59
  "vitest": "^4.1.8"
@@ -0,0 +1,5 @@
1
+ # Copy to .env.production before `npm run build`.
2
+ # Point both URLs at your backend proxies — never embed raw LRS credentials in the bundle.
3
+
4
+ VITE_XAPI_PROXY_URL=
5
+ VITE_ANALYTICS_URL=
@@ -14,8 +14,14 @@ 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
+ ## Production
22
+
23
+ Copy `.env.example` to `.env.production` 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).
24
+
19
25
  ## Docs
20
26
 
21
27
  [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [React quickstart](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.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.3.1",
17
- "@lessonkit/react": "^1.3.1",
18
- "@lessonkit/themes": "^1.3.1",
19
- "@lessonkit/xapi": "^1.3.1",
20
- "react": "^18.3.1",
21
- "react-dom": "^18.3.1"
19
+ "@lessonkit/core": "^1.4.0",
20
+ "@lessonkit/react": "^1.4.0",
21
+ "@lessonkit/themes": "^1.4.0",
22
+ "@lessonkit/xapi": "^1.4.0",
23
+ "react": "^19.2.7",
24
+ "react-dom": "^19.2.7"
22
25
  },
23
26
  "devDependencies": {
24
- "@lessonkit/cli": "^1.3.1",
25
- "@lessonkit/lxpack": "^1.3.1",
27
+ "@lessonkit/cli": "^1.4.0",
28
+ "@lessonkit/lxpack": "^1.4.0",
26
29
  "@testing-library/react": "^16.3.0",
27
- "@types/react": "^18.3.23",
28
- "@types/react-dom": "^18.3.7",
29
- "@vitejs/plugin-react": "^4.6.0",
30
- "jsdom": "^26.1.0",
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",
31
35
  "typescript": "^5.8.3",
32
- "vite": "^7.1.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 without crashing", () => {
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,20 +1,8 @@
1
1
  import React from "react";
2
2
  import { Course, Lesson, Quiz, Scenario, ThemeProvider } from "@lessonkit/react";
3
- import type { TelemetryEvent } from "@lessonkit/core";
4
- import type { XAPIStatement } from "@lessonkit/xapi";
3
+ import { createCourseConfig } 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 (
@@ -0,0 +1,91 @@
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
+
48
+ expect(warn).toHaveBeenCalled();
49
+ warn.mockRestore();
50
+ });
51
+
52
+ it("uses fetch transports when proxy URLs are set", async () => {
53
+ vi.stubEnv("VITE_XAPI_PROXY_URL", "https://lrs.example/statements");
54
+ vi.stubEnv("VITE_ANALYTICS_URL", "https://analytics.example/events");
55
+ const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
56
+ globalThis.fetch = fetchMock as typeof fetch;
57
+
58
+ const config = createCourseConfig();
59
+
60
+ expect(config.tracking?.batchSink).toBeTypeOf("function");
61
+ expect(config.xapi?.transport).toBeTypeOf("function");
62
+
63
+ await config.tracking?.batchSink?.([
64
+ { name: "course_started", timestamp: "t", courseId: "my-course" } as TelemetryEvent,
65
+ ]);
66
+ await config.xapi?.transport?.({
67
+ id: "s1",
68
+ timestamp: "2026-01-01T00:00:00Z",
69
+ verb: "http://adlnet.gov/expapi/verbs/completed",
70
+ object: { id: "https://example.com/a" },
71
+ });
72
+
73
+ expect(fetchMock).toHaveBeenCalled();
74
+ });
75
+
76
+ it("skips production guard when MODE is test even with proxy URLs set", () => {
77
+ vi.stubEnv("NODE_ENV", "production");
78
+ vi.stubEnv("MODE", "test");
79
+ vi.stubEnv("VITE_XAPI_PROXY_URL", "https://lrs.example/statements");
80
+ vi.stubEnv("VITE_ANALYTICS_URL", "https://analytics.example/events");
81
+
82
+ expect(() => createCourseConfig()).not.toThrow();
83
+ });
84
+
85
+ it("throws when production guard is enforced with console sinks", () => {
86
+ vi.stubEnv("NODE_ENV", "production");
87
+ vi.stubEnv("MODE", "production");
88
+
89
+ expect(() => createCourseConfig()).toThrow(/console telemetry sinks|observability hooks/);
90
+ });
91
+ });
@@ -0,0 +1,117 @@
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
+ onXapiTransportError: (err) => report("xapi-transport", { err }),
35
+ };
36
+ }
37
+
38
+ function readProxyUrls(): { xapiProxyUrl?: string; analyticsUrl?: string } {
39
+ return {
40
+ xapiProxyUrl: import.meta.env.VITE_XAPI_PROXY_URL as string | undefined,
41
+ analyticsUrl: import.meta.env.VITE_ANALYTICS_URL as string | undefined,
42
+ };
43
+ }
44
+
45
+ function devConsoleTracking(): LessonkitConfig["tracking"] {
46
+ return {
47
+ sink: (event: TelemetryEvent) => {
48
+ console.log("[telemetry]", event);
49
+ },
50
+ };
51
+ }
52
+
53
+ function devConsoleXapi(): LessonkitConfig["xapi"] {
54
+ return {
55
+ enabled: true,
56
+ transport: (statement: XAPIStatement) => {
57
+ console.log("[xapi]", statement);
58
+ return Promise.resolve();
59
+ },
60
+ };
61
+ }
62
+
63
+ function productionTracking(analyticsUrl: string | undefined): LessonkitConfig["tracking"] {
64
+ if (!analyticsUrl) {
65
+ /* v8 ignore next */
66
+ throw new Error(
67
+ "VITE_ANALYTICS_URL is required in production. Point it at your analytics ingest proxy.",
68
+ );
69
+ }
70
+ const { batchSink, exitBatchSink } = createFetchBatchSink({
71
+ url: analyticsUrl,
72
+ headers: lrsAuthHeaders,
73
+ });
74
+ return {
75
+ enabled: true,
76
+ batchSink,
77
+ exitBatchSink,
78
+ batch: { enabled: true },
79
+ };
80
+ }
81
+
82
+ function productionXapi(xapiProxyUrl: string | undefined): LessonkitConfig["xapi"] {
83
+ if (!xapiProxyUrl) {
84
+ /* v8 ignore next */
85
+ throw new Error(
86
+ "VITE_XAPI_PROXY_URL is required in production. Point it at your LRS proxy (never the raw LRS with embedded secrets).",
87
+ );
88
+ }
89
+ const { transport, exitTransport, abortInFlight } = createFetchTransport({
90
+ url: xapiProxyUrl,
91
+ headers: lrsAuthHeaders,
92
+ });
93
+ return {
94
+ enabled: true,
95
+ transport,
96
+ exitTransport,
97
+ abortInFlight,
98
+ };
99
+ }
100
+
101
+ export function createCourseConfig(): LessonkitConfig {
102
+ const { xapiProxyUrl, analyticsUrl } = readProxyUrls();
103
+ const useProductionTransports = import.meta.env.PROD || (xapiProxyUrl && analyticsUrl);
104
+
105
+ const config: LessonkitConfig = {
106
+ courseId: "my-course",
107
+ lxpack: { bridge: "off" },
108
+ observability: createObservability(),
109
+ tracking: useProductionTransports ? productionTracking(analyticsUrl) : devConsoleTracking(),
110
+ xapi: useProductionTransports ? productionXapi(xapiProxyUrl) : devConsoleXapi(),
111
+ };
112
+
113
+ if (shouldEnforceProductionGuard()) {
114
+ assertProductionCourseConfig(config);
115
+ }
116
+ return config;
117
+ }
@@ -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: 85, functions: 85, lines: 85 },
11
+ thresholds: { statements: 85, branches: 80, functions: 85, lines: 85 },
12
12
  },
13
13
  },
14
14
  });