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/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
+ }