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.
Files changed (108) hide show
  1. package/.editorconfig +520 -0
  2. package/.github/workflows/cd.yaml +20 -0
  3. package/.github/workflows/ci.yaml +32 -0
  4. package/.github/workflows/publish-npm.yaml +46 -0
  5. package/.gitignore +84 -0
  6. package/LICENSE +674 -0
  7. package/README.md +191 -0
  8. package/build.gradle +103 -0
  9. package/docs/first-npm-release-checklist.md +58 -0
  10. package/docs/publish-template.md +148 -0
  11. package/docs/rsbuild-switch.md +90 -0
  12. package/docs/template-pruning.md +43 -0
  13. package/gradle/wrapper/gradle-wrapper.jar +0 -0
  14. package/gradle/wrapper/gradle-wrapper.properties +7 -0
  15. package/gradle.properties +1 -0
  16. package/gradlew +248 -0
  17. package/gradlew.bat +93 -0
  18. package/package.json +67 -7
  19. package/scripts/create-project.mjs +399 -0
  20. package/scripts/init-template.mjs +281 -0
  21. package/scripts/publish-check.mjs +97 -0
  22. package/scripts/release.mjs +278 -0
  23. package/scripts/verify-template.mjs +407 -0
  24. package/settings.gradle +7 -0
  25. package/src/main/java/run/halo/plugintemplate/PluginTemplatePlugin.java +43 -0
  26. package/src/main/java/run/halo/plugintemplate/config/PluginTemplateConfig.java +14 -0
  27. package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateChecklistItem.java +30 -0
  28. package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateFeatureItem.java +30 -0
  29. package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateOverview.java +73 -0
  30. package/src/main/java/run/halo/plugintemplate/dto/PluginTemplateStatItem.java +30 -0
  31. package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplateConsoleEndpoint.java +33 -0
  32. package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplatePublicEndpoint.java +26 -0
  33. package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplateUcEndpoint.java +33 -0
  34. package/src/main/java/run/halo/plugintemplate/endpoint/routes/PluginTemplateOverviewRoutes.java +60 -0
  35. package/src/main/java/run/halo/plugintemplate/model/PluginTemplateAudience.java +23 -0
  36. package/src/main/java/run/halo/plugintemplate/query/PluginTemplateOverviewQuery.java +26 -0
  37. package/src/main/java/run/halo/plugintemplate/reconcile/PluginTemplateSettingsReconciler.java +17 -0
  38. package/src/main/java/run/halo/plugintemplate/scheme/PluginTemplateRecord.java +43 -0
  39. package/src/main/java/run/halo/plugintemplate/service/PluginTemplateOverviewService.java +10 -0
  40. package/src/main/java/run/halo/plugintemplate/service/impl/PluginTemplateOverviewServiceImpl.java +74 -0
  41. package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateGeneralSetting.java +25 -0
  42. package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateSettingKeys.java +24 -0
  43. package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateUiSetting.java +13 -0
  44. package/src/main/java/run/halo/plugintemplate/utils/PluginTemplateSeeds.java +197 -0
  45. package/src/main/resources/extensions/roleTemplate-console.yaml +39 -0
  46. package/src/main/resources/extensions/roleTemplate-uc.yaml +19 -0
  47. package/src/main/resources/extensions/settings.yaml +47 -0
  48. package/src/main/resources/logo.png +0 -0
  49. package/src/main/resources/plugin.yaml +24 -0
  50. package/src/test/java/run/halo/plugintemplate/PluginTemplatePluginTest.java +34 -0
  51. package/src/test/java/run/halo/plugintemplate/service/impl/PluginTemplateOverviewServiceImplTest.java +97 -0
  52. package/ui/build.gradle +41 -0
  53. package/ui/env.d.ts +2 -0
  54. package/ui/eslint.config.ts +30 -0
  55. package/ui/package.json +57 -0
  56. package/ui/pnpm-lock.yaml +5250 -0
  57. package/ui/src/api/__tests__/normalizers.spec.ts +65 -0
  58. package/ui/src/api/generated/.openapi-generator/FILES +23 -0
  59. package/ui/src/api/generated/.openapi-generator/VERSION +1 -0
  60. package/ui/src/api/generated/.openapi-generator-ignore +23 -0
  61. package/ui/src/api/generated/api/plugin-template-console-api.ts +128 -0
  62. package/ui/src/api/generated/api/plugin-template-uc-api.ts +128 -0
  63. package/ui/src/api/generated/api.ts +19 -0
  64. package/ui/src/api/generated/base.ts +86 -0
  65. package/ui/src/api/generated/common.ts +150 -0
  66. package/ui/src/api/generated/configuration.ts +110 -0
  67. package/ui/src/api/generated/git_push.sh +57 -0
  68. package/ui/src/api/generated/index.ts +18 -0
  69. package/ui/src/api/generated/models/add-operation.ts +49 -0
  70. package/ui/src/api/generated/models/copy-operation.ts +49 -0
  71. package/ui/src/api/generated/models/index.ts +11 -0
  72. package/ui/src/api/generated/models/json-patch-inner.ts +41 -0
  73. package/ui/src/api/generated/models/move-operation.ts +49 -0
  74. package/ui/src/api/generated/models/plugin-template-checklist-item.ts +54 -0
  75. package/ui/src/api/generated/models/plugin-template-feature-item.ts +54 -0
  76. package/ui/src/api/generated/models/plugin-template-overview.ts +147 -0
  77. package/ui/src/api/generated/models/plugin-template-stat-item.ts +54 -0
  78. package/ui/src/api/generated/models/remove-operation.ts +43 -0
  79. package/ui/src/api/generated/models/replace-operation.ts +49 -0
  80. package/ui/src/api/generated/models/test-operation.ts +49 -0
  81. package/ui/src/api/index.ts +42 -0
  82. package/ui/src/api/normalizers.ts +65 -0
  83. package/ui/src/assets/element.scss +24 -0
  84. package/ui/src/assets/index.css +361 -0
  85. package/ui/src/assets/logo.svg +1 -0
  86. package/ui/src/components/PluginTemplateAttachmentTab.vue +69 -0
  87. package/ui/src/components/PluginTemplateCommonTable.vue +69 -0
  88. package/ui/src/components/PluginTemplateDashboardWidget.vue +62 -0
  89. package/ui/src/components/PluginTemplateOverviewPage.vue +254 -0
  90. package/ui/src/components/ui/PluginUiProvider.vue +40 -0
  91. package/ui/src/components/ui/UiMetricCard.vue +21 -0
  92. package/ui/src/components/ui/UiSectionCard.vue +25 -0
  93. package/ui/src/components/ui/UiStatusPill.vue +18 -0
  94. package/ui/src/composables/useTemplateOverview.ts +38 -0
  95. package/ui/src/index.ts +88 -0
  96. package/ui/src/lib/__tests__/plugin-ui.spec.ts +19 -0
  97. package/ui/src/lib/plugin-ui.ts +19 -0
  98. package/ui/src/lib/template.spec.ts +24 -0
  99. package/ui/src/lib/template.ts +52 -0
  100. package/ui/src/lib/theme.ts +31 -0
  101. package/ui/src/types/index.ts +59 -0
  102. package/ui/src/views/console/ConsoleDashboardView.vue +7 -0
  103. package/ui/src/views/uc/UcDashboardView.vue +7 -0
  104. package/ui/tsconfig.app.json +12 -0
  105. package/ui/tsconfig.json +14 -0
  106. package/ui/tsconfig.node.json +15 -0
  107. package/ui/tsconfig.vitest.json +11 -0
  108. 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()