Skip to content

Commit 20008e1

Browse files
committed
feat: add generate component command
1 parent a059b5b commit 20008e1

5 files changed

Lines changed: 544 additions & 1 deletion

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
/**
2+
* This command scaffolds a new component folder (and optional supporting files) into a theme.
3+
* The structure supports blocks, elements, sections, etc., and follows a consistent naming and folder convention.
4+
*/
5+
6+
import { Args as OclifArgs } from '@oclif/core'
7+
import { renderSelectPrompt, renderTextPrompt } from '@shopify/cli-kit/node/ui'
8+
import fs from 'node:fs'
9+
import path from 'node:path'
10+
11+
import BaseCommand from '../../../utilities/base-command.js'
12+
import {
13+
generateCSSContent,
14+
generateJSContent,
15+
generateLiquidContent,
16+
generateSetupSectionContent,
17+
generateSetupTemplateContent,
18+
generateSnippetContent,
19+
generateSnippetJSContent,
20+
} from '../../../utilities/content-generation.js'
21+
import { writeFileIfChanged } from '../../../utilities/files.js'
22+
import Flags from '../../../utilities/flags.js'
23+
import { ComponentParts, DirectoryPaths, FileToCreate, GenerationContext } from '../../../utilities/types.js'
24+
25+
export default class GenerateComponent extends BaseCommand {
26+
static override args = {
27+
name: OclifArgs.string({
28+
description: 'component name (e.g., element.button)',
29+
required: false,
30+
}),
31+
}
32+
33+
static override description = 'Generate a new component with optional supporting files'
34+
35+
static override examples = [
36+
'<%= config.bin %> <%= command.id %> element.button --css --js',
37+
'<%= config.bin %> <%= command.id %> element.button --snippets=gift-form,upsell --snippet-css',
38+
'<%= config.bin %> <%= command.id %> --dry-run',
39+
]
40+
41+
static override flags = Flags.getDefinitions([
42+
Flags.CSS,
43+
Flags.DRY_RUN,
44+
Flags.FORCE,
45+
Flags.JS,
46+
Flags.ONLY,
47+
Flags.QUIET,
48+
Flags.SETUP,
49+
Flags.SNIPPET_CSS,
50+
Flags.SNIPPET_JS,
51+
Flags.SNIPPETS,
52+
Flags.TYPE,
53+
])
54+
55+
protected override async init(): Promise<void> {
56+
await super.init(GenerateComponent)
57+
}
58+
59+
public async run(): Promise<void> {
60+
const context = await this.buildGenerationContext()
61+
const directories = this.setupDirectoryPaths(context.component)
62+
const filesToCreate = this.buildFileList(context, directories)
63+
64+
if (this.flags[Flags.DRY_RUN]) {
65+
this.showDryRunResults(context.component, filesToCreate)
66+
return
67+
}
68+
69+
this.createFiles(context.component, filesToCreate)
70+
}
71+
72+
private addAssetFiles(context: GenerationContext, directories: DirectoryPaths, files: FileToCreate[]): void {
73+
if (this.flags[Flags.CSS] && this.shouldGenerate('css', context.onlyList)) {
74+
files.push({
75+
content: generateCSSContent(context.component.fullName),
76+
description: `assets/${context.component.fullName}.css`,
77+
path: path.join(directories.assetsDir, `${context.component.fullName}.css`),
78+
})
79+
}
80+
81+
if (this.flags[Flags.JS] && this.shouldGenerate('js', context.onlyList)) {
82+
files.push({
83+
content: generateJSContent(context.component.fullName),
84+
description: `assets/${context.component.fullName}.js`,
85+
path: path.join(directories.assetsDir, `${context.component.fullName}.js`),
86+
})
87+
}
88+
}
89+
90+
private addLiquidFile(context: GenerationContext, directories: DirectoryPaths, files: FileToCreate[]): void {
91+
if (!this.shouldGenerate('liquid', context.onlyList)) return
92+
93+
files.push({
94+
content: generateLiquidContent(context.component.fullName, this.flags[Flags.JS]),
95+
description: `${context.component.fullName}.liquid`,
96+
path: path.join(directories.componentDir, `${context.component.fullName}.liquid`),
97+
})
98+
}
99+
100+
private addSetupFiles(context: GenerationContext, directories: DirectoryPaths, files: FileToCreate[]): void {
101+
if (!this.flags[Flags.SETUP] || !this.shouldGenerate('setup', context.onlyList)) return
102+
103+
files.push(
104+
{
105+
content: generateSetupSectionContent(context.component.fullName),
106+
description: `setup/sections/${context.component.fullName.replace('.', '-')}.liquid`,
107+
path: path.join(directories.setupSectionsDir, `${context.component.fullName.replace('.', '-')}.liquid`),
108+
},
109+
{
110+
content: generateSetupTemplateContent(context.component.fullName),
111+
description: `setup/templates/index.${context.component.fullName.replace('.', '-')}.json`,
112+
path: path.join(directories.setupTemplatesDir, `index.${context.component.fullName.replace('.', '-')}.json`),
113+
}
114+
)
115+
}
116+
117+
private addSnippetFiles(context: GenerationContext, directories: DirectoryPaths, files: FileToCreate[]): void {
118+
if (context.snippets.length === 0 || !this.shouldGenerate('snippets', context.onlyList)) return
119+
120+
for (const snippetName of context.snippets) {
121+
// Main snippet file
122+
files.push({
123+
content: generateSnippetContent(context.component.fullName, snippetName),
124+
description: `snippets/${context.component.fullName}.${snippetName}.liquid`,
125+
path: path.join(directories.snippetsDir, `${context.component.fullName}.${snippetName}.liquid`),
126+
})
127+
128+
// Snippet CSS
129+
if (this.flags[Flags.SNIPPET_CSS]) {
130+
files.push({
131+
content: generateCSSContent(`${context.component.fullName.replace('.', '-')}__${snippetName}`),
132+
description: `assets/${context.component.fullName}.${snippetName}.css`,
133+
path: path.join(directories.assetsDir, `${context.component.fullName}.${snippetName}.css`),
134+
})
135+
}
136+
137+
// Snippet JavaScript
138+
if (this.flags[Flags.SNIPPET_JS]) {
139+
files.push({
140+
content: generateSnippetJSContent(context.component.fullName, snippetName),
141+
description: `assets/${context.component.fullName}.${snippetName}.js`,
142+
path: path.join(directories.assetsDir, `${context.component.fullName}.${snippetName}.js`),
143+
})
144+
}
145+
}
146+
}
147+
148+
private buildFileList(context: GenerationContext, directories: DirectoryPaths): FileToCreate[] {
149+
const files: FileToCreate[] = []
150+
151+
// Core component files
152+
this.addLiquidFile(context, directories, files)
153+
this.addAssetFiles(context, directories, files)
154+
155+
// Optional files
156+
this.addSnippetFiles(context, directories, files)
157+
this.addSetupFiles(context, directories, files)
158+
159+
return files
160+
}
161+
162+
private async buildGenerationContext(): Promise<GenerationContext> {
163+
const component = await this.parseComponentName()
164+
const snippets = this.parseSnippetList()
165+
const onlyList = this.parseOnlyList()
166+
167+
return { component, onlyList, snippets }
168+
}
169+
170+
private capitalizeFirst(str: string): string {
171+
return str.charAt(0).toUpperCase() + str.slice(1)
172+
}
173+
174+
private createFiles(component: ComponentParts, filesToCreate: FileToCreate[]): void {
175+
const created: string[] = []
176+
const skipped: string[] = []
177+
178+
for (const file of filesToCreate) {
179+
try {
180+
const wasCreated = this.writeFileIfNotExists(file.path, file.content, this.flags[Flags.FORCE])
181+
if (wasCreated) {
182+
created.push(file.description)
183+
} else {
184+
skipped.push(file.description)
185+
}
186+
} catch (error) {
187+
this.error(`Failed to create file ${file.description}: ${error instanceof Error ? error.message : 'Unknown error'}`)
188+
}
189+
}
190+
191+
this.reportResults(component, created, skipped)
192+
}
193+
194+
private ensureDirectoryExists(dirPath: string): void {
195+
if (!fs.existsSync(dirPath)) {
196+
fs.mkdirSync(dirPath, { recursive: true })
197+
}
198+
}
199+
200+
private extractTypeFromName(rawName: string): { name: string; type?: string } {
201+
const hasDot = rawName.includes('.')
202+
203+
if (!hasDot) {
204+
return { name: rawName }
205+
}
206+
207+
const parts = rawName.split('.')
208+
const type = parts[0]
209+
const name = parts.slice(1).join('.')
210+
211+
// Check for type conflicts
212+
if (type && this.flags[Flags.TYPE] && type !== this.flags[Flags.TYPE]) {
213+
this.warn(`Name suggests type "${type}" but --type=${this.flags[Flags.TYPE]} was passed. Using "${type}".`)
214+
}
215+
216+
return { name, type }
217+
}
218+
219+
private async parseComponentName(): Promise<ComponentParts> {
220+
const nameArg = this.args.name
221+
const rawName = nameArg || await this.promptForComponentName()
222+
223+
const { name, type } = this.extractTypeFromName(rawName)
224+
const finalType = type || this.flags[Flags.TYPE] || await this.promptForComponentType()
225+
226+
return {
227+
fullName: `${finalType}.${name}`,
228+
name,
229+
type: finalType,
230+
}
231+
}
232+
233+
private parseOnlyList(): string[] {
234+
const onlyFlag = this.flags[Flags.ONLY]
235+
if (!onlyFlag || onlyFlag.length === 0) return []
236+
237+
return onlyFlag.flatMap((item: string | string[]) =>
238+
typeof item === 'string' ? item.split(',').map(s => s.trim()) : item
239+
).filter(Boolean)
240+
}
241+
242+
private parseSnippetList(): string[] {
243+
const snippetsFlag = this.flags[Flags.SNIPPETS]
244+
if (!snippetsFlag || snippetsFlag.length === 0) return []
245+
246+
return snippetsFlag.flatMap((snippet: string | string[]) =>
247+
typeof snippet === 'string' ? snippet.split(',').map(s => s.trim()) : snippet
248+
).filter(Boolean)
249+
}
250+
251+
private async promptForComponentName(): Promise<string> {
252+
return renderTextPrompt({
253+
message: 'What should the component be called?',
254+
validate(input) {
255+
if (!input.trim()) return 'Component name is required'
256+
if (!/^[\w.-]+$/.test(input)) {
257+
return 'Component name can only contain letters, numbers, dots, hyphens, and underscores'
258+
}
259+
},
260+
})
261+
}
262+
263+
private async promptForComponentType(): Promise<string> {
264+
const componentTypes = ['block', 'element', 'form', 'layout', 'section', 'utility'] as const
265+
const type = await renderSelectPrompt({
266+
choices: [
267+
...componentTypes.map(t => ({ label: this.capitalizeFirst(t), value: t })),
268+
{ label: 'Other (specify custom type)', value: 'other' },
269+
],
270+
message: 'What type of component?',
271+
})
272+
273+
if (type === 'other') {
274+
return renderTextPrompt({
275+
message: 'Enter custom component type:',
276+
validate(input) {
277+
if (!input.trim()) return 'Component type is required'
278+
if (!/^[A-Za-z][\dA-Za-z-]*$/.test(input)) {
279+
return 'Component type must start with a letter and contain only letters, numbers, and hyphens'
280+
}
281+
},
282+
})
283+
}
284+
285+
return type
286+
}
287+
288+
private reportResults(component: ComponentParts, created: string[], skipped: string[]): void {
289+
if (this.flags[Flags.QUIET]) return
290+
291+
if (created.length > 0) {
292+
this.log(`\n✓ Created ${created.length} file(s):`)
293+
for (const file of created) {
294+
this.log(` components/${component.fullName}/${file}`)
295+
}
296+
}
297+
298+
if (skipped.length > 0) {
299+
this.log(`\n⚠ Skipped ${skipped.length} existing file(s):`)
300+
for (const file of skipped) {
301+
this.log(` components/${component.fullName}/${file}`)
302+
}
303+
304+
this.log('Use --force to overwrite existing files')
305+
}
306+
307+
if (created.length === 0 && skipped.length === 0) {
308+
this.log('No files were created')
309+
}
310+
}
311+
312+
private setupDirectoryPaths(component: ComponentParts): DirectoryPaths {
313+
const currentDir = process.cwd()
314+
const componentDir = path.join(currentDir, 'components', component.fullName)
315+
316+
return {
317+
assetsDir: path.join(componentDir, 'assets'),
318+
componentDir,
319+
setupSectionsDir: path.join(componentDir, 'setup', 'sections'),
320+
setupTemplatesDir: path.join(componentDir, 'setup', 'templates'),
321+
snippetsDir: path.join(componentDir, 'snippets'),
322+
}
323+
}
324+
325+
private shouldGenerate(part: string, onlyList: string[]): boolean {
326+
return onlyList.length === 0 || onlyList.includes(part)
327+
}
328+
329+
private showDryRunResults(component: ComponentParts, filesToCreate: FileToCreate[]): void {
330+
this.log('[Dry run]')
331+
for (const file of filesToCreate) {
332+
this.log(`✓ Would create components/${component.fullName}/${file.description}`)
333+
}
334+
}
335+
336+
private writeFileIfNotExists(filePath: string, content: string, force: boolean = false): boolean {
337+
if (fs.existsSync(filePath) && !force) {
338+
return false
339+
}
340+
341+
this.ensureDirectoryExists(path.dirname(filePath))
342+
343+
if (force) {
344+
fs.writeFileSync(filePath, content, 'utf8')
345+
} else {
346+
writeFileIfChanged(content, filePath)
347+
}
348+
349+
return true
350+
}
351+
}

0 commit comments

Comments
 (0)