rook-cli 1.0.2 → 1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rook-cli",
3
- "version": "1.0.2",
4
- "description": "CLI para instalar componentes Shopify de um repositório centralizado",
3
+ "version": "1.1.0",
4
+ "description": "CLI para instalar componentes Shopify",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/chesslabdev/rook-cli.git"
package/src/app.js CHANGED
@@ -20,6 +20,8 @@ import { FileMapper } from './filesystem/FileMapper.js';
20
20
  import { ConflictResolver } from './filesystem/ConflictResolver.js';
21
21
  import { AddCommand } from './commands/AddCommand.js';
22
22
  import { ConfigCommand } from './commands/ConfigCommand.js';
23
+ import { GenerateCommand } from './commands/GenerateCommand.js';
24
+ import { ScaffoldService } from './services/ScaffoldService.js';
23
25
  import { CLI_NAME } from './config/constants.js';
24
26
 
25
27
  export class App {
@@ -50,6 +52,15 @@ export class App {
50
52
  this.logger,
51
53
  this.tokenManager
52
54
  );
55
+
56
+ this.scaffoldService = new ScaffoldService(this.logger);
57
+
58
+ this.generateCommand = new GenerateCommand(
59
+ this.logger,
60
+ this.promptUI,
61
+ this.scaffoldService,
62
+ this.conflictResolver
63
+ );
53
64
  }
54
65
 
55
66
  /**
@@ -80,6 +91,16 @@ export class App {
80
91
  await this.configCommand.executar();
81
92
  });
82
93
 
94
+ // Comando: rook generate [nome-do-componente]
95
+ this.programa
96
+ .command('generate [nome]')
97
+ .alias('g')
98
+ .description('Gera scaffold de componentes Shopify (sections, blocks, snippets, assets)')
99
+ .action(async (nome) => {
100
+ this.logger.banner();
101
+ await this.generateCommand.executar(nome);
102
+ });
103
+
83
104
  // Comando padrão (sem argumentos) — abre o menu interativo
84
105
  this.programa
85
106
  .action(async () => {
@@ -59,18 +59,75 @@ export class AddCommand {
59
59
  }
60
60
 
61
61
  /**
62
- * Fluxo de instalação de um kit completo.
62
+ * Fluxo de instalação de um kit completo (nova arquitetura de composição).
63
+ *
64
+ * Segue o padrão descrito no PRD (Seção 5.3):
65
+ * 1. Lista kits disponíveis (com dados do kit.json)
66
+ * 2. Usuário seleciona o kit
67
+ * 3. Instala cada componente referenciado no manifesto
68
+ * 4. Instala os arquivos locais exclusivos do kit
69
+ *
63
70
  * @private
64
71
  */
65
72
  async _instalarKit() {
66
- // Lista kits disponíveis
73
+ // 1. Lista kits disponíveis (já enriquecidos com dados do kit.json)
67
74
  const kits = await this.githubService.listarKits();
75
+
76
+ if (kits.length === 0) {
77
+ this.logger.aviso('Nenhum kit disponível no repositório.');
78
+ return;
79
+ }
80
+
81
+ // 2. Usuário seleciona o kit
68
82
  const kitSelecionado = await this.promptUI.selecionarKit(kits);
69
83
 
70
84
  this.logger.destaque(`\n📥 Instalando kit: ${kitSelecionado.nome}\n`);
71
85
 
72
- // Baixa e distribui
73
- await this._baixarEDistribuir(kitSelecionado.caminho, kitSelecionado.nome);
86
+ if (kitSelecionado.descricao) {
87
+ this.logger.sutil(kitSelecionado.descricao);
88
+ console.log('');
89
+ }
90
+
91
+ let totalComponentesInstalados = 0;
92
+ let totalArquivosCopiados = 0;
93
+
94
+ // 3. Instala cada componente referenciado no manifesto (kit.json)
95
+ if (kitSelecionado.componentes.length > 0) {
96
+ this.logger.destaque(`🧩 Instalando ${kitSelecionado.componentes.length} componente(s) do manifesto...\n`);
97
+
98
+ for (const nomeComponente of kitSelecionado.componentes) {
99
+ const caminhoComponente = `components/${nomeComponente}`;
100
+
101
+ try {
102
+ this.logger.info(`[${totalComponentesInstalados + 1}/${kitSelecionado.componentes.length}] ${nomeComponente}`);
103
+ const copiados = await this._baixarEDistribuir(caminhoComponente, nomeComponente);
104
+ totalArquivosCopiados += copiados;
105
+ totalComponentesInstalados++;
106
+ } catch (erro) {
107
+ this.logger.erro(`Falha ao instalar componente "${nomeComponente}": ${erro.message}`);
108
+ this.logger.aviso('Continuando com os próximos componentes...');
109
+ }
110
+ }
111
+ }
112
+
113
+ // 4. Instala os arquivos locais exclusivos do kit (ex: layout/theme.liquid, config/)
114
+ this.logger.destaque(`\n📁 Instalando arquivos base do kit "${kitSelecionado.slug}"...\n`);
115
+
116
+ try {
117
+ const copiadosKit = await this._baixarEDistribuir(kitSelecionado.caminho, `kit:${kitSelecionado.slug}`);
118
+ totalArquivosCopiados += copiadosKit;
119
+ } catch (erro) {
120
+ this.logger.erro(`Falha ao instalar arquivos base do kit: ${erro.message}`);
121
+ }
122
+
123
+ // 5. Resumo final
124
+ console.log('');
125
+ this.logger.destaque('═══════════════════════════════════════════');
126
+ this.logger.sucesso(`🎉 Kit "${kitSelecionado.nome}" instalado com sucesso!`);
127
+ this.logger.sutil(` Componentes: ${totalComponentesInstalados}/${kitSelecionado.componentes.length}`);
128
+ this.logger.sutil(` Arquivos copiados: ${totalArquivosCopiados}`);
129
+ this.logger.destaque('═══════════════════════════════════════════');
130
+ console.log('');
74
131
  }
