Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ac5b8b
Add scorm-export package: wrap a DoenetML activity as a SCORM 2004 pa…
cqnykamp Jul 23, 2026
829feb9
scorm-export: consume lz-string as an npm dependency instead of vendo…
cqnykamp Jul 23, 2026
984f7e7
scorm-export: gate the size probe behind --debug instead of shipping it
cqnykamp Jul 23, 2026
ac35b8d
scorm-export: add resize logging to the --debug probe
cqnykamp Jul 23, 2026
a20d40e
Merge branch 'main' into feat-scorm-export
cqnykamp Jul 23, 2026
e9faade
Merge branch 'main' into feat-scorm-export
cqnykamp Aug 14, 2026
54c0d00
scorm-export: apply prettier formatting to templates and debug probe
cqnykamp Aug 15, 2026
5c5ddc3
Add a "Download SCORM" button to the activity viewer
cqnykamp Aug 15, 2026
3c041ce
scorm-export: don't destroy saved state when it outgrows suspend_data
cqnykamp Aug 17, 2026
8034480
scorm-export: test the package builder and the LMS runtime
cqnykamp Aug 17, 2026
b5fe12b
scorm-export: test against a real SCORM runtime instead of our own mock
cqnykamp Aug 17, 2026
3c9a018
scorm-export: parse CLI args with node:util instead of by hand
cqnykamp Aug 17, 2026
d3df1c4
scorm-export: validate the manifest against ADL's official schemas
cqnykamp Aug 17, 2026
2becc5a
Store the vendored SCORM schemas byte-for-byte
cqnykamp Aug 17, 2026
d0ad331
scorm-export: load Node-side assets from one place
cqnykamp Aug 17, 2026
057d4c6
scorm-export: the layout table lists three callers, not two
cqnykamp Aug 17, 2026
8ff74ec
scorm-export: drop the submit button, complete on page exit
cqnykamp Aug 17, 2026
71334d0
scorm-export: ship the SCORM schemas inside the package
cqnykamp Aug 17, 2026
b39b7b3
scorm-export: group vendored files by origin under vendor/
cqnykamp Aug 17, 2026
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
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read -r f; [ -n \"$f\" ] && npx prettier --write --ignore-unknown \"$f\"; } 2>/dev/null || true"
"command": "jq -r '.tool_input.file_path // empty' | { read -r f; [ -n \"$f\" ] && cd \"${CLAUDE_PROJECT_DIR:-$(git -C \"$(dirname \"$f\")\" rev-parse --show-toplevel 2>/dev/null || echo .)}\" && npx prettier --write --ignore-unknown \"$f\"; } 2>/dev/null || true"
}
]
}
Expand Down
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# Shell scripts must keep LF endings (they run inside Linux containers).
*.sh text eol=lf

