177 lines
5.6 KiB
JavaScript
177 lines
5.6 KiB
JavaScript
/*
|
|
* Copyright (C) 2021 - present Instructure, Inc.
|
|
*
|
|
* This file is part of Canvas.
|
|
*
|
|
* Canvas is free software: you can redistribute it and/or modify it under
|
|
* the terms of the GNU Affero General Public License as published by the Free
|
|
* Software Foundation, version 3 of the License.
|
|
*
|
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
* details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License along
|
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
const crypto = require('crypto')
|
|
const fs = require('fs')
|
|
const mkdirp = require('mkdirp')
|
|
const path = require('path')
|
|
|
|
/**
|
|
* Extend source files with code found in plugins.
|
|
*
|
|
* To extend a source file, a plugin must provide a mapping inside its package
|
|
* manifest from that file, relative to canvas-lms root, to the extension file
|
|
* relative to the plugin root:
|
|
*
|
|
* // file: gems/plugins/my_canvas_plugin/package.json
|
|
* {
|
|
* "name": "my_canvas_plugin",
|
|
* "canvas": {
|
|
* "source-file-extensions": {
|
|
* "path/to/source.js": "path/to/extension.js"
|
|
* ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
|
|
* from canvas-lms/ from gems/plugins/my_canvas_plugin/
|
|
* }
|
|
* }
|
|
* }
|
|
*
|
|
* If either file does not exist, the build will be aborted. You may specify
|
|
* an array of extension files for a single source file.
|
|
*
|
|
* The extension file must export a function that receives and returns a single
|
|
* argument -- the source file's default export:
|
|
*
|
|
* // file: gems/plugins/my_canvas_plugin/app/js/extension-for-a.js
|
|
* export default a => {
|
|
* // do something with a and return it
|
|
* return a
|
|
* }
|
|
*
|
|
* Following that example, the generated code will be equivalent to:
|
|
*
|
|
* import a from 'path/to/a.js'
|
|
* import ext1 from 'gems/plugins/my_canvas_plugin/app/js/extension-for-a.js'
|
|
*
|
|
* export default ext1(a)
|
|
*
|
|
* Please be civil with extensions.
|
|
*/
|
|
class SourceFileExtensionsPlugin {
|
|
// @param context: <Path>
|
|
// Root directory for the application (normally: /path/to/canvas-lms)
|
|
//
|
|
// @param include: <Array.<Path>>
|
|
// Paths to package.json manifests to scan for extensions.
|
|
//
|
|
// @param tmpDir: <Path>
|
|
// Directory that will hold the generated extended files. This is
|
|
// intended for internal use by the bundler and should not be served.
|
|
constructor({ context, include, tmpDir }) {
|
|
this.context = context
|
|
this.include = include
|
|
this.tmpDir = tmpDir
|
|
}
|
|
|
|
apply(compiler) {
|
|
const [extensions, extensionErrors] = this.scanManifestsForExtensions()
|
|
const extended = this.generateAndPersistExtendedModules(extensions)
|
|
|
|
compiler.hooks.compilation.tap('SourceFileExtensionsPlugin', compilation => {
|
|
for (const error of extensionErrors) {
|
|
compilation.errors.push(error)
|
|
}
|
|
})
|
|
|
|
compiler.resolverFactory.plugin('resolver normal', resolver => {
|
|
resolver.hooks.result.tap('SourceFileExtensionsPlugin', request => {
|
|
if (extended[request.path] && request.context.issuer !== extended[request.path]) {
|
|
request.path = extended[request.path]
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
scanManifestsForExtensions() {
|
|
const { context, include } = this
|
|
const extensions = {}
|
|
const errors = []
|
|
|
|
for (const file of include) {
|
|
const manifest = require(file)
|
|
const mapping = manifest.canvas && manifest.canvas['source-file-extensions'] || {}
|
|
|
|
for (const [fileInCanvas, filesInPlugin] of Object.entries(mapping)) {
|
|
const sourceFile = path.resolve(context, fileInCanvas)
|
|
|
|
if (fs.existsSync(sourceFile)) {
|
|
// multiple files can extend the same source
|
|
for (const fileInPlugin of [].concat(filesInPlugin)) {
|
|
extensions[sourceFile] = extensions[sourceFile] || []
|
|
extensions[sourceFile].push(path.join(path.dirname(file), fileInPlugin))
|
|
}
|
|
}
|
|
else {
|
|
errors.push(
|
|
new Error(
|
|
`${path.relative(context, file)} - file marked for extension does not exist:\n\n` +
|
|
` ${fileInCanvas}\n\n` +
|
|
`(by SourceFileExtensionsPlugin)`
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return [extensions, errors]
|
|
}
|
|
|
|
generateAndPersistExtendedModules(extensions) {
|
|
const { context, tmpDir } = this
|
|
const extended = {}
|
|
|
|
mkdirp.sync(tmpDir)
|
|
|
|
for (const [sourceFile, extensionFiles] of Object.entries(extensions)) {
|
|
const fileInCanvas = path.relative(context, sourceFile)
|
|
const extendedFile = path.join(tmpDir, md5(fileInCanvas) + '.js')
|
|
const extendedModule = generateExtendedModule({
|
|
context: tmpDir,
|
|
sourceFile,
|
|
extensionFiles
|
|
})
|
|
|
|
fs.writeFileSync(extendedFile, extendedModule, 'utf8')
|
|
|
|
extended[sourceFile] = extendedFile
|
|
}
|
|
|
|
return extended
|
|
}
|
|
}
|
|
|
|
const md5 = string => crypto.createHash('md5').update(string).digest('hex');
|
|
|
|
const generateExtendedModule = ({ context, extensionFiles, sourceFile }) => {
|
|
const relative = file => path.relative(context, file)
|
|
const imports = [`import orig from "${relative(sourceFile)}";`]
|
|
const pipeline = []
|
|
|
|
for (const [i, file] of extensionFiles.entries()) {
|
|
imports.push(`import ext${i} from "${relative(file)}";`)
|
|
pipeline.push(`ext${i}`)
|
|
}
|
|
|
|
return (
|
|
`${imports.join('\n')}\n` +
|
|
`export default ${pipeline.reduce((buf, fn) => `${fn}(${buf})`, 'orig')};` +
|
|
`\n`
|
|
);
|
|
}
|
|
|
|
module.exports = SourceFileExtensionsPlugin
|