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 { runTransformation } = require('vue-codemod')
|
9 | const stringifyJS = require('./util/stringifyJS')
|
10 | const ConfigTransform = require('./ConfigTransform')
|
11 | const { semver, error, getPluginLink, toShortPluginId, loadModule } = require('@vue/cli-shared-utils')
|
12 |
|
13 | const isString = val => typeof val === 'string'
|
14 | const isFunction = val => typeof val === 'function'
|
15 | const isObject = val => val && typeof val === 'object'
|
16 | const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]))
|
17 | function 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 |
|
35 | class GeneratorAPI {
|
36 | |
37 |
|
38 |
|
39 |
|
40 |
|
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 |
|
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 |
|
56 |
|
57 | this._entryFile = undefined
|
58 | }
|
59 |
|
60 | |
61 |
|
62 |
|
63 |
|
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 |
|
75 |
|
76 |
|
77 |
|
78 |
|
79 |
|
80 | _injectFileMiddleware (middleware) {
|
81 | this.generator.fileMiddlewares.push(middleware)
|
82 | }
|
83 |
|
84 | |
85 |
|
86 |
|
87 |
|
88 |
|
89 | _normalizePath (p) {
|
90 | if (path.isAbsolute(p)) {
|
91 | p = path.relative(this.generator.context, p)
|
92 | }
|
93 |
|
94 |
|
95 | return p.replace(/\\/g, '/')
|
96 | }
|
97 |
|
98 | |
99 |
|
100 |
|
101 |
|
102 |
|
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 |
|
132 |
|
133 |
|
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 |
|
166 |
|
167 |
|
168 |
|
169 |
|
170 |
|
171 | hasPlugin (id, version) {
|
172 | return this.generator.hasPlugin(id, version)
|
173 | }
|
174 |
|
175 | |
176 |
|
177 |
|
178 |
|
179 |
|
180 |
|
181 |
|
182 |
|
183 |
|
184 |
|
185 |
|
186 |
|
187 |
|
188 |
|
189 |
|
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 |
|
210 |
|
211 |
|
212 |
|
213 |
|
214 |
|
215 |
|
216 |
|
217 |
|
218 |
|
219 |
|
220 |
|
221 |
|
222 |
|
223 | extendPackage (fields, options = {}) {
|
224 | const extendOptions = {
|
225 | prune: false,
|
226 | merge: true,
|
227 | warnIncompatibleVersions: true
|
228 | }
|
229 |
|
230 |
|
231 |
|
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 |
|
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 |
|
270 |
|
271 |
|
272 |
|
273 |
|
274 |
|
275 |
|
276 |
|
277 |
|
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 |
|
290 |
|
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 |
|
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 |
|
325 |
|
326 |
|
327 |
|
328 |
|
329 | postProcessFiles (cb) {
|
330 | this.generator.postProcessFilesCbs.push(cb)
|
331 | }
|
332 |
|
333 | |
334 |
|
335 |
|
336 |
|
337 |
|
338 | onCreateComplete (cb) {
|
339 | this.afterInvoke(cb)
|
340 | }
|
341 |
|
342 | afterInvoke (cb) {
|
343 | this.generator.afterInvokeCbs.push(cb)
|
344 | }
|
345 |
|
346 | |
347 |
|
348 |
|
349 |
|
350 |
|
351 |
|
352 | afterAnyInvoke (cb) {
|
353 | this.generator.afterAnyInvokeCbs.push(cb)
|
354 | }
|
355 |
|
356 | |
357 |
|
358 |
|
359 |
|
360 |
|
361 |
|
362 | exitLog (msg, type = 'log') {
|
363 | this.generator.exitLogs.push({ id: this.id, msg, type })
|
364 | }
|
365 |
|
366 | |
367 |
|
368 |
|
369 | genJSConfig (value) {
|
370 | return `module.exports = ${stringifyJS(value, null, 2)}`
|
371 | }
|
372 |
|
373 | |
374 |
|
375 |
|
376 |
|
377 | makeJSOnlyValue (str) {
|
378 | const fn = () => {}
|
379 | fn.__expression = str
|
380 | return fn
|
381 | }
|
382 |
|
383 | |
384 |
|
385 |
|
386 |
|
387 |
|
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 |
|
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 |
|
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 |
|
437 |
|
438 |
|
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 |
|
447 |
|
448 |
|
449 |
|
450 | get invoking () {
|
451 | return this.generator.invoking
|
452 | }
|
453 | }
|
454 |
|
455 | function extractCallDir () {
|
456 |
|
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 |
|
464 | const replaceBlockRE = /<%# REPLACE %>([^]*?)<%# END_REPLACE %>/g
|
465 |
|
466 | function renderFile (name, data, ejsOptions) {
|
467 | if (isBinaryFileSync(name)) {
|
468 | return fs.readFileSync(name)
|
469 | }
|
470 | const template = fs.readFileSync(name, 'utf-8')
|
471 |
|
472 |
|
473 |
|
474 |
|
475 |
|
476 |
|
477 |
|
478 |
|
479 |
|
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 |
|
494 |
|
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 |
|
526 | module.exports = GeneratorAPI
|