create-halo-plugin-template 1.0.0 → 1.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/.editorconfig +520 -0
- package/.github/workflows/cd.yaml +20 -0
- package/.github/workflows/ci.yaml +32 -0
- package/.github/workflows/publish-npm.yaml +46 -0
- package/.gitignore +84 -0
- package/LICENSE +674 -0
- package/README.md +191 -0
- package/build.gradle +103 -0
- package/docs/first-npm-release-checklist.md +58 -0
- package/docs/publish-template.md +148 -0
- package/docs/rsbuild-switch.md +90 -0
- package/docs/template-pruning.md +43 -0
- package/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/gradle.properties +1 -0
- package/gradlew +248 -0
- package/gradlew.bat +93 -0
- package/package.json +67 -7
- package/scripts/create-project.mjs +399 -0
- package/scripts/init-template.mjs +281 -0
- package/scripts/publish-check.mjs +97 -0
- package/scripts/release.mjs +278 -0
- package/scripts/verify-template.mjs +407 -0
- package/settings.gradle +7 -0
- package/src/main/java/run/halo/plugintemplate/PluginTemplatePlugin.java +43 -0
- package/src/main/java/run/halo/plugintemplate/config/PluginTemplateConfig.java +14 -0
- package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateChecklistItem.java +30 -0
- package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateFeatureItem.java +30 -0
- package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateOverview.java +73 -0
- package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateStatItem.java +30 -0
- package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplateConsoleEndpoint.java +33 -0
- package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplatePublicEndpoint.java +26 -0
- package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplateUcEndpoint.java +33 -0
- package/src/main/java/run/halo/plugintemplate/endpoint/routes/PluginTemplateOverviewRoutes.java +60 -0
- package/src/main/java/run/halo/plugintemplate/model/PluginTemplateAudience.java +23 -0
- package/src/main/java/run/halo/plugintemplate/query/PluginTemplateOverviewQuery.java +26 -0
- package/src/main/java/run/halo/plugintemplate/reconcile/PluginTemplateSettingsReconciler.java +17 -0
- package/src/main/java/run/halo/plugintemplate/scheme/PluginTemplateRecord.java +43 -0
- package/src/main/java/run/halo/plugintemplate/service/PluginTemplateOverviewService.java +10 -0
- package/src/main/java/run/halo/plugintemplate/service/impl/PluginTemplateOverviewServiceImpl.java +74 -0
- package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateGeneralSetting.java +25 -0
- package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateSettingKeys.java +24 -0
- package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateUiSetting.java +13 -0
- package/src/main/java/run/halo/plugintemplate/utils/PluginTemplateSeeds.java +197 -0
- package/src/main/resources/extensions/roleTemplate-console.yaml +39 -0
- package/src/main/resources/extensions/roleTemplate-uc.yaml +19 -0
- package/src/main/resources/extensions/settings.yaml +47 -0
- package/src/main/resources/logo.png +0 -0
- package/src/main/resources/plugin.yaml +24 -0
- package/src/test/java/run/halo/plugintemplate/PluginTemplatePluginTest.java +34 -0
- package/src/test/java/run/halo/plugintemplate/service/impl/PluginTemplateOverviewServiceImplTest.java +97 -0
- package/ui/build.gradle +41 -0
- package/ui/env.d.ts +2 -0
- package/ui/eslint.config.ts +30 -0
- package/ui/package.json +57 -0
- package/ui/pnpm-lock.yaml +5250 -0
- package/ui/src/api/__tests__/normalizers.spec.ts +65 -0
- package/ui/src/api/generated/.openapi-generator/FILES +23 -0
- package/ui/src/api/generated/.openapi-generator/VERSION +1 -0
- package/ui/src/api/generated/.openapi-generator-ignore +23 -0
- package/ui/src/api/generated/api/plugin-template-console-api.ts +128 -0
- package/ui/src/api/generated/api/plugin-template-uc-api.ts +128 -0
- package/ui/src/api/generated/api.ts +19 -0
- package/ui/src/api/generated/base.ts +86 -0
- package/ui/src/api/generated/common.ts +150 -0
- package/ui/src/api/generated/configuration.ts +110 -0
- package/ui/src/api/generated/git_push.sh +57 -0
- package/ui/src/api/generated/index.ts +18 -0
- package/ui/src/api/generated/models/add-operation.ts +49 -0
- package/ui/src/api/generated/models/copy-operation.ts +49 -0
- package/ui/src/api/generated/models/index.ts +11 -0
- package/ui/src/api/generated/models/json-patch-inner.ts +41 -0
- package/ui/src/api/generated/models/move-operation.ts +49 -0
- package/ui/src/api/generated/models/plugin-template-checklist-item.ts +54 -0
- package/ui/src/api/generated/models/plugin-template-feature-item.ts +54 -0
- package/ui/src/api/generated/models/plugin-template-overview.ts +147 -0
- package/ui/src/api/generated/models/plugin-template-stat-item.ts +54 -0
- package/ui/src/api/generated/models/remove-operation.ts +43 -0
- package/ui/src/api/generated/models/replace-operation.ts +49 -0
- package/ui/src/api/generated/models/test-operation.ts +49 -0
- package/ui/src/api/index.ts +42 -0
- package/ui/src/api/normalizers.ts +65 -0
- package/ui/src/assets/element.scss +24 -0
- package/ui/src/assets/index.css +361 -0
- package/ui/src/assets/logo.svg +1 -0
- package/ui/src/components/PluginTemplateAttachmentTab.vue +69 -0
- package/ui/src/components/PluginTemplateCommonTable.vue +69 -0
- package/ui/src/components/PluginTemplateDashboardWidget.vue +62 -0
- package/ui/src/components/PluginTemplateOverviewPage.vue +254 -0
- package/ui/src/components/ui/PluginUiProvider.vue +40 -0
- package/ui/src/components/ui/UiMetricCard.vue +21 -0
- package/ui/src/components/ui/UiSectionCard.vue +25 -0
- package/ui/src/components/ui/UiStatusPill.vue +18 -0
- package/ui/src/composables/useTemplateOverview.ts +38 -0
- package/ui/src/index.ts +88 -0
- package/ui/src/lib/__tests__/plugin-ui.spec.ts +19 -0
- package/ui/src/lib/plugin-ui.ts +19 -0
- package/ui/src/lib/template.spec.ts +24 -0
- package/ui/src/lib/template.ts +52 -0
- package/ui/src/lib/theme.ts +31 -0
- package/ui/src/types/index.ts +59 -0
- package/ui/src/views/console/ConsoleDashboardView.vue +7 -0
- package/ui/src/views/uc/UcDashboardView.vue +7 -0
- package/ui/tsconfig.app.json +12 -0
- package/ui/tsconfig.json +14 -0
- package/ui/tsconfig.node.json +15 -0
- package/ui/tsconfig.vitest.json +11 -0
- package/ui/vite.config.ts +25 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const TEMPLATE_ROOT = path.resolve(SCRIPT_DIR, '..')
|
|
11
|
+
const SKIP_NAMES = new Set([
|
|
12
|
+
'.git',
|
|
13
|
+
'.gradle',
|
|
14
|
+
'.npm-cache',
|
|
15
|
+
'build',
|
|
16
|
+
'node_modules',
|
|
17
|
+
'workplace',
|
|
18
|
+
'api-docs',
|
|
19
|
+
'.DS_Store',
|
|
20
|
+
])
|
|
21
|
+
const SKIP_RELATIVE_PATHS = new Set([
|
|
22
|
+
'.npmignore',
|
|
23
|
+
'.github/workflows/publish-npm.yaml',
|
|
24
|
+
'bin',
|
|
25
|
+
'docs/publish-template.md',
|
|
26
|
+
'package.json',
|
|
27
|
+
'package-lock.json',
|
|
28
|
+
'pnpm-lock.yaml',
|
|
29
|
+
'scripts/create-project.mjs',
|
|
30
|
+
'scripts/publish-check.mjs',
|
|
31
|
+
'ui/build',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const usage = `
|
|
35
|
+
Usage:
|
|
36
|
+
node scripts/create-project.mjs \\
|
|
37
|
+
--plugin-name hello-world \\
|
|
38
|
+
--base-package com.example.helloworld \\
|
|
39
|
+
--display-name "Hello World" \\
|
|
40
|
+
--author-name "Your Name" \\
|
|
41
|
+
[--target-dir ../hello-world] \\
|
|
42
|
+
[--author-website "https://github.com/your-name"] \\
|
|
43
|
+
[--repo-owner your-name] \\
|
|
44
|
+
[--description "Hello World - Halo 插件"] \\
|
|
45
|
+
[--install] \\
|
|
46
|
+
[--build] \\
|
|
47
|
+
[--halo-server]
|
|
48
|
+
`.trim()
|
|
49
|
+
|
|
50
|
+
const slugify = (value) =>
|
|
51
|
+
value
|
|
52
|
+
.trim()
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
55
|
+
.replace(/^-+|-+$/g, '')
|
|
56
|
+
|
|
57
|
+
const parseArgs = (argv) => {
|
|
58
|
+
const parsed = {
|
|
59
|
+
install: false,
|
|
60
|
+
build: false,
|
|
61
|
+
haloServer: false,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
65
|
+
const token = argv[index]
|
|
66
|
+
|
|
67
|
+
if (token === '--help' || token === '-h') {
|
|
68
|
+
parsed.help = true
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (token === '--install') {
|
|
73
|
+
parsed.install = true
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (token === '--build') {
|
|
78
|
+
parsed.build = true
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (token === '--halo-server') {
|
|
83
|
+
parsed.haloServer = true
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!token.startsWith('--')) {
|
|
88
|
+
throw new Error(`Unknown argument: ${token}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const key = token.slice(2)
|
|
92
|
+
const value = argv[index + 1]
|
|
93
|
+
if (!value || value.startsWith('--')) {
|
|
94
|
+
throw new Error(`Missing value for --${key}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
parsed[key] = value.trim()
|
|
98
|
+
index += 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parsed.help) {
|
|
102
|
+
return {
|
|
103
|
+
pluginName: '',
|
|
104
|
+
basePackage: '',
|
|
105
|
+
displayName: '',
|
|
106
|
+
authorName: '',
|
|
107
|
+
authorWebsite: '',
|
|
108
|
+
repoOwner: '',
|
|
109
|
+
description: '',
|
|
110
|
+
targetDir: '',
|
|
111
|
+
install: false,
|
|
112
|
+
build: false,
|
|
113
|
+
haloServer: false,
|
|
114
|
+
help: true,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const key of ['plugin-name', 'base-package', 'display-name', 'author-name']) {
|
|
119
|
+
if (!parsed[key]) {
|
|
120
|
+
throw new Error(`--${key} is required`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const pluginName = slugify(parsed['plugin-name'])
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
pluginName,
|
|
128
|
+
basePackage: parsed['base-package'],
|
|
129
|
+
displayName: parsed['display-name'],
|
|
130
|
+
authorName: parsed['author-name'],
|
|
131
|
+
authorWebsite: parsed['author-website'] || '',
|
|
132
|
+
repoOwner: parsed['repo-owner'] || '',
|
|
133
|
+
description: parsed.description || '',
|
|
134
|
+
targetDir: path.resolve(parsed['target-dir'] || path.resolve(process.cwd(), pluginName)),
|
|
135
|
+
install: parsed.install,
|
|
136
|
+
build: parsed.build,
|
|
137
|
+
haloServer: parsed.haloServer,
|
|
138
|
+
help: parsed.help,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const normalizeRelativePath = (sourcePath) =>
|
|
143
|
+
path.relative(TEMPLATE_ROOT, sourcePath).split(path.sep).join('/')
|
|
144
|
+
|
|
145
|
+
const shouldCopy = (sourcePath) => {
|
|
146
|
+
const relativePath = normalizeRelativePath(sourcePath)
|
|
147
|
+
if (!relativePath) {
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (SKIP_RELATIVE_PATHS.has(relativePath)) {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const segments = relativePath.split('/')
|
|
156
|
+
return !segments.some((segment) => SKIP_NAMES.has(segment))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ensureTargetDir = async (targetDir) => {
|
|
160
|
+
if (targetDir === TEMPLATE_ROOT || targetDir.startsWith(`${TEMPLATE_ROOT}${path.sep}`)) {
|
|
161
|
+
throw new Error('Target directory must be outside the template repository')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const entries = await fs.readdir(targetDir)
|
|
166
|
+
if (entries.length > 0) {
|
|
167
|
+
throw new Error(`Target directory is not empty: ${targetDir}`)
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
171
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
throw error
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const runCommand = async (command, args, cwd) => {
|
|
179
|
+
await new Promise((resolve, reject) => {
|
|
180
|
+
const child = spawn(command, args, {
|
|
181
|
+
cwd,
|
|
182
|
+
stdio: 'inherit',
|
|
183
|
+
shell: false,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
child.on('error', reject)
|
|
187
|
+
child.on('close', (code) => {
|
|
188
|
+
if (code === 0) {
|
|
189
|
+
resolve(undefined)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
reject(new Error(`Command failed (${code}): ${command} ${args.join(' ')}`))
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const buildGeneratedReadme = (options) => `# ${options.displayName}
|
|
198
|
+
|
|
199
|
+
这个项目由 Halo Plugin Template 初始化生成,当前插件基础信息如下:
|
|
200
|
+
|
|
201
|
+
- 插件名:\`${options.pluginName}\`
|
|
202
|
+
- Java 包名:\`${options.basePackage}\`
|
|
203
|
+
- UI 路由前缀:\`/${options.pluginName}\`
|
|
204
|
+
- 权限前缀:\`plugin:${options.pluginName}:*\`
|
|
205
|
+
|
|
206
|
+
## 环境要求
|
|
207
|
+
|
|
208
|
+
- Java 21+
|
|
209
|
+
- Node.js 22+
|
|
210
|
+
- pnpm 10+
|
|
211
|
+
- Docker(推荐,用于 \`haloServer\`)
|
|
212
|
+
|
|
213
|
+
## 快速开始
|
|
214
|
+
|
|
215
|
+
\`\`\`bash
|
|
216
|
+
pnpm install --dir ui
|
|
217
|
+
./gradlew build
|
|
218
|
+
./gradlew haloServer
|
|
219
|
+
\`\`\`
|
|
220
|
+
|
|
221
|
+
如果只调试前端构建:
|
|
222
|
+
|
|
223
|
+
\`\`\`bash
|
|
224
|
+
cd ui
|
|
225
|
+
pnpm dev
|
|
226
|
+
\`\`\`
|
|
227
|
+
|
|
228
|
+
## 常用命令
|
|
229
|
+
|
|
230
|
+
\`\`\`bash
|
|
231
|
+
# 构建插件
|
|
232
|
+
./gradlew build
|
|
233
|
+
|
|
234
|
+
# 后端单测
|
|
235
|
+
./gradlew test
|
|
236
|
+
|
|
237
|
+
# 一致性检查
|
|
238
|
+
node scripts/verify-template.mjs
|
|
239
|
+
|
|
240
|
+
# 前端检查
|
|
241
|
+
cd ui
|
|
242
|
+
pnpm verify
|
|
243
|
+
|
|
244
|
+
# 生成 OpenAPI 文档和 TS 客户端
|
|
245
|
+
cd ..
|
|
246
|
+
./gradlew generateApiClient
|
|
247
|
+
|
|
248
|
+
# 基于 OpenAPI 生成角色模板草稿
|
|
249
|
+
./gradlew generateRoleTemplates
|
|
250
|
+
\`\`\`
|
|
251
|
+
|
|
252
|
+
## 开发建议
|
|
253
|
+
|
|
254
|
+
- 后端先补接口和 Springdoc 注解,再执行 \`./gradlew generateApiClient\`。
|
|
255
|
+
- 前端统一从 \`ui/src/api/index.ts\` 暴露 API,不要在页面里写裸 URL。
|
|
256
|
+
- 如果这个插件不需要 UC、附件扩展或仪表盘部件,尽早裁剪,避免模板示例残留。
|
|
257
|
+
|
|
258
|
+
## 目录说明
|
|
259
|
+
|
|
260
|
+
- \`src/main/java/\`:后端骨架,按 \`config / endpoint / query / service / scheme / reconcile / setting / utils\` 分层
|
|
261
|
+
- \`src/main/resources/extensions/\`:插件设置和角色模板
|
|
262
|
+
- \`ui/src/index.ts\`:插件 UI 唯一注册入口
|
|
263
|
+
- \`ui/src/components/ui/\`:低层通用 UI 包装
|
|
264
|
+
- \`ui/src/components/\`:业务级共享组件
|
|
265
|
+
- \`ui/src/api/index.ts\`:前端唯一 API 包装出口
|
|
266
|
+
- \`ui/src/api/generated/\`:由 \`generateApiClient\` 生成并已接入的客户端代码
|
|
267
|
+
- \`docs/rsbuild-switch.md\`:从当前模板切换到 Rsbuild 的最小差异说明
|
|
268
|
+
- \`docs/template-pruning.md\`:初始化后如何裁剪模板能力的操作建议
|
|
269
|
+
|
|
270
|
+
## 裁剪模板
|
|
271
|
+
|
|
272
|
+
如果你只做 Console 页面,或不需要附件扩展、UC 页面,可以参考 [docs/template-pruning.md](./docs/template-pruning.md) 做删减。
|
|
273
|
+
|
|
274
|
+
## 许可证
|
|
275
|
+
|
|
276
|
+
[GPL-3.0](./LICENSE)
|
|
277
|
+
`
|
|
278
|
+
|
|
279
|
+
const writeGeneratedReadme = async (options) => {
|
|
280
|
+
await fs.writeFile(
|
|
281
|
+
path.join(options.targetDir, 'README.md'),
|
|
282
|
+
buildGeneratedReadme(options),
|
|
283
|
+
'utf8',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const buildInitArgs = (options) => {
|
|
288
|
+
const args = [
|
|
289
|
+
path.join('scripts', 'init-template.mjs'),
|
|
290
|
+
'--plugin-name',
|
|
291
|
+
options.pluginName,
|
|
292
|
+
'--base-package',
|
|
293
|
+
options.basePackage,
|
|
294
|
+
'--display-name',
|
|
295
|
+
options.displayName,
|
|
296
|
+
'--author-name',
|
|
297
|
+
options.authorName,
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
if (options.authorWebsite) {
|
|
301
|
+
args.push('--author-website', options.authorWebsite)
|
|
302
|
+
}
|
|
303
|
+
if (options.repoOwner) {
|
|
304
|
+
args.push('--repo-owner', options.repoOwner)
|
|
305
|
+
}
|
|
306
|
+
if (options.description) {
|
|
307
|
+
args.push('--description', options.description)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return args
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const buildVerifyArgs = (options) => {
|
|
314
|
+
const args = [
|
|
315
|
+
path.join('scripts', 'verify-template.mjs'),
|
|
316
|
+
'--plugin-name',
|
|
317
|
+
options.pluginName,
|
|
318
|
+
'--base-package',
|
|
319
|
+
options.basePackage,
|
|
320
|
+
'--display-name',
|
|
321
|
+
options.displayName,
|
|
322
|
+
'--author-name',
|
|
323
|
+
options.authorName,
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
if (options.authorWebsite) {
|
|
327
|
+
args.push('--author-website', options.authorWebsite)
|
|
328
|
+
}
|
|
329
|
+
if (options.repoOwner) {
|
|
330
|
+
args.push('--repo-owner', options.repoOwner)
|
|
331
|
+
}
|
|
332
|
+
if (options.description) {
|
|
333
|
+
args.push('--description', options.description)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return args
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const main = async () => {
|
|
340
|
+
try {
|
|
341
|
+
const options = parseArgs(process.argv.slice(2))
|
|
342
|
+
|
|
343
|
+
if (options.help) {
|
|
344
|
+
console.log(usage)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await ensureTargetDir(options.targetDir)
|
|
349
|
+
|
|
350
|
+
await fs.cp(TEMPLATE_ROOT, options.targetDir, {
|
|
351
|
+
recursive: true,
|
|
352
|
+
filter: (sourcePath) => shouldCopy(sourcePath),
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
if (process.platform !== 'win32') {
|
|
356
|
+
await fs.chmod(path.join(options.targetDir, 'gradlew'), 0o755)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
console.log(`Copied template to ${options.targetDir}`)
|
|
360
|
+
|
|
361
|
+
await runCommand(process.execPath, buildInitArgs(options), options.targetDir)
|
|
362
|
+
await writeGeneratedReadme(options)
|
|
363
|
+
await runCommand(process.execPath, buildVerifyArgs(options), options.targetDir)
|
|
364
|
+
|
|
365
|
+
const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
|
366
|
+
const gradleCommand = process.platform === 'win32' ? 'gradlew.bat' : './gradlew'
|
|
367
|
+
|
|
368
|
+
if (options.install) {
|
|
369
|
+
await runCommand(pnpmCommand, ['install', '--dir', 'ui'], options.targetDir)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (options.build) {
|
|
373
|
+
await runCommand(gradleCommand, ['build'], options.targetDir)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
console.log('')
|
|
377
|
+
console.log('Project created successfully')
|
|
378
|
+
console.log(`Target: ${options.targetDir}`)
|
|
379
|
+
console.log('Recommended next steps:')
|
|
380
|
+
console.log(` cd ${options.targetDir}`)
|
|
381
|
+
if (!options.install) {
|
|
382
|
+
console.log(' pnpm install --dir ui')
|
|
383
|
+
}
|
|
384
|
+
if (!options.build) {
|
|
385
|
+
console.log(' ./gradlew build')
|
|
386
|
+
}
|
|
387
|
+
console.log(' ./gradlew haloServer')
|
|
388
|
+
|
|
389
|
+
if (options.haloServer) {
|
|
390
|
+
await runCommand(gradleCommand, ['haloServer'], options.targetDir)
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.error(`Failed to create project: ${error instanceof Error ? error.message : String(error)}`)
|
|
394
|
+
console.error(usage)
|
|
395
|
+
process.exitCode = 1
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await main()
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
const TEMPLATE = {
|
|
8
|
+
pluginName: 'halo-plugin-template',
|
|
9
|
+
basePackage: 'run.halo.plugintemplate',
|
|
10
|
+
classPrefix: 'PluginTemplate',
|
|
11
|
+
displayName: 'Halo Plugin Template',
|
|
12
|
+
authorName: 'Template Author',
|
|
13
|
+
authorWebsite: 'https://github.com/example',
|
|
14
|
+
repoOwner: 'example',
|
|
15
|
+
description:
|
|
16
|
+
'Halo plugin starter with Vite, Element Plus, extension points, and an initialization script.',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TEMPLATE_PACKAGE_PATH = TEMPLATE.basePackage.split('.').join('/')
|
|
20
|
+
const ROOT = process.cwd()
|
|
21
|
+
const TEXT_EXTENSIONS = new Set([
|
|
22
|
+
'.md',
|
|
23
|
+
'.yaml',
|
|
24
|
+
'.yml',
|
|
25
|
+
'.json',
|
|
26
|
+
'.mjs',
|
|
27
|
+
'.ts',
|
|
28
|
+
'.tsx',
|
|
29
|
+
'.vue',
|
|
30
|
+
'.scss',
|
|
31
|
+
'.css',
|
|
32
|
+
'.gradle',
|
|
33
|
+
'.java',
|
|
34
|
+
'.properties',
|
|
35
|
+
'.html',
|
|
36
|
+
])
|
|
37
|
+
const TEXT_FILE_NAMES = new Set([
|
|
38
|
+
'.gitignore',
|
|
39
|
+
'.editorconfig',
|
|
40
|
+
])
|
|
41
|
+
const SKIP_DIRS = new Set([
|
|
42
|
+
'.git',
|
|
43
|
+
'.gradle',
|
|
44
|
+
'build',
|
|
45
|
+
'node_modules',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
const usage = `
|
|
49
|
+
Usage:
|
|
50
|
+
node scripts/init-template.mjs \\
|
|
51
|
+
--plugin-name hello-world \\
|
|
52
|
+
--base-package com.example.helloworld \\
|
|
53
|
+
--display-name "Hello World" \\
|
|
54
|
+
--author-name "Your Name" \\
|
|
55
|
+
[--author-website "https://github.com/your-name"] \\
|
|
56
|
+
[--repo-owner your-name] \\
|
|
57
|
+
[--description "Hello World - Halo 插件"]
|
|
58
|
+
`.trim()
|
|
59
|
+
|
|
60
|
+
const slugify = (value) =>
|
|
61
|
+
value
|
|
62
|
+
.trim()
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
65
|
+
.replace(/^-+|-+$/g, '')
|
|
66
|
+
|
|
67
|
+
const toClassPrefix = (pluginName) => {
|
|
68
|
+
const parts = pluginName.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
|
69
|
+
if (!parts.length) {
|
|
70
|
+
return 'PluginTemplate'
|
|
71
|
+
}
|
|
72
|
+
return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toKebabToken = (value) =>
|
|
76
|
+
value
|
|
77
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
78
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
|
|
81
|
+
const parseArgs = (argv) => {
|
|
82
|
+
const parsed = {}
|
|
83
|
+
|
|
84
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
85
|
+
const token = argv[index]
|
|
86
|
+
if (!token.startsWith('--')) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const key = token.slice(2)
|
|
90
|
+
const value = argv[index + 1]
|
|
91
|
+
if (!value || value.startsWith('--')) {
|
|
92
|
+
throw new Error(`Missing value for --${key}`)
|
|
93
|
+
}
|
|
94
|
+
parsed[key] = value
|
|
95
|
+
index += 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const key of ['plugin-name', 'base-package', 'display-name', 'author-name']) {
|
|
99
|
+
if (!parsed[key]?.trim()) {
|
|
100
|
+
throw new Error(`--${key} is required`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
pluginName: slugify(parsed['plugin-name']),
|
|
106
|
+
basePackage: parsed['base-package'].trim(),
|
|
107
|
+
displayName: parsed['display-name'].trim(),
|
|
108
|
+
authorName: parsed['author-name'].trim(),
|
|
109
|
+
authorWebsite: parsed['author-website']?.trim() || '',
|
|
110
|
+
repoOwner: parsed['repo-owner']?.trim() || '',
|
|
111
|
+
description: parsed.description?.trim() || '',
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isTextFile = (filePath) => {
|
|
116
|
+
const fileName = path.basename(filePath)
|
|
117
|
+
return TEXT_FILE_NAMES.has(fileName) || TEXT_EXTENSIONS.has(path.extname(filePath))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const walkFiles = async (directory) => {
|
|
121
|
+
const entries = await fs.readdir(directory, { withFileTypes: true })
|
|
122
|
+
const files = []
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const absolutePath = path.join(directory, entry.name)
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
files.push(...(await walkFiles(absolutePath)))
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
files.push(absolutePath)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return files
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const replaceContent = async (filePath, replacements) => {
|
|
142
|
+
if (!isTextFile(filePath)) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const original = await fs.readFile(filePath, 'utf8')
|
|
147
|
+
let next = original
|
|
148
|
+
|
|
149
|
+
for (const [from, to] of replacements) {
|
|
150
|
+
next = next.split(from).join(to)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (next !== original) {
|
|
154
|
+
await fs.writeFile(filePath, next, 'utf8')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pruneEmptyDirectories = async (startPath, stopPath) => {
|
|
159
|
+
let current = startPath
|
|
160
|
+
|
|
161
|
+
while (current.startsWith(stopPath) && current !== stopPath) {
|
|
162
|
+
const entries = await fs.readdir(current)
|
|
163
|
+
if (entries.length > 0) {
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
await fs.rmdir(current)
|
|
167
|
+
current = path.dirname(current)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const renamePackageDirectory = async (sourceRoot, targetRoot) => {
|
|
172
|
+
try {
|
|
173
|
+
await fs.access(sourceRoot)
|
|
174
|
+
} catch {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await fs.mkdir(path.dirname(targetRoot), { recursive: true })
|
|
179
|
+
await fs.rename(sourceRoot, targetRoot)
|
|
180
|
+
await pruneEmptyDirectories(path.dirname(sourceRoot), path.dirname(path.dirname(sourceRoot)))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const collectRenameTargets = async (directory, token, result = []) => {
|
|
184
|
+
const entries = await fs.readdir(directory, { withFileTypes: true })
|
|
185
|
+
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const absolutePath = path.join(directory, entry.name)
|
|
192
|
+
if (entry.isDirectory()) {
|
|
193
|
+
await collectRenameTargets(absolutePath, token, result)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (entry.name.includes(token)) {
|
|
197
|
+
result.push(absolutePath)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return result
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const renameClassPrefixPaths = async (root, fromToken, toToken) => {
|
|
205
|
+
const targets = await collectRenameTargets(root, fromToken)
|
|
206
|
+
targets.sort((left, right) => right.length - left.length)
|
|
207
|
+
|
|
208
|
+
for (const current of targets) {
|
|
209
|
+
const target = path.join(path.dirname(current), path.basename(current).replaceAll(fromToken, toToken))
|
|
210
|
+
await fs.rename(current, target)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const main = async () => {
|
|
215
|
+
try {
|
|
216
|
+
const options = parseArgs(process.argv.slice(2))
|
|
217
|
+
const classPrefix = toClassPrefix(options.pluginName)
|
|
218
|
+
const classPrefixKebab = toKebabToken(classPrefix)
|
|
219
|
+
const templateClassPrefixKebab = toKebabToken(TEMPLATE.classPrefix)
|
|
220
|
+
const repoOwner = options.repoOwner || slugify(options.authorName).replace(/-/g, '')
|
|
221
|
+
const authorWebsite = options.authorWebsite || `https://github.com/${repoOwner}`
|
|
222
|
+
const description = options.description || `${options.displayName} - Halo 插件`
|
|
223
|
+
const nextPackagePath = options.basePackage.split('.').join('/')
|
|
224
|
+
|
|
225
|
+
const replacements = [
|
|
226
|
+
[
|
|
227
|
+
'https://github.com/example/halo-plugin-template/blob/main/LICENSE',
|
|
228
|
+
`https://github.com/${repoOwner}/${options.pluginName}/blob/main/LICENSE`,
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
'https://github.com/example/halo-plugin-template/issues',
|
|
232
|
+
`https://github.com/${repoOwner}/${options.pluginName}/issues`,
|
|
233
|
+
],
|
|
234
|
+
[
|
|
235
|
+
'https://github.com/example/halo-plugin-template#readme',
|
|
236
|
+
`https://github.com/${repoOwner}/${options.pluginName}#readme`,
|
|
237
|
+
],
|
|
238
|
+
[
|
|
239
|
+
'https://github.com/example/halo-plugin-template',
|
|
240
|
+
`https://github.com/${repoOwner}/${options.pluginName}`,
|
|
241
|
+
],
|
|
242
|
+
[TEMPLATE.authorWebsite, authorWebsite],
|
|
243
|
+
[TEMPLATE_PACKAGE_PATH, nextPackagePath],
|
|
244
|
+
[TEMPLATE.basePackage, options.basePackage],
|
|
245
|
+
[TEMPLATE.classPrefix, classPrefix],
|
|
246
|
+
[TEMPLATE.displayName, options.displayName],
|
|
247
|
+
[TEMPLATE.description, description],
|
|
248
|
+
[TEMPLATE.authorName, options.authorName],
|
|
249
|
+
[TEMPLATE.pluginName, options.pluginName],
|
|
250
|
+
[templateClassPrefixKebab, classPrefixKebab],
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
const files = await walkFiles(ROOT)
|
|
254
|
+
for (const filePath of files) {
|
|
255
|
+
await replaceContent(filePath, replacements)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await renamePackageDirectory(
|
|
259
|
+
path.join(ROOT, 'src/main/java', TEMPLATE_PACKAGE_PATH),
|
|
260
|
+
path.join(ROOT, 'src/main/java', nextPackagePath),
|
|
261
|
+
)
|
|
262
|
+
await renamePackageDirectory(
|
|
263
|
+
path.join(ROOT, 'src/test/java', TEMPLATE_PACKAGE_PATH),
|
|
264
|
+
path.join(ROOT, 'src/test/java', nextPackagePath),
|
|
265
|
+
)
|
|
266
|
+
await renameClassPrefixPaths(ROOT, TEMPLATE.classPrefix, classPrefix)
|
|
267
|
+
await renameClassPrefixPaths(ROOT, templateClassPrefixKebab, classPrefixKebab)
|
|
268
|
+
|
|
269
|
+
console.log(`Initialized template as ${options.pluginName}`)
|
|
270
|
+
console.log(`Base package: ${options.basePackage}`)
|
|
271
|
+
console.log(`Class prefix: ${classPrefix}`)
|
|
272
|
+
console.log(`Repository owner: ${repoOwner}`)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
275
|
+
console.error('')
|
|
276
|
+
console.error(usage)
|
|
277
|
+
process.exitCode = 1
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await main()
|