UNPKG

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