import { App, Hook } from 'adapt-authoring-core'
import babel from '@babel/core'
import fs from 'fs/promises'
import { glob } from 'glob'
import handlebars from 'handlebars'
import less from 'less'
import path from 'path'
import { promisify } from 'util'
import requirejs from 'requirejs'
const lessPromise = promisify(less.render)
/**
* Builds the AAT user interface
* @memberof ui
*/
class UiBuild {
/**
* Location of the UI rebuild file
* @type {string}
*/
static get rebuildFilePath () {
return path.join(App.instance.rootDir, '.rebuild-ui')
}
/**
* Constants for build status
* @type {object}
*/
static get BUILD_STATUS () {
return {
BUILD_MISSING: 'BUILD_MISSING',
CODE_API: 'CODE_API',
FORCE_FLAG: 'FORCE_FLAG',
NONE: 'NONE',
PROD_MODE: 'PROD_MODE',
REBUILD_FILE: 'REBUILD_FILE',
REST_API: 'REST_API',
}
}
/**
* Checks whether the rebuild file exists
* @return {boolean}
*/
static async checkBuildFileExists () {
return this.fs('access', this.rebuildFilePath, fs.W_OK)
}
/**
* @constructor
* @param {object} options Whether this is a development build
* @param {string} options.status Build status
* @param {boolean} options.isDev Whether this is a development build
* @param {string} options.appRoot Root dir for the main adapt-authoring app
* @param {string} options.srcDir Root dir for the UI source code
* @param {string} options.buildDir Build dir for the output files
* @param {array} options.plugins Any custom UI plugins
*/
constructor ({ status, isDev, appRoot, srcDir, buildDir, plugins}) {
this.status = status
this.isDev = isDev
this.appRoot = appRoot
this.srcDir = srcDir
this.buildDir = buildDir
this.outputFileName = 'adapt'
this.plugins = plugins
this.outputJsDir = path.join(this.buildDir, 'js')
this.outputJsFilePath = path.join(this.outputJsDir, `${this.outputFileName}.js`)
this.requireJsConfigPath = path.join(this.outputJsDir, 'requireJsConfig.js')
this.requireJsConfig = {
baseUrl: srcDir,
name: 'core/app',
out: this.outputJsFilePath,
preserveLicenseComments: isDev,
waitSeconds: 0,
paths: {
'modules/modules': 'modules-bundle',
'plugins/plugins': 'plugins-bundle',
'templates/templates': 'templates',
backbone: 'libraries/backbone',
backboneForms: 'libraries/backbone-forms',
backboneFormsLists: 'libraries/backbone-forms-lists',
ckeditor: 'libraries/ckeditor',
handlebars: 'libraries/handlebars',
imageReady: 'libraries/imageReady',
inview: 'libraries/inview',
jquery: 'libraries/jquery',
jqueryForm: 'libraries/jquery.form',
jqueryTagsInput: 'libraries/jquery.tagsinput.min',
jqueryUI: 'libraries/jquery-ui.min',
moment: 'libraries/moment.min',
polyfill: 'libraries/babel-polyfill.min',
polyglot: 'libraries/polyglot.min',
scrollTo: 'libraries/scrollTo',
selectize: 'libraries/selectize/js/selectize',
underscore: 'libraries/underscore',
velocity: 'libraries/velocity'
},
shim: {
'templates/templates': { deps: ['handlebars'] },
// third-party
backbone: {
deps: ['underscore', 'jquery'],
exports: 'Backbone'
},
backboneForms: {
deps: ['backbone']
},
backboneFormsLists: {
deps: ['backboneForms']
},
handlebars: {
exports: 'Handlebars'
},
imageReady: {
deps: ['jquery'],
exports: 'imageready'
},
inview: {
deps: ['jquery'],
exports: 'inview'
},
jqueryForm: {
deps: ['jquery'],
exports: '$'
},
jqueryTagsInput: {
deps: ['jquery'],
exports: '$'
},
jqueryUI: {
deps: ['jquery'],
exports: '$'
},
moment: {
exports: 'moment'
},
polyglot: {
exports: 'Polyglot'
},
scrollTo: {
deps: ['jquery'],
exports: 'scrollTo'
},
selectize: {
deps: ['jquery'],
exports: '$'
},
underscore: {
exports: '_'
},
velocity: {
deps: ['jquery'],
exports: 'velocity'
}
}
}
this.preBuildHook = new Hook()
this.postBuildHook = new Hook()
}
/**
* Wrapper for filesystem (fs) functions
* @param {string} function Function to run
* @return {boolean} Whether the process was successful
*/
async fs (funcName, ...args) {
try {
await fs[funcName](...args)
return true
} catch (e) {
if (e.code !== 'ENOENT') throw e
return false
}
}
/**
* Initialises the expected build folder structure
* @return {Promise}
*/
async initBuildFolder () {
await this.fs('rm', this.srcDir, { recursive: true })
await this.fs('rm', this.buildDir, { recursive: true })
await this.fs('mkdir', path.resolve(this.buildDir, 'css'), { recursive: true })
await this.fs('mkdir', this.srcDir, { recursive: true })
await this.fs('mkdir', this.outputJsDir, { recursive: true })
}
/**
* Copies any specified UI plugins into place
* * @return {Promise}
*/
async copyPlugins () {
return Promise.all(this.plugins.map(async p => {
const contents = await fs.readdir(p)
return Promise.all(contents.map(c => fs.cp(path.resolve(p, c), path.resolve(this.srcDir, 'plugins', c), { recursive: true, force: true })))
}))
}
/**
* Creates bundle files for the relevant RequireJS files
* @return {Promise}
*/
async bundleRequireImports () {
return Promise.all(['modules', 'plugins'].map(async type => {
const modulePaths = []
let modules = []
try {
modules = await fs.readdir(path.resolve(this.srcDir, type))
} catch (e) {} // folder doesn't exist, no problem
await Promise.all(modules.map(async m => {
try {
const relPath = `${type}/${m}/index.js`
await fs.stat(path.resolve(this.srcDir, relPath))
modulePaths.push(relPath)
} catch (e) {}
}))
fs.writeFile(`${this.isDev ? this.buildDir : this.srcDir}/${type}-bundle.js`, `define(${JSON.stringify(modulePaths)}, function() {});`)
}))
}
/**
* Copy assets
* @return {Promise}
*/
async copyAssets () {
const outputDir = `${this.buildDir}/css/assets`
fs.mkdir(outputDir, { recursive: true })
const assets = await this.getFiles('**/assets/**')
return Promise.all(assets.map(a => fs.copyFile(a, `${outputDir}/${path.basename(a)}`)))
}
/**
* Copy handlebars templates
* @return {Promise}
*/
async copyHbs () {
const files = ['index.hbs', 'loading.hbs']
return Promise.all(files.map(f => fs.copyFile(`${this.srcDir}/core/${f}`, `${this.buildDir}/${f}`)))
}
/**
* Copy source code files
* @return {Promise}
*/
async copySource () {
return fs.cp(this.appRoot, this.srcDir, { recursive: true })
}
/**
* Run LESS tooling to generate output CSS file
* @return {Promise}
*/
async compileLess () {
const cssFilename = `${this.outputFileName}.css`
const cssPath = path.join(this.buildDir, 'css', cssFilename)
const lessOptions = {
compress: this.isDev,
paths: `${this.srcDir}/core/less`
}
if (this.isDev) { // source maps
lessOptions.sourceMap = {
sourceMapFileInline: false,
outputSourceFiles: true,
sourceMapBasepath: 'src',
sourceMapURL: `${cssFilename}.map`
}
}
const lessImports = (await this.getFiles('**/*.less')).sort().reduce((s, p) => `${s}@import '${p}';\n`, '')
const { css, map } = await lessPromise(lessImports, lessOptions)
const tasks = [fs.writeFile(cssPath, css)]
if (map) {
const sourceMapPath = `${cssPath}.map`
const importsPath = `${sourceMapPath}.imports`
tasks.push(
fs.writeFile(sourceMapPath, map),
fs.writeFile(importsPath, lessImports)
)
}
return Promise.all(tasks)
}
/**
* Compile handlebars templates and merge into single file
* @return {Promise}
*/
async compileHandlebars () {
const extName = '.hbs'
const results = await Promise.all((await this.getFiles(`**/*${extName}`)).map(async t => {
const compiled = handlebars.precompile((await fs.readFile(t)).toString())
const name = path.basename(t, extName)
const template = `Handlebars.template(${compiled})`
return name.startsWith('part_')
? `Handlebars.registerPartial("${name}", ${template});`
: `Handlebars.templates["${name}"] = ${template};`
}))
const output = `define(['handlebars'], function(Handlebars) {
Handlebars.templates = Handlebars.templates || {};
${results.join('\n\n')}
return Handlebars;
});`
return fs.writeFile(path.join(this.isDev ? this.buildDir : this.srcDir, 'templates.js'), output)
}
/**
* Write RequireJS config data to file
* @return {Promise}
*/
async writeRequireJsConfig () {
const config = { ...this.requireJsConfig }
if (this.isDev) config.baseUrl = '/'
await this.fs('writeFile', this.requireJsConfigPath, `require.config(${JSON.stringify(config, null, 2)});`)
}
/**
* Runs the RequireJS tooling
* @return {Promise}
*/
async runRequireJs () {
return new Promise((resolve, reject) => requirejs.optimize(this.requireJsConfig, resolve, reject))
}
/**
* Runs the babel tooling
* @return {Promise}
*/
async runBabel () {
const opts = Object.assign({
cwd: this.srcDir,
presets: [['@babel/preset-env', { targets: { ie: '11' } }]],
sourceType: 'script'
}, this.isDev // add task-specific options
? { compact: false, retainLines: true }
: { comments: false, minified: true }
)
const { code } = await babel.transformFileAsync(this.outputJsFilePath, opts)
return fs.writeFile(this.outputJsFilePath, code)
}
/**
* Returns list of source files
* @param {string} globPattern Glob pattern to specify files
* @return {Promise}
*/
async getFiles (globPattern) {
return glob(globPattern, { cwd: this.srcDir, nodir: true, absolute: true })
}
/**
* Main entry point, runs the build process
* @return {Promise}
*/
async run () {
const mode = this.isDev ? 'dev' : 'production'
let rebuildReason = ''
if (this.status === UiBuild.BUILD_STATUS.BUILD_MISSING) rebuildReason = 'no build exists'
if (this.status === UiBuild.BUILD_STATUS.CODE_API) rebuildReason = 'via code API'
if (this.status === UiBuild.BUILD_STATUS.FORCE_FLAG) rebuildReason = '--rebuild-ui flag passed'
if (this.status === UiBuild.BUILD_STATUS.PROD_MODE) rebuildReason = 'in production mode'
if (this.status === UiBuild.BUILD_STATUS.REBUILD_FILE) rebuildReason = `${path.basename(this.rebuildFilePath)} file found`
if (this.status === UiBuild.BUILD_STATUS.REST_API) rebuildReason = 'via REST API'
this.log('debug', 'BUILD', this.status, mode)
this.log('info', `UI build triggered (${rebuildReason}), building in ${mode} mode`)
try {
await this.initBuildFolder()
await Promise.all([
this.copySource(),
this.copyPlugins()
])
await this.preBuildHook.invoke(this)
await Promise.all([
this.bundleRequireImports(),
this.copyAssets(),
this.copyHbs(),
this.compileLess(),
this.compileHandlebars(),
this.writeRequireJsConfig()
])
if (!this.isDev) {
await this.runRequireJs()
await this.runBabel()
await this.fs('rm', this.srcDir, { recursive: true })
}
try {
await this.fs('rm', this.rebuildFilePath)
} catch (e) {} // ignore
await this.postBuildHook.invoke(this)
this.log('info', 'UI built successfully')
} catch (e) {
this.log('error', `failed to build UI, ${e}`)
}
}
/**
* Shim for logging via the ui module
* @return {Promise}
*/
async log (...args) {
const ui = await App.instance.waitForModule('ui')
ui.log(...args)
}
}
export default UiBuild