123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- 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 runCodemod = require('./util/runCodemod')
- const stringifyJS = require('./util/stringifyJS')
- const ConfigTransform = require('./ConfigTransform')
- const { semver, 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)
- }
- /**
- * 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, version) {
- return this.generator.hasPlugin(id, version)
- }
- /**
- * 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 compatiblity 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) {
- this._injectFileMiddleware(files => {
- files[file] = runCodemod(
- codemod,
- { path: this.resolve(file), source: files[file] },
- 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]
- const fileName = callSite.match(/\s\((.*):\d+:\d+\)$/)[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
|