adapt-authoring-adaptframework 3.1.1 → 3.1.2

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.
@@ -3,7 +3,7 @@ import { App, Hook, ensureDir, writeJson } from 'adapt-authoring-core'
3
3
  import { parseObjectId } from 'adapt-authoring-mongodb'
4
4
  import { createWriteStream } from 'node:fs'
5
5
  import AdaptCli from 'adapt-cli'
6
- import { log, logDir, logMemory, copyFrameworkSource, generateLanguageManifest, applyBuildReplacements } from './utils.js'
6
+ import { log, logDir, logMemory, copyFrameworkSource, generateLanguageManifest, applyBuildReplacements, computeVarsHash } from './utils.js'
7
7
  import BuildCache from './BuildCache.js'
8
8
  import fs from 'node:fs/promises'
9
9
  import path from 'upath'
@@ -200,6 +200,9 @@ class AdaptFrameworkBuild {
200
200
 
201
201
  await this.loadCourseData()
202
202
 
203
+ // must hash before preBuildHook/applySchemaDefaults mutate course.data
204
+ const varsHash = this.isPreview ? computeVarsHash(this.courseData.course.data) : null
205
+
203
206
  // Check for cached preview build
204
207
  if (this.isPreview && !contentOnly) {
205
208
  const cache = new BuildCache(path.join(framework.getConfig('buildDir'), 'prebuilt-cache'))
@@ -207,8 +210,8 @@ class AdaptFrameworkBuild {
207
210
  const theme = this.courseData.config.data._theme
208
211
  const menu = this.courseData.config.data._menu
209
212
 
210
- if (await cache.has(pluginHash, theme, menu)) {
211
- await cache.restore(pluginHash, theme, menu, this.buildDir)
213
+ if (await cache.has(pluginHash, theme, menu, varsHash)) {
214
+ await cache.restore(pluginHash, theme, menu, varsHash, this.buildDir)
212
215
  await this.applySchemaDefaults()
213
216
  await this.copyAssets()
214
217
  await this.preBuildHook.invoke(this)
@@ -278,7 +281,7 @@ class AdaptFrameworkBuild {
278
281
  const theme = this.courseData.config.data._theme
279
282
  const menu = this.courseData.config.data._menu
280
283
  try {
281
- await cache.populate(this.buildDir, pluginHash, theme, menu)
284
+ await cache.populate(this.buildDir, pluginHash, theme, menu, varsHash)
282
285
  } catch (e) {
283
286
  log('warn', 'CACHE', `failed to populate prebuilt cache: ${e.message}`)
284
287
  }
package/lib/BuildCache.js CHANGED
@@ -6,7 +6,7 @@ import { log } from './utils/log.js'
6
6
  const SKIP_ENTRIES = new Set(['course'])
7
7
 
8
8
  /**
9
- * Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu).
9
+ * Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu, varsHash).
10
10
  * One instance per cache root; methods are stateless beyond the root path.
11
11
  */
12
12
  class BuildCache {
@@ -20,16 +20,16 @@ class BuildCache {
20
20
  /**
21
21
  * @returns {String} The cache directory path for the given combo
22
22
  */
23
- getPath (pluginHash, theme, menu) {
24
- return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}`)
23
+ getPath (pluginHash, theme, menu, varsHash) {
24
+ return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}_${varsHash}`)
25
25
  }
26
26
 
27
27
  /**
28
28
  * @returns {Promise<Boolean>} Whether a cached build exists for the given combo
29
29
  */
30
- async has (pluginHash, theme, menu) {
30
+ async has (pluginHash, theme, menu, varsHash) {
31
31
  try {
32
- await fs.access(this.getPath(pluginHash, theme, menu))
32
+ await fs.access(this.getPath(pluginHash, theme, menu, varsHash))
33
33
  return true
34
34
  } catch {
35
35
  return false
@@ -41,8 +41,8 @@ class BuildCache {
41
41
  * Uses a temp dir + atomic rename for parallel safety.
42
42
  * @param {String} buildOutputDir The build output directory
43
43
  */
44
- async populate (buildOutputDir, pluginHash, theme, menu) {
45
- const cacheDir = this.getPath(pluginHash, theme, menu)
44
+ async populate (buildOutputDir, pluginHash, theme, menu, varsHash) {
45
+ const cacheDir = this.getPath(pluginHash, theme, menu, varsHash)
46
46
  await fs.mkdir(this.cacheRoot, { recursive: true })
47
47
 
48
48
  const tmpDir = `${cacheDir}_tmp_${Date.now()}`
@@ -54,7 +54,7 @@ class BuildCache {
54
54
  await copyEntry(path.join(buildOutputDir, entry), path.join(tmpDir, entry))
55
55
  }
56
56
  await safeRename(tmpDir, cacheDir)
57
- log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
57
+ log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu}, vars=${varsHash})`)
58
58
  } catch (e) {
59
59
  await fs.rm(tmpDir, { recursive: true, force: true })
60
60
  throw e
@@ -65,10 +65,10 @@ class BuildCache {
65
65
  * Copies cached artifacts to a build directory.
66
66
  * @param {String} destDir Destination build directory
67
67
  */
68
- async restore (pluginHash, theme, menu, destDir) {
68
+ async restore (pluginHash, theme, menu, varsHash, destDir) {
69
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})`)
70
+ await fs.cp(this.getPath(pluginHash, theme, menu, varsHash), destDir, { recursive: true })
71
+ log('info', 'CACHE', `restored from cache for ${pluginHash} (theme=${theme}, menu=${menu}, vars=${varsHash})`)
72
72
  }
73
73
 
74
74
  /**
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * Serialises a value to JSON with object keys sorted (arrays preserve order, undefined → null)
5
+ * @param {*} value
6
+ * @return {String}
7
+ */
8
+ export function canonicalJson (value) {
9
+ if (value === undefined || value === null) return 'null'
10
+ if (typeof value !== 'object') return JSON.stringify(value)
11
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
12
+ const keys = Object.keys(value).sort()
13
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
14
+ }
15
+
16
+ /**
17
+ * Hashes per-course LESS inputs (themeVariables + customStyle) for the cache key
18
+ * @param {Object} [data]
19
+ * @param {Object} [data.themeVariables]
20
+ * @param {String} [data.customStyle]
21
+ * @return {String} 12-char hex hash
22
+ */
23
+ export function computeVarsHash ({ themeVariables, customStyle } = {}) {
24
+ const input = canonicalJson({
25
+ themeVariables: themeVariables ?? null,
26
+ customStyle: customStyle ?? null
27
+ })
28
+ return createHash('sha256').update(input).digest('hex').slice(0, 12)
29
+ }
@@ -5,8 +5,11 @@ import path from 'upath'
5
5
  import { copyFrameworkSource } from './copyFrameworkSource.js'
6
6
  import BuildCache from '../BuildCache.js'
7
7
  import { computePluginHash } from './computePluginHash.js'
8
+ import { computeVarsHash } from './computeVarsHash.js'
8
9
  import { log } from './log.js'
9
10
 
11
+ const DEFAULT_VARS_HASH = computeVarsHash()
12
+
10
13
  /**
11
14
  * Eagerly populates the prebuilt cache for every (theme, menu) combination
12
15
  * of installed plugins. Iterates serially: each iteration runs a full grunt
@@ -54,7 +57,7 @@ export async function prebuildCache ({ buildDir, frameworkDir }) {
54
57
  async function prebuildOne ({ buildDir, cache, pluginHash, theme, menu, allPlugins }) {
55
58
  const app = App.instance
56
59
 
57
- if (await cache.has(pluginHash, theme.name, menu.name)) {
60
+ if (await cache.has(pluginHash, theme.name, menu.name, DEFAULT_VARS_HASH)) {
58
61
  log('info', 'CACHE', `skipping cached combo theme=${theme.name} menu=${menu.name}`)
59
62
  return
60
63
  }
@@ -101,8 +104,8 @@ async function prebuildOne ({ buildDir, cache, pluginHash, theme, menu, allPlugi
101
104
  logger: { log: (...args) => app.logger.log('debug', 'adapt-cli', ...args) }
102
105
  })
103
106
 
104
- if (!await cache.has(pluginHash, theme.name, menu.name)) {
105
- await cache.populate(outputDir, pluginHash, theme.name, menu.name)
107
+ if (!await cache.has(pluginHash, theme.name, menu.name, DEFAULT_VARS_HASH)) {
108
+ await cache.populate(outputDir, pluginHash, theme.name, menu.name, DEFAULT_VARS_HASH)
106
109
  }
107
110
  } finally {
108
111
  await fs.rm(tmpDir, { recursive: true, force: true })
package/lib/utils.js CHANGED
@@ -16,3 +16,4 @@ export { computePluginHash } from './utils/computePluginHash.js'
16
16
  export { prebuildCache } from './utils/prebuildCache.js'
17
17
  export { generateLanguageManifest } from './utils/generateLanguageManifest.js'
18
18
  export { applyBuildReplacements } from './utils/applyBuildReplacements.js'
19
+ export { computeVarsHash } from './utils/computeVarsHash.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-adaptframework",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Adapt framework integration for the Adapt authoring tool",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-adaptframework",
6
6
  "license": "GPL-3.0",
@@ -34,18 +34,29 @@ describe('BuildCache', () => {
34
34
 
35
35
  describe('getPath()', () => {
36
36
  it('returns one combo-keyed directory path', () => {
37
- assert.equal(cache.getPath('abc123', 'vanilla', 'boxMenu'), upath.join(cacheRoot, 'abc123_vanilla_boxMenu'))
37
+ assert.equal(cache.getPath('abc123', 'vanilla', 'boxMenu', 'vhash'), upath.join(cacheRoot, 'abc123_vanilla_boxMenu_vhash'))
38
+ })
39
+
40
+ it('returns distinct paths for different varsHash', () => {
41
+ const a = cache.getPath('abc123', 'vanilla', 'boxMenu', 'aaa')
42
+ const b = cache.getPath('abc123', 'vanilla', 'boxMenu', 'bbb')
43
+ assert.notEqual(a, b)
38
44
  })
39
45
  })
40
46
 
41
47
  describe('has()', () => {
42
48
  it('returns false when cache does not exist', async () => {
43
- assert.equal(await cache.has('hash1', 'theme', 'menu'), false)
49
+ assert.equal(await cache.has('hash1', 'theme', 'menu', 'vhash'), false)
44
50
  })
45
51
 
46
52
  it('returns true when the combo dir exists', async () => {
47
- await fs.mkdir(cache.getPath('hash1', 'theme', 'menu'), { recursive: true })
48
- assert.equal(await cache.has('hash1', 'theme', 'menu'), true)
53
+ await fs.mkdir(cache.getPath('hash1', 'theme', 'menu', 'vhash'), { recursive: true })
54
+ assert.equal(await cache.has('hash1', 'theme', 'menu', 'vhash'), true)
55
+ })
56
+
57
+ it('returns false when only a different varsHash exists', async () => {
58
+ await fs.mkdir(cache.getPath('hash1', 'theme', 'menu', 'vhashA'), { recursive: true })
59
+ assert.equal(await cache.has('hash1', 'theme', 'menu', 'vhashB'), false)
49
60
  })
50
61
  })
51
62
 
@@ -65,9 +76,9 @@ describe('BuildCache', () => {
65
76
  await fs.mkdir(path.join(buildDir, 'course', 'en'), { recursive: true })
66
77
  await fs.writeFile(path.join(buildDir, 'course', 'en', 'course.json'), '{}')
67
78
 
68
- await cache.populate(buildDir, 'hash1', 'theme', 'menu')
79
+ await cache.populate(buildDir, 'hash1', 'theme', 'menu', 'vhash')
69
80
 
70
- const cacheDir = cache.getPath('hash1', 'theme', 'menu')
81
+ const cacheDir = cache.getPath('hash1', 'theme', 'menu', 'vhash')
71
82
  assert.equal(await fs.readFile(path.join(cacheDir, 'adapt', 'js', 'adapt.min.js'), 'utf8'), 'js-content')
72
83
  assert.equal(await fs.readFile(path.join(cacheDir, 'index.html'), 'utf8'), '<html></html>')
73
84
  assert.equal(await fs.readFile(path.join(cacheDir, 'adapt.css'), 'utf8'), 'css-content')
@@ -78,13 +89,13 @@ describe('BuildCache', () => {
78
89
 
79
90
  describe('restore()', () => {
80
91
  it('copies cached artifacts to destination', async () => {
81
- const cacheDir = cache.getPath('hash1', 'theme', 'menu')
92
+ const cacheDir = cache.getPath('hash1', 'theme', 'menu', 'vhash')
82
93
  await fs.mkdir(path.join(cacheDir, 'adapt', 'js'), { recursive: true })
83
94
  await fs.writeFile(path.join(cacheDir, 'adapt', 'js', 'adapt.min.js'), 'cached-js')
84
95
  await fs.writeFile(path.join(cacheDir, 'adapt.css'), 'cached-css')
85
96
 
86
97
  const destDir = path.join(tmpDir, 'restored')
87
- await cache.restore('hash1', 'theme', 'menu', destDir)
98
+ await cache.restore('hash1', 'theme', 'menu', 'vhash', destDir)
88
99
 
89
100
  assert.equal(await fs.readFile(path.join(destDir, 'adapt', 'js', 'adapt.min.js'), 'utf8'), 'cached-js')
90
101
  assert.equal(await fs.readFile(path.join(destDir, 'adapt.css'), 'utf8'), 'cached-css')
@@ -0,0 +1,70 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { canonicalJson, computeVarsHash } from '../lib/utils/computeVarsHash.js'
4
+
5
+ describe('canonicalJson()', () => {
6
+ const cases = [
7
+ { name: 'primitive string', input: 'abc', expected: '"abc"' },
8
+ { name: 'primitive number', input: 42, expected: '42' },
9
+ { name: 'primitive boolean', input: true, expected: 'true' },
10
+ { name: 'null', input: null, expected: 'null' },
11
+ { name: 'undefined coerces to null', input: undefined, expected: 'null' },
12
+ { name: 'empty object', input: {}, expected: '{}' },
13
+ { name: 'empty array', input: [], expected: '[]' },
14
+ { name: 'array preserves order', input: [3, 1, 2], expected: '[3,1,2]' },
15
+ { name: 'sorts top-level keys', input: { b: 1, a: 2 }, expected: '{"a":2,"b":1}' },
16
+ { name: 'sorts nested keys', input: { x: { d: 1, c: 2 } }, expected: '{"x":{"c":2,"d":1}}' },
17
+ { name: 'mixed nesting', input: { a: [{ z: 1, y: 2 }] }, expected: '{"a":[{"y":2,"z":1}]}' }
18
+ ]
19
+ for (const { name, input, expected } of cases) {
20
+ it(name, () => assert.equal(canonicalJson(input), expected))
21
+ }
22
+
23
+ it('produces identical output for objects with different key insertion order', () => {
24
+ const a = { _colors: { primary: 'red', secondary: 'blue' }, _font: { family: 'serif' } }
25
+ const b = { _font: { family: 'serif' }, _colors: { secondary: 'blue', primary: 'red' } }
26
+ assert.equal(canonicalJson(a), canonicalJson(b))
27
+ })
28
+ })
29
+
30
+ describe('computeVarsHash()', () => {
31
+ it('returns a 12-character hex string', () => {
32
+ assert.match(computeVarsHash({ themeVariables: { a: 1 } }), /^[0-9a-f]{12}$/)
33
+ })
34
+
35
+ it('is deterministic for identical inputs', () => {
36
+ const data = { themeVariables: { _colors: { primary: 'green' } }, customStyle: '.foo { color: red; }' }
37
+ assert.equal(computeVarsHash(data), computeVarsHash(data))
38
+ })
39
+
40
+ it('treats missing, undefined, null, and empty equivalently', () => {
41
+ const empty = computeVarsHash()
42
+ assert.equal(computeVarsHash({}), empty)
43
+ assert.equal(computeVarsHash({ themeVariables: null, customStyle: null }), empty)
44
+ assert.equal(computeVarsHash({ themeVariables: undefined, customStyle: undefined }), empty)
45
+ })
46
+
47
+ it('is insensitive to key order within themeVariables', () => {
48
+ const a = computeVarsHash({ themeVariables: { _colors: { primary: 'red', secondary: 'blue' } } })
49
+ const b = computeVarsHash({ themeVariables: { _colors: { secondary: 'blue', primary: 'red' } } })
50
+ assert.equal(a, b)
51
+ })
52
+
53
+ it('differs when themeVariables differ', () => {
54
+ const a = computeVarsHash({ themeVariables: { _colors: { primary: 'red' } } })
55
+ const b = computeVarsHash({ themeVariables: { _colors: { primary: 'green' } } })
56
+ assert.notEqual(a, b)
57
+ })
58
+
59
+ it('differs when customStyle differs', () => {
60
+ const a = computeVarsHash({ customStyle: '.foo { color: red; }' })
61
+ const b = computeVarsHash({ customStyle: '.foo { color: green; }' })
62
+ assert.notEqual(a, b)
63
+ })
64
+
65
+ it('is sensitive to array order (font import precedence)', () => {
66
+ const a = computeVarsHash({ themeVariables: { _font: { _externalFonts: ['a.css', 'b.css'] } } })
67
+ const b = computeVarsHash({ themeVariables: { _font: { _externalFonts: ['b.css', 'a.css'] } } })
68
+ assert.notEqual(a, b)
69
+ })
70
+ })