coursecode 0.1.2 → 0.1.5
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 +22 -33
- package/bin/cli.js +14 -3
- package/framework/docs/FRAMEWORK_GUIDE.md +50 -9
- package/framework/docs/USER_GUIDE.md +96 -0
- package/framework/js/managers/objective-manager.js +32 -2
- package/framework/js/navigation/NavigationActions.js +0 -3
- package/framework/js/navigation/navigation-helpers.js +5 -1
- package/framework/js/utilities/course-channel.js +16 -1
- package/framework/js/utilities/data-reporter.js +28 -5
- package/framework/js/utilities/error-reporter.js +36 -9
- package/lib/create.js +3 -20
- package/lib/headless-browser.js +86 -26
- package/lib/mcp-prompts.js +44 -6
- package/lib/mcp-server.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
# CourseCode
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**AI-first course authoring: create interactive e-learning without writing code, then deploy to any LMS format.**
|
|
4
4
|
|
|
5
5
|
Drop in your existing PDFs, Word docs, or PowerPoints — AI converts them into complete, interactive courses. No vendor lock-in, no subscriptions. Your content, your code, deployed to any LMS.
|
|
6
6
|
|
|
7
|
+
## Start Here
|
|
8
|
+
|
|
9
|
+
If you're creating courses (non-technical or technical), use the **[User Guide](framework/docs/USER_GUIDE.md)** as your primary documentation.
|
|
10
|
+
|
|
11
|
+
- **Course authors (non-technical):** Start with the User Guide for step-by-step workflows
|
|
12
|
+
- **Course authors (technical):** The User Guide is still the best starting point, then use advanced docs as needed
|
|
13
|
+
- **AI assistants:** Use the AI-focused docs in `framework/docs/` (for example `COURSE_AUTHORING_GUIDE.md` and `FRAMEWORK_GUIDE.md`)
|
|
14
|
+
|
|
7
15
|
## Features
|
|
8
16
|
|
|
9
17
|
- **MCP integration**: AI connects directly to your course — previews, screenshots, linting, and testing without manual file sharing
|
|
18
|
+
- **No code writing required**: Describe what you want and let AI build slides, interactions, and structure
|
|
10
19
|
- **Full LMS integration**: SCORM 1.2, SCORM 2004, cmi5, and LTI with complete tracking records
|
|
11
|
-
- **AI-
|
|
20
|
+
- **AI-assisted authoring workflow**: Structured guides and MCP tools for faster course development
|
|
12
21
|
- **Rich UI components**: Images, video, accordions, tabs, and custom sandboxed HTML/JS embeds
|
|
13
22
|
- **Rich interactions**: Multiple choice, drag-drop, fill-in-the-blank, matching, sequencing, and more
|
|
14
23
|
- **Fully accessible**: WCAG 2.1 AA compliant with dark mode, high contrast, and reduced motion
|
|
@@ -16,10 +25,9 @@ Drop in your existing PDFs, Word docs, or PowerPoints — AI converts them into
|
|
|
16
25
|
- **Smart tracking**: Engagement requirements, learning objectives, and progress persistence
|
|
17
26
|
- **Themeable design**: CSS custom properties for easy brand customization
|
|
18
27
|
- **Custom endpoints**: Optional webhooks for error reporting and learning record storage
|
|
19
|
-
- **Drop-in content conversion**: Feed existing PDFs, Word docs, or PowerPoint files to AI and get a complete, interactive course
|
|
20
28
|
- **Live preview**: Visual editing, status dashboard, config panels, catalog browser, and full LMS simulation with debug tools
|
|
21
|
-
- **[CourseCode Cloud](https://www.coursecodecloud.com)
|
|
22
|
-
- **CourseCode Desktop
|
|
29
|
+
- **[CourseCode Cloud](https://www.coursecodecloud.com)**: Deploy from CLI, share preview links, download any LMS format on demand — no rebuilds
|
|
30
|
+
- **CourseCode Desktop**: Native app for Mac and Windows with AI-assisted editing and built-in preview
|
|
23
31
|
|
|
24
32
|
---
|
|
25
33
|
|
|
@@ -57,7 +65,7 @@ Open `http://localhost:4173` to view and edit your course.
|
|
|
57
65
|
|
|
58
66
|
The example course included with every new project is a complete guide to using CourseCode.
|
|
59
67
|
|
|
60
|
-
**New to CourseCode?**
|
|
68
|
+
**New to CourseCode?** Start with the [User Guide](framework/docs/USER_GUIDE.md). It is the primary guide for both non-technical and technical course authors.
|
|
61
69
|
|
|
62
70
|
---
|
|
63
71
|
|
|
@@ -76,7 +84,7 @@ With MCP, your AI can:
|
|
|
76
84
|
- **Browse the component catalog** to discover available UI elements
|
|
77
85
|
- **Read course state** including engagement, scoring, and LMS data
|
|
78
86
|
|
|
79
|
-
See the [User Guide](framework/docs/USER_GUIDE.md#
|
|
87
|
+
See the [User Guide](framework/docs/USER_GUIDE.md#connecting-ai-with-mcp) for setup instructions.
|
|
80
88
|
|
|
81
89
|
---
|
|
82
90
|
|
|
@@ -171,7 +179,7 @@ The preview server provides:
|
|
|
171
179
|
|
|
172
180
|
When ready, deploy:
|
|
173
181
|
|
|
174
|
-
**With [CourseCode Cloud](https://www.coursecodecloud.com)
|
|
182
|
+
**With [CourseCode Cloud](https://www.coursecodecloud.com)**: Push your course and get a live link. Cloud handles hosting, generates any LMS format on demand, and gives you sharable preview links with optional password protection. No ZIP files, no manual uploads.
|
|
175
183
|
|
|
176
184
|
```bash
|
|
177
185
|
coursecode deploy
|
|
@@ -191,39 +199,20 @@ coursecode preview --export
|
|
|
191
199
|
|
|
192
200
|
---
|
|
193
201
|
|
|
194
|
-
##
|
|
202
|
+
## Core Commands
|
|
195
203
|
|
|
196
204
|
| Command | Description |
|
|
197
205
|
|---------|-------------|
|
|
198
206
|
| `coursecode create <name>` | Create a new course project |
|
|
199
207
|
| `coursecode preview` | Preview your course locally |
|
|
200
|
-
| `coursecode build` | Build course package (ZIP for LMS upload) |
|
|
201
|
-
| `coursecode build --format scorm1.2` | Build for specific LMS format |
|
|
202
|
-
| `coursecode mcp` | Start the MCP server for AI integration |
|
|
203
|
-
| `coursecode lint` | Validate course configuration and structure |
|
|
204
|
-
| `coursecode narration` | Generate audio narration from text |
|
|
205
208
|
| `coursecode convert` | Convert PDFs, Word, PowerPoint to markdown |
|
|
206
|
-
| `coursecode
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
| Command | Description |
|
|
211
|
-
|---------|-------------|
|
|
212
|
-
| `coursecode login` | Log in to CourseCode Cloud |
|
|
213
|
-
| `coursecode logout` | Log out of CourseCode Cloud |
|
|
214
|
-
| `coursecode whoami` | Show current Cloud user and organizations |
|
|
215
|
-
| `coursecode courses` | List courses on CourseCode Cloud |
|
|
209
|
+
| `coursecode mcp` | Start the MCP server for AI integration |
|
|
210
|
+
| `coursecode lint` | Validate course structure and content |
|
|
211
|
+
| `coursecode build` | Build a package for LMS upload |
|
|
216
212
|
| `coursecode deploy` | Build and deploy to CourseCode Cloud |
|
|
217
|
-
| `coursecode
|
|
218
|
-
|
|
219
|
-
### Output Format Options
|
|
213
|
+
| `coursecode narration` | Generate audio narration from text |
|
|
220
214
|
|
|
221
|
-
|
|
222
|
-
coursecode build --format cmi5 # cmi5/xAPI (default, recommended)
|
|
223
|
-
coursecode build --format scorm2004 # SCORM 2004 4th Edition
|
|
224
|
-
coursecode build --format scorm1.2 # SCORM 1.2 (widest LMS support)
|
|
225
|
-
coursecode build --format lti # LTI 1.3
|
|
226
|
-
```
|
|
215
|
+
For the full command list and deployment options, see the [User Guide](framework/docs/USER_GUIDE.md#sharing-and-deploying) or run `coursecode --help`.
|
|
227
216
|
|
|
228
217
|
---
|
|
229
218
|
|
package/bin/cli.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* coursecode new <type> - Create new slide, assessment, or config
|
|
13
13
|
* coursecode test-errors - Test error reporting configuration
|
|
14
14
|
* coursecode test-data - Test data reporting configuration
|
|
15
|
-
* coursecode
|
|
15
|
+
* coursecode version - Show version
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Command } from 'commander';
|
|
@@ -30,12 +30,21 @@ program
|
|
|
30
30
|
.description('Multi-format course authoring framework CLI')
|
|
31
31
|
.version(packageJson.version);
|
|
32
32
|
|
|
33
|
+
// Version command (explicit subcommand)
|
|
34
|
+
program
|
|
35
|
+
.command('version')
|
|
36
|
+
.description('Show CLI version')
|
|
37
|
+
.action(() => {
|
|
38
|
+
console.log(packageJson.version);
|
|
39
|
+
});
|
|
40
|
+
|
|
33
41
|
// Create command
|
|
34
42
|
program
|
|
35
43
|
.command('create <name>')
|
|
36
44
|
.description('Create a new course project')
|
|
37
45
|
.option('--blank', 'Create without example slides (clean starter)')
|
|
38
46
|
.option('--no-install', 'Skip npm install')
|
|
47
|
+
.option('--start', 'Auto-start dev server after creation (skip prompt)')
|
|
39
48
|
.action(async (name, options) => {
|
|
40
49
|
const { create } = await import('../lib/create.js');
|
|
41
50
|
await create(name, options);
|
|
@@ -280,8 +289,10 @@ program
|
|
|
280
289
|
program
|
|
281
290
|
.command('logout')
|
|
282
291
|
.description('Log out of CourseCode Cloud')
|
|
283
|
-
.
|
|
284
|
-
|
|
292
|
+
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
293
|
+
.action(async (options) => {
|
|
294
|
+
const { logout, setLocalMode } = await import('../lib/cloud.js');
|
|
295
|
+
if (options.local) setLocalMode();
|
|
285
296
|
await logout();
|
|
286
297
|
});
|
|
287
298
|
|
|
@@ -513,13 +513,21 @@ try {
|
|
|
513
513
|
|
|
514
514
|
### External Communications
|
|
515
515
|
|
|
516
|
-
Three optional
|
|
516
|
+
Three optional utilities for outbound communication. All in `framework/js/utilities/`, initialized in `main.js`. Each uses a **priority chain** for configuration:
|
|
517
517
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
518
|
+
```
|
|
519
|
+
1. <meta name="cc-*"> tag in HTML → Injected by CourseCode Cloud (highest priority)
|
|
520
|
+
2. environment.* in course-config.js → Author-defined (self-hosted fallback)
|
|
521
|
+
3. Skip → Feature disabled (silent)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
When cloud meta tags are present, they **always win** — even if `course-config.js` also has values. This ensures cloud-served courses always report to the correct endpoints.
|
|
525
|
+
|
|
526
|
+
| Utility | Config Key | Meta Tag | Transport | Events |
|
|
527
|
+
|---------|-----------|----------|-----------|--------|
|
|
528
|
+
| `error-reporter.js` | `environment.errorReporting` | `cc-error-endpoint` | POST per error (60s dedup) | `*:error` (14 event types) |
|
|
529
|
+
| `data-reporter.js` | `environment.dataReporting` | `cc-data-endpoint` | Batched POST + `sendBeacon` on unload | `assessment:submitted`, `objective:updated`, `interaction:recorded` |
|
|
530
|
+
| `course-channel.js` | `environment.channel` | `cc-channel-endpoint` + `cc-channel-id` | POST to send, SSE to receive | `channel:message`, `channel:connected`, `channel:disconnected` |
|
|
523
531
|
|
|
524
532
|
**Error Reporter** — Subscribes to all `*:error` events, deduplicates by domain+operation+message (60s window), POSTs to endpoint. Optional `enableUserReports: true` adds "Report Issue" to settings menu. `submitUserReport()` for programmatic user reports.
|
|
525
533
|
|
|
@@ -528,7 +536,7 @@ Three optional, config-driven utilities for outbound communication. All in `fram
|
|
|
528
536
|
**Course Channel** — Generic pub/sub pipe. `sendChannelMessage(data)` POSTs any JSON to `endpoint/channelId`. SSE listener on same URL bridges incoming messages to EventBus. Exponential backoff reconnect (1s → 30s cap). Content-agnostic — the relay is a dumb fan-out router.
|
|
529
537
|
|
|
530
538
|
```javascript
|
|
531
|
-
//
|
|
539
|
+
// Self-hosted config (all optional)
|
|
532
540
|
environment: {
|
|
533
541
|
errorReporting: { endpoint: '...', apiKey: '...', includeContext: true, enableUserReports: true },
|
|
534
542
|
dataReporting: { endpoint: '...', apiKey: '...', batchSize: 10, flushInterval: 30000 },
|
|
@@ -536,7 +544,20 @@ environment: {
|
|
|
536
544
|
}
|
|
537
545
|
```
|
|
538
546
|
|
|
539
|
-
**
|
|
547
|
+
**Cloud meta tags** (injected into `<head>` by CourseCode Cloud):
|
|
548
|
+
```html
|
|
549
|
+
<meta name="cc-error-endpoint" content="https://engine.example.com/errors">
|
|
550
|
+
<meta name="cc-data-endpoint" content="https://engine.example.com/data">
|
|
551
|
+
<meta name="cc-channel-endpoint" content="https://engine.example.com/channel">
|
|
552
|
+
<meta name="cc-channel-id" content="session-abc123">
|
|
553
|
+
<meta name="cc-api-key" content="sk_live_abc123">
|
|
554
|
+
<meta name="cc-license-id" content="lic_xyz">
|
|
555
|
+
<meta name="cc-course-id" content="course_456">
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
**Authentication** — All reporters support an optional `apiKey` field (from config or `cc-api-key` meta tag). When set, it's sent as `Authorization: Bearer <apiKey>` on `fetch()` calls. For `sendBeacon` (page unload), `fetch()` with `keepalive: true` is used instead since `sendBeacon` doesn't support custom headers. For SSE (`EventSource`), the token is passed as a `?token=` URL parameter.
|
|
559
|
+
|
|
560
|
+
**Cloud attribution** — When `cc-license-id` and `cc-course-id` meta tags are present (LTI/cmi5 launches), error and data reporters include `licenseId` and `courseId` in all payloads for engine routing.
|
|
540
561
|
|
|
541
562
|
**Example backends:** `framework/docs/examples/cloudflare-{error-worker,data-worker,channel-relay}.js`
|
|
542
563
|
|
|
@@ -838,15 +859,35 @@ If the preview is not running, runtime tools fail fast with a clear error messag
|
|
|
838
859
|
| `coursecode_state` | Full course snapshot | `{slide, structure, interactions, engagement, lmsState, errors}` |
|
|
839
860
|
| `coursecode_navigate` | Go to slide by ID | `{slideId}` → new state |
|
|
840
861
|
| `coursecode_interact` | Set response + evaluate | `{interactionId, response}` → `{correct, score, feedback}` |
|
|
841
|
-
| `coursecode_screenshot` | Visual capture (
|
|
862
|
+
| `coursecode_screenshot` | Visual capture (JPEG) | Optional `slideId` to navigate first, `fullPage` for scroll capture |
|
|
863
|
+
| `coursecode_viewport` | Set viewport size | Breakpoint name or `{width, height}` → persists until changed |
|
|
842
864
|
| `coursecode_reset` | Clear learner state | `{hard?: boolean}` → reload |
|
|
843
865
|
|
|
866
|
+
### Screenshot Quality Modes
|
|
867
|
+
|
|
868
|
+
Two quality modes optimize for token efficiency — **neither changes the viewport**:
|
|
869
|
+
|
|
870
|
+
| Mode | Quality | Typical Size | Use For |
|
|
871
|
+
|------|---------|-------------|---------|
|
|
872
|
+
| normal (default) | JPEG@50 | ~20-40KB | Layout checks |
|
|
873
|
+
| detailed | JPEG@90 | ~100-200KB | Close text/element inspection |
|
|
874
|
+
|
|
875
|
+
### Viewport Control
|
|
876
|
+
|
|
877
|
+
Use `coursecode_viewport` for responsive design testing. Two input modes:
|
|
878
|
+
|
|
879
|
+
- **Breakpoint name**: `"mobile-portrait"`, `"tablet-landscape"`, etc. — resolved dynamically from the running course's `breakpointManager.getBreakpoints()`, so always in sync with CSS.
|
|
880
|
+
- **Explicit dimensions**: `{width: 375, height: 812}` for specific device sizes.
|
|
881
|
+
|
|
882
|
+
The viewport **persists** until explicitly changed again. Default is 1280×720.
|
|
883
|
+
|
|
844
884
|
### Navigation API
|
|
845
885
|
|
|
846
886
|
Use MCP tools for all course interaction — never use external browser tools:
|
|
847
887
|
|
|
848
888
|
- `coursecode_state` → get all slide IDs, current position, interactions
|
|
849
889
|
- `coursecode_navigate(slideId)` → instant slide navigation
|
|
890
|
+
- `coursecode_viewport(breakpoint)` → set viewport for responsive testing
|
|
850
891
|
- `coursecode_screenshot(slideId)` → navigate + capture in one call
|
|
851
892
|
- `coursecode_interact(id, response)` → answer + evaluate in one call
|
|
852
893
|
|
|
@@ -10,6 +10,7 @@ A complete guide to creating interactive e-learning courses with AI assistance.
|
|
|
10
10
|
- [What You'll Need](#what-youll-need)
|
|
11
11
|
- [Installation](#installation)
|
|
12
12
|
- [Creating Your First Project](#creating-your-first-project)
|
|
13
|
+
- [Importing a PowerPoint (Optional)](#importing-a-powerpoint-optional)
|
|
13
14
|
2. [Your Course Folder](#your-course-folder)
|
|
14
15
|
- [Where Everything Lives](#where-everything-lives)
|
|
15
16
|
- [The Documentation Files](#the-documentation-files)
|
|
@@ -40,11 +41,15 @@ A complete guide to creating interactive e-learning courses with AI assistance.
|
|
|
40
41
|
- [Navigation and Flow](#navigation-and-flow)
|
|
41
42
|
- [Engagement Requirements](#engagement-requirements)
|
|
42
43
|
- [Learning Objectives](#learning-objectives)
|
|
44
|
+
- [Course Completion Feedback](#course-completion-feedback)
|
|
45
|
+
- [Updating Live Courses Safely](#updating-live-courses-safely)
|
|
43
46
|
8. [Sharing and Deploying](#sharing-and-deploying)
|
|
44
47
|
- [Sharing Previews](#sharing-previews)
|
|
48
|
+
- [Preview Export Options](#preview-export-options)
|
|
45
49
|
- [Understanding LMS Formats](#understanding-lms-formats)
|
|
46
50
|
- [Standard Deployment](#standard-deployment)
|
|
47
51
|
- [CDN Deployment (Advanced)](#cdn-deployment-advanced)
|
|
52
|
+
- [Cloud Deployment](#cloud-deployment)
|
|
48
53
|
- [Exporting Content for Review](#exporting-content-for-review)
|
|
49
54
|
9. [Generating Audio Narration](#generating-audio-narration)
|
|
50
55
|
10. [Troubleshooting](#troubleshooting)
|
|
@@ -107,6 +112,22 @@ coursecode new assessment final-quiz # Create a graded quiz
|
|
|
107
112
|
coursecode new config # Create a fresh course-config.js
|
|
108
113
|
```
|
|
109
114
|
|
|
115
|
+
### Importing a PowerPoint (Optional)
|
|
116
|
+
|
|
117
|
+
If you already have a PowerPoint deck, you can import it directly as a presentation-style course:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
coursecode import my-deck.pptx
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
On macOS, CourseCode can drive Microsoft PowerPoint to export slides automatically. On any platform, you can use pre-exported slide images:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
coursecode import my-deck.pptx --slides-dir ./exported-slides
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This creates a project with slide image files, generated slide pages, and extracted text in `course/references/converted/` for AI-assisted enhancement.
|
|
130
|
+
|
|
110
131
|
---
|
|
111
132
|
|
|
112
133
|
## Your Course Folder
|
|
@@ -274,6 +295,7 @@ Once connected, your AI assistant gains these capabilities:
|
|
|
274
295
|
| `coursecode_component_catalog` | Browse available UI components (tabs, accordion, cards, etc.) |
|
|
275
296
|
| `coursecode_interaction_catalog` | Browse available interaction types (multiple choice, drag-drop, etc.) |
|
|
276
297
|
| `coursecode_css_catalog` | Browse available CSS classes by category |
|
|
298
|
+
| `coursecode_icon_catalog` | Browse available icons by name/category |
|
|
277
299
|
| `coursecode_workflow_status` | Get guidance on what to do next based on your project's current state |
|
|
278
300
|
| `coursecode_build` | Build the course for LMS deployment |
|
|
279
301
|
|
|
@@ -459,6 +481,35 @@ Track what learners have accomplished:
|
|
|
459
481
|
- Link objectives to assessment scores
|
|
460
482
|
- Report completion status to your LMS
|
|
461
483
|
|
|
484
|
+
### Course Completion Feedback
|
|
485
|
+
|
|
486
|
+
CourseCode can show an end-of-course feedback section in the completion modal. You can enable:
|
|
487
|
+
|
|
488
|
+
- A 5-star rating
|
|
489
|
+
- A free-text comments field
|
|
490
|
+
|
|
491
|
+
Configure this in `course/course-config.js`:
|
|
492
|
+
|
|
493
|
+
```javascript
|
|
494
|
+
completion: {
|
|
495
|
+
promptForRating: true,
|
|
496
|
+
promptForComments: true
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Set either option to `false` if you do not want to collect that input.
|
|
501
|
+
|
|
502
|
+
### Updating Live Courses Safely
|
|
503
|
+
|
|
504
|
+
When you update a course structure after learners have already started (for example, add/remove slides or change assessments), stored LMS state may no longer match the new structure.
|
|
505
|
+
|
|
506
|
+
CourseCode includes validation and recovery behavior:
|
|
507
|
+
|
|
508
|
+
- In development, mismatches are surfaced as errors so you can fix issues early
|
|
509
|
+
- In production, CourseCode attempts graceful recovery to keep learners moving
|
|
510
|
+
|
|
511
|
+
Best practice: set and increment `metadata.version` in `course/course-config.js` whenever you make meaningful structural changes.
|
|
512
|
+
|
|
462
513
|
---
|
|
463
514
|
|
|
464
515
|
## Sharing and Deploying
|
|
@@ -473,6 +524,24 @@ coursecode preview --export
|
|
|
473
524
|
|
|
474
525
|
This creates a self-contained folder you can upload to any web host (Netlify, GitHub Pages, etc.). You can add password protection and other options — ask your AI assistant for help.
|
|
475
526
|
|
|
527
|
+
### Preview Export Options
|
|
528
|
+
|
|
529
|
+
Useful `coursecode preview --export` options:
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
coursecode preview --export -o ./course-preview
|
|
533
|
+
coursecode preview --export --password "secret"
|
|
534
|
+
coursecode preview --export --skip-build
|
|
535
|
+
coursecode preview --export --nojekyll
|
|
536
|
+
coursecode preview --export --no-content
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
- `-o, --output`: choose output folder
|
|
540
|
+
- `-p, --password`: add password protection to shared preview
|
|
541
|
+
- `--skip-build`: export from existing `dist/` without rebuilding
|
|
542
|
+
- `--nojekyll`: add `.nojekyll` (important for GitHub Pages)
|
|
543
|
+
- `--no-content`: hide the content viewer panel in exported preview
|
|
544
|
+
|
|
476
545
|
### Understanding LMS Formats
|
|
477
546
|
|
|
478
547
|
An LMS (Learning Management System) is the platform your organization uses to deliver training — think Cornerstone, Moodle, Canvas, Docebo, etc. CourseCode packages your course in a format your LMS understands.
|
|
@@ -486,6 +555,8 @@ An LMS (Learning Management System) is the platform your organization uses to de
|
|
|
486
555
|
|
|
487
556
|
**Not sure which to pick?** Ask your LMS administrator. If they don't know, try **SCORM 1.2** — it works with almost everything.
|
|
488
557
|
|
|
558
|
+
> **SCORM 1.2 caveat:** SCORM 1.2 has a strict ~4KB suspend data limit. CourseCode uses a strict storage mode to fit within that limit, which can reduce how much interaction UI state is restored across slides on resume.
|
|
559
|
+
|
|
489
560
|
> **Using CourseCode Cloud?** You don't need to choose a format. Cloud-deployed courses use a universal build — the cloud generates the correct format automatically when you download a ZIP for your LMS. The format setting in `course-config.js` only applies to local `coursecode build` commands.
|
|
490
561
|
|
|
491
562
|
### Standard Deployment
|
|
@@ -514,6 +585,19 @@ For teams that update courses frequently or serve multiple LMS clients, CourseCo
|
|
|
514
585
|
|
|
515
586
|
CDN deployment uses special format variants (`scorm1.2-proxy`, `scorm2004-proxy`, `cmi5-remote`). Ask your AI assistant to set this up — it involves configuring an external URL and access tokens in `course-config.js`.
|
|
516
587
|
|
|
588
|
+
Generate and add client tokens with:
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
coursecode token --add client-a
|
|
592
|
+
coursecode token --add client-b
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Then build your proxy/remote package and deploy:
|
|
596
|
+
|
|
597
|
+
```bash
|
|
598
|
+
coursecode build --format scorm1.2-proxy
|
|
599
|
+
```
|
|
600
|
+
|
|
517
601
|
### Cloud Deployment
|
|
518
602
|
|
|
519
603
|
CourseCode Cloud is the simplest deployment option. Upload your course once and the cloud handles everything:
|
|
@@ -576,6 +660,18 @@ Audio files are generated to `course/assets/audio/` and automatically linked to
|
|
|
576
660
|
- Make sure you're giving it the right documentation files
|
|
577
661
|
- Share error messages so it can fix issues
|
|
578
662
|
|
|
663
|
+
**MCP tools are connected but runtime actions fail**
|
|
664
|
+
- Make sure `coursecode preview` is running in the same project
|
|
665
|
+
- Runtime MCP tools (state, navigate, screenshot, interact, reset) require a live preview connection
|
|
666
|
+
|
|
667
|
+
**Cloud/local format behavior is confusing**
|
|
668
|
+
- Local builds use your selected `--format` (or config default)
|
|
669
|
+
- Cloud deploy uses a universal build and lets you choose format at download time
|
|
670
|
+
|
|
671
|
+
**Returning learners see unexpected progress after major course updates**
|
|
672
|
+
- If you changed slide structure or assessments, old stored LMS state may not fully match new content
|
|
673
|
+
- Increment `metadata.version` and re-test resume behavior in preview and LMS
|
|
674
|
+
|
|
579
675
|
**Need more help?**
|
|
580
676
|
- Check the [GitHub issues](https://github.com/course-code-framework/coursecode/issues)
|
|
581
677
|
- The example course includes troubleshooting tips
|
|
@@ -140,6 +140,13 @@ class ObjectiveManager {
|
|
|
140
140
|
throw new Error('ObjectiveManager: setObjective requires an id.');
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
if (objectiveData.score !== undefined && objectiveData.score !== null) {
|
|
144
|
+
const score = objectiveData.score;
|
|
145
|
+
if (typeof score !== 'number' || !Number.isFinite(score) || score < 0 || score > 100) {
|
|
146
|
+
throw new Error(`ObjectiveManager: Score must be a number between 0 and 100, got ${score}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
143
150
|
const existing = this.objectives[id] || { id };
|
|
144
151
|
const updated = { ...existing, ...objectiveData };
|
|
145
152
|
|
|
@@ -151,7 +158,7 @@ class ObjectiveManager {
|
|
|
151
158
|
eventBus.emit('objective:updated', updated);
|
|
152
159
|
|
|
153
160
|
// Emit specific score event if score was updated (for ScoreManager)
|
|
154
|
-
if (typeof updated.score === 'number') {
|
|
161
|
+
if (typeof updated.score === 'number' && Number.isFinite(updated.score)) {
|
|
155
162
|
eventBus.emit('objective:score:updated', {
|
|
156
163
|
objectiveId: id,
|
|
157
164
|
score: updated.score
|
|
@@ -272,6 +279,15 @@ class ObjectiveManager {
|
|
|
272
279
|
this._checkTimeBasedObjectives(view);
|
|
273
280
|
});
|
|
274
281
|
|
|
282
|
+
// Re-evaluate time-based objectives for the slide just left.
|
|
283
|
+
// Slide durations are finalized on navigation:beforeChange, so fromSlideId
|
|
284
|
+
// is the reliable key to check after navigation completes.
|
|
285
|
+
eventBus.on('navigation:changed', ({ fromSlideId }) => {
|
|
286
|
+
if (fromSlideId) {
|
|
287
|
+
this._checkTimeBasedObjectives(fromSlideId);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
275
291
|
// Listen for flag changes to track flag-based objectives
|
|
276
292
|
eventBus.on('flag:updated', ({ key, value }) => {
|
|
277
293
|
this._checkFlagBasedObjectives(key, value);
|
|
@@ -318,6 +334,10 @@ class ObjectiveManager {
|
|
|
318
334
|
|
|
319
335
|
case 'allSlidesVisited': {
|
|
320
336
|
const requiredSlides = criteria.slideIds || [];
|
|
337
|
+
if (!Array.isArray(requiredSlides) || requiredSlides.length === 0) {
|
|
338
|
+
logger.warn(`[ObjectiveManager] Objective "${objective.id}" has invalid allSlidesVisited criteria: slideIds must be a non-empty array.`);
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
321
341
|
const allVisited = requiredSlides.every(sid => allVisitedSlides.has(sid));
|
|
322
342
|
if (allVisited) {
|
|
323
343
|
this.setCompletionStatus(objective.id, 'completed');
|
|
@@ -352,8 +372,14 @@ class ObjectiveManager {
|
|
|
352
372
|
if (criteria.type === 'timeOnSlide' && criteria.slideId === slideId) {
|
|
353
373
|
const totalMilliseconds = slideDurations[slideId] || 0;
|
|
354
374
|
const totalSeconds = totalMilliseconds / 1000;
|
|
375
|
+
const minSeconds = criteria.minSeconds;
|
|
355
376
|
|
|
356
|
-
if (
|
|
377
|
+
if (typeof minSeconds !== 'number' || !Number.isFinite(minSeconds) || minSeconds <= 0) {
|
|
378
|
+
logger.warn(`[ObjectiveManager] Objective "${objective.id}" has invalid timeOnSlide criteria: minSeconds must be a positive number.`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (totalSeconds >= minSeconds) {
|
|
357
383
|
this.setCompletionStatus(objective.id, 'completed');
|
|
358
384
|
logger.debug(`[ObjectiveManager] Objective "${objective.id}" completed: timeOnSlide (${totalSeconds.toFixed(1)}s)`);
|
|
359
385
|
}
|
|
@@ -400,6 +426,10 @@ class ObjectiveManager {
|
|
|
400
426
|
} else if (criteria.type === 'allFlags') {
|
|
401
427
|
// Multiple flags check - all must be truthy (or match equals values)
|
|
402
428
|
const requiredFlags = criteria.flags || [];
|
|
429
|
+
if (!Array.isArray(requiredFlags) || requiredFlags.length === 0) {
|
|
430
|
+
logger.warn(`[ObjectiveManager] Objective "${objective.id}" has invalid allFlags criteria: flags must be a non-empty array.`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
403
433
|
const allMet = requiredFlags.every(flagConfig => {
|
|
404
434
|
const key = typeof flagConfig === 'string' ? flagConfig : flagConfig.key;
|
|
405
435
|
const value = flags[key];
|
|
@@ -696,9 +696,6 @@ async function _performNavigation(slideId, context = {}, updateBookmark = true)
|
|
|
696
696
|
_setupEngagementListeners(newSlide);
|
|
697
697
|
_updateEngagementIndicator(newSlide);
|
|
698
698
|
|
|
699
|
-
// Start timer for the new slide
|
|
700
|
-
AppActions.startSessionTimer(slideId);
|
|
701
|
-
|
|
702
699
|
// Update progress measure to reflect slide visit
|
|
703
700
|
// Count only sequential slides (excludes remedial/conditional slides)
|
|
704
701
|
const totalSequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs)).length;
|
|
@@ -154,11 +154,15 @@ export function evaluateGatingCondition(condition, stateManager, assessmentConfi
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
case 'timeOnSlide': {
|
|
157
|
+
const minSeconds = condition.minSeconds;
|
|
158
|
+
if (typeof minSeconds !== 'number' || !Number.isFinite(minSeconds) || minSeconds <= 0) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
157
161
|
const sessionData = stateManager.getDomainState('sessionData');
|
|
158
162
|
const slideDurations = sessionData?.slideDurations || {};
|
|
159
163
|
const slideDurationMs = slideDurations[condition.slideId] || 0;
|
|
160
164
|
const totalSeconds = slideDurationMs / 1000;
|
|
161
|
-
return totalSeconds >=
|
|
165
|
+
return totalSeconds >= minSeconds;
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
case 'custom': {
|
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
import { eventBus } from '../core/event-bus.js';
|
|
24
24
|
import { logger } from './logger.js';
|
|
25
25
|
|
|
26
|
+
// ── Cloud meta tag reader ───────────────────────────────────────────
|
|
27
|
+
function getMetaContent(name) {
|
|
28
|
+
const el = document.querySelector(`meta[name="${name}"]`);
|
|
29
|
+
return el ? el.getAttribute('content') : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
// Connection state
|
|
27
33
|
let _config = null;
|
|
28
34
|
let _eventSource = null;
|
|
@@ -186,7 +192,16 @@ function handleUnload() {
|
|
|
186
192
|
* @param {Object} courseConfig - The course configuration object
|
|
187
193
|
*/
|
|
188
194
|
export function initCourseChannel(courseConfig) {
|
|
189
|
-
|
|
195
|
+
// Priority chain: meta tags (cloud-injected) → config (self-hosted) → disabled
|
|
196
|
+
const metaEndpoint = getMetaContent('cc-channel-endpoint');
|
|
197
|
+
const metaChannelId = getMetaContent('cc-channel-id');
|
|
198
|
+
let config;
|
|
199
|
+
if (metaEndpoint && metaChannelId) {
|
|
200
|
+
// Both required — endpoint alone without a channel ID is useless
|
|
201
|
+
config = { endpoint: metaEndpoint, channelId: metaChannelId, apiKey: getMetaContent('cc-api-key') };
|
|
202
|
+
} else {
|
|
203
|
+
config = courseConfig.environment?.channel;
|
|
204
|
+
}
|
|
190
205
|
|
|
191
206
|
_config = config;
|
|
192
207
|
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
import { eventBus } from '../core/event-bus.js';
|
|
19
19
|
import { logger } from './logger.js';
|
|
20
20
|
|
|
21
|
+
// ── Cloud meta tag reader ───────────────────────────────────────────
|
|
22
|
+
function getMetaContent(name) {
|
|
23
|
+
const el = document.querySelector(`meta[name="${name}"]`);
|
|
24
|
+
return el ? el.getAttribute('content') : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
// Batching state
|
|
22
28
|
let batch = [];
|
|
23
29
|
let flushTimer = null;
|
|
@@ -25,6 +31,8 @@ let flushTimer = null;
|
|
|
25
31
|
// Configuration
|
|
26
32
|
let _config = null;
|
|
27
33
|
let _courseConfig = null;
|
|
34
|
+
let _licenseId = null;
|
|
35
|
+
let _courseId = null;
|
|
28
36
|
|
|
29
37
|
const DEFAULT_BATCH_SIZE = 10;
|
|
30
38
|
const DEFAULT_FLUSH_INTERVAL = 30000; // 30 seconds
|
|
@@ -158,6 +166,10 @@ function buildPayload(records) {
|
|
|
158
166
|
};
|
|
159
167
|
}
|
|
160
168
|
|
|
169
|
+
// Cloud attribution context (license and course IDs for engine routing)
|
|
170
|
+
if (_licenseId) payload.licenseId = _licenseId;
|
|
171
|
+
if (_courseId) payload.courseId = _courseId;
|
|
172
|
+
|
|
161
173
|
return payload;
|
|
162
174
|
}
|
|
163
175
|
|
|
@@ -238,11 +250,6 @@ function handleBeforeTerminate() {
|
|
|
238
250
|
* @param {Object} courseConfig - The course configuration object
|
|
239
251
|
*/
|
|
240
252
|
export function initDataReporter(courseConfig) {
|
|
241
|
-
const config = courseConfig.environment?.dataReporting;
|
|
242
|
-
|
|
243
|
-
_config = config;
|
|
244
|
-
_courseConfig = courseConfig;
|
|
245
|
-
|
|
246
253
|
// Never send data during local dev — the preview server and dev command
|
|
247
254
|
// inject VITE_COURSECODE_LOCAL into the Vite build env, which is auto-exposed
|
|
248
255
|
// to client code. Production builds via `coursecode build` don't set this.
|
|
@@ -251,6 +258,22 @@ export function initDataReporter(courseConfig) {
|
|
|
251
258
|
return;
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
// Priority chain: meta tags (cloud-injected) → config (self-hosted) → disabled
|
|
262
|
+
const metaEndpoint = getMetaContent('cc-data-endpoint');
|
|
263
|
+
let config;
|
|
264
|
+
if (metaEndpoint) {
|
|
265
|
+
config = { endpoint: metaEndpoint, apiKey: getMetaContent('cc-api-key') };
|
|
266
|
+
} else {
|
|
267
|
+
config = courseConfig.environment?.dataReporting;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_config = config;
|
|
271
|
+
_courseConfig = courseConfig;
|
|
272
|
+
|
|
273
|
+
// Cloud attribution (present when launched via LTI/cmi5)
|
|
274
|
+
_licenseId = getMetaContent('cc-license-id');
|
|
275
|
+
_courseId = getMetaContent('cc-course-id');
|
|
276
|
+
|
|
254
277
|
// Disabled if no endpoint configured
|
|
255
278
|
if (!config?.endpoint) {
|
|
256
279
|
logger.debug('[DataReporter] Not configured, skipping initialization');
|
|
@@ -42,6 +42,12 @@ const sendTimestamps = []; // Timestamps of recent sends
|
|
|
42
42
|
// ── Re-entrancy guard ───────────────────────────────────────────────
|
|
43
43
|
let _isReporting = false;
|
|
44
44
|
|
|
45
|
+
// ── Cloud meta tag reader ───────────────────────────────────────────
|
|
46
|
+
function getMetaContent(name) {
|
|
47
|
+
const el = document.querySelector(`meta[name="${name}"]`);
|
|
48
|
+
return el ? el.getAttribute('content') : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
/**
|
|
46
52
|
* Generate a unique key for an error to detect duplicates
|
|
47
53
|
*/
|
|
@@ -172,6 +178,10 @@ async function flushBatch(config, courseConfig) {
|
|
|
172
178
|
};
|
|
173
179
|
}
|
|
174
180
|
|
|
181
|
+
// Cloud attribution context (license and course IDs for engine routing)
|
|
182
|
+
if (_licenseId) request.licenseId = _licenseId;
|
|
183
|
+
if (_courseId) request.courseId = _courseId;
|
|
184
|
+
|
|
175
185
|
// Guard re-entrancy: our own logger calls must not trigger new reports
|
|
176
186
|
_isReporting = true;
|
|
177
187
|
try {
|
|
@@ -201,6 +211,8 @@ async function flushBatch(config, courseConfig) {
|
|
|
201
211
|
// Store config globally for user reports
|
|
202
212
|
let _config = null;
|
|
203
213
|
let _courseConfig = null;
|
|
214
|
+
let _licenseId = null;
|
|
215
|
+
let _courseId = null;
|
|
204
216
|
|
|
205
217
|
/**
|
|
206
218
|
* Check if error reporting is configured and user reports are enabled
|
|
@@ -239,6 +251,10 @@ export async function submitUserReport(description, options = {}) {
|
|
|
239
251
|
id: _courseConfig.metadata?.id
|
|
240
252
|
};
|
|
241
253
|
}
|
|
254
|
+
|
|
255
|
+
// Cloud attribution
|
|
256
|
+
if (_licenseId) payload.licenseId = _licenseId;
|
|
257
|
+
if (_courseId) payload.courseId = _courseId;
|
|
242
258
|
|
|
243
259
|
// Include current slide info if available
|
|
244
260
|
if (options.currentSlide) {
|
|
@@ -279,12 +295,6 @@ export async function submitUserReport(description, options = {}) {
|
|
|
279
295
|
* @param {Object} courseConfig - The course configuration object
|
|
280
296
|
*/
|
|
281
297
|
export function initErrorReporter(courseConfig) {
|
|
282
|
-
const config = courseConfig.environment?.errorReporting;
|
|
283
|
-
|
|
284
|
-
// Store for user reports
|
|
285
|
-
_config = config;
|
|
286
|
-
_courseConfig = courseConfig;
|
|
287
|
-
|
|
288
298
|
// Never send reports during local dev — the preview server and dev command
|
|
289
299
|
// inject VITE_COURSECODE_LOCAL into the Vite build env, which is auto-exposed
|
|
290
300
|
// to client code. Production builds via `coursecode build` don't set this.
|
|
@@ -292,15 +302,32 @@ export function initErrorReporter(courseConfig) {
|
|
|
292
302
|
logger.debug('[ErrorReporter] Disabled in local dev mode');
|
|
293
303
|
return;
|
|
294
304
|
}
|
|
295
|
-
|
|
305
|
+
|
|
306
|
+
// Priority chain: meta tags (cloud-injected) → config (self-hosted) → disabled
|
|
307
|
+
const metaEndpoint = getMetaContent('cc-error-endpoint');
|
|
308
|
+
let config;
|
|
309
|
+
if (metaEndpoint) {
|
|
310
|
+
config = { endpoint: metaEndpoint, apiKey: getMetaContent('cc-api-key') };
|
|
311
|
+
} else {
|
|
312
|
+
config = courseConfig.environment?.errorReporting;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Store for user reports
|
|
316
|
+
_config = config;
|
|
317
|
+
_courseConfig = courseConfig;
|
|
318
|
+
|
|
319
|
+
// Cloud attribution (present when launched via LTI/cmi5)
|
|
320
|
+
_licenseId = getMetaContent('cc-license-id');
|
|
321
|
+
_courseId = getMetaContent('cc-course-id');
|
|
322
|
+
|
|
296
323
|
// Disabled if not configured or no endpoint
|
|
297
324
|
if (!config?.endpoint) {
|
|
298
325
|
logger.debug('[ErrorReporter] Not configured, skipping initialization');
|
|
299
326
|
return;
|
|
300
327
|
}
|
|
301
|
-
|
|
328
|
+
|
|
302
329
|
logger.info('[ErrorReporter] Initialized with endpoint:', config.endpoint);
|
|
303
|
-
|
|
330
|
+
|
|
304
331
|
// Subscribe to unified logger events
|
|
305
332
|
eventBus.on('log:error', (errorData) => {
|
|
306
333
|
if (_isReporting) return; // prevent re-entrancy from our own logger calls
|
package/lib/create.js
CHANGED
|
@@ -211,10 +211,8 @@ export async function create(name, options = {}) {
|
|
|
211
211
|
`);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (shouldStart) {
|
|
214
|
+
// --start: auto-start dev server. Otherwise print instructions and exit.
|
|
215
|
+
if (options.start) {
|
|
218
216
|
console.log('\n Starting development server...\n');
|
|
219
217
|
const child = spawn('npx', ['coursecode', 'dev'], {
|
|
220
218
|
cwd: targetDir,
|
|
@@ -227,22 +225,7 @@ export async function create(name, options = {}) {
|
|
|
227
225
|
console.log(` cd ${name} && coursecode dev\n`);
|
|
228
226
|
});
|
|
229
227
|
} else {
|
|
230
|
-
console.log(`
|
|
231
|
-
To start developing:
|
|
232
|
-
|
|
233
|
-
cd ${name}
|
|
234
|
-
coursecode dev
|
|
235
|
-
`);
|
|
228
|
+
console.log(`\n To start developing:\n\n cd ${name}\n coursecode dev\n`);
|
|
236
229
|
}
|
|
237
230
|
}
|
|
238
231
|
|
|
239
|
-
function promptYesNo(question) {
|
|
240
|
-
return new Promise((resolve) => {
|
|
241
|
-
process.stdout.write(question);
|
|
242
|
-
process.stdin.setEncoding('utf8');
|
|
243
|
-
process.stdin.once('data', (data) => {
|
|
244
|
-
const answer = data.trim().toLowerCase();
|
|
245
|
-
resolve(answer === '' || answer === 'y' || answer === 'yes');
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
}
|
package/lib/headless-browser.js
CHANGED
|
@@ -71,6 +71,7 @@ class HeadlessBrowser {
|
|
|
71
71
|
this._reconnectTimer = null;
|
|
72
72
|
this._stopped = false;
|
|
73
73
|
this._consoleLogs = [];
|
|
74
|
+
this._viewport = { width: 1280, height: 720 };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/**
|
|
@@ -106,7 +107,7 @@ class HeadlessBrowser {
|
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
this.page = await this.browser.newPage();
|
|
109
|
-
await this.page.setViewport(
|
|
110
|
+
await this.page.setViewport(this._viewport);
|
|
110
111
|
|
|
111
112
|
// Capture console warnings and errors
|
|
112
113
|
this._consoleLogs = [];
|
|
@@ -250,20 +251,88 @@ class HeadlessBrowser {
|
|
|
250
251
|
return logs;
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Set the headless browser viewport size.
|
|
256
|
+
* Accepts either a dimensions object or a named breakpoint string.
|
|
257
|
+
* The viewport persists until explicitly changed again.
|
|
258
|
+
*
|
|
259
|
+
* @param {object|string} sizeOrBreakpoint - {width, height} or breakpoint name
|
|
260
|
+
* @returns {Promise<{width: number, height: number, breakpoint?: string}>} Applied viewport
|
|
261
|
+
*/
|
|
262
|
+
async setViewport(sizeOrBreakpoint) {
|
|
263
|
+
this._ensureRunning();
|
|
264
|
+
|
|
265
|
+
let width, height, breakpointName;
|
|
266
|
+
|
|
267
|
+
if (typeof sizeOrBreakpoint === 'string') {
|
|
268
|
+
// Resolve breakpoint name dynamically from the running course
|
|
269
|
+
breakpointName = sizeOrBreakpoint;
|
|
270
|
+
await this._ensureCourseFrame();
|
|
271
|
+
const bp = await this.courseFrame.evaluate((name) => {
|
|
272
|
+
const bpManager = window.CourseCode?.breakpointManager;
|
|
273
|
+
if (!bpManager) return null;
|
|
274
|
+
const breakpoints = bpManager.getBreakpoints();
|
|
275
|
+
return breakpoints.find(b => b.name === name) || null;
|
|
276
|
+
}, breakpointName);
|
|
277
|
+
|
|
278
|
+
if (!bp) {
|
|
279
|
+
// Get available names for error message
|
|
280
|
+
const available = await this.courseFrame.evaluate(() => {
|
|
281
|
+
const bpManager = window.CourseCode?.breakpointManager;
|
|
282
|
+
if (!bpManager) return [];
|
|
283
|
+
return bpManager.getBreakpoints().map(b => b.name);
|
|
284
|
+
});
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Unknown breakpoint "${breakpointName}". ` +
|
|
287
|
+
`Available: ${available.join(', ')}`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Use the breakpoint's boundary width
|
|
292
|
+
width = bp.maxWidth ?? bp.minWidth;
|
|
293
|
+
// Scale height proportionally from the 16:9 base (1280x720)
|
|
294
|
+
height = Math.round(width * (9 / 16));
|
|
295
|
+
} else {
|
|
296
|
+
width = sizeOrBreakpoint.width;
|
|
297
|
+
height = sizeOrBreakpoint.height;
|
|
298
|
+
if (!width || !height) {
|
|
299
|
+
throw new Error('Viewport requires both width and height, or a breakpoint name string.');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this._viewport = { width, height };
|
|
304
|
+
await this.page.setViewport(this._viewport);
|
|
305
|
+
// Brief settle for layout reflow
|
|
306
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
307
|
+
|
|
308
|
+
return { width, height, ...(breakpointName ? { breakpoint: breakpointName } : {}) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get the current viewport dimensions.
|
|
313
|
+
* @returns {{width: number, height: number}}
|
|
314
|
+
*/
|
|
315
|
+
getViewport() {
|
|
316
|
+
return { ...this._viewport };
|
|
317
|
+
}
|
|
318
|
+
|
|
253
319
|
/**
|
|
254
320
|
* Take a screenshot of the current page.
|
|
255
|
-
*
|
|
321
|
+
*
|
|
256
322
|
* Two quality modes optimize for token efficiency:
|
|
257
|
-
* - normal (default):
|
|
258
|
-
* - detailed:
|
|
259
|
-
*
|
|
323
|
+
* - normal (default): JPEG@50 (~20-40KB) — quick layout checks
|
|
324
|
+
* - detailed: JPEG@90 (~100-200KB) — close text/element inspection
|
|
325
|
+
*
|
|
326
|
+
* Neither mode changes the viewport. The screenshot captures at the
|
|
327
|
+
* current viewport size. Use setViewport() to change viewport independently.
|
|
328
|
+
*
|
|
260
329
|
* fullPage captures the entire scrollable course content by screenshotting
|
|
261
330
|
* the course iframe element directly (bypasses the stub player chrome).
|
|
262
|
-
*
|
|
331
|
+
*
|
|
263
332
|
* @param {object} options
|
|
264
333
|
* @param {string} [options.slideId] - Navigate to this slide before screenshotting
|
|
265
334
|
* @param {boolean} [options.fullPage=false] - Capture full scrollable content
|
|
266
|
-
* @param {boolean} [options.detailed=false] -
|
|
335
|
+
* @param {boolean} [options.detailed=false] - Higher JPEG quality for close inspection
|
|
267
336
|
* @returns {Promise<{data: string, mimeType: string}>} Base64-encoded JPEG
|
|
268
337
|
*/
|
|
269
338
|
async screenshot(options = {}) {
|
|
@@ -271,10 +340,8 @@ class HeadlessBrowser {
|
|
|
271
340
|
|
|
272
341
|
const { slideId, fullPage = false, detailed = false, scrollY } = options;
|
|
273
342
|
|
|
274
|
-
//
|
|
275
|
-
const
|
|
276
|
-
? { width: 1280, height: 720, quality: 90 }
|
|
277
|
-
: { width: 800, height: 450, quality: 70 };
|
|
343
|
+
// Quality-only presets — viewport is NOT changed
|
|
344
|
+
const quality = detailed ? 90 : 50;
|
|
278
345
|
|
|
279
346
|
// Navigate to specific slide if requested
|
|
280
347
|
if (slideId) {
|
|
@@ -297,18 +364,11 @@ class HeadlessBrowser {
|
|
|
297
364
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
298
365
|
}
|
|
299
366
|
|
|
300
|
-
// Resize viewport for the chosen mode
|
|
301
|
-
await this.page.setViewport({ width: preset.width, height: preset.height });
|
|
302
|
-
// Brief settle after resize
|
|
303
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
304
|
-
|
|
305
367
|
let screenshotBuffer;
|
|
306
368
|
|
|
307
369
|
if (fullPage) {
|
|
308
370
|
// For fullPage, measure the iframe content's full scrollable height,
|
|
309
371
|
// temporarily expand the viewport so nothing is clipped, then screenshot.
|
|
310
|
-
// ElementHandle.screenshot() only captures the element's bounding box,
|
|
311
|
-
// so we need to make the viewport tall enough to show everything.
|
|
312
372
|
await this._ensureCourseFrame();
|
|
313
373
|
const contentHeight = await this.courseFrame.evaluate(() => {
|
|
314
374
|
return Math.max(
|
|
@@ -317,33 +377,33 @@ class HeadlessBrowser {
|
|
|
317
377
|
);
|
|
318
378
|
});
|
|
319
379
|
|
|
320
|
-
const fullHeight = Math.max(contentHeight,
|
|
321
|
-
await this.page.setViewport({ width:
|
|
380
|
+
const fullHeight = Math.max(contentHeight, this._viewport.height);
|
|
381
|
+
await this.page.setViewport({ width: this._viewport.width, height: fullHeight });
|
|
322
382
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
323
383
|
|
|
324
384
|
const iframeElement = await this.page.$('iframe');
|
|
325
385
|
if (iframeElement) {
|
|
326
386
|
screenshotBuffer = await iframeElement.screenshot({
|
|
327
387
|
type: 'jpeg',
|
|
328
|
-
quality
|
|
388
|
+
quality
|
|
329
389
|
});
|
|
330
390
|
} else {
|
|
331
391
|
screenshotBuffer = await this.page.screenshot({
|
|
332
392
|
type: 'jpeg',
|
|
333
|
-
quality
|
|
393
|
+
quality,
|
|
334
394
|
fullPage: true
|
|
335
395
|
});
|
|
336
396
|
}
|
|
397
|
+
|
|
398
|
+
// Restore viewport height (fullPage temporarily expanded it)
|
|
399
|
+
await this.page.setViewport(this._viewport);
|
|
337
400
|
} else {
|
|
338
401
|
screenshotBuffer = await this.page.screenshot({
|
|
339
402
|
type: 'jpeg',
|
|
340
|
-
quality
|
|
403
|
+
quality
|
|
341
404
|
});
|
|
342
405
|
}
|
|
343
406
|
|
|
344
|
-
// Restore default viewport
|
|
345
|
-
await this.page.setViewport({ width: 1280, height: 720 });
|
|
346
|
-
|
|
347
407
|
return {
|
|
348
408
|
data: screenshotBuffer.toString('base64'),
|
|
349
409
|
mimeType: 'image/jpeg'
|
package/lib/mcp-prompts.js
CHANGED
|
@@ -121,9 +121,11 @@ Requires preview server to be running.`,
|
|
|
121
121
|
name: 'coursecode_screenshot',
|
|
122
122
|
description: `Take a screenshot of the course preview. Returns a JPEG image.
|
|
123
123
|
|
|
124
|
-
Two modes
|
|
125
|
-
- normal (default):
|
|
126
|
-
- detailed:
|
|
124
|
+
Two quality modes (neither changes viewport):
|
|
125
|
+
- normal (default): JPEG@50 (~20-40KB) — layout checks
|
|
126
|
+
- detailed: JPEG@90 (~100-200KB) — close text/element inspection
|
|
127
|
+
|
|
128
|
+
Captures at the current viewport size. Use coursecode_viewport to change viewport for responsive testing.
|
|
127
129
|
|
|
128
130
|
Use scrollY to scroll course content before capturing (useful for long slides).
|
|
129
131
|
fullPage captures the course iframe's full content area.
|
|
@@ -143,7 +145,7 @@ Requires preview server to be running.`,
|
|
|
143
145
|
},
|
|
144
146
|
detailed: {
|
|
145
147
|
type: 'boolean',
|
|
146
|
-
description: '
|
|
148
|
+
description: 'Higher JPEG quality for close inspection (does not change viewport)'
|
|
147
149
|
},
|
|
148
150
|
scrollY: {
|
|
149
151
|
type: 'number',
|
|
@@ -153,6 +155,41 @@ Requires preview server to be running.`,
|
|
|
153
155
|
required: []
|
|
154
156
|
}
|
|
155
157
|
},
|
|
158
|
+
{
|
|
159
|
+
name: 'coursecode_viewport',
|
|
160
|
+
description: `Set the headless browser viewport size for responsive design testing.
|
|
161
|
+
|
|
162
|
+
Two modes:
|
|
163
|
+
- Breakpoint name: 'mobile-portrait', 'mobile-landscape', 'tablet-portrait', etc.
|
|
164
|
+
Resolves width dynamically from the course's breakpoint manager (always in sync with CSS).
|
|
165
|
+
Height scales proportionally (16:9).
|
|
166
|
+
- Explicit dimensions: {width: 375, height: 812} for specific device sizes.
|
|
167
|
+
|
|
168
|
+
The viewport PERSISTS until changed again. Call with 'desktop' or {width: 1280, height: 720} to reset.
|
|
169
|
+
|
|
170
|
+
Returns the applied viewport dimensions and breakpoint name (if used).
|
|
171
|
+
|
|
172
|
+
Use before coursecode_screenshot to test responsive layouts.
|
|
173
|
+
Requires preview server to be running.`,
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
breakpoint: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description: 'Named breakpoint from the course (e.g., "mobile-portrait", "tablet-landscape", "desktop")'
|
|
180
|
+
},
|
|
181
|
+
width: {
|
|
182
|
+
type: 'number',
|
|
183
|
+
description: 'Explicit viewport width in pixels (use with height)'
|
|
184
|
+
},
|
|
185
|
+
height: {
|
|
186
|
+
type: 'number',
|
|
187
|
+
description: 'Explicit viewport height in pixels (use with width)'
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
required: []
|
|
191
|
+
}
|
|
192
|
+
},
|
|
156
193
|
// --- Workflow & Build Tools ---
|
|
157
194
|
{
|
|
158
195
|
name: 'coursecode_workflow_status',
|
|
@@ -652,7 +689,7 @@ function buildBrowserRules() {
|
|
|
652
689
|
return `## Browser Architecture (CRITICAL)
|
|
653
690
|
|
|
654
691
|
The MCP server runs its OWN headless Chrome internally via puppeteer-core.
|
|
655
|
-
All runtime tools (state, navigate, interact, screenshot, reset) execute instantly inside this headless browser.
|
|
692
|
+
All runtime tools (state, navigate, interact, screenshot, viewport, reset) execute instantly inside this headless browser.
|
|
656
693
|
|
|
657
694
|
### Preview Server Ownership
|
|
658
695
|
- The MCP does NOT start or manage the preview server
|
|
@@ -663,7 +700,8 @@ All runtime tools (state, navigate, interact, screenshot, reset) execute instant
|
|
|
663
700
|
### Navigation API
|
|
664
701
|
- coursecode_state → get slim TOC with slide IDs, current slide, interactions, engagement, lmsState, apiLog, errors, frameworkLogs, consoleLogs
|
|
665
702
|
- coursecode_navigate(slideId) → go to any slide instantly by ID
|
|
666
|
-
-
|
|
703
|
+
- coursecode_viewport(breakpoint or {width,height}) → set viewport for responsive testing (persists until changed)
|
|
704
|
+
- coursecode_screenshot(slideId) → navigate + capture in one call (quality modes only, never changes viewport)
|
|
667
705
|
- coursecode_interact(interactionId, response) → set response + evaluate in one call
|
|
668
706
|
- NEVER click nav buttons or menu items via browser tools. Use these MCP tools.
|
|
669
707
|
|
package/lib/mcp-server.js
CHANGED
|
@@ -211,6 +211,17 @@ export async function startMcpServer(options = {}) {
|
|
|
211
211
|
}]
|
|
212
212
|
};
|
|
213
213
|
|
|
214
|
+
case 'coursecode_viewport':
|
|
215
|
+
await ensureHeadless(port);
|
|
216
|
+
if (args?.breakpoint) {
|
|
217
|
+
result = await headless.setViewport(args.breakpoint);
|
|
218
|
+
} else if (args?.width && args?.height) {
|
|
219
|
+
result = await headless.setViewport({ width: args.width, height: args.height });
|
|
220
|
+
} else {
|
|
221
|
+
throw new Error('Provide either a breakpoint name or both width and height.');
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
|
|
214
225
|
// === Workflow & build tools ===
|
|
215
226
|
case 'coursecode_workflow_status':
|
|
216
227
|
result = await getWorkflowStatusWithInstructions(port);
|