SCORM 1.2 packager
packageScorm12 consumes a built static site + a CoursePackage descriptor
and produces a SCORM 1.2 zip ready for LMS import. The implementation lives
under packages/packagers/src/scorm12/.
Minimum usageโ
import { packageScorm12 } from '@lernkit/packagers';
import { writeFile } from 'node:fs/promises';
const result = await packageScorm12({
metadata: {
courseId: 'rf-training',
title: 'Robot Framework Training',
version: '1.0.0',
language: 'en',
singleSco: true,
entryLessonId: 'overview',
},
lessons: [
{ id: 'overview', title: 'Overview', href: 'rf-training/index.html',
assets: ['_astro/index.css', '_astro/page.js'] },
{ id: 'install', title: 'Install Python', href: 'rf-training/section-1/1-1-install-python/index.html',
assets: [/* shared + per-lesson assets */] },
// ...
],
distDir: './apps/docs/dist',
});
await writeFile(`./out/${result.filename}`, result.zip);
What goes in the zipโ
The packager produces this layout, regardless of how many lessons you have:
my-course-1.0.0-scorm12.zip
โโโ imsmanifest.xml # at zip root, non-negotiable
โโโ metadata.xml # external LOM, referenced via <adlcp:location>
โโโ adlcp_rootv1p2.xsd # ADL Content Packaging schema
โโโ ims_xml.xsd # supporting XML base types
โโโ imscp_rootv1p1p2.xsd # IMS Content Packaging
โโโ imsmd_rootv1p2p1.xsd # IMS Learning Object Metadata
โโโ lernkit-runtime/scorm12.js # in-browser SCORM API bridge
โโโ _astro/ # shared bundle (CSS / JS)
โโโ <course-root>/<lesson-id>/index.html # one per lesson
Per research ยง3.2 and the strict-LMS observations baked into the manifest shape:
- No
__MACOSX/, no.DS_Store, noThumbs.db, no wrapper directory. - No JSZip-synthesised directory entries (some Java importers reject these).
- All four ADL CAM XSDs co-resident with
imsmanifest.xmlso strict validators that followxsi:schemaLocationsucceed. - LOM lives in a sibling
metadata.xmlreferenced via<adlcp:location>, not inline under the manifest's<metadata>(the inline-LOM shape is rejected by some application-profile-strict importers). - Each SCO HTML has the runtime
<script>injected automatically with the right relative depth.
Configurationโ
CourseMetadataโ
| Field | Type | Required | Notes |
|---|---|---|---|
courseId | string | yes | Becomes the <manifest identifier>; kebab-case ASCII recommended |
title | string | yes | Shown in LMS catalog |
description | string | no | Up to ~2000 chars for LMS compat |
version | string | yes | Semver; bumping signals re-publish |
language | string | yes | ISO 639-1 (e.g. 'en') |
organization | Organization | no | { name, identifier? } |
objectives | readonly string[] | no | Currently unused by the packager; reserved |
masteryScore | number | no | [0..1]. In singleSco mode emits <adlcp:masteryscore> on the single item |
estimatedMinutes | number | no | Currently unused; reserved |
singleSco | boolean | no | true โ single-SCO topology (recommended). Default: false |
entryLessonId | string | no | Required when singleSco: true; matches a Lesson.id |
Lessonโ
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable identifier; becomes the SCO/item ID in the manifest |
title | string | yes | Sidebar / TOC label |
href | string | yes | Path to the lesson's entry HTML, relative to distDir |
assets | readonly string[] | yes | Files this lesson references |
masteryScore | number | no | Per-lesson opt-in; in multi-SCO mode emits <adlcp:masteryscore> |
PackagerOptionsโ
| Field | Type | Notes |
|---|---|---|
filter | (path: string) => boolean | Omit files from the zip (e.g., source maps, .astro/) |
What the packager doesโ
- Manifest: renders
imsmanifest.xmlfrom the Nunjucks template; emitsmetadata.xmlseparately. - XSDs: copies the four ADL CAM XSDs into the zip root (vendored under
packages/packagers/src/scorm12/schemas/). - Runtime: copies
lernkit-runtime/scorm12.jsinto the zip and injects<script src="โฆ/lernkit-runtime/scorm12.js"></script>into the head of every lesson HTML on the way in (depth-aware relative path). - HTML rewrite: root-absolute attribute values
(
href="/X",src="/X",component-url="/X",renderer-url="/X",before-hydration-url="/X") get rewritten to depth-prefixed relative paths or stripped (out-of-bundle). Already-relative directory-style links (Starlight prev/next nav) getindex.htmlsuffixed. - JS bundle rewrite:
"/_astro/<rest>"literals in_astro/*.jsget rewritten to"./<rest>"so worker URLs resolve against the importing module's URL. - ClientRouter cross-depth handling:
addAstroTransitionPersistaddsdata-astro-transition-persist="<kind>:<basename>"to every stylesheet, module preload, module script, and the runtime script.harmoniseStylesheetsensures every lesson references the union of stylesheets used anywhere in the course (the persist match needs the same id in old and new docs to fire).inlineStylesheetsinlines_astro/ec.*.cssas a<style>block on every page โ some LMSes bypass the persist match for this specific chunk; inlining removes the URL-resolution surface entirely.
What the packager doesn't doโ
- Generate the static site. That's Astro/Starlight's job; the packager
consumes
distDir. - Bundle the Pyodide runtime. The
apps/docs/scripts/package-scorm12.mjswrapper bundles it underpyodide/whenINCLUDE_PYODIDE_RUNTIME=1is set; the core packager doesn't know about Pyodide. - Validate semver.
metadata.versionis a string; you choose the convention.
Where to go nextโ
- Topology โ single-SCO vs multi-SCO.
- LMS portability โ the rewrite cascade in detail.
- API reference โ generated from TSDoc.