GeneratorAPI.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. const fs = require('fs')
  2. const ejs = require('ejs')
  3. const path = require('path')
  4. const deepmerge = require('deepmerge')
  5. const resolve = require('resolve')
  6. const { isBinaryFileSync } = require('isbinaryfile')
  7. const mergeDeps = require('./util/mergeDeps')
  8. const runCodemod = require('./util/runCodemod')
  9. const stringifyJS = require('./util/stringifyJS')
  10. const ConfigTransform = require('./ConfigTransform')
  11. const { semver, getPluginLink, toShortPluginId, loadModule } = require('@vue/cli-shared-utils')
  12. const isString = val => typeof val === 'string'
  13. const isFunction = val => typeof val === 'function'
  14. const isObject = val => val && typeof val === 'object'
  15. const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]))
  16. function pruneObject (obj) {
  17. if (typeof obj === 'object') {
  18. for (const k in obj) {
  19. if (!obj.hasOwnProperty(k)) {
  20. continue
  21. }
  22. if (obj[k] == null) {
  23. delete obj[k]
  24. } else {
  25. obj[k] = pruneObject(obj[k])
  26. }
  27. }
  28. }
  29. return obj
  30. }
  31. class GeneratorAPI {
  32. /**
  33. * @param {string} id - Id of the owner plugin
  34. * @param {Generator} generator - The invoking Generator instance
  35. * @param {object} options - generator options passed to this plugin
  36. * @param {object} rootOptions - root options (the entire preset)
  37. */
  38. constructor (id, generator, options, rootOptions) {
  39. this.id = id
  40. this.generator = generator
  41. this.options = options
  42. this.rootOptions = rootOptions
  43. /* eslint-disable no-shadow */
  44. this.pluginsData = generator.plugins
  45. .filter(({ id }) => id !== `@vue/cli-service`)
  46. .map(({ id }) => ({
  47. name: toShortPluginId(id),
  48. link: getPluginLink(id)
  49. }))
  50. /* eslint-enable no-shadow */
  51. this._entryFile = undefined
  52. }
  53. /**
  54. * Resolves the data when rendering templates.
  55. *
  56. * @private
  57. */
  58. _resolveData (additionalData) {
  59. return Object.assign({
  60. options: this.options,
  61. rootOptions: this.rootOptions,
  62. plugins: this.pluginsData
  63. }, additionalData)
  64. }
  65. /**
  66. * Inject a file processing middleware.
  67. *
  68. * @private
  69. * @param {FileMiddleware} middleware - A middleware function that receives the
  70. * virtual files tree object, and an ejs render function. Can be async.
  71. */
  72. _injectFileMiddleware (middleware) {
  73. this.generator.fileMiddlewares.push(middleware)
  74. }
  75. /**
  76. * Resolve path for a project.
  77. *
  78. * @param {string} _paths - A sequence of relative paths or path segments
  79. * @return {string} The resolved absolute path, caculated based on the current project root.
  80. */
  81. resolve (..._paths) {
  82. return path.resolve(this.generator.context, ..._paths)
  83. }
  84. get cliVersion () {
  85. return require('../package.json').version
  86. }
  87. assertCliVersion (range) {
  88. if (typeof range === 'number') {
  89. if (!Number.isInteger(range)) {
  90. throw new Error('Expected string or integer value.')
  91. }
  92. range = `^${range}.0.0-0`
  93. }
  94. if (typeof range !== 'string') {
  95. throw new Error('Expected string or integer value.')
  96. }
  97. if (semver.satisfies(this.cliVersion, range, { includePrerelease: true })) return
  98. throw new Error(
  99. `Require global @vue/cli "${range}", but was invoked by "${this.cliVersion}".`
  100. )
  101. }
  102. get cliServiceVersion () {
  103. // In generator unit tests, we don't write the actual file back to the disk.
  104. // So there is no cli-service module to load.
  105. // In that case, just return the cli version.
  106. if (process.env.VUE_CLI_TEST && process.env.VUE_CLI_SKIP_WRITE) {
  107. return this.cliVersion
  108. }
  109. const servicePkg = loadModule(
  110. '@vue/cli-service/package.json',
  111. this.generator.context
  112. )
  113. return servicePkg.version
  114. }
  115. assertCliServiceVersion (range) {
  116. if (typeof range === 'number') {
  117. if (!Number.isInteger(range)) {
  118. throw new Error('Expected string or integer value.')
  119. }
  120. range = `^${range}.0.0-0`
  121. }
  122. if (typeof range !== 'string') {
  123. throw new Error('Expected string or integer value.')
  124. }
  125. if (semver.satisfies(this.cliServiceVersion, range, { includePrerelease: true })) return
  126. throw new Error(
  127. `Require @vue/cli-service "${range}", but was loaded with "${this.cliServiceVersion}".`
  128. )
  129. }
  130. /**
  131. * Check if the project has a given plugin.
  132. *
  133. * @param {string} id - Plugin id, can omit the (@vue/|vue-|@scope/vue)-cli-plugin- prefix
  134. * @param {string} version - Plugin version. Defaults to ''
  135. * @return {boolean}
  136. */
  137. hasPlugin (id, version) {
  138. return this.generator.hasPlugin(id, version)
  139. }
  140. /**
  141. * Configure how config files are extracted.
  142. *
  143. * @param {string} key - Config key in package.json
  144. * @param {object} options - Options
  145. * @param {object} options.file - File descriptor
  146. * Used to search for existing file.
  147. * Each key is a file type (possible values: ['js', 'json', 'yaml', 'lines']).
  148. * The value is a list of filenames.
  149. * Example:
  150. * {
  151. * js: ['.eslintrc.js'],
  152. * json: ['.eslintrc.json', '.eslintrc']
  153. * }
  154. * By default, the first filename will be used to create the config file.
  155. */
  156. addConfigTransform (key, options) {
  157. const hasReserved = Object.keys(this.generator.reservedConfigTransforms).includes(key)
  158. if (
  159. hasReserved ||
  160. !options ||
  161. !options.file
  162. ) {
  163. if (hasReserved) {
  164. const { warn } = require('@vue/cli-shared-utils')
  165. warn(`Reserved config transform '${key}'`)
  166. }
  167. return
  168. }
  169. this.generator.configTransforms[key] = new ConfigTransform(options)
  170. }
  171. /**
  172. * Extend the package.json of the project.
  173. * Also resolves dependency conflicts between plugins.
  174. * Tool configuration fields may be extracted into standalone files before
  175. * files are written to disk.
  176. *
  177. * @param {object | () => object} fields - Fields to merge.
  178. * @param {object} [options] - Options for extending / merging fields.
  179. * @param {boolean} [options.prune=false] - Remove null or undefined fields
  180. * from the object after merging.
  181. * @param {boolean} [options.merge=true] deep-merge nested fields, note
  182. * that dependency fields are always deep merged regardless of this option.
  183. * @param {boolean} [options.warnIncompatibleVersions=true] Output warning
  184. * if two dependency version ranges don't intersect.
  185. */
  186. extendPackage (fields, options = {}) {
  187. const extendOptions = {
  188. prune: false,
  189. merge: true,
  190. warnIncompatibleVersions: true
  191. }
  192. // this condition statement is added for compatiblity reason, because
  193. // in version 4.0.0 to 4.1.2, there's no `options` object, but a `forceNewVersion` flag
  194. if (typeof options === 'boolean') {
  195. extendOptions.warnIncompatibleVersions = !options
  196. } else {
  197. Object.assign(extendOptions, options)
  198. }
  199. const pkg = this.generator.pkg
  200. const toMerge = isFunction(fields) ? fields(pkg) : fields
  201. for (const key in toMerge) {
  202. const value = toMerge[key]
  203. const existing = pkg[key]
  204. if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
  205. // use special version resolution merge
  206. pkg[key] = mergeDeps(
  207. this.id,
  208. existing || {},
  209. value,
  210. this.generator.depSources,
  211. extendOptions
  212. )
  213. } else if (!extendOptions.merge || !(key in pkg)) {
  214. pkg[key] = value
  215. } else if (Array.isArray(value) && Array.isArray(existing)) {
  216. pkg[key] = mergeArrayWithDedupe(existing, value)
  217. } else if (isObject(value) && isObject(existing)) {
  218. pkg[key] = deepmerge(existing, value, { arrayMerge: mergeArrayWithDedupe })
  219. } else {
  220. pkg[key] = value
  221. }
  222. }
  223. if (extendOptions.prune) {
  224. pruneObject(pkg)
  225. }
  226. }
  227. /**
  228. * Render template files into the virtual files tree object.
  229. *
  230. * @param {string | object | FileMiddleware} source -
  231. * Can be one of:
  232. * - relative path to a directory;
  233. * - Object hash of { sourceTemplate: targetFile } mappings;
  234. * - a custom file middleware function.
  235. * @param {object} [additionalData] - additional data available to templates.
  236. * @param {object} [ejsOptions] - options for ejs.
  237. */
  238. render (source, additionalData = {}, ejsOptions = {}) {
  239. const baseDir = extractCallDir()
  240. if (isString(source)) {
  241. source = path.resolve(baseDir, source)
  242. this._injectFileMiddleware(async (files) => {
  243. const data = this._resolveData(additionalData)
  244. const globby = require('globby')
  245. const _files = await globby(['**/*'], { cwd: source })
  246. for (const rawPath of _files) {
  247. const targetPath = rawPath.split('/').map(filename => {
  248. // dotfiles are ignored when published to npm, therefore in templates
  249. // we need to use underscore instead (e.g. "_gitignore")
  250. if (filename.charAt(0) === '_' && filename.charAt(1) !== '_') {
  251. return `.${filename.slice(1)}`
  252. }
  253. if (filename.charAt(0) === '_' && filename.charAt(1) === '_') {
  254. return `${filename.slice(1)}`
  255. }
  256. return filename
  257. }).join('/')
  258. const sourcePath = path.resolve(source, rawPath)
  259. const content = renderFile(sourcePath, data, ejsOptions)
  260. // only set file if it's not all whitespace, or is a Buffer (binary files)
  261. if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
  262. files[targetPath] = content
  263. }
  264. }
  265. })
  266. } else if (isObject(source)) {
  267. this._injectFileMiddleware(files => {
  268. const data = this._resolveData(additionalData)
  269. for (const targetPath in source) {
  270. const sourcePath = path.resolve(baseDir, source[targetPath])
  271. const content = renderFile(sourcePath, data, ejsOptions)
  272. if (Buffer.isBuffer(content) || content.trim()) {
  273. files[targetPath] = content
  274. }
  275. }
  276. })
  277. } else if (isFunction(source)) {
  278. this._injectFileMiddleware(source)
  279. }
  280. }
  281. /**
  282. * Push a file middleware that will be applied after all normal file
  283. * middelwares have been applied.
  284. *
  285. * @param {FileMiddleware} cb
  286. */
  287. postProcessFiles (cb) {
  288. this.generator.postProcessFilesCbs.push(cb)
  289. }
  290. /**
  291. * Push a callback to be called when the files have been written to disk.
  292. *
  293. * @param {function} cb
  294. */
  295. onCreateComplete (cb) {
  296. this.afterInvoke(cb)
  297. }
  298. afterInvoke (cb) {
  299. this.generator.afterInvokeCbs.push(cb)
  300. }
  301. /**
  302. * Push a callback to be called when the files have been written to disk
  303. * from non invoked plugins
  304. *
  305. * @param {function} cb
  306. */
  307. afterAnyInvoke (cb) {
  308. this.generator.afterAnyInvokeCbs.push(cb)
  309. }
  310. /**
  311. * Add a message to be printed when the generator exits (after any other standard messages).
  312. *
  313. * @param {} msg String or value to print after the generation is completed
  314. * @param {('log'|'info'|'done'|'warn'|'error')} [type='log'] Type of message
  315. */
  316. exitLog (msg, type = 'log') {
  317. this.generator.exitLogs.push({ id: this.id, msg, type })
  318. }
  319. /**
  320. * convenience method for generating a js config file from json
  321. */
  322. genJSConfig (value) {
  323. return `module.exports = ${stringifyJS(value, null, 2)}`
  324. }
  325. /**
  326. * Turns a string expression into executable JS for JS configs.
  327. * @param {*} str JS expression as a string
  328. */
  329. makeJSOnlyValue (str) {
  330. const fn = () => {}
  331. fn.__expression = str
  332. return fn
  333. }
  334. /**
  335. * Run codemod on a script file or the script part of a .vue file
  336. * @param {string} file the path to the file to transform
  337. * @param {Codemod} codemod the codemod module to run
  338. * @param {object} options additional options for the codemod
  339. */
  340. transformScript (file, codemod, options) {
  341. this._injectFileMiddleware(files => {
  342. files[file] = runCodemod(
  343. codemod,
  344. { path: this.resolve(file), source: files[file] },
  345. options
  346. )
  347. })
  348. }
  349. /**
  350. * Add import statements to a file.
  351. */
  352. injectImports (file, imports) {
  353. const _imports = (
  354. this.generator.imports[file] ||
  355. (this.generator.imports[file] = new Set())
  356. )
  357. ;(Array.isArray(imports) ? imports : [imports]).forEach(imp => {
  358. _imports.add(imp)
  359. })
  360. }
  361. /**
  362. * Add options to the root Vue instance (detected by `new Vue`).
  363. */
  364. injectRootOptions (file, options) {
  365. const _options = (
  366. this.generator.rootOptions[file] ||
  367. (this.generator.rootOptions[file] = new Set())
  368. )
  369. ;(Array.isArray(options) ? options : [options]).forEach(opt => {
  370. _options.add(opt)
  371. })
  372. }
  373. /**
  374. * Get the entry file taking into account typescript.
  375. *
  376. * @readonly
  377. */
  378. get entryFile () {
  379. if (this._entryFile) return this._entryFile
  380. return (this._entryFile = fs.existsSync(this.resolve('src/main.ts')) ? 'src/main.ts' : 'src/main.js')
  381. }
  382. /**
  383. * Is the plugin being invoked?
  384. *
  385. * @readonly
  386. */
  387. get invoking () {
  388. return this.generator.invoking
  389. }
  390. }
  391. function extractCallDir () {
  392. // extract api.render() callsite file location using error stack
  393. const obj = {}
  394. Error.captureStackTrace(obj)
  395. const callSite = obj.stack.split('\n')[3]
  396. const fileName = callSite.match(/\s\((.*):\d+:\d+\)$/)[1]
  397. return path.dirname(fileName)
  398. }
  399. const replaceBlockRE = /<%# REPLACE %>([^]*?)<%# END_REPLACE %>/g
  400. function renderFile (name, data, ejsOptions) {
  401. if (isBinaryFileSync(name)) {
  402. return fs.readFileSync(name) // return buffer
  403. }
  404. const template = fs.readFileSync(name, 'utf-8')
  405. // custom template inheritance via yaml front matter.
  406. // ---
  407. // extend: 'source-file'
  408. // replace: !!js/regexp /some-regex/
  409. // OR
  410. // replace:
  411. // - !!js/regexp /foo/
  412. // - !!js/regexp /bar/
  413. // ---
  414. const yaml = require('yaml-front-matter')
  415. const parsed = yaml.loadFront(template)
  416. const content = parsed.__content
  417. let finalTemplate = content.trim() + `\n`
  418. if (parsed.when) {
  419. finalTemplate = (
  420. `<%_ if (${parsed.when}) { _%>` +
  421. finalTemplate +
  422. `<%_ } _%>`
  423. )
  424. // use ejs.render to test the conditional expression
  425. // if evaluated to falsy value, return early to avoid extra cost for extend expression
  426. const result = ejs.render(finalTemplate, data, ejsOptions)
  427. if (!result) {
  428. return ''
  429. }
  430. }
  431. if (parsed.extend) {
  432. const extendPath = path.isAbsolute(parsed.extend)
  433. ? parsed.extend
  434. : resolve.sync(parsed.extend, { basedir: path.dirname(name) })
  435. finalTemplate = fs.readFileSync(extendPath, 'utf-8')
  436. if (parsed.replace) {
  437. if (Array.isArray(parsed.replace)) {
  438. const replaceMatch = content.match(replaceBlockRE)
  439. if (replaceMatch) {
  440. const replaces = replaceMatch.map(m => {
  441. return m.replace(replaceBlockRE, '$1').trim()
  442. })
  443. parsed.replace.forEach((r, i) => {
  444. finalTemplate = finalTemplate.replace(r, replaces[i])
  445. })
  446. }
  447. } else {
  448. finalTemplate = finalTemplate.replace(parsed.replace, content.trim())
  449. }
  450. }
  451. }
  452. return ejs.render(finalTemplate, data, ejsOptions)
  453. }
  454. module.exports = GeneratorAPI