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 +2 -2
- package/src/app.js +21 -0
- package/src/commands/AddCommand.js +73 -7
- package/src/commands/GenerateCommand.js +114 -0
- package/src/services/GitHubService.js +54 -7
- package/src/services/ScaffoldService.js +131 -0
- package/src/templates/block.liquid.txt +52 -0
- package/src/templates/controller.js.txt +27 -0
- package/src/templates/section.liquid.txt +92 -0
- package/src/templates/snippet.liquid.txt +21 -0
- package/src/ui/PromptUI.js +62 -7
- package/src/utils/logger.js +4 -4
- package/src/utils/stringUtils.js +86 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rook-cli",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "CLI para instalar componentes Shopify
|
|
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
|
-
|
|
73
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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>
|
package/src/ui/PromptUI.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
114
|
+
return kits.find(k => k.slug === slug);
|
|
60
115
|
}
|
|
61
116
|
|
|
62
117
|
/**
|
package/src/utils/logger.js
CHANGED
|
@@ -48,10 +48,10 @@ export class Logger {
|
|
|
48
48
|
*/
|
|
49
49
|
banner() {
|
|
50
50
|
console.log('');
|
|
51
|
-
console.log(pc.bold(pc.
|
|
52
|
-
console.log(pc.bold(pc.
|
|
53
|
-
console.log(pc.bold(pc.
|
|
54
|
-
console.log(pc.bold(pc.
|
|
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
|
+
}
|