diff --git a/.prettierignore b/.prettierignore index 76bba0642..30caab25b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,8 @@ dist/ multi-reporter-config.json runner-results/ .astro -package-lock.json \ No newline at end of file +package-lock.json + +# Vendored third-party files — kept verbatim for provenance; never reformat. +# See packages/scorm-export/vendor/VENDORED.md. +packages/scorm-export/vendor/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 79eda18dd..42b439285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2418,6 +2418,10 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@doenet-tools/scorm-export": { + "resolved": "packages/scorm-export", + "link": true + }, "node_modules/@doenet-tools/shared": { "resolved": "packages/shared", "link": true @@ -15706,9 +15710,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -23992,6 +23994,13 @@ "name": "@doenet-tools/eslint-config", "version": "1.0.0" }, + "packages/scorm-export": { + "name": "@doenet-tools/scorm-export", + "version": "1.0.0", + "dependencies": { + "lz-string": "1.5.0" + } + }, "packages/shared": { "name": "@doenet-tools/shared", "version": "1.0.0", diff --git a/packages/scorm-export/README.md b/packages/scorm-export/README.md new file mode 100644 index 000000000..4892f6465 --- /dev/null +++ b/packages/scorm-export/README.md @@ -0,0 +1,101 @@ +# doenet-scorm-export + +Prototype for a "Download SCORM" button on doenet.org: wraps a single +DoenetML activity in an LMS-ready SCORM 2004 package, **without** the PreTeXt +toolchain. The SCORM runtime intelligence is reused from PreTeXt as two +verbatim-vendored JavaScript files (see `vendor/VENDORED.md`). + +## Try it + +```sh +node build.mjs sample/sample.doenet --title "Sample Doenet Activity" +``` + +This writes `dist/sample-scorm.zip`. Upload that zip to an LMS as a SCORM +package (Canvas: Settings → Navigation → enable SCORM, then SCORM → Upload; +Moodle: add a "SCORM package" activity; Brightspace/Blackboard: content +upload menus). The page shows the activity plus a "Submit Assignment" +button; scores flow to the LMS gradebook. + +## What's in a package + +A SCORM package here is just six static files in a flat zip: + +| File | Role | +| ----------------------- | ------------------------------------------------------------------------------- | +| `imsmanifest.xml` | Minimal SCORM 2004 4th Ed. manifest: one item, one SCO, launch `index.html` | +| `index.html` | Chrome-free shell: `div[data-component="doenet"]` wrapping the activity iframe | +| `activity.html` | The iframe content: DoenetML source + `@doenet/standalone` viewer from CDN | +| `ptx_scorm_events.js` | Vendored SCORM bridge (LMS API discovery, scoring, state save/restore, submit) | +| `lti_iframe_resizer.js` | Vendored SPLICE `lti.frameResize` handler so the iframe fits its content | +| `lz-string.min.js` | `lz-string` npm dep, copied in at build time; compresses state for suspend_data | + +Only `activity.html` (DoenetML) and the title/id substitutions vary per +activity; everything else is constant. The two `ptx_*`/`lti_*` files are +vendored (see `vendor/VENDORED.md`); `lz-string.min.js` comes from the pinned +`lz-string` npm dependency, not from `vendor/`. + +## How scoring works at runtime + +1. The LMS launches `index.html` in an iframe and exposes the SCORM API + (`window.API_1484_11` or `window.API`) on a parent window. +2. `activity.html`'s viewer has `data-doenet-message-parent="true"`, so it + speaks SPLICE to its parent: `SPLICE.getState` on load (state restore) + and `SPLICE.reportScoreAndState` on each answer (score in [0,1] plus a + state blob encoding the student's work). +3. `ptx_scorm_events.js` in `index.html` translates those messages into + SCORM calls: `cmi.interactions.*` records, `cmi.score.scaled/raw`, and + completion status. The Doenet state blob is compressed (lz-string) into + `cmi.suspend_data` — the manifest declares SCORM 2004 4th Edition for its + 64,000-char `suspend_data` limit — so both score and state persist + server-side and restore on a fresh LMS launch (localStorage is kept only + as a same-device cache). A size guard drops the state blob, falling back to + localStorage, if it would ever overflow the budget. +4. "Submit Assignment" commits the final grade; the attempt is finalized + when the student leaves the page (this ordering is a hard-won Blackboard + requirement — see the comments in the vendored file). + +## Debugging + +`debug/size-probe.html` is a passive diagnostic that logs, to the browser +console: the size of each state blob Doenet emits and what the LMS actually +returned in `cmi.suspend_data` on launch (`[DOENET-SIZE-PROBE] …`), and each +`lti.frameResize` — the height reported, the height applied to the activity +iframe, and whether `index.html` overflows the box the LMS gave it +(`[DOENET-RESIZE-PROBE] …`; the "OUTER frame overflows" clause means the +scrollbar is the LMS player's, not ours). It is **not** part of a normal +package. Pass `--debug` to inline it into `index.html`: + +```sh +node build.mjs sample/sample.doenet --debug +``` + +The file count is unchanged (it is inlined, not added as a separate file); +without `--debug` the package contains no trace of it. + +## Toward production on doenet.org + +- The build is template substitution + zip, so it can run entirely + client-side behind the button: fetch the six files, substitute, zip with + JSZip, trigger the download. `build.mjs` exists only so the package can be + produced and tested from a shell. +- Keep `--id` stable across re-exports of the same activity: it keys the + student's saved score and state in the LMS and in localStorage. +- The viewer loads from `cdn.jsdelivr.net`; pin `--doenet-version` for + reproducible packages. A fully offline package would need the standalone + viewer bundled into the zip instead. +- `` cannot appear in the DoenetML source (it terminates the inline + script element); `build.mjs` rejects such sources. A production version + could instead ship the source as a separate `.doenet` file fetched at + runtime, which also removes any escaping concerns. +- The vendored files are GPL (v2 or v3) from PreTeXt — preserve + `vendor/VENDORED.md`, don't edit the copies, and pull upstream fixes by + re-copying (instructions in that file). + +## DOM contract with the vendored bridge + +`ptx_scorm_events.js` expects: an element `div[data-component="doenet"]` +with the activity id, containing the iframe whose `contentWindow` sends the +SPLICE messages, all inside `
` (where the submit button is appended). +`index.html` provides exactly this; if you restructure it, keep those +invariants. diff --git a/packages/scorm-export/build.mjs b/packages/scorm-export/build.mjs new file mode 100644 index 000000000..6d86ef564 --- /dev/null +++ b/packages/scorm-export/build.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// Build an LMS-ready SCORM zip for a single DoenetML activity. +// +// Usage: +// node build.mjs [options] +// +// Options: +// --title "Human Title" Title shown in the LMS (default: filename) +// --id slug Activity id used to key scores/state in the +// LMS and localStorage (default: filename slug). +// Keep it stable across re-exports of the same +// activity, or saved student state is orphaned. +// --doenet-version X.Y.Z @doenet/standalone version (default: latest) +// --out dir Output directory (default: ./dist) +// --debug Inline debug/size-probe.html into index.html +// (state-blob / suspend_data console logging). +// Off by default; a normal package omits it. +// +// Output: /-scorm.zip with imsmanifest.xml at the zip root. + +import { + readFileSync, + writeFileSync, + mkdirSync, + copyFileSync, + rmSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { basename, join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// ── argument parsing ──────────────────────────────────────────────────────── +const args = process.argv.slice(2); +const positional = []; +const opts = { "doenet-version": "latest", out: join(here, "dist") }; +const booleanFlags = new Set(["debug"]); +for (let i = 0; i < args.length; i++) { + if (args[i].startsWith("--")) { + const key = args[i].slice(2); + opts[key] = booleanFlags.has(key) ? true : args[++i]; + } else { + positional.push(args[i]); + } +} +if (positional.length !== 1) { + console.error( + "Usage: node build.mjs [--title t] [--id slug] [--doenet-version v] [--out dir]", + ); + process.exit(1); +} + +const sourceFile = positional[0]; +const doenetml = readFileSync(sourceFile, "utf8"); + +// The DoenetML is embedded inside a ', which cannot be embedded in an HTML script element.", + ); + process.exit(1); +} + +const slug = (opts.id || basename(sourceFile).replace(/\.[^.]*$/, "")) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); +const title = opts.title || slug; + +const escapeMarkup = (s) => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +// The size probe lives in debug/size-probe.html and is inlined into +// index.html only under --debug; a normal package substitutes it away. +const debugProbe = opts.debug + ? readFileSync(join(here, "debug", "size-probe.html"), "utf8").trimEnd() + : ""; + +const substitutions = { + TITLE: escapeMarkup(title), + ACTIVITY_ID: slug, + IDENTIFIER: "doenet-scorm-" + slug, + DOENET_VERSION: opts["doenet-version"], + DOENETML: doenetml, + DEBUG_PROBE: debugProbe, +}; + +const fill = (template) => + template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + if (!(key in substitutions)) throw new Error("Unknown placeholder: " + key); + return substitutions[key]; + }); + +// ── assemble the package in a staging directory ───────────────────────────── +const staging = join(opts.out, "staging-" + slug); +rmSync(staging, { recursive: true, force: true }); +mkdirSync(staging, { recursive: true }); + +for (const name of ["imsmanifest.xml", "index.html", "activity.html"]) { + writeFileSync( + join(staging, name), + fill(readFileSync(join(here, "templates", name), "utf8")), + ); +} +// PreTeXt's SCORM bridge and SPLICE resize handler are vendored (locally +// modified; see vendor/VENDORED.md), so they're copied from vendor/. +for (const name of ["ptx_scorm_events.js", "lti_iframe_resizer.js"]) { + copyFileSync(join(here, "vendor", name), join(staging, name)); +} +// lz-string is an unmodified npm dependency (pinned in package.json), not +// vendored: resolve its minified build from node_modules and copy it in under +// the filename index.html references. +const lzStringSrc = fileURLToPath( + import.meta.resolve("lz-string/libs/lz-string.min.js"), +); +copyFileSync(lzStringSrc, join(staging, "lz-string.min.js")); + +// ── zip it (flat: imsmanifest.xml at the zip root, as LMSes require) ──────── +const zipName = slug + "-scorm.zip"; +rmSync(join(staging, zipName), { force: true }); +execFileSync("zip", ["-X", "-q", "-r", zipName, "."], { cwd: staging }); + +const zipPath = join(opts.out, zipName); +copyFileSync(join(staging, zipName), zipPath); +rmSync(staging, { recursive: true, force: true }); + +console.log("SCORM package written to " + zipPath); +console.log( + 'Upload it to your LMS as a SCORM package (title: "' + title + '").', +); diff --git a/packages/scorm-export/debug/size-probe.html b/packages/scorm-export/debug/size-probe.html new file mode 100644 index 000000000..9f86608a3 --- /dev/null +++ b/packages/scorm-export/debug/size-probe.html @@ -0,0 +1,108 @@ + + diff --git a/packages/scorm-export/package.json b/packages/scorm-export/package.json new file mode 100644 index 000000000..73b8df84b --- /dev/null +++ b/packages/scorm-export/package.json @@ -0,0 +1,13 @@ +{ + "name": "@doenet-tools/scorm-export", + "type": "module", + "version": "1.0.0", + "private": true, + "description": "Wraps a single DoenetML activity in an LMS-ready SCORM 2004 package (the doenet.org \"Download SCORM\" feature). Static templates + a build script; no build step.", + "scripts": { + "build:sample": "node build.mjs sample/sample.doenet --title \"Sample Doenet Activity\"" + }, + "dependencies": { + "lz-string": "1.5.0" + } +} diff --git a/packages/scorm-export/sample/sample.doenet b/packages/scorm-export/sample/sample.doenet new file mode 100644 index 000000000..7cf2ccac6 --- /dev/null +++ b/packages/scorm-export/sample/sample.doenet @@ -0,0 +1,372 @@ + + + $set=A + $set=B + + false + false + + + #633075 + #005350 + #701B1A + #414C37 + #4A5568 + + + + + + + + + + + + + + + + + + 1 4 6 7 + 2 4 5 7 + 3 5 6 7 + 1 2 3 4 5 6 7 8 + + + + + + 1.732 + 1.732 + + + + + (-2,0) + (0,2*$n1) + (2,0) + + + 9 + + + + $Acenter.x + 4*cos(x) + $Acenter.y + 4*sin(x) + + $Bcenter.x + 4*cos(x) + $Bcenter.y + 4*sin(x) + + $Ccenter.x + 4*cos(x) + $Ccenter.y + 4*sin(x) + + + $box_hl + -$box_hl + + + + + (2,0) (1.86,1.04) (1.46,2) (0.828,2.83) (0,3.46) + (0,3.46) (0.828,2.83) (1.46,2) (1.86,1.04) (2,0) + (0,3.46) (−0.965,3.86) (−2,4) (−3.04,3.86) (−4,3.46) + (−4,3.46) (−4.83,2.83) (−5.46,2) (−5.86,1.04) (−6,0) + (−6,0) (−5.86,−1.04) (−5.46,−2) (−4.83,−2.83) (−4,−3.46) (−3.04,−3.86) (−2,−4) (−0.965,−3.86) (0,−3.46) + (0,−3.46) (−0.965,−3.86) (−2,−4) (−3.04,−3.86) (−4,−3.46) (−4.83,−2.83) (−5.46,−2) (−5.86,−1.04) (−6,0) (−5.86,1.04) (−5.46,2) (−4.83,2.83) (−4,3.46) + (0,−3.46) (0.828,−2.83) (1.46,−2) (1.86,−1.04) (2,0) + + (−2,−0.000102) (−1.04,−0.4) (0,−0.536) (1.04,−0.4) (2,−0.000102) + (2,−0.000102) (1.04,−0.4) (0,−0.536) (−1.04,−0.4) (−2,−0.000102) + (2,−0.000102) (2.83,0.636) (3.46,1.46) (3.86,2.43) (4,3.46) + (4,3.46) (3.86,4.5) (3.46,5.46) (2.83,6.29) (2,6.93) (1.04,7.33) (0,7.46) (−1.04,7.33) (−2,6.93) (−2.83,6.29) (−3.46,5.46) (−3.86,4.5) (−4,3.46) + (−4,3.46) (−3.86,4.5) (−3.46,5.46) (−2.83,6.29) (−2,6.93) (−1.04,7.33) (0,7.46) (1.04,7.33) (2,6.93) (2.83,6.29) (3.46,5.46) (3.86,4.5) (4,3.46) + (−4,3.46) (−3.86,2.43) (−3.46,1.46) (−2.83,0.636) (−2,−0.000102) + + (0,3.46) (−0.828,2.83) (−1.46,2) (−1.86,1.04) (−2,0) + (−2,0) (−1.86,1.04) (−1.46,2) (−0.828,2.83) (0,3.46) + (−2,0) (−1.86,−1.04) (−1.46,−2) (−0.828,−2.83) (0,−3.46) + (6,0) (5.86,1.04) (5.46,2) (4.83,2.83) (4,3.46) + (0,−3.46) (0.965,−3.86) (2,−4) (3.04,−3.86) (4,−3.46) (4.83,−2.83) (5.46,−2) (5.86,−1.04) (6,0) + (4,3.46) (4.83,2.83) (5.46,2) (5.86,1.04) (6,0) (5.86,−1.04) (5.46,−2) (4.83,−2.83) (4,−3.46) (3.04,−3.86) (2,−4) (0.965,−3.86) (0,−3.46) + (4,3.46) (3.04,3.86) (2,4) (0.965,3.86) (0,3.46) + + + + $p1.open $p2.open $p3.open $p4.open $p5.open $p6.open $p7.open $p8.open + + + + !$p1.open !$p2.open !$p3.open !$p4.open !$p5.open !$p6.open !$p7.open !$p8.open + + + $nsh + + + + + +
+ Venn Diagrams and Set Relationships + The image below shows a Venn diagram with three sets: A, B, and C. For each of the questions below, use the radio buttons to select the regions that correspond to the given set(s) or set operations.
+ +
+ + + + + Problems +Shade the sets given below. Shade exactly the given set with no additional shading. +
+ + + + + + + $s_A $sh[1] $nsh[2] $nsh[3] $sh[4] $nsh[5] $sh[6] $sh[7] $nsh[8] + or + $s_B $nsh[1] $sh[2] $nsh[3] $sh[4] $sh[5] $nsh[6] $sh[7] $nsh[8] + + + + + + + + + + + $s_A $sh[1] $nsh[2] $sh[3] $sh[4] $sh[5] $sh[6] $sh[7] $nsh[8] + or + $s_B $nsh[1] $sh[2] $sh[3] $sh[4] $sh[5] $sh[6] $sh[7] $nsh[8] + + + + + + + + + + + $s_A $nsh[1] $nsh[2] $nsh[3] $nsh[4] $nsh[5] $sh[6] $sh[7] $nsh[8] + or + $s_B $nsh[1] $nsh[2] $nsh[3] $nsh[4] $sh[5] $nsh[6] $sh[7] $nsh[8] + + + + + + + + + + + $s_A $nsh[1] $sh[2] $sh[3] $nsh[4] $sh[5] $nsh[6] $nsh[7] $sh[8] + or + $s_B $sh[1] $nsh[2] $sh[3] $nsh[4] $nsh[5] $sh[6] $nsh[7] $sh[8] + + + + + + + + + + + $nsh[1] $nsh[2] $nsh[3] $nsh[4] $nsh[5] $nsh[6] $sh[7] $nsh[8] + + + + + + + + + + + $sh[1] $sh[2] $sh[3] $sh[4] $sh[5] $sh[6] $sh[7] $nsh[8] + + + + + + + + + + + $nsh[1] $nsh[2] $sh[3] $sh[4] $sh[5] $sh[6] $sh[7] $nsh[8] + + + + + + + + + + + $s_A $nsh[1] $nsh[2] $sh[3] $nsh[4] $sh[5] $nsh[6] $nsh[7] $nsh[8] + or + $s_B $nsh[1] $nsh[2] $sh[3] $nsh[4] $nsh[5] $sh[6] $nsh[7] $nsh[8] + + + + + + +
+ + + + +
+ + Venn Diagram + + + A graph that shows three overlapping circles representing the sets A, B, and C. + + + + + + + + + + + + + + + + + + + + + + + + (-(2+1.732), -1) + (0, 3*1.732) + (2+1.732, -1) + (-(1+1.732), 1+1.732) + (1+1.732, 1+1.732) + (0, -2) + (0, 2*1.732/3) + (-6.75, 6.75) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Congratulations! You have earned credit for all problems in this section. diff --git a/packages/scorm-export/sample/sample2.doenet b/packages/scorm-export/sample/sample2.doenet new file mode 100644 index 000000000..3b8bf1962 --- /dev/null +++ b/packages/scorm-export/sample/sample2.doenet @@ -0,0 +1,11 @@ +

This is a small sample activity for testing the SCORM export.

+ +

What is 2+3? + 5 +

+ +

What is the derivative of x^2? + + 2x + +

diff --git a/packages/scorm-export/templates/activity.html b/packages/scorm-export/templates/activity.html new file mode 100644 index 000000000..d37bbb4a8 --- /dev/null +++ b/packages/scorm-export/templates/activity.html @@ -0,0 +1,34 @@ + + + + + + {{TITLE}} + + + + + + + + + + + +
+
+

Loading activity…

+
+ +
+ + diff --git a/packages/scorm-export/templates/imsmanifest.xml b/packages/scorm-export/templates/imsmanifest.xml new file mode 100644 index 000000000..565c1941e --- /dev/null +++ b/packages/scorm-export/templates/imsmanifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + ADL SCORM + 2004 4th Edition + + + + {{TITLE}} + + {{TITLE}} + + + + + + + + + + + + + diff --git a/packages/scorm-export/templates/index.html b/packages/scorm-export/templates/index.html new file mode 100644 index 000000000..e0e2a7e8f --- /dev/null +++ b/packages/scorm-export/templates/index.html @@ -0,0 +1,44 @@ + + + + + + {{TITLE}} + + + + + {{DEBUG_PROBE}} + +
+ + + + + +
+ +
+
+ + + + + + + + + + + + diff --git a/packages/scorm-export/vendor/VENDORED.md b/packages/scorm-export/vendor/VENDORED.md new file mode 100644 index 000000000..5da7446ae --- /dev/null +++ b/packages/scorm-export/vendor/VENDORED.md @@ -0,0 +1,49 @@ +# Vendored files + +These files are copied from upstream projects. Except where a local +modification is recorded below, they are verbatim copies and must not be edited. +To update, diff upstream's current version against the commit recorded below, +review the changes, and re-copy (re-applying any recorded local modification). + +License: these PreTeXt files are GPL v2 or v3 (see PreTeXt's `COPYING` file). +Attribution and this notice must be preserved. + +Note: `lz-string` (MIT) is **not** vendored here — it is a pinned npm +dependency (see `../package.json`) that `build.mjs` copies into the package +from `node_modules` at build time. + +## ptx_scorm_events.js + +- Source: https://github.com/PreTeXtBook/pretext — `js/ptx_scorm_events.js` +- Copied at commit: `83e8f200248383d1bdbe009d8b57f3d91f379d3e` (2026-07-17) +- Upstream history of interest: PR #2685 (initial SCORM tracking), + PR #2887 (Doenet/SPLICE support), PR #3040 (Blackboard fixes) +- Local modifications: **YES** — search the file for `VENDOR-MOD`. Purpose: + persist the Doenet activity state through the SCORM data model instead of + localStorage only, so student work restores on a fresh LMS launch (upstream + stores it in localStorage, which LMSes do not carry across launches). The + `_doenetStates` map is compressed (LZ-string, base64) into `cmi.suspend_data` + by `buildSuspendData()` and rehydrated by `restoreDoenetStates()` on session + start; `SUSPEND_TOTAL_LIMIT` was raised to 60000 for the 4th-Edition + suspend_data cap. A size guard drops the blob (falling back to localStorage) + if it would overflow the budget. A second gap is also fixed: upstream only + saves the Doenet state blob when Runestone is present (the save is gated on + `RunestoneBase.__ptxScormHooked`), so in a Runestone-free standalone package + the state was never captured at all; the SPLICE `message` handler now forwards + `state` (and the real subject, so the init-guard runs) into `recordInteraction`. + Touch points, all marked `VENDOR-MOD`: `_doenetStates` comment, + `SUSPEND_TOTAL_LIMIT` + `buildSuspendData()`, `restoreDoenetStates()`, the two + restore paths (`initSession`, `loadRestoreData`), and the SPLICE + `reportScoreAndState` → `recordInteraction` call. This is a candidate to + contribute upstream to PreTeXt (Oscar Levin); if accepted, drop the local mod + and re-copy verbatim. +- Requires the `lz-string` npm dependency (loaded as `window.LZString`) and the + manifest declaring SCORM 2004 4th Edition. + +## lti_iframe_resizer.js + +- Source: https://github.com/PreTeXtBook/pretext — `js/lti_iframe_resizer.js` +- Copied at commit: `83e8f200248383d1bdbe009d8b57f3d91f379d3e` (2026-07-17) +- Handles the SPLICE `lti.frameResize` postMessage so the activity iframe + grows to fit its content. +- Local modifications: none diff --git a/packages/scorm-export/vendor/lti_iframe_resizer.js b/packages/scorm-export/vendor/lti_iframe_resizer.js new file mode 100644 index 000000000..be9755506 --- /dev/null +++ b/packages/scorm-export/vendor/lti_iframe_resizer.js @@ -0,0 +1,54 @@ +// SPLICE resize handling - https://cssplice.org/ +// Expected message format: +// { +// subject: lti.frameResize', +// message_id: (a unique string ID), // optional - not used +// height: ..., +// width: ... +// } + +window.addEventListener('message', function (event) { + let edata = event.data; + + //MoM sends event.data as a string instead of JSON + if (typeof event.data == 'string' && event.data.match(/lti\.frameResize/)) { + edata = JSON.parse(event.data); + } + + if (edata.subject === "lti.frameResize") { + if ("frame_id" in edata) { + // MoM may send frame_id + let el = document.getElementById(edata['frame_id']); + document.getElementById(edata['frame_id']).style.height = edata.height + 'px'; + if (edata.wrapheight && document.getElementById(edata['frame_id'] + 'wrap')) { + document.getElementById(edata['frame_id'] + 'wrap').style.height = edata.wrapheight + 'px'; + } + } else if ("iframe_resize_id" in edata) { + // MoM may send iframe_resize_id + document.getElementById(edata['iframe_resize_id']).style.height = edata.height + 'px'; + } else { + // No target element specified, so resize the iframe that sent the message + // event.source.frameElement is only accessible if the iframe is on the same domain + // so loop through iframes to find the one that sent the message + const iFrames = document.getElementsByTagName('iframe'); + for(const iFrame of iFrames) { + if(iFrame.contentWindow === event.source) { + if (edata.height) { + iFrame.height = edata.height; + iFrame.style.height = edata.height + 'px'; + } + if (edata.width) { + iFrame.width = edata.width; + iFrame.style.width = edata.width + 'px'; + } + break; + } + } + } + } + }); + + // Currently only used by My Open Math to request a resize after knowls open + function sendResizeRequest(el) { + el.contentWindow.postMessage("requestResize", "*"); + } diff --git a/packages/scorm-export/vendor/ptx_scorm_events.js b/packages/scorm-export/vendor/ptx_scorm_events.js new file mode 100644 index 000000000..a069a92a2 --- /dev/null +++ b/packages/scorm-export/vendor/ptx_scorm_events.js @@ -0,0 +1,2501 @@ +/** + * ptx-scorm-events.js — PreTeXt SCORM Tracking Adapter + * + * PURPOSE + * ------- + * When a PreTeXt book is exported as a SCORM package (compression="scorm" in + * project.ptx), the resulting zip is uploaded to an LMS (Canvas, Moodle, …). + * The LMS hosts the content in an iframe and exposes a JavaScript object + * called the "SCORM API" on the parent window. This script acts as the + * bridge between PreTeXt/Runestone interactive exercises and that SCORM API. + * + * WHAT IT DOES + * ------------ + * 1. Discovers the SCORM API exposed by the LMS (supports both SCORM 1.2 and + * SCORM 2004). + * 2. Initializes a SCORM session the first time the learner answers a question. + * 3. Monkey-patches RunestoneBase.prototype.logBookEvent so that every + * Runestone exercise submission is intercepted. + * 4. For each graded submission, writes a SCORM "interaction" record (type, + * learner response, result, timestamp) to the LMS grade book. + * 5. Maintains a running score (percentage correct across all answered + * questions on all pages visited in this session) and reports it as + * cmi.score.scaled (SCORM 2004) or cmi.core.score.raw (SCORM 1.2). + * 6. Persists the running totals in cmi.suspend_data so that navigating to + * a new page does not reset the accumulated score. + * 7. Marks the SCO as "completed" after the first question is answered + * (completion means "visited and interacted", not "all correct"). + * + * SCORING POLICY + * -------------- + * The score is simply: (sum of per-question percentage scores) / (questions answered) + * No pass/fail threshold is applied here. The LMS can apply its own mastery + * score to determine pass/fail based on the reported scaled score. + * + * INCLUDED EXERCISE TYPES + * ----------------------- + * All standard Runestone interactive types are handled: + * - multiplechoice / clickablearea → SCORM interaction type "choice" + * - fillintheblank / webwork → "fill-in" + * - parsons / hparsons → "sequencing" + * - dragndrop / matching → "matching" + * - unittest (ActiveCode tests) → "performance" + * - shortanswer / journal → "long-fill-in" (no automatic score; + * recorded as neutral result) + * + * HOW TO INCLUDE + * -------------- + * This file is placed in _static/pretext/js/ by the PreTeXt build system and + * injected into every HTML page via a