Context
The adapt_framework build is orchestrated by Grunt, invoked from the AAT via exec('npx grunt server-build:...') as a subprocess. This causes:
- False build failures: stderr output (e.g. browserslist warnings) is treated as fatal errors
- Slow builds: ~60s per course, with ~4s wasted on subprocess startup (Node + npx + grunt loading), sequential task execution when LESS/Handlebars/JS could run in parallel, and a full ~50MB framework directory copy per build
- No programmatic API: The AAT must shell out through adapt-cli to grunt, making caching, error handling, and integration unnecessarily complex
Key finding: most build output is not course-specific
| Build output |
Determined by |
Course-specific? |
adapt.min.js (Rollup bundle) |
Plugin set only |
No |
templates.js (Handlebars) |
Plugin set only |
No |
adapt.css (LESS) |
Theme + menu |
No |
build.min.js (metadata) |
Plugin set only |
No |
| Plugin assets/fonts/libraries/HTML |
Plugin set + theme |
No |
| Course JSON files |
Course content |
Yes |
| Course assets (images/media) |
Course content |
Yes |
| HTML/XML replace output |
Course + config data |
Yes |
Phase 1: Pre-built Compilation Cache for Previews
Goal: Eliminate ~55s of compilation from every preview build by caching grunt build output and reusing it across courses. No changes to adapt_framework needed.
Design constraints
- Cache logic must be portable to adapt-cli later
- Use adapt-cli's existing
Project class where possible
- Only add new code for logic that doesn't exist or currently shells out to grunt
- Schema defaults are already handled by the jsonschema module at DB save time — no build-time reapplication needed
Cache strategy — two-tier, keyed by what determines the output
| Artifact |
Cache key |
Invalidation trigger |
| JS bundle, templates, build.min.js, plugin assets/fonts/libraries |
pluginHash (hash of all plugin names+versions) |
Plugin or framework change |
| CSS (adapt.css + source map) |
pluginHash + theme + menu |
Plugin, framework, theme, or menu change |
pluginHash is a content hash derived from sorted plugin name+version pairs (via adapt-cli's Project.getInstalledDependencies()), not the framework semver string. This catches any plugin change automatically.
Cache directory layout
{buildDir}/prebuilt-cache/
{pluginHash}/ ← shared artifacts
adapt/js/adapt.min.js
adapt/js/adapt.min.js.map
adapt/js/build.min.js
templates.js
assets/
libraries/
index.html ← with @@placeholders still intact
...
{pluginHash}_{theme}_{menu}/ ← CSS artifacts
adapt/css/adapt.css
adapt/css/adapt.css.map
Notes:
index.html is cached before @@ replacement so it retains placeholders. This avoids caching a file that needs per-course modification.
- Cache lives under the configured temp/build dir for easy cleanup and disk space management.
- Parallel safety: On cache miss,
populateCache writes to a temp dir first, then atomically renames into place. Concurrent misses that race will each produce identical content; last rename wins. On cache hit, each build copies into its own isolated timestamped build dir — no contention.
Build paths
Cache MISS (first build or after invalidation):
- Run full grunt build via
AdaptCli.buildCourse() as today
- After grunt completes, extract shared artifacts into cache dirs (index.html saved with @@placeholders intact)
- Continue with normal build flow
Cache HIT (subsequent preview builds):
- Copy cached shared artifacts + CSS to build dir
- Write course JSON (already in memory from
loadCourseData())
- Copy course assets (streams from storage)
- Generate language manifest from known file list
- Apply
@@ replacements in index.html using in-memory config data
- Done — skip
AdaptCli.buildCourse() entirely
In-memory data flow (cache hit path)
On cache hit, grunt is skipped entirely. Post-processing is minimal:
- Schema defaults: NOT needed — jsonschema module applies defaults at DB save time
- Language manifest: Trivial ~5-line utility listing JSON filenames written to the language dir
- Replace patterns: Small utility applies 4
@@ substitutions in index.html (@@config._defaultLanguage, @@config._defaultDirection, @@build.type, @@build.timestamp) using config data already in memory
The only required filesystem writes on cache hit are: cached artifact copy, final course JSON, language manifest, course assets, modified index.html.
Implementation Step 1: New utility — lib/utils/computePluginHash.js
Computes a deterministic hash from the installed plugin set. Uses adapt-cli's Project class.
import { createHash } from 'node:crypto'
import Project from 'adapt-cli/lib/integration/Project.js'
export function computePluginHash (frameworkDir) {
const project = new Project({ cwd: frameworkDir })
const deps = project.getInstalledDependencies() // { name: version, ... }
const sorted = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))
return createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 16)
}
Test: tests/utils-computePluginHash.spec.js — table-driven with mock project dirs.
Implementation Step 2: New utility — lib/utils/prebuiltCache.js
Portable cache management (no AAT dependencies). Functions:
export function getCachePaths (cacheRoot, pluginHash, theme, menu)
// Returns { sharedDir, cssDir } paths
export async function hasCachedBuild (cacheRoot, pluginHash, theme, menu)
// Returns boolean — checks both shared + CSS dirs exist
export async function populateCache (buildOutputDir, cacheRoot, pluginHash, theme, menu)
// After a full grunt build, copies shared artifacts into cache dirs
// Shared: adapt/js/*, templates.js, plugin assets/*, libraries/*, index.html (with @@placeholders)
// CSS: adapt/css/*
// Uses atomic rename: writes to temp dir first, then renames into place (parallel-safe)
// NOTE: Must save index.html BEFORE replace ran, or restore placeholders from source template
export async function restoreFromCache (cacheRoot, pluginHash, theme, menu, destDir)
// Copies cached shared + CSS artifacts to destDir
export function invalidateCache (cacheRoot)
// Removes entire prebuilt-cache directory (called on plugin/framework change)
Test: tests/utils-prebuiltCache.spec.js — test populate/restore/invalidate with temp dirs.
Implementation Step 3: New utility — lib/utils/generateLanguageManifest.js
Generates the language_data_manifest.js file listing JSON filenames in a language dir. The framework runtime reads this to know which files to fetch.
export function generateLanguageManifest (jsonFileNames) {
return jsonFileNames.filter(f => f !== 'language_data_manifest.js' && f !== 'assets.json')
}
~5 lines. Input is the list of filenames we just wrote (known in memory from this.courseData), so no filesystem glob needed.
Test: tests/utils-generateLanguageManifest.spec.js
Implementation Step 4: New utility — lib/utils/applyBuildReplacements.js
Applies @@ placeholder substitutions in index.html using in-memory data. Only 4 replacements needed:
export async function applyBuildReplacements (buildDir, { defaultLanguage, defaultDirection, buildType, timestamp }) {
// Read index.html from buildDir
// Replace @@config._defaultLanguage → defaultLanguage
// Replace @@config._defaultDirection → defaultDirection
// Replace @@build.type → buildType (e.g. 'development')
// Replace @@build.timestamp → timestamp
// Write back
}
~20 lines total. Values come from this.courseData.config.data (already in memory) and build metadata.
Test: tests/utils-applyBuildReplacements.spec.js — provide template with placeholders, verify substitution.
Implementation Step 5: Modify AdaptFrameworkModule.js
Add cache management and invalidation hooks.
// In init(), after framework install:
const contentplugin = await this.app.waitForModule('contentplugin')
contentplugin.postInsertHook.tap(() => this.invalidatePrebuiltCache())
contentplugin.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
contentplugin.postDeleteHook.tap(() => this.invalidatePrebuiltCache())
this.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
// New method:
invalidatePrebuiltCache () {
const cacheRoot = path.join(this.getConfig('buildDir'), 'prebuilt-cache')
invalidateCache(cacheRoot)
this._pluginHash = null
}
// New getter (cached):
getPluginHash () {
if (!this._pluginHash) {
this._pluginHash = computePluginHash(this.getConfig('frameworkDir'))
}
return this._pluginHash
}
Implementation Step 6: Modify AdaptFrameworkBuild.js
Add cache-hit fast path for preview builds.
In build() method, after loadCourseData() and before the existing copyFrameworkSource() + AdaptCli.buildCourse() block:
// Check for cached preview build
if (this.isPreview) {
const framework = await App.instance.waitForModule('adaptframework')
const cacheRoot = path.join(framework.getConfig('buildDir'), 'prebuilt-cache')
const pluginHash = framework.getPluginHash()
const theme = this.courseData.config.data._theme
const menu = this.courseData.config.data._menu
if (await hasCachedBuild(cacheRoot, pluginHash, theme, menu)) {
// CACHE HIT — fast path
await restoreFromCache(cacheRoot, pluginHash, theme, menu, this.buildDir)
await this.copyAssets()
await this.preBuildHook.invoke(this)
await this.writeContentJson()
// Write language manifest
const langDir = this.courseData.course.dir
const manifest = generateLanguageManifest(
Object.values(this.courseData).filter(d => d.dir === langDir).map(d => d.fileName)
)
await writeJson(path.join(langDir, 'language_data_manifest.js'), manifest)
// Apply @@replacements in index.html
await applyBuildReplacements(this.buildDir, {
defaultLanguage: this.courseData.config.data._defaultLanguage ?? 'en',
defaultDirection: this.courseData.config.data._defaultDirection ?? 'ltr',
buildType: 'development',
timestamp: Date.now()
})
await this.postBuildHook.invoke(this)
this.location = path.join(this.dir, 'build')
this.buildData = await this.recordBuildAttempt()
return this
}
}
// Existing flow (cache miss or non-preview)
// ... copyFrameworkSource, AdaptCli.buildCourse(), etc.
// After successful grunt build for preview, populate cache
if (this.isPreview) {
await populateCache(this.buildDir, cacheRoot, pluginHash, theme, menu)
}
Implementation Step 7: Verify copyFrameworkSource.js
For cache-population builds, we include ALL plugins (no filtering). Check whether passing no enabledPlugins (or all plugins) to copyFrameworkSource() works. Current filter logic skips plugins not in the list — we need to ensure all are included for the initial build.
Files to modify
| File |
Change |
lib/AdaptFrameworkModule.js |
Cache invalidation hooks, getPluginHash(), invalidatePrebuiltCache() |
lib/AdaptFrameworkBuild.js |
Cache-hit fast path in build(), cache population after grunt |
New: lib/utils/computePluginHash.js |
Deterministic hash from plugin set (uses adapt-cli Project) |
New: lib/utils/prebuiltCache.js |
Cache CRUD operations (portable, no AAT deps) |
New: lib/utils/generateLanguageManifest.js |
Language manifest generation (~5 lines) |
New: lib/utils/applyBuildReplacements.js |
@@ placeholder substitution in index.html |
lib/utils.js |
Barrel export updates |
New: tests/utils-computePluginHash.spec.js |
|
New: tests/utils-prebuiltCache.spec.js |
|
New: tests/utils-generateLanguageManifest.spec.js |
|
New: tests/utils-applyBuildReplacements.spec.js |
|
adapt-cli portability
The new utilities are designed to be moved to adapt-cli later:
computePluginHash — already uses adapt-cli's Project class
prebuiltCache — pure filesystem operations, no AAT dependencies
generateLanguageManifest — pure function, no dependencies
applyBuildReplacements — standalone function, plain data inputs, no AAT dependencies
When later phases extract grunt tasks into a JS API in adapt_framework, these utilities can be consumed by adapt-cli's buildCourse() to offer the same caching for standalone CLI builds.
Expected preview build times
| Step |
Time |
| Copy pre-built artifacts |
~0.5s |
| Write course JSON |
~0.1s |
| Copy course assets |
~0.5-2s (depends on asset count) |
| Language manifest + replace |
~0.1s |
| Total |
~1.5-3s |
Down from ~60s.
Verification
- Unit tests for all new utilities
- Cache miss: first preview build takes ~60s (same as today) and populates cache
- Cache hit: second preview of same or different course takes ~2-5s
- Cache invalidation: install/update/remove a plugin → next preview is a cache miss
- Publish builds: unaffected (always run full grunt)
- Diff verification:
diff -r between cache-hit and full-grunt build output should be identical (except timestamp)
- Multi-language builds (which call
buildCourse() multiple times with outputDir) work with cache
Resolved questions
-
Schema defaults: NOT needed at build time. The jsonschema module applies defaults when data is saved to the DB via Schema.validate() with useDefaults: true. Data already has defaults by the time the build reads it.
-
Language manifests: Trivial utility (~5 lines) kept in adaptframework since it's part of the standard build flow. Lists JSON filenames in a language dir, excluding manifest itself and assets.json.
-
Replace pattern format: Uses @@path.to.value dot-notation. Template is src/core/required/index.html. Only 4 placeholders: @@config._defaultLanguage, @@config._defaultDirection, @@build.type, @@build.timestamp. Simple string replacement, no complex pattern engine needed.
-
Plugin scripts (adaptpostcopy/adaptpostbuild): Only adapt-contrib-spoor uses adaptpostcopy — copies SCORM files based on config. Only relevant for publish/export builds. Since we only cache for preview builds, this is not a concern.
Future Phases (out of scope for this issue)
Phase 2: Extract Build Functions from Grunt (adapt_framework)
Extract each grunt task into a standalone async function with a JS API in adapt_framework/lib/build/. Keep grunt tasks intact during transition.
Phase 3: BuildPipeline + AAT Direct Integration
Replace AdaptCli.buildCourse() subprocess with direct BuildPipeline call. Eliminates subprocess overhead (~2-4s), enables parallel LESS + HBS + JS (~20-30% speedup), eliminates framework directory copy.
Phase 4: Advanced Optimisations
- Persistent in-memory Rollup cache across builds
- Concurrent build deduplication (multiple languages share compilation)
Phase 5: Remove Grunt (adapt_framework)
Delete Gruntfile.js and grunt/ directory, remove ~15 grunt dependencies, update adapt-cli to use JS API.
Expected Speed Improvements Summary
| Optimisation |
Saving |
Phase |
| Pre-built cache for previews (cache hit) |
~60s → ~1.5-3s |
1 |
| No subprocess (npx + grunt startup) |
~2-4s on publish |
3 |
| Parallel LESS + HBS + JS |
~20-30% on publish |
3 |
| No framework dir copy |
~1-5s on all builds |
3 |
| In-memory Rollup cache (cache miss) |
~0.5-2s |
4 |
| Concurrent build dedup |
~N-1 redundant passes |
4 |
Context
The adapt_framework build is orchestrated by Grunt, invoked from the AAT via
exec('npx grunt server-build:...')as a subprocess. This causes:Key finding: most build output is not course-specific
adapt.min.js(Rollup bundle)templates.js(Handlebars)adapt.css(LESS)build.min.js(metadata)Phase 1: Pre-built Compilation Cache for Previews
Goal: Eliminate ~55s of compilation from every preview build by caching grunt build output and reusing it across courses. No changes to adapt_framework needed.
Design constraints
Projectclass where possibleCache strategy — two-tier, keyed by what determines the output
pluginHash(hash of all plugin names+versions)pluginHash + theme + menupluginHashis a content hash derived from sorted plugin name+version pairs (via adapt-cli'sProject.getInstalledDependencies()), not the framework semver string. This catches any plugin change automatically.Cache directory layout
Notes:
index.htmlis cached before@@replacement so it retains placeholders. This avoids caching a file that needs per-course modification.populateCachewrites to a temp dir first, then atomically renames into place. Concurrent misses that race will each produce identical content; last rename wins. On cache hit, each build copies into its own isolated timestamped build dir — no contention.Build paths
Cache MISS (first build or after invalidation):
AdaptCli.buildCourse()as todayCache HIT (subsequent preview builds):
loadCourseData())@@replacements in index.html using in-memory config dataAdaptCli.buildCourse()entirelyIn-memory data flow (cache hit path)
On cache hit, grunt is skipped entirely. Post-processing is minimal:
@@substitutions in index.html (@@config._defaultLanguage,@@config._defaultDirection,@@build.type,@@build.timestamp) using config data already in memoryThe only required filesystem writes on cache hit are: cached artifact copy, final course JSON, language manifest, course assets, modified index.html.
Implementation Step 1: New utility —
lib/utils/computePluginHash.jsComputes a deterministic hash from the installed plugin set. Uses adapt-cli's
Projectclass.Test:
tests/utils-computePluginHash.spec.js— table-driven with mock project dirs.Implementation Step 2: New utility —
lib/utils/prebuiltCache.jsPortable cache management (no AAT dependencies). Functions:
Test:
tests/utils-prebuiltCache.spec.js— test populate/restore/invalidate with temp dirs.Implementation Step 3: New utility —
lib/utils/generateLanguageManifest.jsGenerates the
language_data_manifest.jsfile listing JSON filenames in a language dir. The framework runtime reads this to know which files to fetch.~5 lines. Input is the list of filenames we just wrote (known in memory from
this.courseData), so no filesystem glob needed.Test:
tests/utils-generateLanguageManifest.spec.jsImplementation Step 4: New utility —
lib/utils/applyBuildReplacements.jsApplies
@@placeholder substitutions in index.html using in-memory data. Only 4 replacements needed:~20 lines total. Values come from
this.courseData.config.data(already in memory) and build metadata.Test:
tests/utils-applyBuildReplacements.spec.js— provide template with placeholders, verify substitution.Implementation Step 5: Modify
AdaptFrameworkModule.jsAdd cache management and invalidation hooks.
Implementation Step 6: Modify
AdaptFrameworkBuild.jsAdd cache-hit fast path for preview builds.
In
build()method, afterloadCourseData()and before the existingcopyFrameworkSource()+AdaptCli.buildCourse()block:Implementation Step 7: Verify
copyFrameworkSource.jsFor cache-population builds, we include ALL plugins (no filtering). Check whether passing no
enabledPlugins(or all plugins) tocopyFrameworkSource()works. Current filter logic skips plugins not in the list — we need to ensure all are included for the initial build.Files to modify
lib/AdaptFrameworkModule.jsgetPluginHash(),invalidatePrebuiltCache()lib/AdaptFrameworkBuild.jsbuild(), cache population after gruntlib/utils/computePluginHash.jsProject)lib/utils/prebuiltCache.jslib/utils/generateLanguageManifest.jslib/utils/applyBuildReplacements.js@@placeholder substitution in index.htmllib/utils.jstests/utils-computePluginHash.spec.jstests/utils-prebuiltCache.spec.jstests/utils-generateLanguageManifest.spec.jstests/utils-applyBuildReplacements.spec.jsadapt-cli portability
The new utilities are designed to be moved to adapt-cli later:
computePluginHash— already uses adapt-cli'sProjectclassprebuiltCache— pure filesystem operations, no AAT dependenciesgenerateLanguageManifest— pure function, no dependenciesapplyBuildReplacements— standalone function, plain data inputs, no AAT dependenciesWhen later phases extract grunt tasks into a JS API in adapt_framework, these utilities can be consumed by adapt-cli's
buildCourse()to offer the same caching for standalone CLI builds.Expected preview build times
Down from ~60s.
Verification
diff -rbetween cache-hit and full-grunt build output should be identical (except timestamp)buildCourse()multiple times withoutputDir) work with cacheResolved questions
Schema defaults: NOT needed at build time. The jsonschema module applies defaults when data is saved to the DB via
Schema.validate()withuseDefaults: true. Data already has defaults by the time the build reads it.Language manifests: Trivial utility (~5 lines) kept in adaptframework since it's part of the standard build flow. Lists JSON filenames in a language dir, excluding manifest itself and assets.json.
Replace pattern format: Uses
@@path.to.valuedot-notation. Template issrc/core/required/index.html. Only 4 placeholders:@@config._defaultLanguage,@@config._defaultDirection,@@build.type,@@build.timestamp. Simple string replacement, no complex pattern engine needed.Plugin scripts (adaptpostcopy/adaptpostbuild): Only
adapt-contrib-spoorusesadaptpostcopy— copies SCORM files based on config. Only relevant for publish/export builds. Since we only cache for preview builds, this is not a concern.Future Phases (out of scope for this issue)
Phase 2: Extract Build Functions from Grunt (adapt_framework)
Extract each grunt task into a standalone async function with a JS API in
adapt_framework/lib/build/. Keep grunt tasks intact during transition.Phase 3: BuildPipeline + AAT Direct Integration
Replace
AdaptCli.buildCourse()subprocess with directBuildPipelinecall. Eliminates subprocess overhead (~2-4s), enables parallel LESS + HBS + JS (~20-30% speedup), eliminates framework directory copy.Phase 4: Advanced Optimisations
Phase 5: Remove Grunt (adapt_framework)
Delete Gruntfile.js and grunt/ directory, remove ~15 grunt dependencies, update adapt-cli to use JS API.
Expected Speed Improvements Summary