@dengzhibo/create-vue-template 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +63 -3
- package/bin/index.js +39 -0
- package/lib/generate.js +100 -0
- package/lib/options.js +53 -0
- package/lib/prompts.js +318 -0
- package/package.json +46 -3
- package/templates/base/.gitattributes +1 -0
- package/templates/base/.vscode/extensions.json +5 -0
- package/templates/base/.vscode/settings.json +10 -0
- package/templates/base/README.md +75 -0
- package/templates/base/_gitignore +44 -0
- package/templates/base/env.d.ts +1 -0
- package/templates/base/index.html +13 -0
- package/templates/base/package.json +32 -0
- package/templates/base/pnpm-workspace.yaml +19 -0
- package/templates/base/public/favicon.ico +0 -0
- package/templates/base/src/App.vue +85 -0
- package/templates/base/src/assets/base.css +86 -0
- package/templates/base/src/assets/logo.svg +1 -0
- package/templates/base/src/assets/main.css +35 -0
- package/templates/base/src/components/HelloWorld.vue +41 -0
- package/templates/base/src/components/TheWelcome.vue +95 -0
- package/templates/base/src/components/WelcomeItem.vue +87 -0
- package/templates/base/src/components/icons/IconCommunity.vue +7 -0
- package/templates/base/src/components/icons/IconDocumentation.vue +7 -0
- package/templates/base/src/components/icons/IconEcosystem.vue +7 -0
- package/templates/base/src/components/icons/IconSupport.vue +7 -0
- package/templates/base/src/components/icons/IconTooling.vue +19 -0
- package/templates/base/src/main.ts +14 -0
- package/templates/base/src/router/index.ts +23 -0
- package/templates/base/src/stores/counter.ts +12 -0
- package/templates/base/src/views/AboutView.vue +15 -0
- package/templates/base/src/views/HomeView.vue +9 -0
- package/templates/base/tsconfig.app.json +18 -0
- package/templates/base/tsconfig.json +11 -0
- package/templates/base/tsconfig.node.json +28 -0
- package/templates/base/vite.config.ts +18 -0
- package/templates/features/code-quality/.editorconfig +8 -0
- package/templates/features/code-quality/.oxfmtrc.json +5 -0
- package/templates/features/code-quality/.oxlintrc.json +10 -0
- package/templates/features/code-quality/eslint.config.ts +38 -0
- package/templates/features/code-quality/package.fragment.json +21 -0
- package/templates/features/code-quality/settings.fragment.json +7 -0
- package/templates/features/e2e/e2e/tsconfig.json +4 -0
- package/templates/features/e2e/e2e/vue.spec.ts +8 -0
- package/templates/features/e2e/package.fragment.json +8 -0
- package/templates/features/e2e/playwright.config.ts +110 -0
- package/templates/features/unit-test/package.fragment.json +11 -0
- package/templates/features/unit-test/src/components/__tests__/HelloWorld.spec.ts +11 -0
- package/templates/features/unit-test/tsconfig.vitest.json +19 -0
- package/templates/features/unit-test/vitest.config.ts +14 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2026] [DengZhiBo]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# Vue + Vite 模板脚手架
|
|
2
|
+
|
|
3
|
+
生成现有 Vue + Vite + TypeScript 应用,保留 Router、Pinia 和 Vue DevTools。
|
|
4
|
+
|
|
5
|
+
## 用户使用(发布到 npm 后)
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm create @dengzhibo/vue-template@latest my-vue-app
|
|
9
|
+
|
|
10
|
+
# 等价的完整包名调用
|
|
11
|
+
pnpm dlx @dengzhibo/create-vue-template@latest my-vue-app
|
|
12
|
+
|
|
13
|
+
# 全选并安装依赖
|
|
14
|
+
pnpm create @dengzhibo/vue-template@latest my-vue-app --yes "--features=code-quality,unit-test,e2e" --install
|
|
15
|
+
|
|
16
|
+
# 不选任何功能,不安装依赖
|
|
17
|
+
pnpm create @dengzhibo/vue-template@latest my-vue-app --yes --features=
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
以上命令会从 npm 获取并运行脚手架,无需下载源码或全局安装;必须先发布对应包。`--install` 控制的是生成项目的依赖安装,不是脚手架自身的下载。每个示例独立执行,请使用空目录。
|
|
21
|
+
|
|
22
|
+
命令说明:[pnpm create](https://pnpm.io/cli/create)、[pnpm dlx](https://pnpm.io/cli/dlx)。
|
|
23
|
+
|
|
24
|
+
项目名取目标目录名,支持 my-vue-app 等带连字符名称;目录参数可使用相对路径、绝对路径或当前目录 .。目标目录必须为空,允许保留 .git。
|
|
25
|
+
|
|
26
|
+
向导顺序:创建位置 → 功能多选 → 是否安装依赖 → 确认、返回修改或取消。
|
|
27
|
+
|
|
28
|
+
功能默认全部不选,Space 勾选,Enter 确认,可任意组合:
|
|
29
|
+
|
|
30
|
+
- 代码规范(code-quality):ESLint + Oxlint + Oxfmt。
|
|
31
|
+
- 单元测试(unit-test):Vitest、Vue Test Utils 和现有测试示例。
|
|
32
|
+
- 端到端测试(e2e):Playwright 和现有测试示例。
|
|
33
|
+
|
|
34
|
+
Windows PowerShell 中,包含逗号的 `--features` 参数应加引号,如上例。
|
|
35
|
+
|
|
36
|
+
--yes 使用非交互模式;--features= 指定逗号分隔的功能,空值为全部不选;--install 执行 pnpm install,默认不安装。
|
|
37
|
+
|
|
38
|
+
未选择的功能不生成对应配置、依赖、脚本或编辑器推荐。测试的 ESLint 规则仅在代码规范与相应测试同时选择时生成。
|
|
39
|
+
|
|
40
|
+
不生成 Git hooks、CI、Docker 或部署管理。基础模板现有 Vue RC 与 TypeScript bridge 覆盖保持原样;本次仅拆分现有能力。
|
|
41
|
+
|
|
42
|
+
CLI Node 要求见 package.json;生成应用的 Node 要求见 templates/base/package.json。
|
|
43
|
+
|
|
44
|
+
## 源码开发与维护
|
|
45
|
+
|
|
46
|
+
仅在本包源码目录中使用以下命令进行本地调试:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
node bin/index.js my-vue-app
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
验证:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
node --test tests/*.test.mjs
|
|
56
|
+
node scripts/check-package.mjs
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
测试覆盖 8 种功能组合、CLI 参数、向导返回修改、取消及目标目录保护。不要在 templates 内安装依赖。
|
|
60
|
+
|
|
61
|
+
## 发布文件范围
|
|
62
|
+
|
|
63
|
+
`bin/`、`lib/`、`templates/`、README、CHANGELOG、LICENSE 与 package.json 随 npm 包发布。测试、预检脚本、AGENTS.md 和本地缓存仅用于仓库维护,不需要上传 npm。`_gitignore` 在生成项目时恢复为 `.gitignore`。
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
4
|
+
import { createPlan, writePlan } from '../lib/generate.js'
|
|
5
|
+
import { help, parseArgs } from '../lib/options.js'
|
|
6
|
+
import { Cancelled, collectOptions } from '../lib/prompts.js'
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const args = process.argv.slice(2)
|
|
10
|
+
const parsed = parseArgs(args)
|
|
11
|
+
if (parsed.help) {
|
|
12
|
+
console.log(help)
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
if (!parsed.yes && (!process.stdin.isTTY || !process.stdout.isTTY))
|
|
16
|
+
throw new Error('非交互环境请使用 --yes')
|
|
17
|
+
const options = parsed.yes ? parsed.options : await collectOptions(parsed.options)
|
|
18
|
+
if (!options) {
|
|
19
|
+
console.log('已取消,未创建项目。')
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const target = writePlan(createPlan(options))
|
|
23
|
+
console.log(`✔ 项目已创建:${target}`)
|
|
24
|
+
if (options.install) {
|
|
25
|
+
try {
|
|
26
|
+
execSync('pnpm install', { cwd: target, stdio: 'inherit' })
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
console.error('安装失败,文件保留,请手动运行 pnpm install')
|
|
30
|
+
process.exitCode = 1
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
console.log(`cd "${target}"\n${options.install ? '' : 'pnpm install\n'}pnpm dev`)
|
|
35
|
+
}
|
|
36
|
+
main().catch((error) => {
|
|
37
|
+
console.error(error instanceof Cancelled ? '已取消,未创建项目。' : error.message)
|
|
38
|
+
process.exitCode = error instanceof Cancelled ? 130 : 1
|
|
39
|
+
})
|
package/lib/generate.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { assertTarget, featureLabels, normalizeOptions } from './options.js'
|
|
5
|
+
|
|
6
|
+
const templateDir = fileURLToPath(new URL('../templates/', import.meta.url))
|
|
7
|
+
const skip = new Set(['node_modules', 'dist', '.npmignore', '.git', '.DS_Store', 'pnpm-lock.yaml', '.turbo'])
|
|
8
|
+
const rename = { _gitignore: '.gitignore' }
|
|
9
|
+
const encode = value => `${JSON.stringify(value, null, 2)}\n`
|
|
10
|
+
export function createPlan(input = {}, cwd = process.cwd()) {
|
|
11
|
+
const options = normalizeOptions(input)
|
|
12
|
+
const target = path.resolve(cwd, options.location)
|
|
13
|
+
const name = path.basename(target)
|
|
14
|
+
if (!/^[a-z0-9][\w.-]*$/i.test(name))
|
|
15
|
+
throw new Error('项目名不合法')
|
|
16
|
+
assertTarget(target)
|
|
17
|
+
const files = new Map()
|
|
18
|
+
const has = feature => options.features.includes(feature)
|
|
19
|
+
function add(directory, relative = '') {
|
|
20
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
21
|
+
if (skip.has(entry.name) || entry.name.endsWith('.fragment.json'))
|
|
22
|
+
continue
|
|
23
|
+
const source = path.join(directory, entry.name)
|
|
24
|
+
if (entry.isSymbolicLink())
|
|
25
|
+
throw new Error(`模板不支持符号链接:${source}`)
|
|
26
|
+
const destination = path.posix.join(relative, rename[entry.name] || entry.name)
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
add(source, destination)
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
if (files.has(destination))
|
|
32
|
+
throw new Error(`模板文件冲突:${destination}`)
|
|
33
|
+
files.set(destination, fs.readFileSync(source))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
add(path.join(templateDir, 'base'))
|
|
38
|
+
const parse = file => JSON.parse(files.get(file).toString())
|
|
39
|
+
const pkg = parse('package.json')
|
|
40
|
+
for (const feature of options.features) {
|
|
41
|
+
const directory = path.join(templateDir, 'features', feature)
|
|
42
|
+
add(directory)
|
|
43
|
+
const fragment = JSON.parse(fs.readFileSync(path.join(directory, 'package.fragment.json'), 'utf8'))
|
|
44
|
+
for (const field of ['scripts', 'dependencies', 'devDependencies']) {
|
|
45
|
+
for (const [key, value] of Object.entries(fragment[field] || {})) {
|
|
46
|
+
pkg[field] ||= {}
|
|
47
|
+
if (Object.hasOwn(pkg[field], key) && pkg[field][key] !== value)
|
|
48
|
+
throw new Error(`配置冲突:${key}`)
|
|
49
|
+
pkg[field][key] = value
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const extensions = parse('.vscode/extensions.json')
|
|
54
|
+
if (has('unit-test')) {
|
|
55
|
+
const tsconfig = parse('tsconfig.json')
|
|
56
|
+
tsconfig.references.push({ path: './tsconfig.vitest.json' })
|
|
57
|
+
files.set('tsconfig.json', encode(tsconfig))
|
|
58
|
+
extensions.recommendations.push('vitest.explorer')
|
|
59
|
+
}
|
|
60
|
+
if (has('e2e'))
|
|
61
|
+
extensions.recommendations.push('ms-playwright.playwright')
|
|
62
|
+
if (has('code-quality')) {
|
|
63
|
+
let eslint = files.get('eslint.config.ts').toString().replace(/\r\n/g, '\n')
|
|
64
|
+
for (const [feature, plugin, dependency] of [['unit-test', 'pluginVitest', '@vitest/eslint-plugin'], ['e2e', 'pluginPlaywright', 'eslint-plugin-playwright']]) {
|
|
65
|
+
if (!has(feature)) {
|
|
66
|
+
delete pkg.devDependencies[dependency]
|
|
67
|
+
eslint = eslint.replace(new RegExp(`^import ${plugin}.*\\n`, 'm'), '')
|
|
68
|
+
eslint = eslint.replace(new RegExp(` \{\\n \\.\\.\\.${plugin}[\\s\\S]*?\\n \},\\n`), '')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
files.set('eslint.config.ts', eslint)
|
|
72
|
+
const oxlint = parse('.oxlintrc.json')
|
|
73
|
+
if (!has('unit-test'))
|
|
74
|
+
oxlint.plugins = oxlint.plugins.filter(p => p !== 'vitest')
|
|
75
|
+
files.set('.oxlintrc.json', encode(oxlint))
|
|
76
|
+
const settings = JSON.parse(fs.readFileSync(path.join(templateDir, 'features/code-quality/settings.fragment.json'), 'utf8'))
|
|
77
|
+
files.set('.vscode/settings.json', encode({ ...parse('.vscode/settings.json'), ...settings }))
|
|
78
|
+
extensions.recommendations.push('dbaeumer.vscode-eslint', 'EditorConfig.EditorConfig', 'oxc.oxc-vscode')
|
|
79
|
+
}
|
|
80
|
+
files.set('.vscode/extensions.json', encode(extensions))
|
|
81
|
+
pkg.name = name
|
|
82
|
+
files.set('package.json', encode(pkg))
|
|
83
|
+
let readme = files.get('README.md').toString().replace(/\r\n/g, '\n')
|
|
84
|
+
const sections = [['unit-test', 'Run Unit Tests'], ['e2e', 'Run End-to-End Tests'], ['code-quality', 'Lint with']]
|
|
85
|
+
for (const [feature, title] of sections) {
|
|
86
|
+
if (!has(feature))
|
|
87
|
+
readme = readme.replace(new RegExp(`### ${title}[\\s\\S]*?(?=\\n### |$)`), '')
|
|
88
|
+
}
|
|
89
|
+
files.set('README.md', `${readme.replace('# base', `# ${name}`).trimEnd()}\n\n已选功能:${options.features.map(f => featureLabels[f]).join('、') || '无'}\n`)
|
|
90
|
+
return { target, options, files }
|
|
91
|
+
}
|
|
92
|
+
export function writePlan(plan) {
|
|
93
|
+
assertTarget(plan.target)
|
|
94
|
+
for (const [relative, content] of plan.files) {
|
|
95
|
+
const file = path.join(plan.target, relative)
|
|
96
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
97
|
+
fs.writeFileSync(file, content)
|
|
98
|
+
}
|
|
99
|
+
return plan.target
|
|
100
|
+
}
|
package/lib/options.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const featureLabels = { 'code-quality': '代码规范', 'unit-test': '单元测试', 'e2e': '端到端测试' }
|
|
5
|
+
export function normalizeOptions(input = {}) {
|
|
6
|
+
const features = input.features ?? []
|
|
7
|
+
if (!Array.isArray(features) || features.some(f => !Object.hasOwn(featureLabels, f)))
|
|
8
|
+
throw new Error('功能仅支持 code-quality、unit-test、e2e')
|
|
9
|
+
return { location: input.location || '.', features: Object.keys(featureLabels).filter(f => features.includes(f)), install: Boolean(input.install) }
|
|
10
|
+
}
|
|
11
|
+
export function assertTarget(target) {
|
|
12
|
+
let current = target
|
|
13
|
+
while (true) {
|
|
14
|
+
if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink())
|
|
15
|
+
throw new Error('目标路径不能经过符号链接')
|
|
16
|
+
const parent = path.dirname(current)
|
|
17
|
+
if (parent === current)
|
|
18
|
+
break
|
|
19
|
+
current = parent
|
|
20
|
+
}
|
|
21
|
+
if (fs.existsSync(target) && (!fs.statSync(target).isDirectory() || fs.readdirSync(target).some(name => name !== '.git')))
|
|
22
|
+
throw new Error('目标必须为空目录(允许保留 .git)')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const help = `用法:create-vue-template [目录] [选项]
|
|
26
|
+
--yes, -y 非交互创建
|
|
27
|
+
--features=code-quality,unit-test,e2e 选择功能,默认全部不选
|
|
28
|
+
--features= 全部不选
|
|
29
|
+
--install 安装依赖,默认不安装
|
|
30
|
+
--help, -h 帮助
|
|
31
|
+
项目名取目标目录名,例如 my-vue-app。`
|
|
32
|
+
export function parseArgs(args) {
|
|
33
|
+
if (args.includes('--help') || args.includes('-h'))
|
|
34
|
+
return { help: true }
|
|
35
|
+
const options = normalizeOptions()
|
|
36
|
+
const locations = []
|
|
37
|
+
let yes = false
|
|
38
|
+
for (const arg of args) {
|
|
39
|
+
if (['--yes', '-y'].includes(arg))
|
|
40
|
+
yes = true
|
|
41
|
+
else if (arg.startsWith('--features='))
|
|
42
|
+
options.features = arg.slice('--features='.length) === '' ? [] : arg.slice('--features='.length).split(',')
|
|
43
|
+
else if (arg === '--install')
|
|
44
|
+
options.install = true
|
|
45
|
+
else if (arg.startsWith('-'))
|
|
46
|
+
throw new Error(`未知参数:${arg}`)
|
|
47
|
+
else locations.push(arg)
|
|
48
|
+
}
|
|
49
|
+
if (locations.length > 1)
|
|
50
|
+
throw new Error('只能指定一个创建目录')
|
|
51
|
+
options.location = locations[0] || '.'
|
|
52
|
+
return { options: normalizeOptions(options), yes }
|
|
53
|
+
}
|
package/lib/prompts.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
/**
|
|
5
|
+
* 命令行交互问答模块
|
|
6
|
+
*
|
|
7
|
+
* 作用:
|
|
8
|
+
* 为脚手架提供终端交互能力,并把问答结果整理成创建计划所需的选项。
|
|
9
|
+
* bin 入口只负责调用,所有输入框、单选、多选的渲染和按键处理都在本文件。
|
|
10
|
+
*
|
|
11
|
+
* 导出:
|
|
12
|
+
* Cancelled 用户主动取消(Ctrl+C / Esc / 输入流结束)时抛出的异常
|
|
13
|
+
* prompt 通用交互组件,支持三种形式:
|
|
14
|
+
* - 文本输入(不传 choices)
|
|
15
|
+
* - 单选(传 choices)
|
|
16
|
+
* - 多选(传 choices 且 multiple: true)
|
|
17
|
+
* collectOptions 配置向导,按顺序提问并处理选项之间的联动
|
|
18
|
+
* summary 把创建计划整理成确认页的文字摘要
|
|
19
|
+
*
|
|
20
|
+
* 实现要点:
|
|
21
|
+
* - 基于 Node 原生 readline 的按键事件,开启 Raw 模式后自行绘制界面,不依赖第三方库。
|
|
22
|
+
* - 当前问题限制在终端可视高度内,长列表随当前选项滚动;完成后收起为一行摘要。
|
|
23
|
+
* - 选中状态用符号和颜色双重表示:即使终端不支持颜色,符号也能区分选中与否。
|
|
24
|
+
* - 颜色在 NO_COLOR 环境变量存在时自动关闭。
|
|
25
|
+
*
|
|
26
|
+
* 终端兼容:
|
|
27
|
+
* 多选符号曾使用 ☑ / ☐,在部分 Windows 终端字体里 ☑ 缺失,显示成空方块,
|
|
28
|
+
* 导致已选和未选无法区分,因此改用 ◉ / ◯。若仍有显示问题,
|
|
29
|
+
* 将下方 MARK_ON / MARK_OFF 改为 '[x]' / '[ ]' 即可。
|
|
30
|
+
*/
|
|
31
|
+
import readline from 'node:readline'
|
|
32
|
+
import { featureLabels, normalizeOptions } from './options.js'
|
|
33
|
+
|
|
34
|
+
/** 用户主动中断(Ctrl+C 或 Esc)时抛出,由调用方统一捕获 */
|
|
35
|
+
export class Cancelled extends Error {}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// 显示样式
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
// 多选框符号(已选 / 未选)
|
|
42
|
+
const MARK_ON = '◉'
|
|
43
|
+
const MARK_OFF = '◯'
|
|
44
|
+
// 单选框符号(当前项 / 其他项)
|
|
45
|
+
const RADIO_ON = '●'
|
|
46
|
+
const RADIO_OFF = '○'
|
|
47
|
+
|
|
48
|
+
const useColor = !process.env.NO_COLOR
|
|
49
|
+
const paint = code => text => (useColor ? `\x1B[${code}m${text}\x1B[0m` : text)
|
|
50
|
+
const green = paint(32)
|
|
51
|
+
const cyan = paint(36)
|
|
52
|
+
const red = paint(31)
|
|
53
|
+
const gray = paint(90)
|
|
54
|
+
|
|
55
|
+
// 去掉颜色转义序列,用于计算真实显示宽度
|
|
56
|
+
// eslint-disable-next-line no-control-regex
|
|
57
|
+
const stripAnsi = text => text.replace(/\x1B\[[0-9;]*m/g, '')
|
|
58
|
+
|
|
59
|
+
// 单个字符的显示宽度:中日韩、全角字符和表情占 2 格,其余占 1 格
|
|
60
|
+
function charWidth(char) {
|
|
61
|
+
const code = char.codePointAt(0)
|
|
62
|
+
if (/\p{Mark}/u.test(char) || code === 0x200D)
|
|
63
|
+
return 0
|
|
64
|
+
const wide
|
|
65
|
+
= (code >= 0x1100 && code <= 0x115F)
|
|
66
|
+
|| (code >= 0x2E80 && code <= 0xA4CF)
|
|
67
|
+
|| (code >= 0xAC00 && code <= 0xD7A3)
|
|
68
|
+
|| (code >= 0xF900 && code <= 0xFAFF)
|
|
69
|
+
|| (code >= 0xFE30 && code <= 0xFE6F)
|
|
70
|
+
|| (code >= 0xFF00 && code <= 0xFF60)
|
|
71
|
+
|| (code >= 0xFFE0 && code <= 0xFFE6)
|
|
72
|
+
|| (code >= 0x1F300 && code <= 0x1F64F)
|
|
73
|
+
|| (code >= 0x20000 && code <= 0x3FFFD)
|
|
74
|
+
return wide ? 2 : 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 每行保留一格,避免行尾自动换行使光标回退行数失真;不截断颜色转义序列。
|
|
78
|
+
function fitLine(line, columns) {
|
|
79
|
+
const limit = Math.max(1, columns - 1)
|
|
80
|
+
if ([...stripAnsi(line)].reduce((width, char) => width + charWidth(char), 0) <= limit)
|
|
81
|
+
return line
|
|
82
|
+
let result = ''
|
|
83
|
+
let width = 0
|
|
84
|
+
// eslint-disable-next-line no-control-regex
|
|
85
|
+
for (const token of line.match(/\x1B\[[0-9;]*m|[\s\S]/gu) || []) {
|
|
86
|
+
if (token.startsWith('\x1B')) {
|
|
87
|
+
result += token
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
width += charWidth(token)
|
|
91
|
+
if (width > limit - 1)
|
|
92
|
+
break
|
|
93
|
+
result += token
|
|
94
|
+
}
|
|
95
|
+
return `${result}…\x1B[0m`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// 通用交互组件
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 显示一个交互问题并返回用户的回答。
|
|
104
|
+
*
|
|
105
|
+
* 参数:
|
|
106
|
+
* title 问题标题
|
|
107
|
+
* choices 选项数组 [{ value, label, group?, required? }];不传则为文本输入
|
|
108
|
+
* multiple 是否多选(需要 choices)
|
|
109
|
+
* initial 初始值:单选为某个 value,多选为 value 数组,文本为字符串
|
|
110
|
+
* defaultValue 文本输入留空时使用的默认值
|
|
111
|
+
* hint 标题下方的多行提示
|
|
112
|
+
* validate 校验函数,返回错误文字则不允许提交
|
|
113
|
+
*
|
|
114
|
+
* 返回:文本为字符串,单选为选中的 value,多选为选中的 value 数组。
|
|
115
|
+
* 非终端环境会直接抛错,提示改用 --yes 和显式选项。
|
|
116
|
+
*/
|
|
117
|
+
export function prompt({ title, choices, multiple = false, initial, defaultValue = '', hint = '', validate = () => undefined }) {
|
|
118
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
119
|
+
throw new Error('交互问答需要终端;自动化调用请使用 --yes 和显式选项,详见 --help')
|
|
120
|
+
|
|
121
|
+
const input = process.stdin
|
|
122
|
+
const output = process.stdout
|
|
123
|
+
readline.emitKeypressEvents(input)
|
|
124
|
+
const wasRaw = input.isRaw
|
|
125
|
+
|
|
126
|
+
// Raw 模式:接管按键事件,阻止终端默认回显
|
|
127
|
+
input.setRawMode(true)
|
|
128
|
+
input.resume()
|
|
129
|
+
|
|
130
|
+
let index = Math.max(0, choices?.findIndex(choice => choice.value === initial) ?? 0)
|
|
131
|
+
let value = typeof initial === 'string' && !choices ? initial : ''
|
|
132
|
+
const checked = new Set(Array.isArray(initial) ? initial : [])
|
|
133
|
+
|
|
134
|
+
// 必选项始终处于选中状态
|
|
135
|
+
for (const choice of choices || []) {
|
|
136
|
+
if (choice.required)
|
|
137
|
+
checked.add(choice.value)
|
|
138
|
+
}
|
|
139
|
+
let rows = 0
|
|
140
|
+
let message = ''
|
|
141
|
+
let offset = 0
|
|
142
|
+
let columns = output.columns || 80
|
|
143
|
+
|
|
144
|
+
const clearFrame = () => {
|
|
145
|
+
if (rows) {
|
|
146
|
+
output.write(`\x1B[${rows}A\r\x1B[0J`)
|
|
147
|
+
rows = 0
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 最后一行留给光标,整个活动问题始终处于可见屏幕中,不回退到历史缓冲区。
|
|
152
|
+
const draw = (resized = false) => {
|
|
153
|
+
const height = Math.max(1, (output.rows || 24) - 1)
|
|
154
|
+
const nextColumns = Math.max(2, output.columns || 80)
|
|
155
|
+
if (resized && (nextColumns !== columns || rows > height)) {
|
|
156
|
+
// 改变宽度会使旧输出重新折行,缩小高度也可能丢失旧起点,不能再按旧行数回退。
|
|
157
|
+
output.write('\x1B[H\x1B[2J')
|
|
158
|
+
rows = 0
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
clearFrame()
|
|
162
|
+
}
|
|
163
|
+
columns = nextColumns
|
|
164
|
+
|
|
165
|
+
const entries = []
|
|
166
|
+
let activeRow = 0
|
|
167
|
+
if (choices) {
|
|
168
|
+
for (const [i, choice] of choices.entries()) {
|
|
169
|
+
if (choice.group && choice.group !== choices[i - 1]?.group)
|
|
170
|
+
entries.push(` ${gray(choice.group)}`)
|
|
171
|
+
|
|
172
|
+
const active = index === i
|
|
173
|
+
if (active)
|
|
174
|
+
activeRow = entries.length
|
|
175
|
+
const selected = multiple ? checked.has(choice.value) : active
|
|
176
|
+
const on = multiple ? MARK_ON : RADIO_ON
|
|
177
|
+
const off = multiple ? MARK_OFF : RADIO_OFF
|
|
178
|
+
const mark = selected ? green(on) : gray(off)
|
|
179
|
+
const pointer = active ? cyan('›') : gray('│')
|
|
180
|
+
const label = active ? cyan(choice.label) : choice.label
|
|
181
|
+
const required = choice.required ? gray('(必需)') : ''
|
|
182
|
+
entries.push(`${pointer} ${mark} ${label}${required}`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
entries.push(`${gray('│')} ${value || gray(`${defaultValue}(默认)`)}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hints = hint ? hint.split('\n').slice(0, Math.max(0, height - 5)).map(line => `${gray('│')} ${gray(line)}`) : []
|
|
190
|
+
const tip = choices ? (multiple ? '↑↓ 移动,Space 勾选,Enter 确认' : '↑↓ 选择,Enter 确认') : 'Enter 确认'
|
|
191
|
+
let lines
|
|
192
|
+
if (height < 3) {
|
|
193
|
+
// 极小窗口优先保留当前选项;两行可用时同时显示校验或操作提示。
|
|
194
|
+
lines = [entries[activeRow]]
|
|
195
|
+
if (height === 2)
|
|
196
|
+
lines.push(`${gray('└')} ${message ? red(message) : gray(tip)}`)
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const budget = height - hints.length - 2
|
|
200
|
+
const overflow = entries.length > budget
|
|
201
|
+
const indicators = overflow && budget >= 3
|
|
202
|
+
const capacity = indicators ? budget - 2 : budget
|
|
203
|
+
offset = Math.max(0, Math.min(offset, activeRow, entries.length - capacity))
|
|
204
|
+
if (activeRow >= offset + capacity)
|
|
205
|
+
offset = activeRow - capacity + 1
|
|
206
|
+
const visible = entries.slice(offset, offset + capacity)
|
|
207
|
+
if (indicators) {
|
|
208
|
+
visible.unshift(gray(offset ? '│ ↑ 上方还有选项' : '│ 已到列表顶部'))
|
|
209
|
+
visible.push(gray(offset + capacity < entries.length ? '│ ↓ 下方还有选项' : '│ 已到列表底部'))
|
|
210
|
+
}
|
|
211
|
+
const position = overflow && choices ? ` [${index + 1}/${choices.length}]` : ''
|
|
212
|
+
lines = [`${cyan('◆')} ${title}`, ...visible, ...hints, `${gray('└')} ${message ? red(message) : gray(`${position} ${tip}`.trim())}`]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
rows = lines.length
|
|
216
|
+
output.write(`${lines.map(line => fitLine(line, columns)).join('\n')}\n`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
// 还原终端状态并移除监听,任何结束路径都必须调用
|
|
221
|
+
function cleanup() {
|
|
222
|
+
input.removeListener('keypress', onKey)
|
|
223
|
+
input.removeListener('end', onEnd)
|
|
224
|
+
input.removeListener('error', onError)
|
|
225
|
+
output.removeListener('resize', onResize)
|
|
226
|
+
input.setRawMode(Boolean(wasRaw))
|
|
227
|
+
input.pause()
|
|
228
|
+
output.write('\x1B[?25h')
|
|
229
|
+
}
|
|
230
|
+
function onEnd() {
|
|
231
|
+
clearFrame()
|
|
232
|
+
cleanup()
|
|
233
|
+
reject(new Cancelled('已取消'))
|
|
234
|
+
}
|
|
235
|
+
function onError(error) {
|
|
236
|
+
cleanup()
|
|
237
|
+
reject(error)
|
|
238
|
+
}
|
|
239
|
+
function onResize() {
|
|
240
|
+
draw(true)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 按键处理:取消、确认、上下移动、空格勾选、文本输入
|
|
244
|
+
function onKey(str, key = {}) {
|
|
245
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
|
|
246
|
+
onEnd()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
message = ''
|
|
250
|
+
if (key.name === 'return') {
|
|
251
|
+
const result = choices
|
|
252
|
+
? multiple
|
|
253
|
+
? choices.filter(choice => checked.has(choice.value)).map(choice => choice.value)
|
|
254
|
+
: choices[index].value
|
|
255
|
+
: value.trim() || defaultValue
|
|
256
|
+
message = validate(result) || ''
|
|
257
|
+
if (message) {
|
|
258
|
+
draw()
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const answer = choices
|
|
262
|
+
? choices.filter(choice => multiple ? checked.has(choice.value) : choice.value === result).map(choice => choice.label).join('、') || '未选择'
|
|
263
|
+
: result
|
|
264
|
+
clearFrame()
|
|
265
|
+
output.write(`${fitLine(`${green('✓')} ${title}:${answer}`, columns)}\n`)
|
|
266
|
+
cleanup()
|
|
267
|
+
resolve(result)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (choices) {
|
|
271
|
+
if (key.name === 'up')
|
|
272
|
+
index = (index - 1 + choices.length) % choices.length
|
|
273
|
+
if (key.name === 'down')
|
|
274
|
+
index = (index + 1) % choices.length
|
|
275
|
+
if (multiple && key.name === 'space' && !choices[index].required) {
|
|
276
|
+
const selected = choices[index].value
|
|
277
|
+
if (checked.has(selected))
|
|
278
|
+
checked.delete(selected)
|
|
279
|
+
else checked.add(selected)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (key.name === 'backspace') {
|
|
283
|
+
value = [...value].slice(0, -1).join('')
|
|
284
|
+
}
|
|
285
|
+
else if (key.ctrl && key.name === 'u') {
|
|
286
|
+
value = ''
|
|
287
|
+
}
|
|
288
|
+
else if (str && !key.ctrl && !key.meta && [...str].every(char => char.codePointAt(0) >= 32 && char.codePointAt(0) !== 127)) {
|
|
289
|
+
value += str
|
|
290
|
+
}
|
|
291
|
+
draw()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
input.on('keypress', onKey)
|
|
295
|
+
input.once('end', onEnd)
|
|
296
|
+
input.once('error', onError)
|
|
297
|
+
output.on('resize', onResize)
|
|
298
|
+
output.write('\x1B[?25l')
|
|
299
|
+
draw()
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
export async function collectOptions(previous = {}, ask = prompt) {
|
|
306
|
+
const options = normalizeOptions(previous)
|
|
307
|
+
options.location = await ask({ title: '项目创建位置', initial: options.location === '.' ? '' : options.location, defaultValue: '.', hint: '输入 my-vue-app:新建项目文件夹;输入 .:在当前目录创建;支持相对或绝对路径' }) || '.'
|
|
308
|
+
options.features = await ask({ title: '选择可选功能', multiple: true, choices: Object.entries(featureLabels).map(([value, label]) => ({ value, label })), initial: options.features, hint: 'Space 勾选,Enter 确认;可全部不选或全部选择' })
|
|
309
|
+
options.install = await ask({ title: '是否立即安装依赖?', choices: [{ value: false, label: '暂不安装' }, { value: true, label: '立即安装' }], initial: options.install })
|
|
310
|
+
console.log(summary(options))
|
|
311
|
+
const action = await ask({ title: '确认配置', choices: [{ value: 'create', label: '开始创建' }, { value: 'edit', label: '返回修改' }, { value: 'cancel', label: '取消' }] })
|
|
312
|
+
if (action === 'edit')
|
|
313
|
+
return collectOptions(options, ask)
|
|
314
|
+
return action === 'create' ? normalizeOptions(options) : null
|
|
315
|
+
}
|
|
316
|
+
export function summary(options) {
|
|
317
|
+
return [`创建位置:${path.resolve(options.location)}`, `可选功能:${options.features.map(f => featureLabels[f]).join('、') || '无'}`, `安装依赖:${options.install ? '立即安装' : '暂不安装'}`].join('\n')
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dengzhibo/create-vue-template",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"private": false,
|
|
6
|
+
"description": "Vue + Vite 应用脚手架,可选代码规范、单元测试和端到端测试",
|
|
7
|
+
"main": "bin/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-vue-template": "./bin/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"templates",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"lib"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org/",
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"author": "THINKA <your-email@example.com>",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"vue",
|
|
27
|
+
"vite",
|
|
28
|
+
"typescript",
|
|
29
|
+
"scaffold",
|
|
30
|
+
"create"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://github.com/THINKA19/My-CLI/tree/main/packages/templates/frontend/vite/vue-template#readme",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/THINKA19/My-CLI.git",
|
|
36
|
+
"directory": "packages/templates/frontend/vite/vue-template"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/THINKA19/My-CLI/issues"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test tests/*.test.mjs",
|
|
46
|
+
"pub": "pnpm publish",
|
|
47
|
+
"test:pack": "node scripts/check-package.mjs"
|
|
48
|
+
}
|
|
6
49
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|