mirror of
https://github.com/vuejs/vue-cli.git
synced 2026-01-14 11:20:20 -06:00
538 lines
16 KiB
JavaScript
538 lines
16 KiB
JavaScript
const fs = require('fs')
|
|
const ejs = require('ejs')
|
|
const path = require('path')
|
|
const deepmerge = require('deepmerge')
|
|
const resolve = require('resolve')
|
|
const { isBinaryFileSync } = require('isbinaryfile')
|
|
const mergeDeps = require('./util/mergeDeps')
|
|
const { runTransformation } = require('vue-codemod')
|
|
const stringifyJS = require('./util/stringifyJS')
|
|
const ConfigTransform = require('./ConfigTransform')
|
|
const { semver, error, getPluginLink, toShortPluginId, loadModule } = require('@vue/cli-shared-utils')
|
|
|
|
const isString = val => typeof val === 'string'
|
|
const isFunction = val => typeof val === 'function'
|
|
const isObject = val => val && typeof val === 'object'
|
|
const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]))
|
|
function pruneObject (obj) {
|
|
if (typeof obj === 'object') {
|
|
for (const k in obj) {
|
|
if (!obj.hasOwnProperty(k)) {
|
|
continue
|
|
}
|
|
|
|
if (obj[k] == null) {
|
|
delete obj[k]
|
|
} else {
|
|
obj[k] = pruneObject(obj[k])
|
|
}
|
|
}
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
class GeneratorAPI {
|
|
/**
|
|
* @param {string} id - Id of the owner plugin
|
|
* @param {Generator} generator - The invoking Generator instance
|
|
* @param {object} options - generator options passed to this plugin
|
|
* @param {object} rootOptions - root options (the entire preset)
|
|
*/
|
|
constructor (id, generator, options, rootOptions) {
|
|
this.id = id
|
|
this.generator = generator
|
|
this.options = options
|
|
this.rootOptions = rootOptions
|
|
|
|
/* eslint-disable no-shadow */
|
|
this.pluginsData = generator.plugins
|
|
.filter(({ id }) => id !== `@vue/cli-service`)
|
|
.map(({ id }) => ({
|
|
name: toShortPluginId(id),
|
|
link: getPluginLink(id)
|
|
}))
|
|
/* eslint-enable no-shadow */
|
|
|
|
this._entryFile = undefined
|
|
}
|
|
|
|
/**
|
|
* Resolves the data when rendering templates.
|
|
*
|
|
* @private
|
|
*/
|
|
_resolveData (additionalData) {
|
|
return Object.assign({
|
|
options: this.options,
|
|
rootOptions: this.rootOptions,
|
|
plugins: this.pluginsData
|
|
}, additionalData)
|
|
}
|
|
|
|
/**
|
|
* Inject a file processing middleware.
|
|
*
|
|
* @private
|
|
* @param {FileMiddleware} middleware - A middleware function that receives the
|
|
* virtual files tree object, and an ejs render function. Can be async.
|
|
*/
|
|
_injectFileMiddleware (middleware) {
|
|
this.generator.fileMiddlewares.push(middleware)
|
|
}
|
|
|
|
/**
|
|
* Normalize absolute path, Windows-style path
|
|
* to the relative path used as index in this.files
|
|
* @param {string} p the path to normalize
|
|
*/
|
|
_normalizePath (p) {
|
|
if (path.isAbsolute(p)) {
|
|
p = path.relative(this.generator.context, p)
|
|
}
|
|
// The `files` tree always use `/` in its index.
|
|
// So we need to normalize the path string in case the user passes a Windows path.
|
|
return p.replace(/\\/g, '/')
|
|
}
|
|
|
|
/**
|
|
* Resolve path for a project.
|
|
*
|
|
* @param {string} _paths - A sequence of relative paths or path segments
|
|
* @return {string} The resolved absolute path, caculated based on the current project root.
|
|
*/
|
|
resolve (..._paths) {
|
|
return path.resolve(this.generator.context, ..._paths)
|
|
}
|
|
|
|
get cliVersion () {
|
|
return require('../package.json').version
|
|
}
|
|
|
|
assertCliVersion (range) {
|
|
if (typeof range === 'number') {
|
|
if (!Number.isInteger(range)) {
|
|
throw new Error('Expected string or integer value.')
|
|
}
|
|
range = `^${range}.0.0-0`
|
|
}
|
|
if (typeof range !== 'string') {
|
|
throw new Error('Expected string or integer value.')
|
|
}
|
|
|
|
if (semver.satisfies(this.cliVersion, range, { includePrerelease: true })) return
|
|
|
|
throw new Error(
|
|
`Require global @vue/cli "${range}", but was invoked by "${this.cliVersion}".`
|
|
)
|
|
}
|
|
|
|
get cliServiceVersion () {
|
|
// In generator unit tests, we don't write the actual file back to the disk.
|
|
// So there is no cli-service module to load.
|
|
// In that case, just return the cli version.
|
|
if (process.env.VUE_CLI_TEST && process.env.VUE_CLI_SKIP_WRITE) {
|
|
return this.cliVersion
|
|
}
|
|
|
|
const servicePkg = loadModule(
|
|
'@vue/cli-service/package.json',
|
|
this.generator.context
|
|
)
|
|
|
|
return servicePkg.version
|
|
}
|
|
|
|
assertCliServiceVersion (range) {
|
|
if (typeof range === 'number') {
|
|
if (!Number.isInteger(range)) {
|
|
throw new Error('Expected string or integer value.')
|
|
}
|
|
range = `^${range}.0.0-0`
|
|
}
|
|
if (typeof range !== 'string') {
|
|
throw new Error('Expected string or integer value.')
|
|
}
|
|
|
|
if (semver.satisfies(this.cliServiceVersion, range, { includePrerelease: true })) return
|
|
|
|
throw new Error(
|
|
`Require @vue/cli-service "${range}", but was loaded with "${this.cliServiceVersion}".`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check if the project has a given plugin.
|
|
*
|
|
* @param {string} id - Plugin id, can omit the (@vue/|vue-|@scope/vue)-cli-plugin- prefix
|
|
* @param {string} version - Plugin version. Defaults to ''
|
|
* @return {boolean}
|
|
*/
|
|
hasPlugin (id, versionRange) {
|
|
return this.generator.hasPlugin(id, versionRange)
|
|
}
|
|
|
|
/**
|
|
* Configure how config files are extracted.
|
|
*
|
|
* @param {string} key - Config key in package.json
|
|
* @param {object} options - Options
|
|
* @param {object} options.file - File descriptor
|
|
* Used to search for existing file.
|
|
* Each key is a file type (possible values: ['js', 'json', 'yaml', 'lines']).
|
|
* The value is a list of filenames.
|
|
* Example:
|
|
* {
|
|
* js: ['.eslintrc.js'],
|
|
* json: ['.eslintrc.json', '.eslintrc']
|
|
* }
|
|
* By default, the first filename will be used to create the config file.
|
|
*/
|
|
addConfigTransform (key, options) {
|
|
const hasReserved = Object.keys(this.generator.reservedConfigTransforms).includes(key)
|
|
if (
|
|
hasReserved ||
|
|
!options ||
|
|
!options.file
|
|
) {
|
|
if (hasReserved) {
|
|
const { warn } = require('@vue/cli-shared-utils')
|
|
warn(`Reserved config transform '${key}'`)
|
|
}
|
|
return
|
|
}
|
|
|
|
this.generator.configTransforms[key] = new ConfigTransform(options)
|
|
}
|
|
|
|
/**
|
|
* Extend the package.json of the project.
|
|
* Also resolves dependency conflicts between plugins.
|
|
* Tool configuration fields may be extracted into standalone files before
|
|
* files are written to disk.
|
|
*
|
|
* @param {object | () => object} fields - Fields to merge.
|
|
* @param {object} [options] - Options for extending / merging fields.
|
|
* @param {boolean} [options.prune=false] - Remove null or undefined fields
|
|
* from the object after merging.
|
|
* @param {boolean} [options.merge=true] deep-merge nested fields, note
|
|
* that dependency fields are always deep merged regardless of this option.
|
|
* @param {boolean} [options.warnIncompatibleVersions=true] Output warning
|
|
* if two dependency version ranges don't intersect.
|
|
*/
|
|
extendPackage (fields, options = {}) {
|
|
const extendOptions = {
|
|
prune: false,
|
|
merge: true,
|
|
warnIncompatibleVersions: true
|
|
}
|
|
|
|
// this condition statement is added for compatibility reason, because
|
|
// in version 4.0.0 to 4.1.2, there's no `options` object, but a `forceNewVersion` flag
|
|
if (typeof options === 'boolean') {
|
|
extendOptions.warnIncompatibleVersions = !options
|
|
} else {
|
|
Object.assign(extendOptions, options)
|
|
}
|
|
|
|
const pkg = this.generator.pkg
|
|
const toMerge = isFunction(fields) ? fields(pkg) : fields
|
|
for (const key in toMerge) {
|
|
const value = toMerge[key]
|
|
const existing = pkg[key]
|
|
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
|
|
// use special version resolution merge
|
|
pkg[key] = mergeDeps(
|
|
this.id,
|
|
existing || {},
|
|
value,
|
|
this.generator.depSources,
|
|
extendOptions
|
|
)
|
|
} else if (!extendOptions.merge || !(key in pkg)) {
|
|
pkg[key] = value
|
|
} else if (Array.isArray(value) && Array.isArray(existing)) {
|
|
pkg[key] = mergeArrayWithDedupe(existing, value)
|
|
} else if (isObject(value) && isObject(existing)) {
|
|
pkg[key] = deepmerge(existing, value, { arrayMerge: mergeArrayWithDedupe })
|
|
} else {
|
|
pkg[key] = value
|
|
}
|
|
}
|
|
|
|
if (extendOptions.prune) {
|
|
pruneObject(pkg)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render template files into the virtual files tree object.
|
|
*
|
|
* @param {string | object | FileMiddleware} source -
|
|
* Can be one of:
|
|
* - relative path to a directory;
|
|
* - Object hash of { sourceTemplate: targetFile } mappings;
|
|
* - a custom file middleware function.
|
|
* @param {object} [additionalData] - additional data available to templates.
|
|
* @param {object} [ejsOptions] - options for ejs.
|
|
*/
|
|
render (source, additionalData = {}, ejsOptions = {}) {
|
|
const baseDir = extractCallDir()
|
|
if (isString(source)) {
|
|
source = path.resolve(baseDir, source)
|
|
this._injectFileMiddleware(async (files) => {
|
|
const data = this._resolveData(additionalData)
|
|
const globby = require('globby')
|
|
const _files = await globby(['**/*'], { cwd: source })
|
|
for (const rawPath of _files) {
|
|
const targetPath = rawPath.split('/').map(filename => {
|
|
// dotfiles are ignored when published to npm, therefore in templates
|
|
// we need to use underscore instead (e.g. "_gitignore")
|
|
if (filename.charAt(0) === '_' && filename.charAt(1) !== '_') {
|
|
return `.${filename.slice(1)}`
|
|
}
|
|
if (filename.charAt(0) === '_' && filename.charAt(1) === '_') {
|
|
return `${filename.slice(1)}`
|
|
}
|
|
return filename
|
|
}).join('/')
|
|
const sourcePath = path.resolve(source, rawPath)
|
|
const content = renderFile(sourcePath, data, ejsOptions)
|
|
// only set file if it's not all whitespace, or is a Buffer (binary files)
|
|
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
|
|
files[targetPath] = content
|
|
}
|
|
}
|
|
})
|
|
} else if (isObject(source)) {
|
|
this._injectFileMiddleware(files => {
|
|
const data = this._resolveData(additionalData)
|
|
for (const targetPath in source) {
|
|
const sourcePath = path.resolve(baseDir, source[targetPath])
|
|
const content = renderFile(sourcePath, data, ejsOptions)
|
|
if (Buffer.isBuffer(content) || content.trim()) {
|
|
files[targetPath] = content
|
|
}
|
|
}
|
|
})
|
|
} else if (isFunction(source)) {
|
|
this._injectFileMiddleware(source)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Push a file middleware that will be applied after all normal file
|
|
* middelwares have been applied.
|
|
*
|
|
* @param {FileMiddleware} cb
|
|
*/
|
|
postProcessFiles (cb) {
|
|
this.generator.postProcessFilesCbs.push(cb)
|
|
}
|
|
|
|
/**
|
|
* Push a callback to be called when the files have been written to disk.
|
|
*
|
|
* @param {function} cb
|
|
*/
|
|
onCreateComplete (cb) {
|
|
this.afterInvoke(cb)
|
|
}
|
|
|
|
afterInvoke (cb) {
|
|
this.generator.afterInvokeCbs.push(cb)
|
|
}
|
|
|
|
/**
|
|
* Push a callback to be called when the files have been written to disk
|
|
* from non invoked plugins
|
|
*
|
|
* @param {function} cb
|
|
*/
|
|
afterAnyInvoke (cb) {
|
|
this.generator.afterAnyInvokeCbs.push(cb)
|
|
}
|
|
|
|
/**
|
|
* Add a message to be printed when the generator exits (after any other standard messages).
|
|
*
|
|
* @param {} msg String or value to print after the generation is completed
|
|
* @param {('log'|'info'|'done'|'warn'|'error')} [type='log'] Type of message
|
|
*/
|
|
exitLog (msg, type = 'log') {
|
|
this.generator.exitLogs.push({ id: this.id, msg, type })
|
|
}
|
|
|
|
/**
|
|
* convenience method for generating a js config file from json
|
|
*/
|
|
genJSConfig (value) {
|
|
return `module.exports = ${stringifyJS(value, null, 2)}`
|
|
}
|
|
|
|
/**
|
|
* Turns a string expression into executable JS for JS configs.
|
|
* @param {*} str JS expression as a string
|
|
*/
|
|
makeJSOnlyValue (str) {
|
|
const fn = () => {}
|
|
fn.__expression = str
|
|
return fn
|
|
}
|
|
|
|
/**
|
|
* Run codemod on a script file or the script part of a .vue file
|
|
* @param {string} file the path to the file to transform
|
|
* @param {Codemod} codemod the codemod module to run
|
|
* @param {object} options additional options for the codemod
|
|
*/
|
|
transformScript (file, codemod, options) {
|
|
const normalizedPath = this._normalizePath(file)
|
|
|
|
this._injectFileMiddleware(files => {
|
|
if (typeof files[normalizedPath] === 'undefined') {
|
|
error(`Cannot find file ${normalizedPath}`)
|
|
return
|
|
}
|
|
|
|
files[normalizedPath] = runTransformation(
|
|
{
|
|
path: this.resolve(normalizedPath),
|
|
source: files[normalizedPath]
|
|
},
|
|
codemod,
|
|
options
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Add import statements to a file.
|
|
*/
|
|
injectImports (file, imports) {
|
|
const _imports = (
|
|
this.generator.imports[file] ||
|
|
(this.generator.imports[file] = new Set())
|
|
)
|
|
;(Array.isArray(imports) ? imports : [imports]).forEach(imp => {
|
|
_imports.add(imp)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Add options to the root Vue instance (detected by `new Vue`).
|
|
*/
|
|
injectRootOptions (file, options) {
|
|
const _options = (
|
|
this.generator.rootOptions[file] ||
|
|
(this.generator.rootOptions[file] = new Set())
|
|
)
|
|
;(Array.isArray(options) ? options : [options]).forEach(opt => {
|
|
_options.add(opt)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get the entry file taking into account typescript.
|
|
*
|
|
* @readonly
|
|
*/
|
|
get entryFile () {
|
|
if (this._entryFile) return this._entryFile
|
|
return (this._entryFile = fs.existsSync(this.resolve('src/main.ts')) ? 'src/main.ts' : 'src/main.js')
|
|
}
|
|
|
|
/**
|
|
* Is the plugin being invoked?
|
|
*
|
|
* @readonly
|
|
*/
|
|
get invoking () {
|
|
return this.generator.invoking
|
|
}
|
|
}
|
|
|
|
function extractCallDir () {
|
|
// extract api.render() callsite file location using error stack
|
|
const obj = {}
|
|
Error.captureStackTrace(obj)
|
|
const callSite = obj.stack.split('\n')[3]
|
|
|
|
// the regexp for the stack when called inside a named function
|
|
const namedStackRegExp = /\s\((.*):\d+:\d+\)$/
|
|
// the regexp for the stack when called inside an anonymous
|
|
const anonymousStackRegExp = /at (.*):\d+:\d+$/
|
|
|
|
let matchResult = callSite.match(namedStackRegExp)
|
|
if (!matchResult) {
|
|
matchResult = callSite.match(anonymousStackRegExp)
|
|
}
|
|
|
|
const fileName = matchResult[1]
|
|
return path.dirname(fileName)
|
|
}
|
|
|
|
const replaceBlockRE = /<%# REPLACE %>([^]*?)<%# END_REPLACE %>/g
|
|
|
|
function renderFile (name, data, ejsOptions) {
|
|
if (isBinaryFileSync(name)) {
|
|
return fs.readFileSync(name) // return buffer
|
|
}
|
|
const template = fs.readFileSync(name, 'utf-8')
|
|
|
|
// custom template inheritance via yaml front matter.
|
|
// ---
|
|
// extend: 'source-file'
|
|
// replace: !!js/regexp /some-regex/
|
|
// OR
|
|
// replace:
|
|
// - !!js/regexp /foo/
|
|
// - !!js/regexp /bar/
|
|
// ---
|
|
const yaml = require('yaml-front-matter')
|
|
const parsed = yaml.loadFront(template)
|
|
const content = parsed.__content
|
|
let finalTemplate = content.trim() + `\n`
|
|
|
|
if (parsed.when) {
|
|
finalTemplate = (
|
|
`<%_ if (${parsed.when}) { _%>` +
|
|
finalTemplate +
|
|
`<%_ } _%>`
|
|
)
|
|
|
|
// use ejs.render to test the conditional expression
|
|
// if evaluated to falsy value, return early to avoid extra cost for extend expression
|
|
const result = ejs.render(finalTemplate, data, ejsOptions)
|
|
if (!result) {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
if (parsed.extend) {
|
|
const extendPath = path.isAbsolute(parsed.extend)
|
|
? parsed.extend
|
|
: resolve.sync(parsed.extend, { basedir: path.dirname(name) })
|
|
finalTemplate = fs.readFileSync(extendPath, 'utf-8')
|
|
if (parsed.replace) {
|
|
if (Array.isArray(parsed.replace)) {
|
|
const replaceMatch = content.match(replaceBlockRE)
|
|
if (replaceMatch) {
|
|
const replaces = replaceMatch.map(m => {
|
|
return m.replace(replaceBlockRE, '$1').trim()
|
|
})
|
|
parsed.replace.forEach((r, i) => {
|
|
finalTemplate = finalTemplate.replace(r, replaces[i])
|
|
})
|
|
}
|
|
} else {
|
|
finalTemplate = finalTemplate.replace(parsed.replace, content.trim())
|
|
}
|
|
}
|
|
}
|
|
|
|
return ejs.render(finalTemplate, data, ejsOptions)
|
|
}
|
|
|
|
module.exports = GeneratorAPI
|