@pugpigjs/create-wp-project 0.0.2-beta.0
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/README.md +236 -0
- package/package.json +32 -0
- package/src/cli.js +46 -0
- package/src/commands/add.js +151 -0
- package/src/commands/init.js +187 -0
- package/src/commands/remove.js +123 -0
- package/src/commands/update.js +229 -0
- package/src/utils/constants.js +68 -0
- package/src/utils/docker-compose.js +25 -0
- package/src/utils/shared.js +359 -0
- package/src/utils/templating.js +167 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
import simpleGit from 'simple-git'
|
|
7
|
+
import { TEMPLATES, EXCLUDED_ITEMS, EXCLUDE_PATTERNS } from './constants.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate target directory is safe and meets naming requirements
|
|
11
|
+
* @param {string} directory - The directory path to validate
|
|
12
|
+
* @throws {Error} Exits process if validation fails
|
|
13
|
+
*/
|
|
14
|
+
export async function validateTargetDirectory(directory) {
|
|
15
|
+
const targetDir = path.resolve(directory)
|
|
16
|
+
|
|
17
|
+
// Check if directory exists
|
|
18
|
+
if (!await fse.pathExists(targetDir)) {
|
|
19
|
+
console.error(chalk.red(`\n❌ Target directory does not exist: ${targetDir}`))
|
|
20
|
+
console.error(chalk.yellow('Please create the directory first or use an existing one.'))
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Safety check: block system directories
|
|
25
|
+
const sensitiveDirs = [os.homedir(), '/', '/usr', '/etc', '/var', '/bin', '/sbin']
|
|
26
|
+
if (sensitiveDirs.includes(targetDir)) {
|
|
27
|
+
console.error(chalk.red(`\n❌ Cannot operate in system directory: ${targetDir}`))
|
|
28
|
+
console.error(chalk.yellow('Please use a dedicated project directory.'))
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Safety check: directory name must end with -server
|
|
33
|
+
const dirName = path.basename(targetDir)
|
|
34
|
+
if (!dirName?.toLowerCase()?.endsWith('-server')) {
|
|
35
|
+
console.error(chalk.red(`\n❌ Target directory must end with '-server': ${dirName}`))
|
|
36
|
+
console.error(chalk.yellow('This is a safety measure to prevent accidental operations in wrong directories.'))
|
|
37
|
+
console.error(chalk.gray(`Example: my-project-server, wordpress-server, etc.`))
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return targetDir
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shared validation functions for prompts
|
|
46
|
+
*/
|
|
47
|
+
export const validators = {
|
|
48
|
+
namespace: (input) => {
|
|
49
|
+
if (!input.trim()) return 'Namespace is required'
|
|
50
|
+
if (!/^[A-Z][a-zA-Z0-9]*$/.test(input)) return 'Namespace must start with uppercase letter and contain only letters and numbers'
|
|
51
|
+
return true
|
|
52
|
+
},
|
|
53
|
+
projectName: (input) => {
|
|
54
|
+
if (!input.trim()) return 'Project name is required'
|
|
55
|
+
if (!/^[a-z0-9_]+$/.test(input)) return 'Project name must be in snake_case (lowercase letters, numbers, and underscores only)'
|
|
56
|
+
if (input.startsWith('_') || input.endsWith('_')) return 'Project name cannot start or end with an underscore'
|
|
57
|
+
if (input.includes('__')) return 'Project name cannot contain consecutive underscores'
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shared prompt configurations
|
|
64
|
+
*/
|
|
65
|
+
export const prompts = {
|
|
66
|
+
template: {
|
|
67
|
+
type: 'list',
|
|
68
|
+
name: 'template',
|
|
69
|
+
message: 'Select a template:',
|
|
70
|
+
choices: Object.entries(TEMPLATES).map(([key, config]) => ({
|
|
71
|
+
name: `${config.name} - ${config.description}`,
|
|
72
|
+
value: key
|
|
73
|
+
}))
|
|
74
|
+
},
|
|
75
|
+
namespace: {
|
|
76
|
+
type: 'input',
|
|
77
|
+
name: 'namespace',
|
|
78
|
+
message: 'Enter namespace (e.g., "Pugpig", "Acme"):',
|
|
79
|
+
default: 'Pugpig',
|
|
80
|
+
validate: validators.namespace
|
|
81
|
+
},
|
|
82
|
+
getProjectNamePrompt: (previousAnswers) => ({
|
|
83
|
+
type: 'input',
|
|
84
|
+
name: 'projectName',
|
|
85
|
+
message: 'Enter project name (snake_case, e.g., "my_custom_plugin"):',
|
|
86
|
+
validate: (input) => {
|
|
87
|
+
// First run the basic validation
|
|
88
|
+
const basicValidation = validators.projectName(input)
|
|
89
|
+
if (basicValidation !== true) return basicValidation
|
|
90
|
+
|
|
91
|
+
// Check if project name starts with namespace prefix
|
|
92
|
+
if (previousAnswers && previousAnswers.namespace) {
|
|
93
|
+
const namespacePrefix = `${previousAnswers.namespace.toLowerCase()}_`
|
|
94
|
+
if (input.startsWith(namespacePrefix)) {
|
|
95
|
+
return `Project name should not start with "${namespacePrefix}" as the namespace will be automatically prepended to the directory name`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
description: {
|
|
103
|
+
type: 'input',
|
|
104
|
+
name: 'description',
|
|
105
|
+
message: 'Enter project description:',
|
|
106
|
+
default: (answers) => {
|
|
107
|
+
return answers.projectName
|
|
108
|
+
.split('_')
|
|
109
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
110
|
+
.join(' ')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clone the git repository to a temporary directory, or copy from local path
|
|
117
|
+
* @param {string} gitUrlOrPath - Git URL or local filesystem path
|
|
118
|
+
* @param {string} tempDir - Temporary directory to clone/copy into
|
|
119
|
+
*/
|
|
120
|
+
export async function cloneRepo(gitUrlOrPath, tempDir) {
|
|
121
|
+
// Check if it's a local path
|
|
122
|
+
const isLocalPath = !gitUrlOrPath.startsWith('http://') &&
|
|
123
|
+
!gitUrlOrPath.startsWith('https://') &&
|
|
124
|
+
!gitUrlOrPath.startsWith('git@')
|
|
125
|
+
|
|
126
|
+
console.log(chalk.gray(` Using temporary directory: ${tempDir}`))
|
|
127
|
+
|
|
128
|
+
if (isLocalPath) {
|
|
129
|
+
const spinner = ora('Copying from local template...').start()
|
|
130
|
+
try {
|
|
131
|
+
// Pass tempDir as excludePath to prevent copying the temp directory into itself
|
|
132
|
+
await copyDirectory(gitUrlOrPath, tempDir, tempDir)
|
|
133
|
+
spinner.succeed('Local template copied')
|
|
134
|
+
} catch (error) {
|
|
135
|
+
spinner.fail('Failed to copy local template')
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const spinner = ora('Cloning template repository...').start()
|
|
140
|
+
try {
|
|
141
|
+
const git = simpleGit()
|
|
142
|
+
await git.clone(gitUrlOrPath, tempDir, ['--depth', '1'])
|
|
143
|
+
spinner.succeed('Template repository cloned')
|
|
144
|
+
} catch (error) {
|
|
145
|
+
spinner.fail('Failed to clone repository')
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a path should be excluded from copying
|
|
153
|
+
*/
|
|
154
|
+
function shouldExclude(itemName) {
|
|
155
|
+
// Exact match exclusions
|
|
156
|
+
if (EXCLUDED_ITEMS.includes(itemName)) {
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
// Pattern exclusions: .temp-* directories
|
|
160
|
+
if (itemName.startsWith('.temp-')) {
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Copy template directory recursively using fs-extra
|
|
168
|
+
* Handles the case where target is inside source by copying item-by-item
|
|
169
|
+
* @param {string} source - Source directory path
|
|
170
|
+
* @param {string} target - Target directory path
|
|
171
|
+
* @param {string} [excludePath] - Additional path to exclude (used to prevent copying target into itself)
|
|
172
|
+
*/
|
|
173
|
+
export async function copyDirectory(source, target, excludePath = null) {
|
|
174
|
+
const resolvedSource = path.resolve(source)
|
|
175
|
+
const resolvedTarget = path.resolve(target)
|
|
176
|
+
const resolvedExcludePath = excludePath ? path.resolve(excludePath) : null
|
|
177
|
+
|
|
178
|
+
// Check if target is inside source - if so, we need to copy item-by-item
|
|
179
|
+
const targetIsInsideSource = resolvedTarget.startsWith(resolvedSource + path.sep)
|
|
180
|
+
|
|
181
|
+
if (targetIsInsideSource) {
|
|
182
|
+
// Copy each item individually, skipping the target directory
|
|
183
|
+
await fse.ensureDir(target)
|
|
184
|
+
const entries = await fse.readdir(source, { withFileTypes: true })
|
|
185
|
+
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
const sourcePath = path.join(source, entry.name)
|
|
188
|
+
const targetPath = path.join(target, entry.name)
|
|
189
|
+
const resolvedSourcePath = path.resolve(sourcePath)
|
|
190
|
+
|
|
191
|
+
// Skip if this would create a loop
|
|
192
|
+
if (resolvedSourcePath === resolvedTarget || resolvedSourcePath.startsWith(resolvedTarget + path.sep)) {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Skip excluded items
|
|
197
|
+
if (shouldExclude(entry.name)) {
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Skip exclude path
|
|
202
|
+
if (resolvedExcludePath && resolvedSourcePath === resolvedExcludePath) {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (entry.isDirectory()) {
|
|
207
|
+
await copyDirectory(sourcePath, targetPath, excludePath)
|
|
208
|
+
} else {
|
|
209
|
+
await fse.copy(sourcePath, targetPath)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
// Normal copy with filter
|
|
214
|
+
await fse.copy(source, target, {
|
|
215
|
+
filter: (src) => {
|
|
216
|
+
const resolvedSrc = path.resolve(src)
|
|
217
|
+
const basename = path.basename(src)
|
|
218
|
+
|
|
219
|
+
// Skip excluded items (both exact matches and patterns)
|
|
220
|
+
if (shouldExclude(basename)) {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Skip if this is the exclude path
|
|
225
|
+
if (resolvedExcludePath && resolvedSrc === resolvedExcludePath) {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Recursively get all files in a directory, excluding certain patterns
|
|
238
|
+
* @param {string} dir - Directory to scan
|
|
239
|
+
* @param {string[]} excludePatterns - Patterns to exclude (e.g., 'node_modules', '.git')
|
|
240
|
+
* @returns {Promise<string[]>} - Array of file paths
|
|
241
|
+
*/
|
|
242
|
+
async function getAllFiles(dir, excludePatterns = []) {
|
|
243
|
+
const entries = await fse.readdir(dir, { withFileTypes: true })
|
|
244
|
+
const files = []
|
|
245
|
+
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
const fullPath = path.join(dir, entry.name)
|
|
248
|
+
|
|
249
|
+
// Skip excluded patterns
|
|
250
|
+
if (excludePatterns.some(pattern => entry.name === pattern || entry.name.startsWith(pattern))) {
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (entry.isDirectory()) {
|
|
255
|
+
// Recursively get files from subdirectories
|
|
256
|
+
const subFiles = await getAllFiles(fullPath, excludePatterns)
|
|
257
|
+
files.push(...subFiles)
|
|
258
|
+
} else {
|
|
259
|
+
files.push(fullPath)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return files
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Replace template placeholders in all files (NEW placeholder-based approach)
|
|
268
|
+
* This is the simplified replacement that works with placeholder-based templates
|
|
269
|
+
*
|
|
270
|
+
* @param {string} targetDir - The target directory
|
|
271
|
+
* @param {string} namespace - The namespace (e.g., "Acme")
|
|
272
|
+
* @param {string} projectName - The project name (snake_case)
|
|
273
|
+
* @param {string} description - The project description
|
|
274
|
+
* @param {string[]} additionalFiles - Optional additional files to process
|
|
275
|
+
*/
|
|
276
|
+
export async function replacePlaceholdersInFiles(targetDir, namespace, projectName, description, additionalFiles = []) {
|
|
277
|
+
const { replaceInFile: replaceInFilePlaceholder } = await import('./templating.js')
|
|
278
|
+
const spinner = ora('Replacing template placeholders...').start()
|
|
279
|
+
|
|
280
|
+
const directoryName = `${namespace.toLowerCase()}_${projectName}`
|
|
281
|
+
const values = {
|
|
282
|
+
namespace,
|
|
283
|
+
projectName,
|
|
284
|
+
directoryName,
|
|
285
|
+
description
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Get all files in the target directory, excluding certain patterns
|
|
289
|
+
const allFiles = await getAllFiles(targetDir, EXCLUDE_PATTERNS)
|
|
290
|
+
|
|
291
|
+
// Add any additional files specified (e.g., root files for init)
|
|
292
|
+
const additionalFilePaths = additionalFiles.map(file => path.join(targetDir, file))
|
|
293
|
+
const filesToProcess = [...allFiles, ...additionalFilePaths]
|
|
294
|
+
|
|
295
|
+
// Apply placeholder replacements to all files
|
|
296
|
+
let replacedCount = 0
|
|
297
|
+
for (const filePath of filesToProcess) {
|
|
298
|
+
const wasReplaced = await replaceInFilePlaceholder(filePath, values)
|
|
299
|
+
if (wasReplaced) {
|
|
300
|
+
replacedCount++
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
spinner.succeed(`Template placeholders replaced (${replacedCount} files updated)`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Cleanup temporary directory
|
|
309
|
+
* Now uses directories within project scope for maximum safety
|
|
310
|
+
*/
|
|
311
|
+
export async function cleanup(tempDir) {
|
|
312
|
+
const basename = path.basename(tempDir)
|
|
313
|
+
|
|
314
|
+
// Safety check: only remove directories that look like our temp directories
|
|
315
|
+
if (!basename.startsWith('.temp-')) {
|
|
316
|
+
throw new Error(`Refusing to remove directory that doesn't look like a temp directory: ${tempDir}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Additional safety: basename must be exactly .temp-{8 hex chars}
|
|
320
|
+
if (!/^\.temp-[a-f0-9]{8}$/.test(basename)) {
|
|
321
|
+
throw new Error(`Invalid temp directory format: ${tempDir}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const spinner = ora('Cleaning up...').start()
|
|
325
|
+
console.log(chalk.gray(` Removing temporary directory: ${basename}`))
|
|
326
|
+
await fse.remove(tempDir)
|
|
327
|
+
spinner.succeed('Cleanup complete')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Display next steps after project initialization or template addition
|
|
332
|
+
* @param {string} targetDir - The target directory where files were created
|
|
333
|
+
* @param {string} projectName - The project name
|
|
334
|
+
* @param {string} templateType - The template type key
|
|
335
|
+
* @param {boolean} isInit - Whether this is for init (true) or add (false) command
|
|
336
|
+
*/
|
|
337
|
+
export function displayNextSteps(targetDir, projectName, templateType, isInit = false) {
|
|
338
|
+
// Calculate relative paths from current working directory
|
|
339
|
+
const relativePath = path.relative(process.cwd(), targetDir)
|
|
340
|
+
const cdCommand = relativePath || '.'
|
|
341
|
+
|
|
342
|
+
const projectPath = path.join(targetDir, projectName)
|
|
343
|
+
const relativeProjectPath = path.relative(process.cwd(), projectPath)
|
|
344
|
+
|
|
345
|
+
const commitMessage = isInit
|
|
346
|
+
? `"Initial commit: Add ${projectName} project"`
|
|
347
|
+
: `"Add ${projectName} template"`
|
|
348
|
+
|
|
349
|
+
console.log(chalk.cyan('Add files to git:'))
|
|
350
|
+
console.log(chalk.white(` 1. cd ${cdCommand}`))
|
|
351
|
+
console.log(chalk.white(` 2. git add .`))
|
|
352
|
+
console.log(chalk.white(` 3. git commit -m ${commitMessage}`))
|
|
353
|
+
console.log()
|
|
354
|
+
console.log(chalk.cyan('Run the project:'))
|
|
355
|
+
console.log(chalk.white(` 1. cd ${relativeProjectPath}`))
|
|
356
|
+
console.log(chalk.white(` 2. npm ci`))
|
|
357
|
+
console.log(chalk.white(` 3. npm run ${TEMPLATES[templateType].command}`))
|
|
358
|
+
console.log()
|
|
359
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template placeholder replacement utilities
|
|
3
|
+
*
|
|
4
|
+
* This module handles replacing placeholders in template files with actual values.
|
|
5
|
+
* Uses a simple placeholder approach (__PLACEHOLDER__) instead of complex pattern matching.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fse from 'fs-extra'
|
|
9
|
+
import path from 'path'
|
|
10
|
+
import { SKIP_PLACEHOLDER_REPLACEMENT } from './constants.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert snake_case to PascalCase
|
|
14
|
+
* @param {string} str - Snake case string
|
|
15
|
+
* @returns {string} PascalCase string
|
|
16
|
+
* @example toPascalCase('my_custom_plugin') // 'MyCustomPlugin'
|
|
17
|
+
*/
|
|
18
|
+
function toPascalCase(str) {
|
|
19
|
+
return str
|
|
20
|
+
.replace(/[-_](.)/g, (_, char) => char.toUpperCase())
|
|
21
|
+
.replace(/^(.)/, (_, char) => char.toUpperCase())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Available placeholder types and their transformations
|
|
26
|
+
*
|
|
27
|
+
* Placeholders in template files (case-insensitive):
|
|
28
|
+
* - TPL_NAMESPACE / tpl_namespace - Original namespace (e.g., "Acme")
|
|
29
|
+
* - TPL_NAMESPACE_LOWER / tpl_namespace_lower - Lowercase namespace (e.g., "acme")
|
|
30
|
+
* - TPL_PROJECT_NAME / tpl_project_name - Project name in snake_case (e.g., "my_custom_plugin")
|
|
31
|
+
* - TPL_PROJECT_NAME_PASCAL / tpl_project_name_pascal - Project name in PascalCase (e.g., "MyPlugin")
|
|
32
|
+
* - TPL_PROJECT_NAME_SLUG / tpl_project_name_slug - Project name in kebab-case (e.g., "my-plugin")
|
|
33
|
+
* - TPL_PROJECT_NAME_FLAT / tpl_project_name_flat - Project name with no separators (e.g., "myplugin")
|
|
34
|
+
* - TPL_FULL_PROJECT_NAME / tpl_full_project_name - Full project name (e.g., "acme_my_custom_plugin")
|
|
35
|
+
* - TPL_FULL_PROJECT_NAME_SLUG / tpl_full_project_name_slug - Full name kebab-case (e.g., "acme-my-custom-plugin")
|
|
36
|
+
* - TPL_FULL_PROJECT_NAME_TITLE / tpl_full_project_name_title - Title Case (e.g., "Acme My Custom Plugin")
|
|
37
|
+
* - TPL_FULL_PROJECT_NAME_UPPER / tpl_full_project_name_upper - UPPER CASE (e.g., "ACME MY CUSTOM PLUGIN")
|
|
38
|
+
* - TPL_DESCRIPTION / tpl_description - Human-readable description (e.g., "My Custom Plugin")
|
|
39
|
+
* - pkg_* (wildcard) - Any pkg_* gets replaced with full project name slug
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replace all placeholders in content with actual values
|
|
44
|
+
*
|
|
45
|
+
* @param {string} content - Template content containing placeholders
|
|
46
|
+
* @param {object} values - Values to replace placeholders with
|
|
47
|
+
* @param {string} values.namespace - Namespace (e.g., "Acme")
|
|
48
|
+
* @param {string} values.projectName - Project name in snake_case (e.g., "my_custom_plugin")
|
|
49
|
+
* @param {string} values.directoryName - Full directory name (e.g., "acme_my_custom_plugin")
|
|
50
|
+
* @param {string} values.description - Human-readable description
|
|
51
|
+
* @returns {string} Content with all placeholders replaced
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const content = 'namespace TPL_NAMESPACE\\TPL_PROJECT_NAME_PASCAL;'
|
|
55
|
+
* const result = replaceTemplatePlaceholders(content, {
|
|
56
|
+
* namespace: 'Acme',
|
|
57
|
+
* projectName: 'my_plugin',
|
|
58
|
+
* directoryName: 'acme_my_plugin',
|
|
59
|
+
* description: 'My Plugin'
|
|
60
|
+
* })
|
|
61
|
+
* // Returns: 'namespace Acme\\MyPlugin;'
|
|
62
|
+
*/
|
|
63
|
+
export function replaceTemplatePlaceholders(content, values) {
|
|
64
|
+
const { namespace, projectName, description } = values
|
|
65
|
+
|
|
66
|
+
// Get all placeholder values using the centralised function
|
|
67
|
+
const placeholders = getPlaceholderValues(namespace, projectName, description)
|
|
68
|
+
|
|
69
|
+
// Perform all replacements
|
|
70
|
+
// Sort keys by length (longest first) to avoid partial replacements
|
|
71
|
+
// e.g., replace TPL_NAMESPACE_LOWER before TPL_NAMESPACE
|
|
72
|
+
let result = content
|
|
73
|
+
const sortedKeys = Object.keys(placeholders).sort((a, b) => b.length - a.length)
|
|
74
|
+
|
|
75
|
+
// Replace each placeholder (both uppercase and lowercase variants)
|
|
76
|
+
for (const key of sortedKeys) {
|
|
77
|
+
const value = placeholders[key]
|
|
78
|
+
const placeholderUpper = `TPL_${key}`
|
|
79
|
+
const placeholderLower = placeholderUpper.toLowerCase()
|
|
80
|
+
|
|
81
|
+
// Escape regex special chars and replace both variants
|
|
82
|
+
result = result.replace(new RegExp(placeholderUpper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
|
83
|
+
result = result.replace(new RegExp(placeholderLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Replace pkg_* wildcard with full project name slug
|
|
87
|
+
result = result.replace(/pkg_[a-z_]*/g, placeholders.FULL_PROJECT_NAME_SLUG)
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get all available placeholder values for a given project
|
|
94
|
+
* Useful for debugging or displaying what values will be used
|
|
95
|
+
*
|
|
96
|
+
* @param {string} namespace - Namespace (e.g., "Acme")
|
|
97
|
+
* @param {string} projectName - Project name in snake_case
|
|
98
|
+
* @param {string} description - Human-readable description
|
|
99
|
+
* @returns {object} Object with all placeholder values
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const values = getPlaceholderValues('Acme', 'my_plugin', 'My Plugin')
|
|
103
|
+
* console.log(values)
|
|
104
|
+
* // {
|
|
105
|
+
* // NAMESPACE: 'Acme',
|
|
106
|
+
* // NAMESPACE_LOWER: 'acme',
|
|
107
|
+
* // PROJECT_NAME: 'my_plugin',
|
|
108
|
+
* // PROJECT_NAME_PASCAL: 'MyPlugin',
|
|
109
|
+
* // ...
|
|
110
|
+
* // }
|
|
111
|
+
*/
|
|
112
|
+
export function getPlaceholderValues(namespace, projectName, description) {
|
|
113
|
+
const fullProjectName = `${namespace.toLowerCase()}_${projectName}`
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
NAMESPACE: namespace,
|
|
117
|
+
NAMESPACE_LOWER: namespace.toLowerCase(),
|
|
118
|
+
PROJECT_NAME: projectName,
|
|
119
|
+
PROJECT_NAME_PASCAL: toPascalCase(projectName),
|
|
120
|
+
PROJECT_NAME_SLUG: projectName.replace(/_/g, '-'),
|
|
121
|
+
PROJECT_NAME_FLAT: projectName.replace(/_/g, ''),
|
|
122
|
+
FULL_PROJECT_NAME: fullProjectName,
|
|
123
|
+
FULL_PROJECT_NAME_SLUG: fullProjectName.replace(/_/g, '-'),
|
|
124
|
+
FULL_PROJECT_NAME_TITLE: fullProjectName
|
|
125
|
+
.split('_')
|
|
126
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
127
|
+
.join(' '),
|
|
128
|
+
FULL_PROJECT_NAME_UPPER: fullProjectName
|
|
129
|
+
.split('_')
|
|
130
|
+
.map(word => word.toUpperCase())
|
|
131
|
+
.join(' '),
|
|
132
|
+
DESCRIPTION: description
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Replace placeholders in a single file
|
|
138
|
+
*
|
|
139
|
+
* @param {string} filePath - Path to the file
|
|
140
|
+
* @param {object} values - Placeholder values
|
|
141
|
+
* @returns {Promise<boolean>} True if file was modified, false otherwise
|
|
142
|
+
*/
|
|
143
|
+
export async function replaceInFile(filePath, values) {
|
|
144
|
+
try {
|
|
145
|
+
// Check if this file should be skipped
|
|
146
|
+
const fileName = path.basename(filePath)
|
|
147
|
+
if (SKIP_PLACEHOLDER_REPLACEMENT.includes(fileName)) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const content = await fse.readFile(filePath, 'utf-8')
|
|
152
|
+
const updated = replaceTemplatePlaceholders(content, values)
|
|
153
|
+
|
|
154
|
+
if (content !== updated) {
|
|
155
|
+
await fse.writeFile(filePath, updated, 'utf-8')
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return false
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// Ignore binary files or read errors
|
|
162
|
+
if (error.code !== 'EISDIR' && error.code !== 'ENOENT') {
|
|
163
|
+
console.warn(`Warning: Could not process ${filePath}: ${error.message}`)
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
}
|