Skip to content

Commit 50e71e5

Browse files
committed
fix(add-template): expose template options
1 parent 634eeb7 commit 50e71e5

3 files changed

Lines changed: 138 additions & 39 deletions

File tree

packages/nuxt-cli/src/commands/add-template.ts

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { TemplateName } from '../utils/templates/names'
22

3-
import { existsSync, promises as fsp } from 'node:fs'
3+
import { promises as fsp } from 'node:fs'
44
import process from 'node:process'
5-
65
import { styleText } from 'node:util'
7-
import { cancel, intro, outro } from '@clack/prompts'
6+
7+
import { intro, outro } from '@clack/prompts'
88
import { defineCommand } from 'citty'
9-
import { dirname, extname, resolve } from 'pathe'
9+
import { dirname, resolve } from 'pathe'
1010

1111
import { loadKit } from '../utils/kit'
1212
import { logger } from '../utils/logger'
@@ -25,9 +25,31 @@ export default defineCommand({
2525
...logLevelArgs,
2626
force: {
2727
type: 'boolean',
28-
description: 'Force override file if it already exists',
28+
description: 'Overwrite the file if it already exists',
2929
default: false,
3030
},
31+
mode: {
32+
type: 'string',
33+
valueHint: 'client|server',
34+
description: 'Add a client or server suffix to a component or plugin',
35+
},
36+
method: {
37+
type: 'string',
38+
valueHint: 'connect|delete|get|head|options|patch|post|put|trace',
39+
description: 'Add an HTTP method suffix to an API route',
40+
},
41+
global: {
42+
type: 'boolean',
43+
description: 'Create global route middleware',
44+
},
45+
api: {
46+
type: 'boolean',
47+
description: 'Create a server route in the API directory',
48+
},
49+
pages: {
50+
type: 'boolean',
51+
description: 'Include NuxtPage and NuxtLayout in the app template',
52+
},
3153
template: {
3254
type: 'positional',
3355
required: true,
@@ -47,55 +69,56 @@ export default defineCommand({
4769

4870
const templateName = ctx.args.template as TemplateName
4971

50-
// Validate template name
5172
if (!templateNames.includes(templateName)) {
52-
const templateNames = Object.keys(templates).map(name => styleText('cyan', name))
53-
const lastTemplateName = templateNames.pop()
73+
const supported = templateNames.map(name => styleText('cyan', name))
74+
const last = supported.pop()
5475
logger.error(`Template ${styleText('cyan', templateName)} is not supported.`)
55-
logger.info(`Possible values are ${templateNames.join(', ')} or ${lastTemplateName}.`)
76+
logger.info(`Possible values are ${supported.join(', ')} or ${last}.`)
5677
process.exit(1)
5778
}
5879

59-
// Validate options
60-
const ext = extname(ctx.args.name)
61-
const name
62-
= ext === '.vue' || ext === '.ts'
63-
? ctx.args.name.replace(ext, '')
64-
: ctx.args.name
65-
66-
if (!name) {
67-
cancel('name argument is missing!')
80+
if (ctx.args.mode && ctx.args.mode !== 'client' && ctx.args.mode !== 'server') {
81+
logger.error(`Mode must be ${styleText('cyan', 'client')} or ${styleText('cyan', 'server')}.`)
82+
process.exit(1)
83+
}
84+
if (ctx.args.method && !['connect', 'delete', 'get', 'head', 'options', 'patch', 'post', 'put', 'trace'].includes(ctx.args.method)) {
85+
logger.error(`HTTP method ${styleText('cyan', ctx.args.method)} is not supported.`)
6886
process.exit(1)
6987
}
7088

71-
// Load config in order to respect srcDir
72-
const kit = await loadKit(cwd)
73-
const config = await kit.loadNuxtConfig({ cwd })
74-
75-
// Resolve template
76-
const template = templates[templateName as keyof typeof templates]
77-
78-
const res = template({ name, args: ctx.args, nuxtOptions: config })
89+
const ext = ['.vue', '.ts'].find(ext => ctx.args.name.endsWith(ext))
90+
const name = ext
91+
? ctx.args.name.slice(0, -ext.length)
92+
: ctx.args.name
7993

80-
// Ensure not overriding user code
81-
if (!ctx.args.force && existsSync(res.path)) {
82-
logger.error(`File exists at ${styleText('cyan', relativeToProcess(res.path))}.`)
83-
logger.info(`Use ${styleText('cyan', '--force')} to override or use a different name.`)
94+
if (!name) {
95+
logger.error('Template name must not be empty.')
8496
process.exit(1)
8597
}
8698

87-
// Ensure parent directory exists
99+
const kit = await loadKit(cwd)
100+
const config = await kit.loadNuxtConfig({ cwd })
101+
const res = templates[templateName]({ name, args: ctx.args, nuxtOptions: config })
88102
const parentDir = dirname(res.path)
89-
if (!existsSync(parentDir)) {
90-
logger.step(`Creating directory ${styleText('cyan', relativeToProcess(parentDir))}.`)
103+
const createdDir = await fsp.mkdir(parentDir, { recursive: true })
104+
if (createdDir) {
105+
logger.step(`Created directory ${styleText('cyan', relativeToProcess(parentDir))}.`)
91106
if (templateName === 'page') {
92107
logger.info('This enables vue-router functionality!')
93108
}
94-
await fsp.mkdir(parentDir, { recursive: true })
95109
}
96110

97-
// Write file
98-
await fsp.writeFile(res.path, `${res.contents.trim()}\n`)
111+
try {
112+
await fsp.writeFile(res.path, `${res.contents.trim()}\n`, { flag: ctx.args.force ? 'w' : 'wx' })
113+
}
114+
catch (error) {
115+
if (!ctx.args.force && (error as NodeJS.ErrnoException).code === 'EEXIST') {
116+
logger.error(`File exists at ${styleText('cyan', relativeToProcess(res.path))}.`)
117+
logger.info(`Use ${styleText('cyan', '--force')} to overwrite it or use a different name.`)
118+
process.exit(1)
119+
}
120+
throw error
121+
}
99122
logger.success(`Created ${styleText('cyan', relativeToProcess(res.path))}.`)
100123
outro(`Generated a new ${styleText('cyan', templateName)}!`)
101124
},
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
import command from '../../../src/commands/add-template'
7+
import { runCommandDef } from '../../../src/run-command'
8+
9+
let cwd: string
10+
11+
beforeEach(async () => {
12+
cwd = await mkdtemp(join(tmpdir(), 'nuxt-add-template-'))
13+
vi.spyOn(process, 'exit').mockImplementation((code) => {
14+
throw new Error(`process exited with code ${code}`)
15+
})
16+
})
17+
18+
afterEach(async () => {
19+
vi.restoreAllMocks()
20+
await rm(cwd, { recursive: true, force: true })
21+
})
22+
23+
async function run(...args: string[]) {
24+
return runCommandDef(command, [...args, '--cwd', cwd])
25+
}
26+
27+
describe('add-template command', () => {
28+
it('generates nested templates and strips only the final supported extension', async () => {
29+
await run('component', 'admin/user-card.vue')
30+
31+
const path = join(cwd, 'components/admin/user-card.vue')
32+
expect(await readFile(path, 'utf8')).toContain('Component: admin/user-card')
33+
expect(await readFile(path, 'utf8')).toMatch(/\n$/)
34+
})
35+
36+
it('exposes template-specific options', async () => {
37+
await run('api', 'users', '--method', 'get')
38+
await run('component', 'island', '--mode', 'client')
39+
await run('middleware', 'auth', '--global')
40+
await run('server-route', 'health', '--api')
41+
await run('app', 'ignored', '--pages')
42+
43+
await expect(readFile(join(cwd, 'server/api/users.get.ts'), 'utf8')).resolves.toContain('return \'Hello users\'')
44+
await expect(readFile(join(cwd, 'components/island.client.vue'), 'utf8')).resolves.toContain('Component: island')
45+
await expect(readFile(join(cwd, 'middleware/auth.global.ts'), 'utf8')).resolves.toContain('defineNuxtRouteMiddleware')
46+
await expect(readFile(join(cwd, 'server/api/health.ts'), 'utf8')).resolves.toContain('defineEventHandler')
47+
await expect(readFile(join(cwd, 'app.vue'), 'utf8')).resolves.toContain('<NuxtPage/>')
48+
})
49+
50+
it('rejects unsupported suffix options', async () => {
51+
await expect(run('component', 'island', '--mode', 'worker')).rejects.toThrow('process exited with code 1')
52+
await expect(run('api', 'users', '--method', 'fetch')).rejects.toThrow('process exited with code 1')
53+
})
54+
55+
it('rejects names containing only an extension', async () => {
56+
await expect(run('component', '.vue')).rejects.toThrow('process exited with code 1')
57+
await expect(readFile(join(cwd, 'components/.vue'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
58+
})
59+
60+
it('does not overwrite an existing file without force', async () => {
61+
await run('composable', 'counter')
62+
const path = join(cwd, 'composables/counter.ts')
63+
await writeFile(path, 'existing\n')
64+
65+
await expect(run('composable', 'counter')).rejects.toThrow()
66+
await expect(readFile(path, 'utf8')).resolves.toBe('existing\n')
67+
68+
await run('composable', 'counter', '--force')
69+
await expect(readFile(path, 'utf8')).resolves.toContain('export const useCounter')
70+
})
71+
})

packages/nuxt-cli/test/unit/help.spec.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,14 @@ describe('help', () => {
9393
9494
OPTIONS
9595
96-
--cwd=<directory> Specify the root directory of your Nuxt project (Default: .)
97-
--logLevel=<silent|info|verbose> Specify build-time log level
98-
--force Force override file if it already exists (Default: false)
96+
--cwd=<directory> Specify the root directory of your Nuxt project (Default: .)
97+
--logLevel=<silent|info|verbose> Specify build-time log level
98+
--force Overwrite the file if it already exists (Default: false)
99+
--mode=<client|server> Add a client or server suffix to a component or plugin
100+
--method=<connect|delete|get|head|options|patch|post|put|trace> Add an HTTP method suffix to an API route
101+
--global Create global route middleware
102+
--api Create a server route in the API directory
103+
--pages Include NuxtPage and NuxtLayout in the app template
99104
"
100105
`)
101106
})

0 commit comments

Comments
 (0)