Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ dist/
multi-reporter-config.json
runner-results/
.astro
package-lock.json
package-lock.json

# Vendored third-party files — kept verbatim for provenance; never reformat.
# See packages/scorm-export/vendor/VENDORED.md.
packages/scorm-export/vendor/
13 changes: 11 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions packages/scorm-export/README.md
Original file line number Diff line number Diff line change
@@ -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.
- `</script>` 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 `<main>` (where the submit button is appended).
`index.html` provides exactly this; if you restructure it, keep those
invariants.
137 changes: 137 additions & 0 deletions packages/scorm-export/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env node
// Build an LMS-ready SCORM zip for a single DoenetML activity.
//
// Usage:
// node build.mjs <activity.doenet> [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: <out>/<id>-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 <activity.doenet> [--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 <script type="text/doenetml"> element,
// whose content is raw text terminated only by "</script". Rather than
// escape (the viewer would see the escaped form), refuse such sources.
if (/<\/script/i.test(doenetml)) {
console.error(
"Error: DoenetML source contains '</script>', 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");

// 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 + '").',
);
108 changes: 108 additions & 0 deletions packages/scorm-export/debug/size-probe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<!-- ─── DIAGNOSTIC: Doenet state-blob size probe ────────────────────────
Inlined into index.html only when build.mjs is run with --debug; a
normal package never contains it (see build.mjs / README "Debugging").

Passively measures the `state` blob Doenet sends in each
SPLICE.reportScoreAndState message — the blob that would need to fit
in cmi.suspend_data if we persist state through the SCORM API — reads
back on each launch what the LMS actually returned in cmi.suspend_data,
and logs each lti.frameResize so we can tell whether the activity iframe
is growing and whether the scrollbar is ours or the LMS player's. It
only reads; it does not interfere with the bridge or the resizer.
Registered before the iframe loads so it catches the first report. -->
<script>
(function () {
var SPM_3RD = 4000; // cmi.suspend_data SPM, SCORM 2004 3rd Edition
var SPM_4TH = 64000; // cmi.suspend_data SPM, SCORM 2004 4th Edition
window.addEventListener('message', function (event) {
var data = event.data;
if (!data || typeof data !== 'object') return;
if (data.subject !== 'SPLICE.reportScoreAndState') return;
if (data.state === undefined || data.state === null) {
console.log('[DOENET-SIZE-PROBE] reportScoreAndState with no state field.');
return;
}
var str = typeof data.state === 'string' ? data.state : JSON.stringify(data.state);
var chars = str.length; // SPM is defined in characters
var bytes = (new TextEncoder().encode(str)).length; // UTF-8 bytes, for reference
console.log(
'[DOENET-SIZE-PROBE] Doenet state blob: ' + chars + ' chars (' + bytes + ' UTF-8 bytes). ' +
'Fits 3rd Ed (' + SPM_3RD + '): ' + (chars <= SPM_3RD ? 'YES' : 'NO') + '. ' +
'Fits 4th Ed (' + SPM_4TH + '): ' + (chars <= SPM_4TH ? 'YES' : 'NO') + '.'
);
});

// ── LAUNCH PROBE: what did the LMS actually hand back in suspend_data? ──
// Reads cmi.suspend_data directly on each launch (independent of the
// bridge's debug flag) so we can tell whether the state was persisted by
// the LMS and returned. This is the key signal for diagnosing restore:
// • "suspend_data = 0 chars" → LMS did not persist it
// • "contains Doenet state (dz): true" → persisted; look downstream
function findSCORMAPI() {
function scan(w) {
var hops = 0;
while (w && hops++ < 12) {
if (w.API_1484_11) return { api: w.API_1484_11, get: 'GetValue', err: 'GetLastError' };
if (w.API) return { api: w.API, get: 'LMSGetValue', err: 'LMSGetLastError' };
if (w.parent === w) break;
try { w = w.parent; } catch (e) { break; } // cross-origin guard
}
return null;
}
var found = scan(window);
if (!found) { try { found = window.top && scan(window.top); } catch (e) {} }
if (!found) { try { found = window.opener && scan(window.opener); } catch (e) {} }
return found;
}
window.addEventListener('load', function () {
// Delay so the bridge has discovered the API and Initialized the session.
setTimeout(function () {
var f = findSCORMAPI();
if (!f) { console.log('[DOENET-SIZE-PROBE] launch: no SCORM API reachable from probe.'); return; }
var sd = '';
try { sd = f.api[f.get]('cmi.suspend_data') || ''; } catch (e) {
console.log('[DOENET-SIZE-PROBE] launch: suspend_data read threw', e); return;
}
var hasDz = false, dzLen = 0;
try { var o = JSON.parse(sd); hasDz = !!(o && o.dz); dzLen = hasDz ? o.dz.length : 0; } catch (e) {}
console.log('[DOENET-SIZE-PROBE] launch: suspend_data = ' + sd.length +
' chars; contains Doenet state (dz): ' + hasDz +
(hasDz ? ' (' + dzLen + ' compressed chars)' : ''));
}, 2000);
});

// ── RESIZE PROBE: is the inner activity iframe actually growing, and is
// the scrollbar ours or the LMS player's? ──────────────────────────────
// Logs each lti.frameResize Doenet emits, the height lti_iframe_resizer.js
// then applied to the activity iframe, and whether index.html overflows
// the box the LMS gave it. The decisive signal is the last clause:
// • "fits viewport" → our iframe holds it; no scrollbar from us
// • "OUTER frame overflows" → index.html is taller than the space the
// LMS player allotted, so the scrollbar is
// the LMS player's (not fixable in the SCO)
window.addEventListener('message', function (event) {
var data = event.data;
// lti_iframe_resizer.js accepts a JSON-string form too; mirror that.
if (typeof data === 'string' && /lti\.frameResize/.test(data)) {
try { data = JSON.parse(data); } catch (e) { return; }
}
if (!data || typeof data !== 'object' || data.subject !== 'lti.frameResize') return;
var reported = data.height;
// Defer so lti_iframe_resizer.js — registered later, so it runs after
// this listener — has applied the height before we read it back.
setTimeout(function () {
var frame = document.querySelector('[data-component="doenet"] iframe');
var applied = frame ? Math.round(frame.getBoundingClientRect().height) : null;
var docH = document.documentElement.scrollHeight;
var viewH = window.innerHeight;
var overflow = docH > viewH + 1; // +1 absorbs sub-pixel rounding
console.log('[DOENET-RESIZE-PROBE] lti.frameResize height=' + reported +
'; activity iframe now ' + applied + 'px; page ' + docH +
'px vs viewport ' + viewH + 'px → ' +
(overflow
? 'OUTER frame overflows (scrollbar is the LMS player, not our iframe)'
: 'fits viewport'));
}, 0);
});
})();
</script>
Loading
Loading