# Vendored schema files are stored byte-for-byte as published, so that a future
# re-copy from upstream diffs cleanly instead of showing every line as changed.
# See packages/scorm-export/vendor/VENDORED.md.
packages/scorm-export/vendor/scorm/*.xsd -text
6 changes: 6 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ jobs:
- name: Test shared package
run: npm test --workspace @doenet-tools/shared

# -----------------------------
# SCORM export tests
# -----------------------------
- name: Test scorm-export package
run: npm test --workspace @doenet-tools/scorm-export

# -----------------------------
# Start MySQL + Test server
# -----------------------------
Expand Down
7 changes: 6 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ 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/pretext/*.js
packages/scorm-export/vendor/scorm/*.xsd
2 changes: 2 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@chakra-ui/icons": "^2.2.4",
"@chakra-ui/react": "^2.10.9",
"@doenet-tools/scorm-export": "*",
"@doenet-tools/shared": "*",
"@doenet/assignment-viewer": "^0.1.0-alpha-17",
"@doenet/doenetml-iframe": "^0.7.24",
Expand All @@ -38,6 +39,7 @@
"export-to-csv": "^1.4.0",
"framer-motion": "^12.25.0",
"luxon": "^3.7.1",
"lz-string": "1.5.0",
"math-expressions": "^2.0.0-alpha76",
"react": "19.2.3",
"react-dom": "19.2.3",
Expand Down
48 changes: 48 additions & 0 deletions apps/app/src/paths/ActivityViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ import {
Text,
Tooltip,
useDisclosure,
useToast,
VStack,
} from "@chakra-ui/react";
import { BsPlayBtnFill } from "react-icons/bs";
import {
MdEditNote,
MdOutlineAdd,
MdOutlineEditOff,
MdOutlineFileDownload,
MdOutlineInfo,
} from "react-icons/md";
import { useFetcher } from "react-router";
Expand Down Expand Up @@ -163,6 +165,7 @@ export function ActivityViewer() {
allLicenses.find((l) => l.code === activityData.licenseCode) ?? null;

const navigate = useNavigate();
const toast = useToast();

const infoBtnRef = useRef<HTMLButtonElement>(null);
const doenetViewerContainer = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -462,6 +465,49 @@ export function ActivityViewer() {
const contentTypeName = contentTypeToName[data.type];
const ownerName = createNameCheckCurateTag(activityData.owner!);

// Only single documents can be exported: a SCORM package is one SCO wrapping
// one DoenetML source in one iframe, so problem sets and sequences would need
// a different manifest and shell.
const scormButton =
data.type === "singleDoc" ? (
<Tooltip
hasArrow
label="Download as a SCORM package to upload to an LMS"
placement="bottom-end"
>
<Button
data-test="Download SCORM Button"
size="sm"
pr={{ base: "0px", md: "10px" }}
colorScheme="blue"
leftIcon={<MdOutlineFileDownload size={20} />}
onClick={async () => {
try {
// Loaded on demand: the exporter inlines the SCORM bridge and the
// 4th Edition schemas as strings, which nobody who only views the
// activity should have to download.
const { downloadScormPackage } = await import("../utils/scorm");
downloadScormPackage({
doenetML: data.doenetML,
title: activityData.name,
contentId: activityData.contentId,
doenetmlVersion: data.doenetmlVersion.fullVersion,
});
} catch (error) {
console.error("SCORM export failed", error);
toast({
title: "Could not create the SCORM package.",
status: "error",
isClosable: true,
});
}
}}
>
<Show above="md">SCORM</Show>
</Button>
</Tooltip>
) : null;

const { iconImage, iconColor } = getIconInfo(data.type, false);

const typeIcon = (
Expand Down Expand Up @@ -757,6 +803,8 @@ export function ActivityViewer() {
</>
)}

{scormButton}

<Tooltip
hasArrow
label={`${contentTypeName} Information`}
Expand Down
81 changes: 81 additions & 0 deletions apps/app/src/utils/scorm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Builds the "Download SCORM" zip entirely in the browser.
//
// A SCORM package is template substitution plus a zip, so there is no reason
// to round-trip through the API: @doenet-tools/scorm-export exposes the same
// pure builder that its CLI (build.mjs) uses, and we hand it the package's
// constant files as strings. Vite's `?raw` inlines them at build time, so the
// vendored copies in the package stay the single verbatim source (they are
// GPL and must not be edited — see the package's vendor/VENDORED.md).

import {
buildScormPackage,
scormSlug,
type ScormAssets,
} from "@doenet-tools/scorm-export";

import imsmanifestXml from "@doenet-tools/scorm-export/templates/imsmanifest.xml?raw";
import indexHtml from "@doenet-tools/scorm-export/templates/index.html?raw";
import activityHtml from "@doenet-tools/scorm-export/templates/activity.html?raw";
import ptxScormEventsJs from "@doenet-tools/scorm-export/vendor/pretext/ptx_scorm_events.js?raw";
import ltiIframeResizerJs from "@doenet-tools/scorm-export/vendor/pretext/lti_iframe_resizer.js?raw";
// Pinned to the same version the scorm-export package pins; index.html loads
// it under this filename to compress activity state into cmi.suspend_data.
import lzStringJs from "lz-string/libs/lz-string.min.js?raw";

import { schemaAssets } from "./scormSchemas";

const assets: ScormAssets = {
"imsmanifest.xml": imsmanifestXml,
"index.html": indexHtml,
"activity.html": activityHtml,
"ptx_scorm_events.js": ptxScormEventsJs,
"lti_iframe_resizer.js": ltiIframeResizerJs,
"lz-string.min.js": lzStringJs,
// The SCORM control documents imsmanifest.xml points at, shipped at the zip
// root so a strict validator can resolve them offline.
...schemaAssets,
};

export function downloadScormPackage({
doenetML,
title,
contentId,
doenetmlVersion,
}: {
doenetML: string;
title: string;
/**
* The activity's contentId, used as the SCORM activity id. It keys the
* student's score and saved state in the LMS and in localStorage, so it has
* to survive renames — which is exactly why the id is the contentId and not
* a slug of the title.
*/
contentId: string;
doenetmlVersion: string;
}) {
const { name, zip } = buildScormPackage(assets, {
doenetML,
title,
id: contentId,
// Pin the viewer the activity was authored against, so an uploaded package
// does not drift under the LMS the way "latest" would.
doenetVersion: doenetmlVersion,
});

// The id makes a stable but unreadable filename (it is a hash), so name the
// download after the title instead; only the in-package id must be stable.
const titleSlug = scormSlug(title);
const filename = titleSlug ? `${titleSlug}-scorm.zip` : name;

const url = URL.createObjectURL(
new Blob([zip as BlobPart], { type: "application/zip" }),
);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
// Revoking synchronously can race the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
44 changes: 44 additions & 0 deletions apps/app/src/utils/scormSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// The SCORM 2004 4th Edition control documents, inlined for the browser build.
//
// imsmanifest.xml names five of these in its xsi:schemaLocation and a content
// package traditionally carries them at its root, so they ship inside the zip
// (see the scorm-export package's vendor/scorm/VENDORED.md). Listed one by one
// rather than globbed because they come from a workspace package, where Vite's
// import.meta.glob does not reliably reach.
//
// This is ~88 KB of string, which is why the export path is loaded on demand —
// see downloadScormPackage's caller.

import imscp from "@doenet-tools/scorm-export/vendor/scorm/imscp_v1p1.xsd?raw";
import adlcp from "@doenet-tools/scorm-export/vendor/scorm/adlcp_v1p3.xsd?raw";
import adlseq from "@doenet-tools/scorm-export/vendor/scorm/adlseq_v1p3.xsd?raw";
import adlnav from "@doenet-tools/scorm-export/vendor/scorm/adlnav_v1p3.xsd?raw";
import imsss from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0.xsd?raw";
import imsssAuxresource from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0auxresource.xsd?raw";
import imsssControl from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0control.xsd?raw";
import imsssDelivery from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0delivery.xsd?raw";
import imsssLimit from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0limit.xsd?raw";
import imsssObjective from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0objective.xsd?raw";
import imsssRandom from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0random.xsd?raw";
import imsssRollup from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0rollup.xsd?raw";
import imsssSeqrule from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0seqrule.xsd?raw";
import imsssUtil from "@doenet-tools/scorm-export/vendor/scorm/imsss_v1p0util.xsd?raw";
import xml from "@doenet-tools/scorm-export/vendor/scorm/xml.xsd?raw";

export const schemaAssets: Record<string, string> = {
"imscp_v1p1.xsd": imscp,
"adlcp_v1p3.xsd": adlcp,
"adlseq_v1p3.xsd": adlseq,
"adlnav_v1p3.xsd": adlnav,
"imsss_v1p0.xsd": imsss,
"imsss_v1p0auxresource.xsd": imsssAuxresource,
"imsss_v1p0control.xsd": imsssControl,
"imsss_v1p0delivery.xsd": imsssDelivery,
"imsss_v1p0limit.xsd": imsssLimit,
"imsss_v1p0objective.xsd": imsssObjective,
"imsss_v1p0random.xsd": imsssRandom,
"imsss_v1p0rollup.xsd": imsssRollup,
"imsss_v1p0seqrule.xsd": imsssSeqrule,
"imsss_v1p0util.xsd": imsssUtil,
"xml.xsd": xml,
};
Loading
Loading