75
132
 
76
133
  /**
@@ -84,10 +141,16 @@ export class AddCommand {
84
141
 
85
142
  this.logger.destaque(`\n📥 Instalando ${selecionados.length} componente(s)...\n`);
86
143
 
144
+ let totalArquivosCopiados = 0;
145
+
87
146
  // Baixa e distribui cada componente
88
147
  for (const componente of selecionados) {
89
- await this._baixarEDistribuir(componente.caminho, componente.nome);
148
+ const copiados = await this._baixarEDistribuir(componente.caminho, componente.nome);
149
+ totalArquivosCopiados += copiados;
90
150
  }
151
+
152
+ console.log('');
153
+ this.logger.sucesso(`🎉 ${selecionados.length} componente(s) instalado(s)! (${totalArquivosCopiados} arquivo(s) copiados)\n`);
91
154
  }
92
155
 
93
156
  /**
@@ -95,6 +158,7 @@ export class AddCommand {
95
158
  *
96
159
  * @param {string} caminhoRemoto - Caminho no repositório (ex: "components/whatsapp-btn")
97
160
  * @param {string} nomePacote - Nome do pacote para exibição nos logs
161
+ * @returns {Promise<number>} Número de arquivos copiados
98
162
  * @private
99
163
  */
100
164
  async _baixarEDistribuir(caminhoRemoto, nomePacote) {
@@ -104,14 +168,16 @@ export class AddCommand {
104
168
  try {
105
169
  await fs.ensureDir(pastaTmp);
106
170
 
107
- // Download via tiged
171
+ // Download via tiged ou GitHub API
108
172
  await this.downloadService.baixar(caminhoRemoto, pastaTmp);
109
173
 
110
174
  // Distribui nas pastas Shopify locais
111
175
  const diretorioAtual = process.cwd();
112
176
  const totalCopiados = await this.fileMapper.distribuir(pastaTmp, diretorioAtual);
113
177
 
114
- this.logger.sucesso(`\n🎉 "${nomePacote}" instalado! (${totalCopiados} arquivo(s) copiados)\n`);
178
+ this.logger.sucesso(` ✔ "${nomePacote}" ${totalCopiados} arquivo(s)`);
179
+
180
+ return totalCopiados;
115
181
 
116
182
  } finally {
117
183
  // Limpa pasta temporária
@@ -0,0 +1,114 @@
1
+ /**
2
+ * GenerateCommand — Comando "generate" do CLI.
3
+ *
4
+ * Orquestra o fluxo de scaffold de componentes:
5
+ * 1. Recebe o nome do componente (argumento ou prompt)
6
+ * 2. Exibe menu multi-select para tipos de arquivo
7
+ * 3. Normaliza o nome para os formatos corretos (kebab, Pascal, snake)
8
+ * 4. Delega a geração ao ScaffoldService
9
+ * 5. Grava os arquivos via FileSystemManager (fs-extra)
10
+ * 6. Trata conflitos de sobrescrita
11
+ *
12
+ * Princípio: Inversão de Dependência (DIP) — todas as dependências são injetadas
13
+ * Princípio: Responsabilidade Única (SRP) — orquestra apenas o fluxo do comando "generate"
14
+ */
15
+
16
+ import path from 'path';
17
+ import fs from 'fs-extra';
18
+ import { generateNames } from '../utils/stringUtils.js';
19
+
20
+ export class GenerateCommand {
21
+
22
+ /**
23
+ * @param {import('../utils/logger.js').Logger} logger
24
+ * @param {import('../ui/PromptUI.js').PromptUI} promptUI
25
+ * @param {import('../services/ScaffoldService.js').ScaffoldService} scaffoldService
26
+ * @param {import('../filesystem/ConflictResolver.js').ConflictResolver} conflictResolver
27
+ */
28
+ constructor(logger, promptUI, scaffoldService, conflictResolver) {
29
+ this.logger = logger;
30
+ this.promptUI = promptUI;
31
+ this.scaffoldService = scaffoldService;
32
+ this.conflictResolver = conflictResolver;
33
+ }
34
+
35
+ /**
36
+ * Executa o fluxo principal do comando "generate".
37
+ *
38
+ * @param {string} [nomeComponente] - Nome passado como argumento CLI (opcional)
39
+ * @returns {Promise<void>}
40
+ */
41
+ async executar(nomeComponente) {
42
+ try {
43
+ // 1. Se o nome não veio como argumento, pergunta ao usuário
44
+ const nome = nomeComponente || await this.promptUI.perguntarNomeComponente();
45
+
46
+ if (!nome || nome.trim() === '') {
47
+ this.logger.erro('Nome do componente é obrigatório.');
48
+ return;
49
+ }
50
+
51
+ // 2. Normaliza os nomes
52
+ const names = generateNames(nome);
53
+
54
+ this.logger.destaque(`\n🔧 Gerando componente: ${names.PascalName} (${names.kebabName})\n`);
55
+
56
+ // 3. Pergunta quais tipos gerar
57
+ const tiposSelecionados = await this.promptUI.selecionarTiposGerador();
58
+
59
+ if (tiposSelecionados.length === 0) {
60
+ this.logger.aviso('Nenhum tipo selecionado. Operação cancelada.');
61
+ return;
62
+ }
63
+
64
+ // 4. Gera os arquivos via ScaffoldService
65
+ const arquivosGerados = await this.scaffoldService.generateMultiple(tiposSelecionados, names);
66
+
67
+ // 5. Grava os arquivos no disco
68
+ const diretorioAtual = process.cwd();
69
+ let totalGravados = 0;
70
+
71
+ // Reseta decisões anteriores do ConflictResolver
72
+ this.conflictResolver.resetar();
73
+
74
+ for (const arquivo of arquivosGerados) {
75
+ const destDir = path.join(diretorioAtual, arquivo.outputDir);
76
+ const destPath = path.join(destDir, arquivo.fileName);
77
+ const caminhoRelativo = path.join(arquivo.outputDir, arquivo.fileName);
78
+
79
+ // RF03: Verifica se já existe
80
+ if (await fs.pathExists(destPath)) {
81
+ const sobrescrever = await this.conflictResolver.resolver(caminhoRelativo);
82
+
83
+ if (!sobrescrever) {
84
+ this.logger.aviso(` ⏩ Pulando: ${caminhoRelativo}`);
85
+ continue;
86
+ }
87
+ }
88
+
89
+ // Cria o diretório se não existe e grava o arquivo
90
+ await fs.ensureDir(destDir);
91
+ await fs.writeFile(destPath, arquivo.content, 'utf-8');
92
+
93
+ this.logger.sucesso(` ✅ ${caminhoRelativo}`);
94
+ totalGravados++;
95
+ }
96
+
97
+ // 6. Resumo final
98
+ console.log('');
99
+ this.logger.destaque('═══════════════════════════════════════════');
100
+ this.logger.sucesso(`🎉 ${totalGravados}/${arquivosGerados.length} arquivo(s) gerado(s) com sucesso!`);
101
+ this.logger.sutil(`Componente: ${names.PascalName} (${names.kebabName})`);
102
+ this.logger.destaque('═══════════════════════════════════════════');
103
+ console.log('');
104
+
105
+ } catch (erro) {
106
+ // Trata cancelamento do usuário (Ctrl+C)
107
+ if (erro.name === 'ExitPromptError') {
108
+ this.logger.aviso('Operação cancelada pelo usuário.');
109
+ return;
110
+ }
111
+ this.logger.erro(`Erro durante a geração: ${erro.message}`);
112
+ }
113
+ }
114
+ }
@@ -65,19 +65,66 @@ export class GitHubService {
65
65
 
66
66
  /**
67
67
  * Lista os kits disponíveis no repositório.
68
- * @returns {Promise<Array<{nome: string, caminho: string}>>} Lista de kits
68
+ * Busca o kit.json de cada kit para obter nome e descrição.
69
+ *
70
+ * @returns {Promise<Array<{nome: string, slug: string, descricao: string, componentes: string[], caminho: string}>>} Lista de kits
69
71
  */
70
72
  async listarKits() {
71
73
  this.logger.info('Buscando kits disponíveis...');
72
74
 
73
75
  const itens = await this.listarDiretorio(REMOTE_PATHS.KITS);
76
+ const pastasKit = itens.filter(item => item.type === 'dir');
74
77
 
75
- return itens
76
- .filter(item => item.type === 'dir')
77
- .map(item => ({
78
- nome: item.name,
79
- caminho: `${REMOTE_PATHS.KITS}/${item.name}`,
80
- }));
78
+ const kits = [];
79
+
80
+ for (const pasta of pastasKit) {
81
+ try {
82
+ const manifesto = await this.buscarKitManifesto(pasta.name);
83
+ kits.push({
84
+ nome: manifesto.name || pasta.name,
85
+ slug: pasta.name,
86
+ descricao: manifesto.description || '',
87
+ componentes: manifesto.components || [],
88
+ caminho: `${REMOTE_PATHS.KITS}/${pasta.name}`,
89
+ });
90
+ } catch {
91
+ // Se o kit não tem kit.json válido, lista com dados básicos
92
+ this.logger.aviso(`Kit "${pasta.name}" sem kit.json válido. Ignorando.`);
93
+ }
94
+ }
95
+
96
+ return kits;
97
+ }
98
+
99
+ /**
100
+ * Busca e parseia o manifesto (kit.json) de um kit específico.
101
+ *
102
+ * @param {string} nomeKit - Nome/slug da pasta do kit (ex: "starter-base")
103
+ * @returns {Promise<{name: string, version: string, description: string, components: string[]}>} Manifesto do kit
104
+ */
105
+ async buscarKitManifesto(nomeKit) {
106
+ const caminho = `${REMOTE_PATHS.KITS}/${nomeKit}/kit.json`;
107
+ const url = `${GITHUB_API_BASE}/${caminho}`;
108
+ const headers = await this.tokenManager.obterHeaders();
109
+
110
+ try {
111
+ const resposta = await fetch(url, { headers });
112
+
113
+ if (!resposta.ok) {
114
+ throw new Error(`kit.json não encontrado para "${nomeKit}" (HTTP ${resposta.status})`);
115
+ }
116
+
117
+ const dados = await resposta.json();
118
+
119
+ // A API do GitHub retorna o conteúdo em base64
120
+ const conteudo = Buffer.from(dados.content, 'base64').toString('utf-8');
121
+ const manifesto = JSON.parse(conteudo);
122
+
123
+ return manifesto;
124
+ } catch (erro) {
125
+ this.logger.erro(`Falha ao ler kit.json de "${nomeKit}": ${erro.message}`);
126
+ throw erro;
127
+ }
81
128
  }
82
129
 
83
130
  /**
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ScaffoldService — Motor de geração de scaffold via templates.
3
+ *
4
+ * Responsável por:
5
+ * - Ler os templates base da pasta src/templates/
6
+ * - Substituir placeholders ({{kebabName}}, {{PascalName}}, etc.)
7
+ * - Retornar o conteúdo final já renderizado
8
+ *
9
+ * Princípio: Responsabilidade Única (SRP) — lida apenas com renderização de templates
10
+ * Princípio: Aberto/Fechado (OCP) — novos templates são adicionados sem alterar lógica existente
11
+ */
12
+
13
+ import fs from 'fs-extra';
14
+ import path from 'path';
15
+ import { fileURLToPath } from 'url';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ /** Diretório onde os templates estão armazenados */
21
+ const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates');
22
+
23
+ /**
24
+ * Mapa de tipos para seus respectivos arquivos de template
25
+ * e destinos de saída.
26
+ *
27
+ * @type {Object.<string, { templateFile: string, outputDir: string, outputFileName: (kebabName: string) => string }>}
28
+ */
29
+ const TEMPLATE_MAP = {
30
+ section: {
31
+ templateFile: 'section.liquid.txt',
32
+ outputDir: 'sections',
33
+ outputFileName: (kebabName) => `${kebabName}.liquid`,
34
+ },
35
+ block: {
36
+ templateFile: 'block.liquid.txt',
37
+ outputDir: 'blocks',
38
+ outputFileName: (kebabName) => `${kebabName}-block.liquid`,
39
+ },
40
+ snippet: {
41
+ templateFile: 'snippet.liquid.txt',
42
+ outputDir: 'snippets',
43
+ outputFileName: (kebabName) => `${kebabName}.liquid`,
44
+ },
45
+ controller: {
46
+ templateFile: 'controller.js.txt',
47
+ outputDir: 'assets',
48
+ outputFileName: (kebabName) => `${kebabName}-controller.js`,
49
+ },
50
+ };
51
+
52
+ export class ScaffoldService {
53
+
54
+ /**
55
+ * @param {import('../utils/logger.js').Logger} logger
56
+ */
57
+ constructor(logger) {
58
+ this.logger = logger;
59
+ }
60
+
61
+ /**
62
+ * Retorna os tipos de componente disponíveis para geração.
63
+ * @returns {string[]} Lista de tipos disponíveis
64
+ */
65
+ getAvailableTypes() {
66
+ return Object.keys(TEMPLATE_MAP);
67
+ }
68
+
69
+ /**
70
+ * Renderiza um template substituindo todos os placeholders.
71
+ * Utiliza replace via regex (RF02 do PRD).
72
+ *
73
+ * @param {string} templateContent - Conteúdo bruto do template
74
+ * @param {{ kebabName: string, PascalName: string, snake_name: string }} names - Nomes formatados
75
+ * @returns {string} Conteúdo final renderizado
76
+ */
77
+ render(templateContent, names) {
78
+ return templateContent
79
+ .replace(/\{\{kebabName\}\}/g, names.kebabName)
80
+ .replace(/\{\{PascalName\}\}/g, names.PascalName)
81
+ .replace(/\{\{snake_name\}\}/g, names.snake_name);
82
+ }
83
+
84
+ /**
85
+ * Gera um único componente: lê o template, renderiza e retorna
86
+ * o conteúdo com as informações de destino.
87
+ *
88
+ * @param {string} type - Tipo do componente ('section', 'block', 'snippet', 'controller')
89
+ * @param {{ kebabName: string, PascalName: string, snake_name: string }} names - Nomes formatados
90
+ * @returns {Promise<{ content: string, outputDir: string, fileName: string }>}
91
+ * @throws {Error} Se o tipo não for suportado
92
+ */
93
+ async generate(type, names) {
94
+ const config = TEMPLATE_MAP[type];
95
+
96
+ if (!config) {
97
+ throw new Error(`Tipo de componente não suportado: "${type}". Tipos válidos: ${this.getAvailableTypes().join(', ')}`);
98
+ }
99
+
100
+ // Lê o template do disco
101
+ const templatePath = path.join(TEMPLATES_DIR, config.templateFile);
102
+ const templateContent = await fs.readFile(templatePath, 'utf-8');
103
+
104
+ // Renderiza com as variáveis
105
+ const content = this.render(templateContent, names);
106
+
107
+ return {
108
+ content,
109
+ outputDir: config.outputDir,
110
+ fileName: config.outputFileName(names.kebabName),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Gera múltiplos componentes de uma vez.
116
+ *
117
+ * @param {string[]} types - Lista de tipos a serem gerados
118
+ * @param {{ kebabName: string, PascalName: string, snake_name: string }} names - Nomes formatados
119
+ * @returns {Promise<Array<{ content: string, outputDir: string, fileName: string }>>}
120
+ */
121
+ async generateMultiple(types, names) {
122
+ const results = [];
123
+
124
+ for (const type of types) {
125
+ const result = await this.generate(type, names);
126
+ results.push(result);
127
+ }
128
+
129
+ return results;
130
+ }
131
+ }
@@ -0,0 +1,52 @@
1
+ {% doc %}
2
+ Block: {{PascalName}} Block
3
+ Usage: Use inside sections that support blocks.
4
+ {% enddoc %}
5
+
6
+ {% liquid
7
+ assign block_id = block.id
8
+ %}
9
+
10
+ <{{kebabName}}-block
11
+ id="block-{{ block_id }}"
12
+ class="{{kebabName}}-block"
13
+ data-block-id="{{ block_id }}"
14
+ {{ block.shopify_attributes }}
15
+ >
16
+ <div class="{{kebabName}}-block__inner">
17
+ {% if block.settings.title != blank %}
18
+ <h3 class="{{kebabName}}-block__title">{{ block.settings.title | escape }}</h3>
19
+ {% endif %}
20
+ </div>
21
+ </{{kebabName}}-block>
22
+
23
+ {% schema %}
24
+ {
25
+ "name": "{{PascalName}} Block",
26
+ "target": "section",
27
+ "settings": [
28
+ {
29
+ "type": "text",
30
+ "id": "title",
31
+ "label": "Block Title",
32
+ "default": "Feature"
33
+ }
34
+ ]
35
+ }
36
+ {% endschema %}
37
+
38
+ {% javascript %}
39
+ class {{PascalName}}Block extends HTMLElement {
40
+ connectedCallback() {
41
+ this.addEventListener('click', this.handleClick.bind(this));
42
+ }
43
+
44
+ handleClick() {
45
+ console.log('Block clicked:', this.dataset.blockId);
46
+ }
47
+ }
48
+
49
+ if (!customElements.get('{{kebabName}}-block')) {
50
+ customElements.define('{{kebabName}}-block', {{PascalName}}Block);
51
+ }
52
+ {% endjavascript %}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Controller: {{PascalName}}Controller
3
+ * Path: assets/{{kebabName}}-controller.js
4
+ * Description: Logic separated for reuse across multiple sections/blocks
5
+ */
6
+
7
+ class {{PascalName}}Controller extends HTMLElement {
8
+ connectedCallback() {
9
+ const sectionId = this.dataset.sectionId;
10
+ if (!sectionId) return;
11
+
12
+ this.root = document.getElementById(`section-${sectionId}`);
13
+ this.bindEvents();
14
+ }
15
+
16
+ bindEvents() {
17
+ // Add event listeners
18
+ }
19
+
20
+ disconnectedCallback() {
21
+ // Remove event listeners
22
+ }
23
+ }
24
+
25
+ if (!customElements.get('{{kebabName}}-controller')) {
26
+ customElements.define('{{kebabName}}-controller', {{PascalName}}Controller);
27
+ }
@@ -0,0 +1,92 @@
1
+ {% doc %}
2
+ Section: {{PascalName}}
3
+ Description: Componente principal controlado via Web Component.
4
+ Architecture:
5
+ - HTML: Semântico com IDs dinâmicos
6
+ - JS: Web Component desacoplado
7
+ - CSS: Scoped ou via Assets
8
+ {% enddoc %}
9
+
10
+ {% liquid
11
+ assign section_id = section.id
12
+ assign container_id = 'section-' | append: section_id
13
+ %}
14
+
15
+ <{{kebabName}}-section
16
+ id="{{ container_id }}"
17
+ class="{{kebabName}} section-container"
18
+ data-section-id="{{ section_id }}"
19
+ >
20
+ <div class="{{kebabName}}__wrapper page-width">
21
+ {%- if section.settings.heading != blank -%}
22
+ <h2 class="{{kebabName}}__heading">{{ section.settings.heading | escape }}</h2>
23
+ {%- endif -%}
24
+
25
+ <div class="{{kebabName}}__content">
26
+ {% content_for 'blocks' %}
27
+ </div>
28
+ </div>
29
+ </{{kebabName}}-section>
30
+
31
+ {% schema %}
32
+ {
33
+ "name": "{{PascalName}}",
34
+ "tag": "section",
35
+ "class": "section-{{kebabName}}",
36
+ "settings": [
37
+ {
38
+ "type": "text",
39
+ "id": "heading",
40
+ "label": "Heading",
41
+ "default": "{{PascalName}}"
42
+ }
43
+ ],
44
+ "blocks": [
45
+ {
46
+ "type": "@theme"
47
+ }
48
+ ],
49
+ "presets": [
50
+ {
51
+ "name": "{{PascalName}}"
52
+ }
53
+ ]
54
+ }
55
+ {% endschema %}
56
+
57
+ {% stylesheet %}
58
+ /* BEM Styling */
59
+ .{{kebabName}} {
60
+ display: block;
61
+ position: relative;
62
+ padding: 2rem 0;
63
+ }
64
+ .{{kebabName}}__heading {
65
+ margin-bottom: 1.5rem;
66
+ }
67
+ {% endstylesheet %}
68
+
69
+ {% javascript %}
70
+ class {{PascalName}}Section extends HTMLElement {
71
+ constructor() {
72
+ super();
73
+ }
74
+
75
+ connectedCallback() {
76
+ this.init();
77
+ }
78
+
79
+ init() {
80
+ console.log('{{PascalName}} initialized', this.dataset.sectionId);
81
+ // Logic goes here
82
+ }
83
+
84
+ disconnectedCallback() {
85
+ // Cleanup events
86
+ }
87
+ }
88
+
89
+ if (!customElements.get('{{kebabName}}-section')) {
90
+ customElements.define('{{kebabName}}-section', {{PascalName}}Section);
91
+ }
92
+ {% endjavascript %}
@@ -0,0 +1,21 @@
1
+ {% doc %}
2
+ Snippet: {{PascalName}}
3
+ Renders a reusable UI component.
4
+
5
+ @param {string} id - Unique DOM id
6
+ @param {string} [modifier_class] - Optional CSS class
7
+ @param {string} [content] - Optional inner HTML
8
+
9
+ @example
10
+ {% render '{{kebabName}}', id: 'my-id', content: 'Hello' %}
11
+ {% enddoc %}
12
+
13
+ <div
14
+ id="{{ id | escape }}"
15
+ class="{{kebabName}} {{ modifier_class }}"
16
+ data-ui-component="{{kebabName}}"
17
+ >
18
+ {% if content %}
19
+ {{ content }}
20
+ {% endif %}
21
+ </div>
@@ -8,10 +8,63 @@
8
8
  * Princípio: Aberto/Fechado (OCP) — fácil adicionar novos menus
9
9
  */
10
10
 
11
- import { select, checkbox } from '@inquirer/prompts';
11
+ import { select, checkbox, input } from '@inquirer/prompts';
12
12
 
13
13
  export class PromptUI {
14
14
 
15
+ /**
16
+ * Pergunta o nome do componente ao usuário.
17
+ * Usado quando o nome não é fornecido via argumento CLI.
18
+ *
19
+ * @returns {Promise<string>} Nome digitado pelo usuário
20
+ */
21
+ async perguntarNomeComponente() {
22
+ const nome = await input({
23
+ message: '📝 Qual o nome do componente?',
24
+ validate: (value) => {
25
+ if (!value || value.trim() === '') {
26
+ return 'O nome do componente é obrigatório.';
27
+ }
28
+ return true;
29
+ },
30
+ });
31
+
32
+ return nome.trim();
33
+ }
34
+
35
+ /**
36
+ * Exibe menu multi-select para escolher os tipos de arquivo a serem gerados.
37
+ * Implementa o RF do PRD: "O que você deseja criar?"
38
+ *
39
+ * @returns {Promise<string[]>} Tipos selecionados ('section', 'block', 'snippet', 'controller')
40
+ */
41
+ async selecionarTiposGerador() {
42
+ const tipos = await checkbox({
43
+ message: '🧱 O que você deseja criar?',
44
+ choices: [
45
+ {
46
+ name: '📄 Section — Cria sections/[nome].liquid',
47
+ value: 'section',
48
+ },
49
+ {
50
+ name: '🧩 Block — Cria blocks/[nome]-block.liquid',
51
+ value: 'block',
52
+ },
53
+ {
54
+ name: '🔗 Snippet — Cria snippets/[nome].liquid',
55
+ value: 'snippet',
56
+ },
57
+ {
58
+ name: '⚡ Asset JS Controller — Cria assets/[nome]-controller.js',
59
+ value: 'controller',
60
+ },
61
+ ],
62
+ required: true,
63
+ });
64
+
65
+ return tipos;
66
+ }
67
+
15
68
  /**
16
69
  * Exibe o menu principal com as opções de instalação.
17
70
  *
@@ -39,24 +92,26 @@ export class PromptUI {
39
92
 
40
93
  /**
41
94
  * Exibe a lista de kits disponíveis para seleção.
95
+ * Mostra nome e descrição vindos do kit.json (manifesto).
42
96
  *
43
- * @param {Array<{nome: string, caminho: string}>} kits - Kits disponíveis
44
- * @returns {Promise<{nome: string, caminho: string}>} Kit selecionado
97
+ * @param {Array<{nome: string, slug: string, descricao: string, componentes: string[], caminho: string}>} kits - Kits disponíveis
98
+ * @returns {Promise<{nome: string, slug: string, descricao: string, componentes: string[], caminho: string}>} Kit selecionado
45
99
  */
46
100
  async selecionarKit(kits) {
47
101
  if (kits.length === 0) {
48
102
  throw new Error('Nenhum kit disponível no repositório.');
49
103
  }
50
104
 
51
- const caminho = await select({
105
+ const slug = await select({
52
106
  message: '🎯 Selecione o kit para instalar:',
53
107
  choices: kits.map(kit => ({
54
- name: `📁 ${kit.nome}`,
55
- value: kit.caminho,
108
+ name: `📁 ${kit.nome} (${kit.componentes.length} componentes)`,
109
+ value: kit.slug,
110
+ description: kit.descricao || undefined,
56
111
  })),
57
112
  });
58
113
 
59
- return kits.find(k => k.caminho === caminho);
114
+ return kits.find(k => k.slug === slug);
60
115
  }
61
116
 
62
117
  /**
@@ -48,10 +48,10 @@ export class Logger {
48
48
  */
49
49
  banner() {
50
50
  console.log('');
51
- console.log(pc.bold(pc.magenta(' ╔══════════════════════════════╗')));
52
- console.log(pc.bold(pc.magenta(' ║ ♟️ ROOK CLI v1.0.2 ║')));
53
- console.log(pc.bold(pc.magenta(' ║ Shopify Component Tool ║')));
54
- console.log(pc.bold(pc.magenta(' ╚══════════════════════════════╝')));
51
+ console.log(pc.bold(pc.white(' ╔══════════════════════════════╗')));
52
+ console.log(pc.bold(pc.white(' ║ ♟️ ROOK CLI v1.0.3 ║')));
53
+ console.log(pc.bold(pc.white(' ║ Shopify Component Tool ║')));
54
+ console.log(pc.bold(pc.white(' ╚══════════════════════════════╝')));
55
55
  console.log('');
56
56
  }
57
57
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * stringUtils — Utilitários puros de manipulação de strings.
3
+ *
4
+ * Responsável pelas conversões de casing necessárias
5
+ * no fluxo de geração de componentes (scaffold).
6
+ *
7
+ * Princípio: Responsabilidade Única (SRP) — funções puras sem side-effects
8
+ * Princípio: Aberto/Fechado (OCP) — novos formatadores são adicionados sem alterar existentes
9
+ */
10
+
11
+ /**
12
+ * Converte qualquer input para kebab-case.
13
+ * Usado em: nomes de arquivo, classes CSS (BEM), tags HTML.
14
+ *
15
+ * Exemplos:
16
+ * "hero banner" → "hero-banner"
17
+ * "HeroBanner" → "hero-banner"
18
+ * "hero_banner" → "hero-banner"
19
+ * "HERO-BANNER" → "hero-banner"
20
+ *
21
+ * @param {string} input - String para converter
22
+ * @returns {string} String em kebab-case
23
+ */
24
+ export function toKebabCase(input) {
25
+ return input
26
+ // Insere hífen antes de letras maiúsculas em camelCase/PascalCase
27
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
28
+ // Substitui espaços, underscores e múltiplos hífens por hífen único
29
+ .replace(/[\s_]+/g, '-')
30
+ // Remove múltiplos hífens consecutivos
31
+ .replace(/-+/g, '-')
32
+ // Remove hífens no início e final
33
+ .replace(/^-|-$/g, '')
34
+ .toLowerCase();
35
+ }
36
+
37
+ /**
38
+ * Converte qualquer input para PascalCase.
39
+ * Usado em: classes JS (Web Components), labels do Schema Shopify.
40
+ *
41
+ * Exemplos:
42
+ * "hero banner" → "HeroBanner"
43
+ * "hero-banner" → "HeroBanner"
44
+ * "hero_banner" → "HeroBanner"
45
+ * "HeroBanner" → "HeroBanner"
46
+ *
47
+ * @param {string} input - String para converter
48
+ * @returns {string} String em PascalCase
49
+ */
50
+ export function toPascalCase(input) {
51
+ return toKebabCase(input)
52
+ .split('-')
53
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
54
+ .join('');
55
+ }
56
+
57
+ /**
58
+ * Converte qualquer input para snake_case.
59
+ * Usado em: variáveis Liquid, IDs de settings.
60
+ *
61
+ * Exemplos:
62
+ * "hero banner" → "hero_banner"
63
+ * "hero-banner" → "hero_banner"
64
+ * "HeroBanner" → "hero_banner"
65
+ *
66
+ * @param {string} input - String para converter
67
+ * @returns {string} String em snake_case
68
+ */
69
+ export function toSnakeCase(input) {
70
+ return toKebabCase(input).replace(/-/g, '_');
71
+ }
72
+
73
+ /**
74
+ * Gera todos os formatos de nome a partir de um input qualquer.
75
+ * Retorna um objeto padronizado usado nos templates.
76
+ *
77
+ * @param {string} input - Nome original do componente
78
+ * @returns {{ kebabName: string, PascalName: string, snake_name: string }}
79
+ */
80
+ export function generateNames(input) {
81
+ return {
82
+ kebabName: toKebabCase(input),
83
+ PascalName: toPascalCase(input),
84
+ snake_name: toSnakeCase(input),
85
+ };
86
+ }