async function init() {
// process.stdout.isTTY 是否在终端运行
// process.stdout.getColorDepth() 支持的颜色数量
console.log(
process.stdout.isTTY && process.stdout.getColorDepth() > 8
? // banners.gradientBanner 彩色文字
banners.gradientBanner
: banners.defaultBanner
)
...
}
banners
const defaultBanner = 'Vue.js - The Progressive JavaScript Framework'
// generated by the following code:
//
// require('gradient-string')([
// { color: '#42d392', pos: 0 },
// { color: '#42d392', pos: 0.1 },
// { color: '#647eff', pos: 1 }
// ])('Vue.js - The Progressive JavaScript Framework'))
//
// Use the output directly here to keep the bundle small.
const gradientBanner =
'\x1B[38;2;66;211;146mV\x1B[39m\x1B[38;2;66;211;146mu\x1B[39m\x1B[38;2;66;211;146me\x1B[39m\x1B[38;2;66;211;146m.\x1B[39m\x1B[38;2;66;211;146mj\x1B[39m\x1B[38;2;67;209;149ms\x1B[39m \x1B[38;2;68;206;152m-\x1B[39m \x1B[38;2;69;204;155mT\x1B[39m\x1B[38;2;70;201;158mh\x1B[39m\x1B[38;2;71;199;162me\x1B[39m \x1B[38;2;72;196;165mP\x1B[39m\x1B[38;2;73;194;168mr\x1B[39m\x1B[38;2;74;192;171mo\x1B[39m\x1B[38;2;75;189;174mg\x1B[39m\x1B[38;2;76;187;177mr\x1B[39m\x1B[38;2;77;184;180me\x1B[39m\x1B[38;2;78;182;183ms\x1B[39m\x1B[38;2;79;179;186ms\x1B[39m\x1B[38;2;80;177;190mi\x1B[39m\x1B[38;2;81;175;193mv\x1B[39m\x1B[38;2;82;172;196me\x1B[39m \x1B[38;2;83;170;199mJ\x1B[39m\x1B[38;2;83;167;202ma\x1B[39m\x1B[38;2;84;165;205mv\x1B[39m\x1B[38;2;85;162;208ma\x1B[39m\x1B[38;2;86;160;211mS\x1B[39m\x1B[38;2;87;158;215mc\x1B[39m\x1B[38;2;88;155;218mr\x1B[39m\x1B[38;2;89;153;221mi\x1B[39m\x1B[38;2;90;150;224mp\x1B[39m\x1B[38;2;91;148;227mt\x1B[39m \x1B[38;2;92;145;230mF\x1B[39m\x1B[38;2;93;143;233mr\x1B[39m\x1B[38;2;94;141;236ma\x1B[39m\x1B[38;2;95;138;239mm\x1B[39m\x1B[38;2;96;136;243me\x1B[39m\x1B[38;2;97;133;246mw\x1B[39m\x1B[38;2;98;131;249mo\x1B[39m\x1B[38;2;99;128;252mr\x1B[39m\x1B[38;2;100;126;255mk\x1B[39m'
export { defaultBanner, gradientBanner }
async function init() {
const cwd = process.cwd()
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --pinia
// --with-tests / --tests (equals to `--vitest --cypress`)
// --vitest
// --cypress
// --nightwatch
// --playwright
// --eslint
// --eslint-with-prettier (only support prettier through eslint for simplicity)
// --force (for force overwriting)
// 解析命令行参数
const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'], // 别名映射, typescript 还会被映射成 ts
'with-tests': ['tests'],
router: ['vue-router']
},
string: ['_'],
// all arguments are treated as booleans
boolean: true
})
// if any of the feature flags is set, we would skip the feature prompts
// 是否命令行传入了参数,传入了则跳过后续交互式选择
const isFeatureFlagsUsed =
typeof (
argv.default ??
argv.ts ??
argv.jsx ??
argv.router ??
argv.pinia ??
argv.tests ??
argv.vitest ??
argv.cypress ??
argv.nightwatch ??
argv.playwright ??
argv.eslint
) === 'boolean'
// 获取创建的文件名
let targetDir = argv._[0]
console.log('@targetDir', targetDir)
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
const forceOverwrite = argv.force
}
// 根据用户时区语言,拿到对应的预设国际化内容 (locales文件夹下)
const language = getLanguage()
// 返回用户语言
function getLocale() {
const shellLocale =
Intl.DateTimeFormat().resolvedOptions().locale || // Built-in ECMA-402 support
process.env.LC_ALL || // POSIX locale environment variables
process.env.LC_MESSAGES ||
process.env.LANG ||
// TODO: Windows support if needed, could consider https://www.npmjs.com/package/os-locale
'en-US' // Default fallback
const locale = shellLocale.split('.')[0].replace('_', '-')
return locale
}
// 拿到国际化文件内容
export default function getLanguage() {
const locale = getLocale()
// Note here __dirname would not be transpiled,
// so it refers to the __dirname of the file `/outfile.cjs`
// TODO: use glob import once https://github.com/evanw/esbuild/issues/3320 is fixed
const localesRoot = path.resolve(__dirname, 'locales')
const languageFilePath = path.resolve(localesRoot, `${locale}.json`)
const doesLanguageExist = fs.existsSync(languageFilePath)
if (!doesLanguageExist) {
console.warn(
`\x1B[33mThe locale langage "${locale}" is not supported, fallback to "en-US".\n\x1B[39m`
)
}
const lang: Language = doesLanguageExist
? require(languageFilePath)
: require(path.resolve(localesRoot, 'en-US.json'))
return lang
}
let result: {
projectName?: string
shouldOverwrite?: boolean
packageName?: string
needsTypeScript?: boolean
needsJsx?: boolean
needsRouter?: boolean
needsPinia?: boolean
needsVitest?: boolean
needsE2eTesting?: false | 'cypress' | 'nightwatch' | 'playwright'
needsEslint?: boolean
needsPrettier?: boolean
} = {}
try {
// Prompts:
// - Project name:
// - whether to overwrite the existing directory or not?
// - enter a valid package name for package.json
// - Project language: JavaScript / TypeScript
// - Add JSX Support?
// - Install Vue Router for SPA development?
// - Install Pinia for state management?
// - Add Cypress for testing?
// - Add Nightwatch for testing?
// - Add Playwright for end-to-end testing?
// - Add ESLint for code quality?
// - Add Prettier for code formatting?
console.log('@target', targetDir)
result = await prompts(
[
{
name: 'projectName',
type: targetDir ? null : 'text',
message: language.projectName.message,
initial: defaultProjectName,
onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},
{
name: 'shouldOverwrite',
type: () => (canSkipEmptying(targetDir) || forceOverwrite ? null : 'toggle'),
message: () => {
const dirForPrompt =
targetDir === '.'
? language.shouldOverwrite.dirForPrompts.current
: `${language.shouldOverwrite.dirForPrompts.target} "${targetDir}"`
return `${dirForPrompt} ${language.shouldOverwrite.message}`
},
initial: true,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'overwriteChecker',
type: (prev, values) => {
if (values.shouldOverwrite === false) {
throw new Error(red('✖') + ` ${language.errors.operationCancelled}`)
}
return null
}
},
{
name: 'packageName', //输入package.json包名,默认和项目名 targetDir 一致
type: () => (isValidPackageName(targetDir) ? null : 'text'),
message: language.packageName.message,
initial: () => toValidPackageName(targetDir), // 不合法的 targetDir 会进行转换
validate: (dir) => isValidPackageName(dir) || language.packageName.invalidMessage
},
{
name: 'needsTypeScript',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: language.needsTypeScript.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'needsJsx',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: language.needsJsx.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'needsRouter',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: language.needsRouter.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'needsPinia',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: language.needsPinia.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'needsVitest',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: language.needsVitest.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'needsE2eTesting',
type: () => (isFeatureFlagsUsed ? null : 'select'),
hint: language.needsE2eTesting.hint,
message: language.needsE2eTesting.message,
initial: 0,
choices: (prev, answers) => [
{
title: language.needsE2eTesting.selectOptions.negative.title,
value: false
},
{
title: language.needsE2eTesting.selectOptions.cypress.title,
description: answers.needsVitest
? undefined
: language.needsE2eTesting.selectOptions.cypress.desc,
value: 'cypress'
},
{
title: language.needsE2eTesting.selectOptions.nightwatch.title,
description: answers.needsVitest
? undefined
: language.needsE2eTesting.selectOptions.nightwatch.desc,
value: 'nightwatch'
},
{
title: language.needsE2eTesting.selectOptions.playwright.title,
value: 'playwright'
}
]
},
{
name: 'needsEslint',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: language.needsEslint.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
},
{
name: 'needsPrettier',
type: (prev, values) => {
if (isFeatureFlagsUsed || !values.needsEslint) {
return null
}
return 'toggle'
},
message: language.needsPrettier.message,
initial: false,
active: language.defaultToggleOptions.active,
inactive: language.defaultToggleOptions.inactive
}
],
{
onCancel: () => {
throw new Error(red('✖') + ` ${language.errors.operationCancelled}`)
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
process.exit(1)
}
// `initial` won't take effect if the prompt type is null
// so we still have to assign the default values here
const {
projectName,
packageName = projectName ?? defaultProjectName,
shouldOverwrite = argv.force,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsPinia = argv.pinia,
needsVitest = argv.vitest || argv.tests,
needsEslint = argv.eslint || argv['eslint-with-prettier'],
needsPrettier = argv['eslint-with-prettier']
} = result
const { needsE2eTesting } = result
const needsCypress = argv.cypress || argv.tests || needsE2eTesting === 'cypress'
const needsCypressCT = needsCypress && !needsVitest
const needsNightwatch = argv.nightwatch || needsE2eTesting === 'nightwatch'
const needsNightwatchCT = needsNightwatch && !needsVitest
const needsPlaywright = argv.playwright || needsE2eTesting === 'playwright'
const root = path.join(cwd, targetDir)
// 递归删除文件夹和文件
if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return
}
postOrderDirectoryTraverse(
dir,
(dir) => fs.rmdirSync(dir),
(file) => fs.unlinkSync(file)
)
}
export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
for (const filename of fs.readdirSync(dir)) {
if (filename === '.git') {
continue
}
const fullpath = path.resolve(dir, filename)
if (fs.lstatSync(fullpath).isDirectory()) {
postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
dirCallback(fullpath)
continue
}
fileCallback(fullpath)
}
}
console.log(`\n${language.infos.scaffolding} ${root}...`)
const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
const templateRoot = path.resolve(__dirname, 'template')
// ejs 模版提供了渲染数据的回调
const callbacks = []
const render = function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root, callbacks)
}
function renderTemplate(src, dest, callbacks) {
const stats = fs.statSync(src)
// path.basename 返回当前路径的目录或文件
if (stats.isDirectory()) {
// skip node_module
if (path.basename(src) === 'node_modules') {
return
}
// if it's a directory, render its subdirectories and files recursively
// dest 使用者要创建的目录地址
fs.mkdirSync(dest, { recursive: true })
// 递归文件夹创建文件
for (const file of fs.readdirSync(src)) {
renderTemplate(path.resolve(src, file), path.resolve(dest, file), callbacks)
}
return
}
const filename = path.basename(src)
// 在上一步已经根据 dest 创建好了 package.json
if (filename === 'package.json' && fs.existsSync(dest)) {
// merge instead of overwriting
const existing = JSON.parse(fs.readFileSync(dest, 'utf8'))
const newPackage = JSON.parse(fs.readFileSync(src, 'utf8'))
// 合并、去重、排序(字符序) package.json
const pkg = sortDependencies(deepMerge(existing, newPackage))
fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n')
return
}
if (filename === 'extensions.json' && fs.existsSync(dest)) {
// merge instead of overwriting
const existing = JSON.parse(fs.readFileSync(dest, 'utf8'))
const newExtensions = JSON.parse(fs.readFileSync(src, 'utf8'))
const extensions = deepMerge(existing, newExtensions)
fs.writeFileSync(dest, JSON.stringify(extensions, null, 2) + '\n')
return
}
if (filename.startsWith('_')) {
// rename `_file` to `.file`
dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))
}
// 追加 .gitignore
if (filename === '_gitignore' && fs.existsSync(dest)) {
// append to existing .gitignore
const existing = fs.readFileSync(dest, 'utf8')
const newGitignore = fs.readFileSync(src, 'utf8')
fs.writeFileSync(dest, existing + '\n' + newGitignore)
return
}
// data file for EJS templates
// node 环境中使用 mjs 语法,import 只能导入 .mjs 模块
if (filename.endsWith('.data.mjs')) {
// use dest path as key for the data store
dest = dest.replace(/\.data\.mjs$/, '')
// Add a callback to the array for late usage when template files are being processed
callbacks.push(async (dataStore) => {
const getData = (await import(pathToFileURL(src).toString())).default
// Though current `getData` are all sync, we still retain the possibility of async
dataStore[dest] = await getData({
oldData: dataStore[dest] || {}
})
})
return // skip copying the data file
}
// [node_modules, package.json, extensions.json, _gitignore, data.mjs] 以外的模块直接复制
fs.copyFileSync(src, dest)
}
// Render base template
render('base')
// Add configs.
if (needsJsx) {
render('config/jsx')
}
if (needsRouter) {
render('config/router')
}
if (needsPinia) {
render('config/pinia')
}
if (needsVitest) {
render('config/vitest')
}
if (needsCypress) {
render('config/cypress')
}
if (needsCypressCT) {
render('config/cypress-ct')
}
if (needsNightwatch) {
render('config/nightwatch')
}
if (needsNightwatchCT) {
render('config/nightwatch-ct')
}
if (needsPlaywright) {
render('config/playwright')
}
if (needsTypeScript) {
render('config/typescript')
// Render tsconfigs
render('tsconfig/base')
if (needsCypress) {
render('tsconfig/cypress')
}
if (needsCypressCT) {
render('tsconfig/cypress-ct')
}
if (needsPlaywright) {
render('tsconfig/playwright')
}
if (needsVitest) {
render('tsconfig/vitest')
}
if (needsNightwatch) {
render('tsconfig/nightwatch')
}
if (needsNightwatchCT) {
render('tsconfig/nightwatch-ct')
}
}
// Render ESLint config
if (needsEslint) {
renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })
}
// Render code template.
// prettier-ignore
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// Render entry file (main.js/ts).
if (needsPinia && needsRouter) {
render('entry/router-and-pinia')
} else if (needsPinia) {
render('entry/pinia')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
// An external data store for callbacks to share data
const dataStore = {}
// Process callbacks
for (const cb of callbacks) {
await cb(dataStore)
}
// EJS template rendering
// 从生成的 root 目录开始渲染 EJS 模版
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.ejs')) {
const template = fs.readFileSync(filepath, 'utf-8')
const dest = filepath.replace(/\.ejs$/, '')
const content = ejs.render(template, dataStore[dest])
fs.writeFileSync(dest, content)
fs.unlinkSync(filepath)
}
}
)
if (needsTypeScript) {
// 转化 js 文件 -> ts 文件 (rename),删除掉原有的 jsconfig.json
// 修改 index.html 中的 js 引入
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.js') && !FILES_TO_FILTER.includes(path.basename(filepath))) {
const tsFilePath = filepath.replace(/\.js$/, '.ts')
if (fs.existsSync(tsFilePath)) {
fs.unlinkSync(filepath)
} else {
fs.renameSync(filepath, tsFilePath)
}
} else if (path.basename(filepath) === 'jsconfig.json') {
fs.unlinkSync(filepath)
}
}
)
// Rename entry in `index.html`
const indexHtmlPath = path.resolve(root, 'index.html')
const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
} else {
// Remove all the remaining `.ts` files
// 不需要 ts 时 删除掉目录中的 ts 文件
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.ts')) {
fs.unlinkSync(filepath)
}
}
)
}
// 确定包管理器: pnpm > yarn > npm
const userAgent = process.env.npm_config_user_agent ?? ''
const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'
// README generation
// 根据需要的文件生成所有的 README
fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName ?? result.packageName ?? defaultProjectName,
packageManager,
needsTypeScript,
needsVitest,
needsCypress,
needsNightwatch,
needsPlaywright,
needsNightwatchCT,
needsCypressCT,
needsEslint
})
)
// 生成项目完成,提示后续辅助工作
// cd xxx
if (root !== cwd) {
// 假设生成的路径是:/Users/username/Projects/My Project
const cdProjectName = path.relative(cwd, root)
// 则控制台命令会被格式化为cd "My Project",而不是cd My Project
console.log(
` ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`
)
}
// pnpm|yarn|npm install
console.log(` ${bold(green(getCommand(packageManager, 'install')))}`)
// prettier format
if (needsPrettier) {
console.log(` ${bold(green(getCommand(packageManager, 'format')))}`)
}
// npm dev
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()