adapt-authoring-adaptframework 2.5.6 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,8 @@ import AdaptFrameworkImport from './AdaptFrameworkImport.js'
4
4
  import fs from 'node:fs/promises'
5
5
  import { getHandler, postHandler, importHandler, postUpdateHandler, getUpdateHandler } from './handlers.js'
6
6
  import { loadRouteConfig, registerRoutes } from 'adapt-authoring-server'
7
- import { runCliCommand } from './utils.js'
7
+ import { runCliCommand, readFrameworkPluginVersions, migrateExistingCourses, computePluginHash, prebuildCache } from './utils.js'
8
+ import BuildCache from './BuildCache.js'
8
9
  import path from 'node:path'
9
10
  import semver from 'semver'
10
11
 
@@ -83,6 +84,17 @@ class AdaptFrameworkModule extends AbstractModule {
83
84
 
84
85
  await this.installFramework()
85
86
 
87
+ this.app.waitForModule('contentplugin').then(contentplugin => {
88
+ contentplugin.postInsertHook.tap(() => this.invalidatePrebuiltCache())
89
+ contentplugin.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
90
+ contentplugin.postDeleteHook.tap(() => this.invalidatePrebuiltCache())
91
+ })
92
+ this.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
93
+
94
+ if (this.getConfig('prebuildCache')) {
95
+ this.prebuildCache()
96
+ }
97
+
86
98
  process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1'
87
99
 
88
100
  if (this.app.args['update-framework'] === true) {
@@ -209,6 +221,7 @@ class AdaptFrameworkModule extends AbstractModule {
209
221
  * @return {Promise}
210
222
  */
211
223
  async updateFramework (version) {
224
+ let migrationResult
212
225
  try {
213
226
  if (version) {
214
227
  this.checkVersionCompatibility(version)
@@ -216,13 +229,71 @@ class AdaptFrameworkModule extends AbstractModule {
216
229
  if (!version && this.targetVersionRange) {
217
230
  version = await this.getLatestVersion()
218
231
  }
232
+ const fromPlugins = await readFrameworkPluginVersions(this.path)
219
233
  await this.runCliCommand('updateFramework', { version })
220
234
  this._version = await this.runCliCommand('getCurrentFrameworkVersion')
235
+ const toPlugins = await readFrameworkPluginVersions(this.path)
236
+ migrationResult = await migrateExistingCourses({ fromPlugins, toPlugins, frameworkDir: this.path })
221
237
  } catch (e) {
222
238
  this.log('error', `failed to update framework, ${e.message}`)
223
239
  throw e.statusCode ? e : this.app.errors.FW_UPDATE_FAILED.setData({ reason: e.message })
224
240
  }
225
- this.postUpdateHook.invoke()
241
+ await this.postUpdateHook.invoke()
242
+ return migrationResult
243
+ }
244
+
245
+ /**
246
+ * Returns a cached plugin hash, computing it on first call
247
+ * @return {Promise<String>}
248
+ */
249
+ async getPluginHash () {
250
+ if (!this._pluginHash) {
251
+ this._pluginHash = await computePluginHash(this.path)
252
+ }
253
+ return this._pluginHash
254
+ }
255
+
256
+ /**
257
+ * Invalidates the prebuilt compilation cache and optionally
258
+ * triggers an eager rebuild of the shared cache in the background
259
+ */
260
+ async invalidatePrebuiltCache () {
261
+ this._pluginHash = null
262
+
263
+ try {
264
+ await new BuildCache(path.join(this.getConfig('buildDir'), 'prebuilt-cache')).invalidate()
265
+ } catch (e) {
266
+ this.log('warn', `failed to invalidate prebuilt cache: ${e.message}`)
267
+ }
268
+
269
+ if (this.getConfig('prebuildCache')) {
270
+ this.prebuildCache()
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Eagerly rebuilds the prebuilt cache in the background, iterating every
276
+ * (theme, menu) combination of installed plugins. Safe to call multiple
277
+ * times — if a build is already in progress, it will be reused.
278
+ * Idempotent — already-cached combos are skipped.
279
+ * @return {Promise<void>}
280
+ */
281
+ prebuildCache () {
282
+ if (this._eagerBuildPromise) {
283
+ return this._eagerBuildPromise
284
+ }
285
+ this._eagerBuildPromise = prebuildCache({
286
+ buildDir: this.getConfig('buildDir'),
287
+ frameworkDir: this.path
288
+ }).catch(e => {
289
+ this.log('warn', `eager prebuild failed: ${e.message}`)
290
+ if (e.cmd) this.log('warn', `cmd: ${e.cmd}`)
291
+ if (e.raw) this.log('warn', `output: ${e.raw}`)
292
+ if (e.stderr) this.log('warn', `stderr: ${e.stderr}`)
293
+ }).finally(() => {
294
+ this._eagerBuildPromise = null
295
+ })
296
+ return this._eagerBuildPromise
226
297
  }
227
298
 
228
299
  /**
@@ -245,7 +316,7 @@ class AdaptFrameworkModule extends AbstractModule {
245
316
  async loadSchemas () {
246
317
  const jsonschema = await this.app.waitForModule('jsonschema')
247
318
  const schemas = (await this.runCliCommand('getSchemaPaths')).filter(s => s.includes('/core/'))
248
- await Promise.all(schemas.map(s => jsonschema.registerSchema(s)))
319
+ schemas.forEach(s => jsonschema.registerSchema(s))
249
320
  }
250
321
 
251
322
  /**
@@ -265,7 +336,7 @@ class AdaptFrameworkModule extends AbstractModule {
265
336
  if (!course) {
266
337
  return
267
338
  }
268
- const shareWithUsers = course?._shareWithUsers.map(id => id.toString()) ?? []
339
+ const shareWithUsers = course._shareWithUsers?.map(id => id.toString()) ?? []
269
340
  const userId = req.auth.user._id.toString()
270
341
  return course.createdBy.toString() === userId || course._isShared || shareWithUsers.includes(userId)
271
342
  }
@@ -307,6 +378,18 @@ class AdaptFrameworkModule extends AbstractModule {
307
378
  this.contentMigrations.push(migration)
308
379
  }
309
380
 
381
+ /**
382
+ * Migrates content for specific courses. Called by contentplugin on plugin update.
383
+ * @param {Object} options
384
+ * @param {Array<{name: String, version: String}>} options.fromPlugins Plugin versions before the update
385
+ * @param {Array<{name: String, version: String}>} options.toPlugins Plugin versions after the update
386
+ * @param {String[]} options.courseIds Course IDs to migrate
387
+ * @returns {Promise<{migrated: Number, failed: Number, errors: Array}>}
388
+ */
389
+ async migrateCourses ({ fromPlugins, toPlugins, courseIds }) {
390
+ return migrateExistingCourses({ fromPlugins, toPlugins, frameworkDir: this.path, courseIds })
391
+ }
392
+
310
393
  /**
311
394
  * Builds a single Adapt framework course
312
395
  * @param {AdaptFrameworkBuildOptions} options
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'upath'
3
+ import { log } from './utils/log.js'
4
+
5
+ /** Build output entries that aren't cached (rebuilt per-build from course data) */
6
+ const SKIP_ENTRIES = new Set(['course'])
7
+
8
+ /**
9
+ * Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu).
10
+ * One instance per cache root; methods are stateless beyond the root path.
11
+ */
12
+ class BuildCache {
13
+ /**
14
+ * @param {String} cacheRoot Root cache directory
15
+ */
16
+ constructor (cacheRoot) {
17
+ this.cacheRoot = cacheRoot
18
+ }
19
+
20
+ /**
21
+ * @returns {String} The cache directory path for the given combo
22
+ */
23
+ getPath (pluginHash, theme, menu) {
24
+ return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}`)
25
+ }
26
+
27
+ /**
28
+ * @returns {Promise<Boolean>} Whether a cached build exists for the given combo
29
+ */
30
+ async has (pluginHash, theme, menu) {
31
+ try {
32
+ await fs.access(this.getPath(pluginHash, theme, menu))
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Copies the build output (minus per-course content) into the cache for the given combo.
41
+ * Uses a temp dir + atomic rename for parallel safety.
42
+ * @param {String} buildOutputDir The build output directory
43
+ */
44
+ async populate (buildOutputDir, pluginHash, theme, menu) {
45
+ const cacheDir = this.getPath(pluginHash, theme, menu)
46
+ await fs.mkdir(this.cacheRoot, { recursive: true })
47
+
48
+ const tmpDir = `${cacheDir}_tmp_${Date.now()}`
49
+ try {
50
+ await fs.mkdir(tmpDir, { recursive: true })
51
+ const entries = await fs.readdir(buildOutputDir)
52
+ for (const entry of entries) {
53
+ if (SKIP_ENTRIES.has(entry)) continue
54
+ await copyEntry(path.join(buildOutputDir, entry), path.join(tmpDir, entry))
55
+ }
56
+ await safeRename(tmpDir, cacheDir)
57
+ log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
58
+ } catch (e) {
59
+ await fs.rm(tmpDir, { recursive: true, force: true })
60
+ throw e
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Copies cached artifacts to a build directory.
66
+ * @param {String} destDir Destination build directory
67
+ */
68
+ async restore (pluginHash, theme, menu, destDir) {
69
+ await fs.mkdir(destDir, { recursive: true })
70
+ await fs.cp(this.getPath(pluginHash, theme, menu), destDir, { recursive: true })
71
+ log('info', 'CACHE', `restored from cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
72
+ }
73
+
74
+ /**
75
+ * Removes the entire cache root.
76
+ */
77
+ async invalidate () {
78
+ await fs.rm(this.cacheRoot, { recursive: true, force: true })
79
+ log('info', 'CACHE', 'invalidated prebuilt cache')
80
+ }
81
+ }
82
+
83
+ async function copyEntry (src, dest) {
84
+ const stat = await fs.stat(src)
85
+ if (stat.isDirectory()) {
86
+ await fs.cp(src, dest, { recursive: true })
87
+ } else {
88
+ await fs.mkdir(path.dirname(dest), { recursive: true })
89
+ await fs.copyFile(src, dest)
90
+ }
91
+ }
92
+
93
+ async function safeRename (src, dest) {
94
+ try {
95
+ await fs.rename(src, dest)
96
+ } catch (e) {
97
+ if (e.code === 'ENOTEMPTY' || e.code === 'EEXIST') {
98
+ await fs.rm(src, { recursive: true, force: true })
99
+ } else {
100
+ throw e
101
+ }
102
+ }
103
+ }
104
+
105
+ export default BuildCache
package/lib/handlers.js CHANGED
@@ -133,11 +133,12 @@ export async function postUpdateHandler (req, res, next) {
133
133
  }
134
134
  log('info', 'running framework update')
135
135
  const previousVersion = framework.version
136
- await framework.updateFramework(req.body.version)
136
+ const migrationResult = await framework.updateFramework(req.body.version)
137
137
  const currentVersion = framework.version !== previousVersion ? framework.version : undefined
138
138
  res.json({
139
139
  from: previousVersion,
140
- to: currentVersion
140
+ to: currentVersion,
141
+ migration: migrationResult
141
142
  })
142
143
  } catch (e) {
143
144
  return next(e)
@@ -0,0 +1,17 @@
1
+ const FIELDS = ['_backgroundRepeat', '_backgroundSize', '_backgroundPosition']
2
+
3
+ function clean (styles) {
4
+ if (!styles || typeof styles !== 'object') return
5
+ for (const f of FIELDS) {
6
+ if (styles[f] === '' || styles[f] === null) delete styles[f]
7
+ }
8
+ }
9
+
10
+ async function VanillaBackgroundStyles (data) {
11
+ const v = data._vanilla
12
+ if (!v || typeof v !== 'object') return
13
+ clean(v._backgroundStyles)
14
+ clean(v._pageHeader?._backgroundStyles)
15
+ }
16
+
17
+ export default VanillaBackgroundStyles
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'upath'
3
+
4
+ /**
5
+ * Applies @@placeholder substitutions in index.html
6
+ * @param {String} buildDir The build output directory
7
+ * @param {Object} data Replacement values
8
+ * @param {String} data.defaultLanguage The default language code
9
+ * @param {String} data.defaultDirection The default text direction
10
+ * @param {String} data.buildType The build type (e.g. 'development')
11
+ * @param {Number} data.timestamp The build timestamp
12
+ * @return {Promise}
13
+ */
14
+ export async function applyBuildReplacements (buildDir, { defaultLanguage, defaultDirection, buildType, timestamp }) {
15
+ const indexPath = path.join(buildDir, 'index.html')
16
+ let html = await fs.readFile(indexPath, 'utf8')
17
+ html = html
18
+ .replace(/@@config\._defaultLanguage/g, defaultLanguage)
19
+ .replace(/@@config\._defaultDirection/g, defaultDirection)
20
+ .replace(/@@build\.type/g, buildType)
21
+ .replace(/@@build\.timestamp/g, String(timestamp))
22
+ await fs.writeFile(indexPath, html)
23
+ }
@@ -0,0 +1,15 @@
1
+ import { glob } from 'glob'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Collects all migration script paths from the framework's src directory
6
+ * @param {String} frameworkDir Absolute path to the framework directory
7
+ * @returns {Promise<String[]>} Absolute paths to migration scripts
8
+ */
9
+ export async function collectMigrationScripts (frameworkDir) {
10
+ const srcDir = path.join(frameworkDir, 'src')
11
+ return glob([
12
+ 'core/migrations/**/*.js',
13
+ '*/*/migrations/**/*.js'
14
+ ], { cwd: srcDir, absolute: true })
15
+ }
@@ -0,0 +1,14 @@
1
+ import { createHash } from 'node:crypto'
2
+ import Project from 'adapt-cli/lib/integration/Project.js'
3
+
4
+ /**
5
+ * Computes a deterministic hash from the installed plugin set
6
+ * @param {String} frameworkDir Path to the local framework installation
7
+ * @return {Promise<String>} 16-char hex hash
8
+ */
9
+ export async function computePluginHash (frameworkDir) {
10
+ const project = new Project({ cwd: frameworkDir })
11
+ const deps = await project.getInstalledDependencies()
12
+ const sorted = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))
13
+ return createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 16)
14
+ }
@@ -17,7 +17,7 @@ export async function copyFrameworkSource (options) {
17
17
  if (options.copyNodeModules !== true) BLACKLIST.push('node_modules')
18
18
 
19
19
  const srcDir = path.join(fwPath, 'src')
20
- const enabledPlugins = options.enabledPlugins ?? []
20
+ const enabledPlugins = options.enabledPlugins
21
21
  await fs.cp(fwPath, options.destDir, {
22
22
  recursive: true,
23
23
  filter: f => {
@@ -25,7 +25,7 @@ export async function copyFrameworkSource (options) {
25
25
  const [type, name] = path.relative(srcDir, f).split('/')
26
26
  const isPlugin = f.startsWith(srcDir) && type && type !== 'core' && !!name
27
27
 
28
- if (isPlugin && !enabledPlugins.includes(name)) {
28
+ if (isPlugin && enabledPlugins && !enabledPlugins.includes(name)) {
29
29
  return false
30
30
  }
31
31
  return !BLACKLIST.includes(path.basename(f))
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Returns the list of JSON filenames that belong in a language manifest.
3
+ * The framework runtime reads this to know which data files to fetch.
4
+ * @param {Array<String>} jsonFileNames All JSON filenames written to the language dir
5
+ * @return {Array<String>} Filtered list excluding the manifest itself and assets.json
6
+ */
7
+ export function generateLanguageManifest (jsonFileNames) {
8
+ return jsonFileNames.filter(f => f !== 'language_data_manifest.js' && f !== 'assets.json')
9
+ }
package/lib/utils/log.js CHANGED
@@ -3,15 +3,13 @@ import bytes from 'bytes'
3
3
  import fsSync from 'node:fs'
4
4
  import path from 'upath'
5
5
 
6
- let fw
7
-
8
6
  /**
9
- * Logs a message using the framework module
10
- * @param {...*} args Arguments to be logged
7
+ * Logs a message using the framework module's namespace
8
+ * @param {String} level Log level
9
+ * @param {...*} rest Arguments to be logged
11
10
  */
12
- export async function log (...args) {
13
- if (!fw) fw = await App.instance.waitForModule('adaptframework')
14
- return fw.log(...args)
11
+ export function log (level, ...rest) {
12
+ App.instance?.logger?.log(level, 'adaptframework', ...rest)
15
13
  }
16
14
 
17
15
  /**
@@ -0,0 +1,87 @@
1
+ import { App } from 'adapt-authoring-core'
2
+ import { isDeepStrictEqual } from 'node:util'
3
+ import { collectMigrationScripts } from './collectMigrationScripts.js'
4
+ import { runContentMigration } from './runContentMigration.js'
5
+ import { log } from './log.js'
6
+
7
+ /**
8
+ * Migrates content for a set of courses by courseId
9
+ * @param {Object} options
10
+ * @param {Array<{name: String, version: String}>} options.fromPlugins Plugin versions before update
11
+ * @param {Array<{name: String, version: String}>} options.toPlugins Plugin versions after update
12
+ * @param {String} options.frameworkDir Absolute path to the framework directory
13
+ * @param {String[]} [options.courseIds] Specific course IDs to migrate (if omitted, migrates all)
14
+ * @returns {Promise<{migrated: Number, failed: Number, errors: Array}>}
15
+ */
16
+ export async function migrateExistingCourses ({ fromPlugins, toPlugins, frameworkDir, courseIds }) {
17
+ const content = await App.instance.waitForModule('content')
18
+ const scripts = await collectMigrationScripts(frameworkDir)
19
+
20
+ if (!scripts.length) {
21
+ log('debug', 'no migration scripts found, skipping')
22
+ return { migrated: 0, failed: 0, errors: [] }
23
+ }
24
+
25
+ const foundCourses = courseIds
26
+ ? await Promise.all(courseIds.map(async _id => content.findOne({ _id, _type: 'course' }, { strict: false })))
27
+ : await content.find({ _type: 'course' })
28
+
29
+ let migrated = 0
30
+ let failed = 0
31
+ const errors = []
32
+
33
+ for (let ci = 0; ci < foundCourses.length; ci++) {
34
+ const course = foundCourses[ci]
35
+ if (!course) {
36
+ const courseId = courseIds?.[ci] ?? 'unknown'
37
+ log('warn', `course ${courseId} not found, skipping`)
38
+ errors.push({ courseId, error: 'course not found' })
39
+ failed++
40
+ continue
41
+ }
42
+ try {
43
+ const courseId = course._id.toString()
44
+ log('debug', `migrating course ${courseId}`)
45
+
46
+ const courseContent = await fetchCourseContent(content, course)
47
+ const originals = courseContent.map(item => JSON.parse(JSON.stringify(item)))
48
+
49
+ const migratedContent = await runContentMigration({
50
+ content: courseContent,
51
+ fromPlugins: JSON.parse(JSON.stringify(fromPlugins)),
52
+ toPlugins,
53
+ scripts
54
+ })
55
+
56
+ let updatedCount = 0
57
+ for (let i = 0; i < migratedContent.length; i++) {
58
+ const normalized = JSON.parse(JSON.stringify(migratedContent[i]))
59
+ if (!isDeepStrictEqual(originals[i], normalized)) {
60
+ await content.update({ _id: migratedContent[i]._id }, normalized)
61
+ updatedCount++
62
+ }
63
+ }
64
+ if (updatedCount > 0) {
65
+ log('info', `migrated ${updatedCount} items in course ${courseId}`)
66
+ }
67
+ migrated++
68
+ } catch (e) {
69
+ const courseId = course?._id?.toString() ?? 'unknown'
70
+ log('error', `migration failed for course ${courseId}`, e.message)
71
+ errors.push({ courseId, error: e.message })
72
+ failed++
73
+ }
74
+ }
75
+
76
+ log('info', `migration complete: ${migrated} succeeded, ${failed} failed`)
77
+ return { migrated, failed, errors }
78
+ }
79
+
80
+ async function fetchCourseContent (content, course) {
81
+ const config = await content.findOne({ _courseId: course._id, _type: 'config' }, { strict: false })
82
+ const items = await content.find({ _courseId: course._id, _type: { $nin: ['course', 'config'] } })
83
+ const result = [course]
84
+ if (config) result.push(config)
85
+ result.push(...items)
86
+ return result
87
+ }
@@ -0,0 +1,110 @@
1
+ import { App, ensureDir, writeJson } from 'adapt-authoring-core'
2
+ import AdaptCli from 'adapt-cli'
3
+ import fs from 'node:fs/promises'
4
+ import path from 'upath'
5
+ import { copyFrameworkSource } from './copyFrameworkSource.js'
6
+ import BuildCache from '../BuildCache.js'
7
+ import { computePluginHash } from './computePluginHash.js'
8
+ import { log } from './log.js'
9
+
10
+ /**
11
+ * Eagerly populates the prebuilt cache for every (theme, menu) combination
12
+ * of installed plugins. Iterates serially: each iteration runs a full grunt
13
+ * build with the chosen theme/menu and caches the output.
14
+ *
15
+ * Idempotent — combos that already have a cache entry are skipped, so
16
+ * re-runs only build what's missing. Per-iteration failures are logged
17
+ * but don't abort the whole prebuild.
18
+ * @param {Object} options
19
+ * @param {String} options.buildDir Root build directory
20
+ * @param {String} options.frameworkDir Path to the adapt_framework source
21
+ * @return {Promise}
22
+ */
23
+ export async function prebuildCache ({ buildDir, frameworkDir }) {
24
+ const app = App.instance
25
+ const cache = new BuildCache(path.join(buildDir, 'prebuilt-cache'))
26
+ const pluginHash = await computePluginHash(frameworkDir)
27
+
28
+ const contentplugin = await app.waitForModule('contentplugin')
29
+ const allPlugins = await contentplugin.find({})
30
+ const themes = allPlugins.filter(p => p.type === 'theme')
31
+ const menus = allPlugins.filter(p => p.type === 'menu')
32
+
33
+ if (!themes.length || !menus.length) {
34
+ throw new Error('Cannot prebuild cache: no theme or menu plugin installed')
35
+ }
36
+
37
+ log('info', 'CACHE', `starting eager prebuild for ${themes.length * menus.length} (theme,menu) combinations`)
38
+
39
+ for (const theme of themes) {
40
+ for (const menu of menus) {
41
+ try {
42
+ await prebuildOne({ buildDir, cache, pluginHash, theme, menu, allPlugins })
43
+ } catch (e) {
44
+ log('warn', 'CACHE', `eager prebuild failed for theme=${theme.name} menu=${menu.name}: ${e.message}`)
45
+ if (e.cmd) log('warn', 'CACHE', `cmd: ${e.cmd}`)
46
+ if (e.stderr) log('warn', 'CACHE', `stderr: ${e.stderr}`)
47
+ }
48
+ }
49
+ }
50
+
51
+ log('info', 'CACHE', 'eager prebuild complete')
52
+ }
53
+
54
+ async function prebuildOne ({ buildDir, cache, pluginHash, theme, menu, allPlugins }) {
55
+ const app = App.instance
56
+
57
+ if (await cache.has(pluginHash, theme.name, menu.name)) {
58
+ log('info', 'CACHE', `skipping cached combo theme=${theme.name} menu=${menu.name}`)
59
+ return
60
+ }
61
+
62
+ // Only one theme/menu can be active per build — drop the others so
63
+ // the framework's less:dev task doesn't glob multiple themes' LESS
64
+ // into a single adapt.css (see adapt_framework#3802).
65
+ const includedPlugins = allPlugins.filter(p =>
66
+ (p.type !== 'theme' && p.type !== 'menu') || p.name === theme.name || p.name === menu.name
67
+ )
68
+ const pluginNames = includedPlugins.map(p => p.name)
69
+
70
+ const tmpDir = path.join(buildDir, `_eager_cache_${Date.now()}_${theme.name}_${menu.name}`)
71
+ try {
72
+ log('info', 'CACHE', `building combo theme=${theme.name} menu=${menu.name}`)
73
+
74
+ await copyFrameworkSource({
75
+ destDir: tmpDir,
76
+ enabledPlugins: pluginNames,
77
+ linkNodeModules: true
78
+ })
79
+
80
+ const outputDir = path.join(tmpDir, 'build')
81
+ const buildCourseDir = path.join(outputDir, 'course', 'en')
82
+ await ensureDir(buildCourseDir)
83
+ await writeJson(path.join(outputDir, 'course', 'config.json'), {
84
+ _defaultLanguage: 'en',
85
+ _theme: theme.name,
86
+ _menu: menu.name,
87
+ _enabledPlugins: pluginNames
88
+ })
89
+ await writeJson(path.join(buildCourseDir, 'course.json'), {
90
+ title: '_eager_cache_build',
91
+ _latestTrackingId: 0
92
+ })
93
+ const cacheDir = path.join(buildDir, 'cache')
94
+ await ensureDir(cacheDir)
95
+
96
+ await AdaptCli.buildCourse({
97
+ cwd: tmpDir,
98
+ sourceMaps: true,
99
+ outputDir,
100
+ cachePath: path.join(cacheDir, '_eager_cache'),
101
+ logger: { log: (...args) => app.logger.log('debug', 'adapt-cli', ...args) }
102
+ })
103
+
104
+ if (!await cache.has(pluginHash, theme.name, menu.name)) {
105
+ await cache.populate(outputDir, pluginHash, theme.name, menu.name)
106
+ }
107
+ } finally {
108
+ await fs.rm(tmpDir, { recursive: true, force: true })
109
+ }
110
+ }
@@ -0,0 +1,21 @@
1
+ import { readJson } from 'adapt-authoring-core'
2
+ import { glob } from 'glob'
3
+ import path from 'node:path'
4
+
5
+ /**
6
+ * Reads bower.json files from the framework's src directory to build a list of plugin names and versions
7
+ * @param {String} frameworkDir Absolute path to the framework directory
8
+ * @returns {Promise<Array<{name: String, version: String}>>}
9
+ */
10
+ export async function readFrameworkPluginVersions (frameworkDir) {
11
+ const srcDir = path.join(frameworkDir, 'src')
12
+ const bowerPaths = await glob([
13
+ 'core/bower.json',
14
+ '{components,extensions,menu,theme}/*/bower.json'
15
+ ], { cwd: srcDir, absolute: true })
16
+ const plugins = await Promise.all(bowerPaths.map(async p => {
17
+ const { name, version } = await readJson(p)
18
+ return { name, version }
19
+ }))
20
+ return plugins
21
+ }