vstruct-cli 0.1.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/AIREF.md +479 -0
- package/package.json +28 -0
- package/skills/SKILL.md +69 -0
- package/src/cli.js +432 -0
- package/src/commands/generate.js +191 -0
- package/src/commands/new.js +188 -0
- package/src/templates/index.js +521 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// vstruct — CLI para proyectos Vue 3 con estructura 100% predecible
|
|
4
|
+
//
|
|
5
|
+
// Uso:
|
|
6
|
+
// vstruct new <nombre>
|
|
7
|
+
// vstruct g|c|p|s|sv|co|l <nombre>
|
|
8
|
+
// vstruct analyze
|
|
9
|
+
// vstruct init
|
|
10
|
+
// vstruct manifest
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import { newProject, NewProjectError } from './commands/new.js'
|
|
14
|
+
import { generate, GenerateError } from './commands/generate.js'
|
|
15
|
+
import { VSTRUCT_MANIFEST } from './templates/index.js'
|
|
16
|
+
import * as fs from 'node:fs'
|
|
17
|
+
import * as path from 'node:path'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Arg parser
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const args = []
|
|
25
|
+
const flags = {}
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const token = argv[i]
|
|
29
|
+
if (token.startsWith('--')) {
|
|
30
|
+
const key = token.slice(2)
|
|
31
|
+
const next = argv[i + 1]
|
|
32
|
+
if (next && !next.startsWith('--')) {
|
|
33
|
+
flags[key] = next
|
|
34
|
+
i++
|
|
35
|
+
} else {
|
|
36
|
+
flags[key] = true
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
args.push(token)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { args, flags }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Commands
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function runNew(args, flags) {
|
|
51
|
+
const name = args[0]
|
|
52
|
+
if (!name) {
|
|
53
|
+
console.error('Error: vstruct new <nombre> — falta el nombre del proyecto')
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const dryRun = flags['dry-run'] === true
|
|
59
|
+
const result = newProject(name, { dryRun })
|
|
60
|
+
console.log(`\n✅ Proyecto "${name}" creado en ${result.projectDir}\n`)
|
|
61
|
+
if (!dryRun) {
|
|
62
|
+
console.log('Próximos pasos:')
|
|
63
|
+
console.log(` cd ${name} && npm install && npm run dev\n`)
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof NewProjectError) {
|
|
67
|
+
console.error(`Error: ${err.message}`)
|
|
68
|
+
} else {
|
|
69
|
+
console.error(err)
|
|
70
|
+
}
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runGenerate(args) {
|
|
76
|
+
const [rawType, name] = args
|
|
77
|
+
|
|
78
|
+
if (!rawType) {
|
|
79
|
+
console.error('Error: vstruct g <tipo> <nombre>')
|
|
80
|
+
console.error('Tipos: c(component) p(page) s(store) sv(service) co(composable) l(layout)')
|
|
81
|
+
process.exit(1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!name) {
|
|
85
|
+
console.error(`Error: vstruct g ${rawType} <nombre> — falta el nombre`)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const result = generate(rawType, name)
|
|
91
|
+
console.log(`✅ Generado: ${result.filePath}`)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof GenerateError) {
|
|
94
|
+
console.error(`Error: ${err.message}`)
|
|
95
|
+
} else {
|
|
96
|
+
console.error(err)
|
|
97
|
+
}
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runAnalyze() {
|
|
103
|
+
const cwd = process.cwd()
|
|
104
|
+
const manifestPath = path.join(cwd, 'vstruct.json')
|
|
105
|
+
|
|
106
|
+
if (!fs.existsSync(manifestPath)) {
|
|
107
|
+
console.error('Error: No se encontró vstruct.json. ¿Estás en un proyecto vstruct?')
|
|
108
|
+
console.error('Ejecutá: vstruct init')
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const violations = []
|
|
113
|
+
|
|
114
|
+
// --- 1. Required directories ---
|
|
115
|
+
const srcDir = path.join(cwd, 'src')
|
|
116
|
+
if (!fs.existsSync(srcDir)) {
|
|
117
|
+
violations.push({ severity: 'error', message: 'Falta src/' })
|
|
118
|
+
console.log(formatViolations(violations))
|
|
119
|
+
process.exit(1)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const requiredDirs = [
|
|
123
|
+
'src/components/ui', 'src/components/features',
|
|
124
|
+
'src/pages', 'src/layouts', 'src/stores', 'src/services',
|
|
125
|
+
'src/composables', 'src/types', 'src/router', 'src/styles',
|
|
126
|
+
]
|
|
127
|
+
for (const dir of requiredDirs) {
|
|
128
|
+
if (!fs.existsSync(path.join(cwd, dir))) {
|
|
129
|
+
violations.push({ severity: 'warning', message: `Falta el directorio ${dir}/` })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- 2. Naming conventions ---
|
|
134
|
+
function checkDir(dirPath, pattern, label) {
|
|
135
|
+
if (!fs.existsSync(dirPath)) return
|
|
136
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (entry.isFile() && entry.name === '.gitkeep') continue
|
|
139
|
+
if (entry.isFile() && pattern && !pattern.test(entry.name)) {
|
|
140
|
+
violations.push({
|
|
141
|
+
severity: 'error',
|
|
142
|
+
message: `Naming ${label}: "${entry.name}" no sigue la convención`,
|
|
143
|
+
fix: `Renombrar a: ${suggestName(entry.name, label)}`,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function suggestName(name, label) {
|
|
150
|
+
if (label.includes('components') || label.includes('layouts')) return toPascalCase(name.replace(/\.\w+$/, '')) + '.vue'
|
|
151
|
+
if (label === 'pages') return name.replace(/\.vue$/, '').replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') + '.vue'
|
|
152
|
+
if (label === 'stores') return name.replace(/\.\w+$/, '').replace(/^./, (c) => c.toLowerCase()) + '.store.ts'
|
|
153
|
+
if (label === 'services') return toPascalCase(name.replace(/\.\w+$/, '')) + '.service.ts'
|
|
154
|
+
if (label === 'composables') return 'use' + toPascalCase(name.replace(/^use/i, '').replace(/\.\w+$/, '')) + '.ts'
|
|
155
|
+
return name
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toPascalCase(s) { return s.charAt(0).toUpperCase() + s.slice(1) }
|
|
159
|
+
|
|
160
|
+
checkDir(path.join(cwd, 'src/components/ui'), /^[A-Z][A-Za-z0-9]*\.vue$/, 'components/ui')
|
|
161
|
+
checkDir(path.join(cwd, 'src/components/features'), /^[A-Z][A-Za-z0-9]*\.vue$/, 'components/features')
|
|
162
|
+
checkDir(path.join(cwd, 'src/pages'), /^[a-z][a-z0-9]*(-[a-z0-9]+)*\.vue$/, 'pages')
|
|
163
|
+
checkDir(path.join(cwd, 'src/layouts'), /^[A-Z][A-Za-z0-9]*\.vue$/, 'layouts')
|
|
164
|
+
checkDir(path.join(cwd, 'src/stores'), /^[a-z][A-Za-z0-9]*\.store\.ts$/, 'stores')
|
|
165
|
+
checkDir(path.join(cwd, 'src/services'), /^[A-Z][A-Za-z0-9]*\.service\.ts$/, 'services')
|
|
166
|
+
checkDir(path.join(cwd, 'src/composables'), /^use[A-Z][A-Za-z0-9]*\.ts$/, 'composables')
|
|
167
|
+
|
|
168
|
+
// --- 3. Content anti-pattern checks ---
|
|
169
|
+
function walkDir(dirPath, callback) {
|
|
170
|
+
if (!fs.existsSync(dirPath)) return
|
|
171
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const fullPath = path.join(dirPath, entry.name)
|
|
174
|
+
if (entry.isDirectory()) walkDir(fullPath, callback)
|
|
175
|
+
else if (entry.isFile() && entry.name !== '.gitkeep') callback(fullPath)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const srcRoot = path.join(cwd, 'src')
|
|
180
|
+
|
|
181
|
+
// Check .vue files for anti-patterns
|
|
182
|
+
walkDir(srcRoot, (filePath) => {
|
|
183
|
+
if (!filePath.endsWith('.vue')) return
|
|
184
|
+
const relPath = path.relative(cwd, filePath)
|
|
185
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
186
|
+
|
|
187
|
+
// <a href= → should use <router-link>
|
|
188
|
+
if (/<a\s+href=["']\/(?!\/)/.test(content)) {
|
|
189
|
+
violations.push({
|
|
190
|
+
severity: 'error',
|
|
191
|
+
message: `${relPath}: <a href="/..."> detectado — usar <router-link to="...">`,
|
|
192
|
+
fix: `Reemplazar <a href="/..."> por <router-link to="/...">`,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// fetch( in .vue files (not services)
|
|
197
|
+
if (!filePath.includes('/services/') && /\bfetch\s*\(/.test(content)) {
|
|
198
|
+
violations.push({
|
|
199
|
+
severity: 'warning',
|
|
200
|
+
message: `${relPath}: fetch() en componente — mover a un service en services/`,
|
|
201
|
+
fix: `Crear un archivo en src/services/ y llamar desde ahí`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// <script> without lang="ts"
|
|
206
|
+
const scriptTag = content.match(/<script\b([^>]*)>/)
|
|
207
|
+
if (scriptTag && !scriptTag[1].includes('lang=')) {
|
|
208
|
+
violations.push({
|
|
209
|
+
severity: 'error',
|
|
210
|
+
message: `${relPath}: <script> sin lang="ts"`,
|
|
211
|
+
fix: `Cambiar a <script setup lang="ts">`,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// <style> without scoped (non-layouts)
|
|
216
|
+
if (!filePath.includes('/layouts/')) {
|
|
217
|
+
const styleMatch = content.match(/<style\b([^>]*)>/)
|
|
218
|
+
if (styleMatch && !styleMatch[1].includes('scoped')) {
|
|
219
|
+
violations.push({
|
|
220
|
+
severity: 'warning',
|
|
221
|
+
message: `${relPath}: <style> sin scoped`,
|
|
222
|
+
fix: `Cambiar a <style scoped>`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Check store files for anti-patterns
|
|
229
|
+
walkDir(path.join(cwd, 'src/stores'), (filePath) => {
|
|
230
|
+
if (!filePath.endsWith('.store.ts')) return
|
|
231
|
+
const relPath = path.relative(cwd, filePath)
|
|
232
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
233
|
+
|
|
234
|
+
// Store importing useRouter
|
|
235
|
+
if (/useRouter/.test(content) && /from\s+['"]vue-router['"]/.test(content)) {
|
|
236
|
+
violations.push({
|
|
237
|
+
severity: 'error',
|
|
238
|
+
message: `${relPath}: store importa useRouter — dependencia circular`,
|
|
239
|
+
fix: `El componente debe hacer router.push() después de llamar al store`,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Check service files
|
|
245
|
+
walkDir(path.join(cwd, 'src/services'), (filePath) => {
|
|
246
|
+
if (!filePath.endsWith('.service.ts')) return
|
|
247
|
+
const relPath = path.relative(cwd, filePath)
|
|
248
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
249
|
+
|
|
250
|
+
// Service importing stores
|
|
251
|
+
if (/import.*from\s+['"]@\/stores\//.test(content) || /import.*from\s+['"]\.\.\/stores\//.test(content)) {
|
|
252
|
+
violations.push({
|
|
253
|
+
severity: 'error',
|
|
254
|
+
message: `${relPath}: service importa store — los services son puros HTTP`,
|
|
255
|
+
fix: `El store debe orquestar el service, no al revés`,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// --- 4. Check for files in wrong places ---
|
|
261
|
+
walkDir(srcRoot, (filePath) => {
|
|
262
|
+
const relPath = path.relative(cwd, filePath)
|
|
263
|
+
const fileName = path.basename(filePath)
|
|
264
|
+
|
|
265
|
+
// .vue files outside expected dirs
|
|
266
|
+
if (fileName.endsWith('.vue') && !fileName.endsWith('.store.ts')) {
|
|
267
|
+
const isInComponents = relPath.startsWith('src/components/')
|
|
268
|
+
const isInPages = relPath.startsWith('src/pages/')
|
|
269
|
+
const isInLayouts = relPath.startsWith('src/layouts/')
|
|
270
|
+
const isAppVue = relPath === 'src/App.vue'
|
|
271
|
+
if (!isInComponents && !isInPages && !isInLayouts && !isAppVue) {
|
|
272
|
+
violations.push({
|
|
273
|
+
severity: 'error',
|
|
274
|
+
message: `${relPath}: .vue fuera de components/, pages/, o layouts/`,
|
|
275
|
+
fix: `Mover a la carpeta correcta según la tabla canónica`,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// .store.ts outside stores/
|
|
281
|
+
if (fileName.endsWith('.store.ts') && !relPath.startsWith('src/stores/')) {
|
|
282
|
+
violations.push({
|
|
283
|
+
severity: 'error',
|
|
284
|
+
message: `${relPath}: .store.ts fuera de src/stores/`,
|
|
285
|
+
fix: `Mover a src/stores/`,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// .service.ts outside services/
|
|
290
|
+
if (fileName.endsWith('.service.ts') && !relPath.startsWith('src/services/')) {
|
|
291
|
+
violations.push({
|
|
292
|
+
severity: 'error',
|
|
293
|
+
message: `${relPath}: .service.ts fuera de src/services/`,
|
|
294
|
+
fix: `Mover a src/services/`,
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
console.log(formatViolations(violations))
|
|
300
|
+
|
|
301
|
+
const errors = violations.filter((v) => v.severity === 'error')
|
|
302
|
+
if (errors.length > 0) process.exit(1)
|
|
303
|
+
process.exit(0)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatViolations(violations) {
|
|
307
|
+
if (violations.length === 0) {
|
|
308
|
+
return '✅ Estructura del proyecto válida — 0 violaciones.\n'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const errors = violations.filter((v) => v.severity === 'error')
|
|
312
|
+
const warnings = violations.filter((v) => v.severity === 'warning')
|
|
313
|
+
|
|
314
|
+
let output = `\n🔍 Análisis de estructura — ${violations.length} violaciones encontradas.\n\n`
|
|
315
|
+
|
|
316
|
+
for (const v of errors) {
|
|
317
|
+
output += ` ❌ ${v.message}\n`
|
|
318
|
+
if (v.fix) output += ` 💡 fix: ${v.fix}\n`
|
|
319
|
+
}
|
|
320
|
+
for (const v of warnings) {
|
|
321
|
+
output += ` ⚠️ ${v.message}\n`
|
|
322
|
+
if (v.fix) output += ` 💡 fix: ${v.fix}\n`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
output += `\n Errores: ${errors.length} | Warnings: ${warnings.length}\n`
|
|
326
|
+
return output
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function runInit(args) {
|
|
330
|
+
const cwd = process.cwd()
|
|
331
|
+
const manifestPath = path.join(cwd, 'vstruct.json')
|
|
332
|
+
|
|
333
|
+
if (fs.existsSync(manifestPath) && !args.includes('--force')) {
|
|
334
|
+
console.error('Error: vstruct.json ya existe. Usá --force para sobrescribir.')
|
|
335
|
+
process.exit(1)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const projectName = path.basename(cwd)
|
|
339
|
+
const content = VSTRUCT_MANIFEST(projectName)
|
|
340
|
+
fs.writeFileSync(manifestPath, content, 'utf-8')
|
|
341
|
+
console.log(`✅ vstruct.json creado en ${manifestPath}`)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function runManifest() {
|
|
345
|
+
const cwd = process.cwd()
|
|
346
|
+
const manifestPath = path.join(cwd, 'vstruct.json')
|
|
347
|
+
|
|
348
|
+
if (!fs.existsSync(manifestPath)) {
|
|
349
|
+
console.error('Error: vstruct.json no encontrado. Ejecutá: vstruct init')
|
|
350
|
+
process.exit(1)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const content = fs.readFileSync(manifestPath, 'utf-8')
|
|
354
|
+
console.log(content)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function showHelp() {
|
|
358
|
+
console.log(`
|
|
359
|
+
vstruct — CLI para proyectos Vue 3 con estructura predecible
|
|
360
|
+
|
|
361
|
+
Comandos:
|
|
362
|
+
vstruct new <nombre> Crea un nuevo proyecto Vue 3
|
|
363
|
+
vstruct g <tipo> <nombre> Genera un archivo en el lugar correcto
|
|
364
|
+
vstruct analyze Valida la estructura del proyecto
|
|
365
|
+
vstruct init [--force] Crea vstruct.json en proyecto existente
|
|
366
|
+
vstruct manifest Muestra vstruct.json
|
|
367
|
+
vstruct help Esta ayuda
|
|
368
|
+
|
|
369
|
+
Tipos de generate (g):
|
|
370
|
+
c | component → src/components/features/<PascalCase>.vue
|
|
371
|
+
p | page → src/pages/<kebab-case>.vue
|
|
372
|
+
s | store → src/stores/<camelCase>.store.ts
|
|
373
|
+
sv | service → src/services/<PascalCase>.service.ts
|
|
374
|
+
co | composable → src/composables/useXxx.ts
|
|
375
|
+
l | layout → src/layouts/<PascalCase>.vue
|
|
376
|
+
|
|
377
|
+
Reglas de naming:
|
|
378
|
+
Componente → PascalCase (UserCard)
|
|
379
|
+
Página → kebab-case (user-list)
|
|
380
|
+
Store → camelCase (cart, auth) → se genera use<Name>Store
|
|
381
|
+
Service → PascalCase (Product) → se genera <Name>Service
|
|
382
|
+
Composable → useXxx (useFetch) → debe empezar con "use"
|
|
383
|
+
Layout → PascalCase (Main, Admin)
|
|
384
|
+
`)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Main
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
export function main(argv = process.argv.slice(2)) {
|
|
392
|
+
const { args, flags } = parseArgs(argv)
|
|
393
|
+
const command = args[0]
|
|
394
|
+
const rest = args.slice(1)
|
|
395
|
+
|
|
396
|
+
switch (command) {
|
|
397
|
+
case 'new':
|
|
398
|
+
runNew(rest, flags)
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
case 'generate':
|
|
402
|
+
case 'g':
|
|
403
|
+
runGenerate(rest)
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
case 'analyze':
|
|
407
|
+
runAnalyze()
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
case 'init':
|
|
411
|
+
runInit(rest)
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
case 'manifest':
|
|
415
|
+
runManifest()
|
|
416
|
+
break
|
|
417
|
+
|
|
418
|
+
case 'help':
|
|
419
|
+
case '--help':
|
|
420
|
+
case '-h':
|
|
421
|
+
case undefined:
|
|
422
|
+
showHelp()
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
default:
|
|
426
|
+
console.error(`Error: comando desconocido "${command}". Ejecutá "vstruct help".`)
|
|
427
|
+
process.exit(1)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Run when called directly
|
|
432
|
+
main()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// vstruct generate — genera archivos en el lugar correcto automáticamente
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import * as fs from 'node:fs'
|
|
6
|
+
import * as path from 'node:path'
|
|
7
|
+
import {
|
|
8
|
+
componentTemplate,
|
|
9
|
+
pageTemplate,
|
|
10
|
+
storeTemplate,
|
|
11
|
+
serviceTemplate,
|
|
12
|
+
composableTemplate,
|
|
13
|
+
layoutTemplate,
|
|
14
|
+
} from '../templates/index.js'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types & aliases
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const TYPE_ALIASES = {
|
|
21
|
+
c: 'component',
|
|
22
|
+
component: 'component',
|
|
23
|
+
p: 'page',
|
|
24
|
+
page: 'page',
|
|
25
|
+
s: 'store',
|
|
26
|
+
store: 'store',
|
|
27
|
+
sv: 'service',
|
|
28
|
+
service: 'service',
|
|
29
|
+
co: 'composable',
|
|
30
|
+
composable: 'composable',
|
|
31
|
+
l: 'layout',
|
|
32
|
+
layout: 'layout',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveType(raw) {
|
|
36
|
+
return TYPE_ALIASES[raw] ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Validators
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export function isPascalCase(name) {
|
|
44
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isCamelCase(name) {
|
|
48
|
+
return /^[a-z][A-Za-z0-9]*$/.test(name)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isKebabCase(name) {
|
|
52
|
+
return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isUsePrefixed(name) {
|
|
56
|
+
return /^use[A-Z]/.test(name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Path builders
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function buildComponentPath(cwd, name) {
|
|
64
|
+
return path.join(cwd, 'src', 'components', 'features', `${name}.vue`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildPagePath(cwd, name) {
|
|
68
|
+
return path.join(cwd, 'src', 'pages', `${name}.vue`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildStorePath(cwd, name) {
|
|
72
|
+
return path.join(cwd, 'src', 'stores', `${name}.store.ts`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildServicePath(cwd, name) {
|
|
76
|
+
return path.join(cwd, 'src', 'services', `${name}.service.ts`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildComposablePath(cwd, name) {
|
|
80
|
+
const fnName = name.startsWith('use') ? name : 'use' + capitalize(name)
|
|
81
|
+
return path.join(cwd, 'src', 'composables', `${fnName}.ts`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildLayoutPath(cwd, name) {
|
|
85
|
+
return path.join(cwd, 'src', 'layouts', `${name}.vue`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function capitalize(str) {
|
|
89
|
+
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Generate
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export function generate(rawType, name, options = {}) {
|
|
97
|
+
const cwd = options.cwd ?? process.cwd()
|
|
98
|
+
const type = resolveType(rawType)
|
|
99
|
+
const dryRun = options.dryRun ?? false
|
|
100
|
+
|
|
101
|
+
if (!type) {
|
|
102
|
+
throw new GenerateError(
|
|
103
|
+
`Tipo "${rawType}" desconocido. Válidos: c (component), p (page), s (store), sv (service), co (composable), l (layout)`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!name) {
|
|
108
|
+
throw new GenerateError(`Falta el nombre para generate ${rawType}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Validate naming
|
|
112
|
+
switch (type) {
|
|
113
|
+
case 'component':
|
|
114
|
+
if (!isPascalCase(name))
|
|
115
|
+
throw new GenerateError(`"${name}" debe ser PascalCase (ej: UserCard)`)
|
|
116
|
+
break
|
|
117
|
+
case 'page':
|
|
118
|
+
if (!isKebabCase(name))
|
|
119
|
+
throw new GenerateError(`"${name}" debe ser kebab-case (ej: user-list)`)
|
|
120
|
+
break
|
|
121
|
+
case 'store':
|
|
122
|
+
if (!isCamelCase(name))
|
|
123
|
+
throw new GenerateError(`"${name}" debe ser camelCase singular (ej: cart)`)
|
|
124
|
+
break
|
|
125
|
+
case 'service':
|
|
126
|
+
if (!isPascalCase(name))
|
|
127
|
+
throw new GenerateError(`"${name}" debe ser PascalCase (ej: Product)`)
|
|
128
|
+
break
|
|
129
|
+
case 'composable':
|
|
130
|
+
if (!isUsePrefixed(name))
|
|
131
|
+
throw new GenerateError(`"${name}" debe empezar con "use" (ej: useFetch)`)
|
|
132
|
+
break
|
|
133
|
+
case 'layout':
|
|
134
|
+
if (!isPascalCase(name))
|
|
135
|
+
throw new GenerateError(`"${name}" debe ser PascalCase (ej: Main)`)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build path and content
|
|
140
|
+
let filePath, content
|
|
141
|
+
switch (type) {
|
|
142
|
+
case 'component':
|
|
143
|
+
filePath = buildComponentPath(cwd, name)
|
|
144
|
+
content = componentTemplate(name)
|
|
145
|
+
break
|
|
146
|
+
case 'page':
|
|
147
|
+
filePath = buildPagePath(cwd, name)
|
|
148
|
+
content = pageTemplate(name)
|
|
149
|
+
break
|
|
150
|
+
case 'store':
|
|
151
|
+
filePath = buildStorePath(cwd, name)
|
|
152
|
+
content = storeTemplate(name)
|
|
153
|
+
break
|
|
154
|
+
case 'service':
|
|
155
|
+
filePath = buildServicePath(cwd, name)
|
|
156
|
+
content = serviceTemplate(name)
|
|
157
|
+
break
|
|
158
|
+
case 'composable':
|
|
159
|
+
filePath = buildComposablePath(cwd, name)
|
|
160
|
+
content = composableTemplate(name)
|
|
161
|
+
break
|
|
162
|
+
case 'layout':
|
|
163
|
+
filePath = buildLayoutPath(cwd, name)
|
|
164
|
+
content = layoutTemplate(name)
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!dryRun) {
|
|
169
|
+
const dir = path.dirname(filePath)
|
|
170
|
+
if (!fs.existsSync(dir)) {
|
|
171
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
172
|
+
}
|
|
173
|
+
if (fs.existsSync(filePath)) {
|
|
174
|
+
throw new GenerateError(`Ya existe: "${filePath}". Elimínalo o elige otro nombre.`)
|
|
175
|
+
}
|
|
176
|
+
fs.writeFileSync(filePath, content, 'utf-8')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { filePath, content }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Error
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export class GenerateError extends Error {
|
|
187
|
+
constructor(message) {
|
|
188
|
+
super(message)
|
|
189
|
+
this.name = 'GenerateError'
|
|
190
|
+
}
|
|
191
|
+
}
|