文章目录
- 命令交互
- 输出渐变标题
- 解析命令行参数
- 命令行交互
- 国际化提示
- prompts 库实现命令行交互
- 生成模版
- 创建项目输出文件夹
- 生成 packge.json
- 查找预设的模版文件
- 根据路径生成模块文件
- render 生成模版
- 填充 ejs 模版数据
- 根据生成项目是 ts 还是 js 后置处理
- 根据需要的模块生成所有对应的 README.md 文件
- 结尾提示
命令交互
输出渐变标题
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 booleansboolean: 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' : targetDirconst forceOverwrite = argv.force}
命令行交互
国际化提示
// 根据用户时区语言,拿到对应的预设国际化内容 (locales文件夹下)const language = getLanguage()
// 返回用户语言
function getLocale() {const shellLocale =Intl.DateTimeFormat().resolvedOptions().locale || // Built-in ECMA-402 supportprocess.env.LC_ALL || // POSIX locale environment variablesprocess.env.LC_MESSAGES ||process.env.LANG ||// TODO: Windows support if needed, could consider https://www.npmjs.com/package/os-locale'en-US' // Default fallbackconst 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 `<repositoryRoot>/outfile.cjs`// TODO: use glob import once https://github.com/evanw/esbuild/issues/3320 is fixedconst 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
}
- 国际化配置文件如下所示
prompts 库实现命令行交互
let result: {projectName?: stringshouldOverwrite?: booleanpackageName?: stringneedsTypeScript?: booleanneedsJsx?: booleanneedsRouter?: booleanneedsPinia?: booleanneedsVitest?: booleanneedsE2eTesting?: false | 'cypress' | 'nightwatch' | 'playwright'needsEslint?: booleanneedsPrettier?: 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 hereconst {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']} = resultconst { needsE2eTesting } = resultconst needsCypress = argv.cypress || argv.tests || needsE2eTesting === 'cypress'const needsCypressCT = needsCypress && !needsVitestconst needsNightwatch = argv.nightwatch || needsE2eTesting === 'nightwatch'const needsNightwatchCT = needsNightwatch && !needsVitestconst 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)}
}
生成 packge.json
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)}
- 预设模版文件
根据路径生成模块文件
- [node_modules, package.json, extensions.json, _gitignore, data.mjs] 以外的模块直接复制
function renderTemplate(src, dest, callbacks) {const stats = fs.statSync(src)// path.basename 返回当前路径的目录或文件if (stats.isDirectory()) {// skip node_moduleif (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.jsonif (filename === 'package.json' && fs.existsSync(dest)) {// merge instead of overwritingconst existing = JSON.parse(fs.readFileSync(dest, 'utf8'))const newPackage = JSON.parse(fs.readFileSync(src, 'utf8'))// 合并、去重、排序(字符序) package.jsonconst 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 overwritingconst 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(/^_/, '.'))}// 追加 .gitignoreif (filename === '_gitignore' && fs.existsSync(dest)) {// append to existing .gitignoreconst 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 storedest = dest.replace(/\.data\.mjs$/, '')// Add a callback to the array for late usage when template files are being processedcallbacks.push(async (dataStore) => {const getData = (await import(pathToFileURL(src).toString())).default// Though current `getData` are all sync, we still retain the possibility of asyncdataStore[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 生成模版
// Render base templaterender('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 tsconfigsrender('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 configif (needsEslint) {renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })}// Render code template.// prettier-ignoreconst 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')}
填充 ejs 模版数据
// An external data store for callbacks to share dataconst dataStore = {}// Process callbacksfor (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)}})
根据生成项目是 ts 还是 js 后置处理
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)}})}
根据需要的模块生成所有对应的 README.md 文件
// 确定包管理器: pnpm > yarn > npmconst userAgent = process.env.npm_config_user_agent ?? ''const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'// README generation// 根据需要的文件生成所有的 READMEfs.writeFileSync(path.resolve(root, 'README.md'),generateReadme({projectName: result.projectName ?? result.packageName ?? defaultProjectName,packageManager,needsTypeScript,needsVitest,needsCypress,needsNightwatch,needsPlaywright,needsNightwatchCT,needsCypressCT,needsEslint}))
结尾提示
// 生成项目完成,提示后续辅助工作// cd xxxif (root !== cwd) {// 假设生成的路径是:/Users/username/Projects/My Projectconst cdProjectName = path.relative(cwd, root)// 则控制台命令会被格式化为cd "My Project",而不是cd My Projectconsole.log(` ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`)}// pnpm|yarn|npm installconsole.log(` ${bold(green(getCommand(packageManager, 'install')))}`)// prettier formatif (needsPrettier) {console.log(` ${bold(green(getCommand(packageManager, 'format')))}`)}// npm devconsole.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)console.log()