rook-cli 1.0.3 → 1.2.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/README.md CHANGED
@@ -40,6 +40,53 @@ node bin/rook.js config
40
40
  node bin/rook.js add
41
41
  ```
42
42
 
43
+ ## Modo Headless (Automação / CI)
44
+
45
+ Para rodar os comandos sem interação (útil para scripts ou MCP), utilize as flags adicionais:
46
+
47
+ ```bash
48
+ # Adicionar componente/kit diretamente e sobrescrever conflitos
49
+ rook add --type componente --name whatsapp-float --force
50
+
51
+ # Gerar scaffold de arquivos Liquid diretamente
52
+ rook generate hero-banner --type section --yes
53
+ ```
54
+
55
+ ## Integração com IA (MCP Server)
56
+
57
+ O Rook CLI expõe todas as suas funcionalidades como ferramentas (*tools*) através do protocolo **Model Context Protocol (MCP)**. Isso permite que Inteligências Artificiais como Claude Desktop e Cursor instalem componentes e gerem arquivos no seu projeto automaticamente.
58
+
59
+ Existem duas formas de configurar o servidor MCP dependendo de como você instalou o pacote:
60
+
61
+ ### Opção 1: Usando `npx` (Recomendada)
62
+ Ideal se o pacote for instalado remotamente via NPM. O npx garante que a IA sempre fará o bypass do PATH sem problemas:
63
+
64
+ **Em `claude_desktop_config.json` ou `.cursor/mcp.json`:**
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "rook-cli": {
69
+ "command": "npx",
70
+ "args": ["-y", "--package", "rook-cli", "rook-mcp"]
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ### Opção 2: Instalando globalmente (`npm install -g rook-cli`)
77
+ Se você instalou o CLI como pacote global no seu sistema, o comando `rook-mcp` estará disponível nativamente.
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "rook-cli": {
83
+ "command": "rook-mcp",
84
+ "args": []
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
43
90
  ## Estrutura do Projeto
44
91
 
45
92
  ```
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "rook-cli",
3
- "version": "1.0.3",
4
- "description": "CLI para instalar componentes Shopify de um repositório centralizado",
3
+ "version": "1.2.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"
8
8
  },
9
9
  "type": "module",
10
10
  "bin": {
11
- "rook": "bin/rook.js"
11
+ "rook": "bin/rook.js",
12
+ "rook-mcp": "src/mcp/server.js"
12
13
  },
13
14
  "files": [
14
15
  "bin",
@@ -16,6 +17,7 @@
16
17
  ],
17
18
  "scripts": {
18
19
  "start": "node bin/rook.js",
20
+ "mcp": "node src/mcp/server.js",
19
21
  "test": "echo \"No tests yet\" && exit 0"
20
22
  },
21
23
  "keywords": [
@@ -28,10 +30,12 @@
28
30
  "license": "ISC",
29
31
  "dependencies": {
30
32
  "@inquirer/prompts": "^8.3.0",
33
+ "@modelcontextprotocol/sdk": "^1.27.1",
31
34
  "commander": "^14.0.3",
32
35
  "dotenv": "^17.3.1",
33
36
  "fs-extra": "^11.3.3",
34
37
  "picocolors": "^1.1.1",
35
- "tiged": "^2.12.7"
38
+ "tiged": "^2.12.7",
39
+ "zod": "^4.3.6"
36
40
  }
37
41
  }
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
  /**
@@ -63,12 +74,16 @@ export class App {
63
74
  .version('1.0.1');
64
75
 
65
76
  // Comando: rook add
77
+ // Flags de headless mode: --type, --name, --force
66
78
  this.programa
67
79
  .command('add')
68
80
  .description('Adiciona kits ou componentes ao tema Shopify atual')
69
- .action(async () => {
81
+ .option('--type <tipo>', 'Tipo de instalação: "kit" ou "componente"')
82
+ .option('--name <nome>', 'Nome do kit ou componente a instalar')
83
+ .option('--force', 'Sobrescreve arquivos existentes sem perguntar')
84
+ .action(async (opcoes) => {
70
85
  this.logger.banner();
71
- await this.addCommand.executar();
86
+ await this.addCommand.executar(opcoes);
72
87
  });
73
88
 
74
89
  // Comando: rook config
@@ -80,6 +95,19 @@ export class App {
80
95
  await this.configCommand.executar();
81
96
  });
82
97
 
98
+ // Comando: rook generate [nome-do-componente]
99
+ // Flags de headless mode: --type, --yes
100
+ this.programa
101
+ .command('generate [nome]')
102
+ .alias('g')
103
+ .description('Gera scaffold de componentes Shopify (sections, blocks, snippets, assets)')
104
+ .option('--type <tipo>', 'Tipo de componente: section, block, snippet, controller')
105
+ .option('--yes', 'Pula confirmações e sobrescreve arquivos existentes')
106
+ .action(async (nome, opcoes) => {
107
+ this.logger.banner();
108
+ await this.generateCommand.executar(nome, opcoes);
109
+ });
110
+
83
111
  // Comando padrão (sem argumentos) — abre o menu interativo
84
112
  this.programa
85
113
  .action(async () => {
@@ -34,18 +34,32 @@ export class AddCommand {
34
34
 
35
35
  /**
36
36
  * Executa o fluxo principal do comando "add".
37
+ *
38
+ * Suporta dois modos:
39
+ * - Interativo (padrão): exibe menus para o usuário
40
+ * - Headless (com flags): executa direto, sem prompts (para uso com MCP/IA)
41
+ *
42
+ * @param {Object} [opcoes={}] - Opções passadas via flags do Commander
43
+ * @param {string} [opcoes.type] - Tipo de instalação ("kit" ou "componente")
44
+ * @param {string} [opcoes.name] - Nome do kit ou componente
45
+ * @param {boolean} [opcoes.force] - Sobrescreve sem perguntar
37
46
  * @returns {Promise<void>}
38
47
  */
39
- async executar() {
48
+ async executar(opcoes = {}) {
40
49
  try {
41
- // 1. Menu principal
42
- const tipoInstalacao = await this.promptUI.menuPrincipal();
50
+ // Modo headless: se --force foi passado, configura o ConflictResolver
51
+ if (opcoes.force) {
52
+ this.fileMapper.conflictResolver?.definirDecisaoGlobal?.(true);
53
+ }
54
+
55
+ // 1. Determina o tipo (flag ou menu interativo)
56
+ const tipoInstalacao = opcoes.type || await this.promptUI.menuPrincipal();
43
57
 
44
58
  // 2. Delega para o fluxo correto
45
59
  if (tipoInstalacao === 'kit') {
46
- await this._instalarKit();
60
+ await this._instalarKit(opcoes.name);
47
61
  } else {
48
- await this._instalarComponentes();
62
+ await this._instalarComponentes(opcoes.name);
49
63
  }
50
64
 
51
65
  } catch (erro) {
@@ -63,13 +77,14 @@ export class AddCommand {
63
77
  *
64
78
  * Segue o padrão descrito no PRD (Seção 5.3):
65
79
  * 1. Lista kits disponíveis (com dados do kit.json)
66
- * 2. Usuário seleciona o kit
80
+ * 2. Usuário seleciona o kit (ou recebe via flag headless)
67
81
  * 3. Instala cada componente referenciado no manifesto
68
82
  * 4. Instala os arquivos locais exclusivos do kit
69
83
  *
84
+ * @param {string} [nomeKit] - Nome do kit (modo headless). Se omitido, exibe menu.
70
85
  * @private
71
86
  */
72
- async _instalarKit() {
87
+ async _instalarKit(nomeKit) {
73
88
  // 1. Lista kits disponíveis (já enriquecidos com dados do kit.json)
74
89
  const kits = await this.githubService.listarKits();
75
90
 
@@ -78,8 +93,17 @@ export class AddCommand {
78
93
  return;
79
94
  }
80
95
 
81
- // 2. Usuário seleciona o kit
82
- const kitSelecionado = await this.promptUI.selecionarKit(kits);
96
+ // 2. Seleciona o kit (headless ou interativo)
97
+ let kitSelecionado;
98
+ if (nomeKit) {
99
+ kitSelecionado = kits.find(k => k.slug === nomeKit || k.nome === nomeKit);
100
+ if (!kitSelecionado) {
101
+ this.logger.erro(`Kit "${nomeKit}" não encontrado.`);
102
+ return;
103
+ }
104
+ } else {
105
+ kitSelecionado = await this.promptUI.selecionarKit(kits);
106
+ }
83
107
 
84
108
  this.logger.destaque(`\n📥 Instalando kit: ${kitSelecionado.nome}\n`);
85
109
 
@@ -132,12 +156,26 @@ export class AddCommand {
132
156
 
133
157
  /**
134
158
  * Fluxo de instalação de componentes individuais.
159
+ *
160
+ * @param {string} [nomeComponente] - Nome do componente (modo headless). Se omitido, exibe menu.
135
161
  * @private
136
162
  */
137
- async _instalarComponentes() {
163
+ async _instalarComponentes(nomeComponente) {
138
164
  // Lista componentes disponíveis
139
165
  const componentes = await this.githubService.listarComponentes();
140
- const selecionados = await this.promptUI.selecionarComponentes(componentes);
166
+
167
+ let selecionados;
168
+ if (nomeComponente) {
169
+ // Modo headless: busca pelo nome diretamente
170
+ const encontrado = componentes.find(c => c.nome === nomeComponente);
171
+ if (!encontrado) {
172
+ this.logger.erro(`Componente "${nomeComponente}" não encontrado no repositório.`);
173
+ return;
174
+ }
175
+ selecionados = [encontrado];
176
+ } else {
177
+ selecionados = await this.promptUI.selecionarComponentes(componentes);
178
+ }
141
179
 
142
180
  this.logger.destaque(`\n📥 Instalando ${selecionados.length} componente(s)...\n`);
143
181
 
@@ -0,0 +1,143 @@
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
+ * Suporta dois modos:
39
+ * - Interativo (padrão): pergunta nome e tipos ao usuário
40
+ * - Headless (com flags): executa direto, sem prompts (para uso com MCP/IA)
41
+ *
42
+ * @param {string} [nomeComponente] - Nome passado como argumento CLI (opcional)
43
+ * @param {Object} [opcoes={}] - Opções passadas via flags do Commander
44
+ * @param {string} [opcoes.type] - Tipo de componente (pula multi-select)
45
+ * @param {boolean} [opcoes.yes] - Sobrescreve arquivos sem perguntar
46
+ * @returns {Promise<void>}
47
+ */
48
+ async executar(nomeComponente, opcoes = {}) {
49
+ try {
50
+ // Modo headless: se --yes foi passado, sobrescreve tudo
51
+ if (opcoes.yes) {
52
+ this.conflictResolver.definirDecisaoGlobal(true);
53
+ }
54
+
55
+ // 1. Se o nome não veio como argumento, pergunta ao usuário
56
+ const nome = nomeComponente || await this.promptUI.perguntarNomeComponente();
57
+
58
+ if (!nome || nome.trim() === '') {
59
+ this.logger.erro('Nome do componente é obrigatório.');
60
+ return;
61
+ }
62
+
63
+ // 2. Normaliza os nomes
64
+ const names = generateNames(nome);
65
+
66
+ this.logger.destaque(`\n🔧 Gerando componente: ${names.PascalName} (${names.kebabName})\n`);
67
+
68
+ // 3. Determina os tipos (flag headless ou multi-select interativo)
69
+ let tiposSelecionados;
70
+ if (opcoes.type) {
71
+ // Headless: aceita tipo único ou múltiplos separados por vírgula
72
+ tiposSelecionados = opcoes.type.split(',').map(t => t.trim());
73
+
74
+ // Valida tipos
75
+ const tiposValidos = this.scaffoldService.getAvailableTypes();
76
+ for (const tipo of tiposSelecionados) {
77
+ if (!tiposValidos.includes(tipo)) {
78
+ this.logger.erro(`Tipo inválido: "${tipo}". Tipos válidos: ${tiposValidos.join(', ')}`);
79
+ return;
80
+ }
81
+ }
82
+ } else {
83
+ tiposSelecionados = await this.promptUI.selecionarTiposGerador();
84
+ }
85
+
86
+ if (tiposSelecionados.length === 0) {
87
+ this.logger.aviso('Nenhum tipo selecionado. Operação cancelada.');
88
+ return;
89
+ }
90
+
91
+ // 4. Gera os arquivos via ScaffoldService
92
+ const arquivosGerados = await this.scaffoldService.generateMultiple(tiposSelecionados, names);
93
+
94
+ // 5. Grava os arquivos no disco
95
+ const diretorioAtual = process.cwd();
96
+ let totalGravados = 0;
97
+
98
+ // Reseta decisões anteriores do ConflictResolver (se não é headless)
99
+ if (!opcoes.yes) {
100
+ this.conflictResolver.resetar();
101
+ }
102
+
103
+ for (const arquivo of arquivosGerados) {
104
+ const destDir = path.join(diretorioAtual, arquivo.outputDir);
105
+ const destPath = path.join(destDir, arquivo.fileName);
106
+ const caminhoRelativo = path.join(arquivo.outputDir, arquivo.fileName);
107
+
108
+ // RF03: Verifica se já existe
109
+ if (await fs.pathExists(destPath)) {
110
+ const sobrescrever = await this.conflictResolver.resolver(caminhoRelativo);
111
+
112
+ if (!sobrescrever) {
113
+ this.logger.aviso(` ⏩ Pulando: ${caminhoRelativo}`);
114
+ continue;
115
+ }
116
+ }
117
+
118
+ // Cria o diretório se não existe e grava o arquivo
119
+ await fs.ensureDir(destDir);
120
+ await fs.writeFile(destPath, arquivo.content, 'utf-8');
121
+
122
+ this.logger.sucesso(` ✅ ${caminhoRelativo}`);
123
+ totalGravados++;
124
+ }
125
+
126
+ // 6. Resumo final
127
+ console.log('');
128
+ this.logger.destaque('═══════════════════════════════════════════');
129
+ this.logger.sucesso(`🎉 ${totalGravados}/${arquivosGerados.length} arquivo(s) gerado(s) com sucesso!`);
130
+ this.logger.sutil(`Componente: ${names.PascalName} (${names.kebabName})`);
131
+ this.logger.destaque('═══════════════════════════════════════════');
132
+ console.log('');
133
+
134
+ } catch (erro) {
135
+ // Trata cancelamento do usuário (Ctrl+C)
136
+ if (erro.name === 'ExitPromptError') {
137
+ this.logger.aviso('Operação cancelada pelo usuário.');
138
+ return;
139
+ }
140
+ this.logger.erro(`Erro durante a geração: ${erro.message}`);
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Rook MCP Server — Ponte entre IA e o CLI Rook.
5
+ *
6
+ * Expõe as funcionalidades do CLI como Tools que clientes MCP
7
+ * (Claude Desktop, Cursor, etc.) podem descobrir e executar.
8
+ *
9
+ * Tools disponíveis:
10
+ * - list_components: Lista kits e componentes do repositório remoto
11
+ * - install_component: Instala um componente ou kit no tema local
12
+ * - generate_scaffold: Gera boilerplate de componentes Liquid
13
+ *
14
+ * Princípio: Composição — reutiliza os serviços existentes do CLI
15
+ * Princípio: Responsabilidade Única (SRP) — apenas expõe tools via protocolo MCP
16
+ */
17
+
18
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import {
21
+ CallToolRequestSchema,
22
+ ListToolsRequestSchema,
23
+ } from '@modelcontextprotocol/sdk/types.js';
24
+ import { z } from 'zod';
25
+
26
+ // --- Dependências internas do CLI (reutilizadas) ---
27
+ import { Logger } from '../utils/logger.js';
28
+ import { TokenManager } from '../auth/TokenManager.js';
29
+ import { GitHubService } from '../services/GitHubService.js';
30
+ import { DownloadService } from '../services/DownloadService.js';
31
+ import { ScaffoldService } from '../services/ScaffoldService.js';
32
+ import { FileMapper } from '../filesystem/FileMapper.js';
33
+ import { ConflictResolver } from '../filesystem/ConflictResolver.js';
34
+ import { generateNames } from '../utils/stringUtils.js';
35
+
36
+ import path from 'path';
37
+ import os from 'os';
38
+ import fs from 'fs-extra';
39
+ import { fileURLToPath } from 'url';
40
+
41
+ // -- Resolve .env relativo ao diretório do CLI --
42
+ const __filename = fileURLToPath(import.meta.url);
43
+ const __dirname = path.dirname(__filename);
44
+ const cliRoot = path.resolve(__dirname, '..', '..');
45
+
46
+ import dotenv from 'dotenv';
47
+ dotenv.config({ path: path.join(cliRoot, '.env') });
48
+
49
+ // ═══════════════════════════════════════════════════════════════
50
+ // Composition Root — instanciação de dependências (mesmo padrão do app.js)
51
+ // ═══════════════════════════════════════════════════════════════
52
+
53
+ const logger = new Logger();
54
+ const tokenManager = new TokenManager(logger);
55
+ const githubService = new GitHubService(logger, tokenManager);
56
+ const downloadService = new DownloadService(logger, tokenManager);
57
+ const conflictResolver = new ConflictResolver(logger);
58
+ const fileMapper = new FileMapper(logger, conflictResolver);
59
+ const scaffoldService = new ScaffoldService(logger);
60
+
61
+ // No modo MCP, o ConflictResolver opera em modo headless (sobrescreve tudo)
62
+ conflictResolver.definirDecisaoGlobal(true);
63
+
64
+ // ═══════════════════════════════════════════════════════════════
65
+ // Schemas de validação (Zod)
66
+ // ═══════════════════════════════════════════════════════════════
67
+
68
+ const ListComponentsSchema = z.object({
69
+ category: z
70
+ .enum(['kits', 'components', 'all'])
71
+ .optional()
72
+ .default('all')
73
+ .describe('Filtrar por categoria: "kits", "components" ou "all" (padrão).'),
74
+ });
75
+
76
+ const InstallComponentSchema = z.object({
77
+ name: z
78
+ .string()
79
+ .min(1)
80
+ .describe('Nome do componente ou kit a ser instalado (ex: "whatsapp-float").'),
81
+ type: z
82
+ .enum(['component', 'kit'])
83
+ .describe('Tipo do pacote: "component" para componente individual, "kit" para kit completo.'),
84
+ });
85
+
86
+ const GenerateScaffoldSchema = z.object({
87
+ name: z
88
+ .string()
89
+ .min(1)
90
+ .describe('Nome do novo recurso (ex: "hero-banner"). Será convertido para kebab-case automaticamente.'),
91
+ elementType: z
92
+ .enum(['section', 'block', 'snippet', 'controller'])
93
+ .describe('Tipo de recurso Shopify a ser gerado.'),
94
+ });
95
+
96
+ // ═══════════════════════════════════════════════════════════════
97
+ // Funções Handler das Tools
98
+ // ═══════════════════════════════════════════════════════════════
99
+
100
+ /**
101
+ * Handler: list_components
102
+ * Lista kits e/ou componentes disponíveis no repositório remoto.
103
+ */
104
+ async function handleListComponents(args) {
105
+ const { category } = ListComponentsSchema.parse(args);
106
+
107
+ const resultado = {};
108
+
109
+ if (category === 'kits' || category === 'all') {
110
+ const kits = await githubService.listarKits();
111
+ resultado.kits = kits.map(kit => ({
112
+ name: kit.nome,
113
+ slug: kit.slug,
114
+ description: kit.descricao,
115
+ components: kit.componentes,
116
+ path: kit.caminho,
117
+ }));
118
+ }
119
+
120
+ if (category === 'components' || category === 'all') {
121
+ const componentes = await githubService.listarComponentes();
122
+ resultado.components = componentes.map(comp => ({
123
+ name: comp.nome,
124
+ path: comp.caminho,
125
+ }));
126
+ }
127
+
128
+ const totalKits = resultado.kits?.length || 0;
129
+ const totalComponents = resultado.components?.length || 0;
130
+
131
+ return {
132
+ content: [{
133
+ type: 'text',
134
+ text: JSON.stringify({
135
+ summary: `Encontrados ${totalKits} kit(s) e ${totalComponents} componente(s).`,
136
+ ...resultado,
137
+ }, null, 2),
138
+ }],
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Handler: install_component
144
+ * Instala um componente individual ou kit completo no tema do usuário.
145
+ */
146
+ async function handleInstallComponent(args) {
147
+ const { name, type } = InstallComponentSchema.parse(args);
148
+ const diretorioAtual = process.cwd();
149
+
150
+ if (type === 'kit') {
151
+ // --- Instalação de Kit ---
152
+ const kits = await githubService.listarKits();
153
+ const kit = kits.find(k => k.slug === name || k.nome === name);
154
+
155
+ if (!kit) {
156
+ const disponiveis = kits.map(k => k.slug).join(', ');
157
+ throw new Error(`Kit "${name}" não encontrado. Kits disponíveis: ${disponiveis}`);
158
+ }
159
+
160
+ let totalComponentes = 0;
161
+ let totalArquivos = 0;
162
+
163
+ // Instala cada componente do manifesto
164
+ for (const nomeComponente of kit.componentes) {
165
+ const copiados = await baixarEDistribuir(`components/${nomeComponente}`, diretorioAtual);
166
+ totalArquivos += copiados;
167
+ totalComponentes++;
168
+ }
169
+
170
+ // Instala arquivos base do kit
171
+ const copiadosKit = await baixarEDistribuir(kit.caminho, diretorioAtual);
172
+ totalArquivos += copiadosKit;
173
+
174
+ return {
175
+ content: [{
176
+ type: 'text',
177
+ text: JSON.stringify({
178
+ success: true,
179
+ message: `Kit "${kit.nome}" instalado com sucesso.`,
180
+ components_installed: totalComponentes,
181
+ files_copied: totalArquivos,
182
+ }, null, 2),
183
+ }],
184
+ };
185
+
186
+ } else {
187
+ // --- Instalação de Componente Individual ---
188
+ const caminhoRemoto = `components/${name}`;
189
+ const copiados = await baixarEDistribuir(caminhoRemoto, diretorioAtual);
190
+
191
+ return {
192
+ content: [{
193
+ type: 'text',
194
+ text: JSON.stringify({
195
+ success: true,
196
+ message: `Componente "${name}" instalado com sucesso.`,
197
+ files_copied: copiados,
198
+ }, null, 2),
199
+ }],
200
+ };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Handler: generate_scaffold
206
+ * Gera boilerplate de um componente Liquid (section, block, snippet, controller).
207
+ */
208
+ async function handleGenerateScaffold(args) {
209
+ const { name, elementType } = GenerateScaffoldSchema.parse(args);
210
+
211
+ // Normaliza nomes (kebab, Pascal, snake)
212
+ const names = generateNames(name);
213
+
214
+ // Gera o conteúdo via ScaffoldService
215
+ const arquivo = await scaffoldService.generate(elementType, names);
216
+
217
+ // Grava no disco
218
+ const diretorioAtual = process.cwd();
219
+ const destDir = path.join(diretorioAtual, arquivo.outputDir);
220
+ const destPath = path.join(destDir, arquivo.fileName);
221
+ const caminhoRelativo = path.join(arquivo.outputDir, arquivo.fileName);
222
+
223
+ await fs.ensureDir(destDir);
224
+ await fs.writeFile(destPath, arquivo.content, 'utf-8');
225
+
226
+ return {
227
+ content: [{
228
+ type: 'text',
229
+ text: JSON.stringify({
230
+ success: true,
231
+ message: `Scaffold gerado com sucesso: ${caminhoRelativo}`,
232
+ file: caminhoRelativo,
233
+ names: {
234
+ kebab: names.kebabName,
235
+ pascal: names.PascalName,
236
+ snake: names.snake_name,
237
+ },
238
+ }, null, 2),
239
+ }],
240
+ };
241
+ }
242
+
243
+ // ═══════════════════════════════════════════════════════════════
244
+ // Função auxiliar: Download + Distribuição
245
+ // ═══════════════════════════════════════════════════════════════
246
+
247
+ /**
248
+ * Processo genérico: baixa do repositório e distribui nas pastas locais.
249
+ * Reutiliza o mesmo padrão do AddCommand._baixarEDistribuir().
250
+ *
251
+ * @param {string} caminhoRemoto - Caminho no repositório remoto
252
+ * @param {string} diretorioDestino - Diretório raiz do tema Shopify local
253
+ * @returns {Promise<number>} Número de arquivos copiados
254
+ */
255
+ async function baixarEDistribuir(caminhoRemoto, diretorioDestino) {
256
+ const pastaTmp = path.join(os.tmpdir(), `rook-mcp-${Date.now()}`);
257
+
258
+ try {
259
+ await fs.ensureDir(pastaTmp);
260
+ await downloadService.baixar(caminhoRemoto, pastaTmp);
261
+ const totalCopiados = await fileMapper.distribuir(pastaTmp, diretorioDestino);
262
+ return totalCopiados;
263
+ } finally {
264
+ await fs.remove(pastaTmp);
265
+ }
266
+ }
267
+
268
+ // ═══════════════════════════════════════════════════════════════
269
+ // Inicialização do Servidor MCP
270
+ // ═══════════════════════════════════════════════════════════════
271
+
272
+ const server = new Server(
273
+ {
274
+ name: 'rook-cli-server',
275
+ version: '1.0.0',
276
+ },
277
+ {
278
+ capabilities: {
279
+ tools: {},
280
+ },
281
+ }
282
+ );
283
+
284
+ // --- Registrar lista de Tools disponíveis ---
285
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
286
+ return {
287
+ tools: [
288
+ {
289
+ name: 'list_components',
290
+ description:
291
+ 'Lista componentes e kits disponíveis no repositório Rook. ' +
292
+ 'Use para descobrir o que pode ser instalado no tema Shopify do usuário.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ category: {
297
+ type: 'string',
298
+ enum: ['kits', 'components', 'all'],
299
+ description: 'Filtrar por categoria: "kits", "components" ou "all" (padrão).',
300
+ default: 'all',
301
+ },
302
+ },
303
+ },
304
+ },
305
+ {
306
+ name: 'install_component',
307
+ description:
308
+ 'Instala um componente individual ou kit completo no tema Shopify atual. ' +
309
+ 'Os arquivos são automaticamente distribuídos nas pastas corretas (sections/, snippets/, blocks/, assets/).',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ name: {
314
+ type: 'string',
315
+ description: 'Nome/slug do componente ou kit (ex: "whatsapp-float", "starter-base").',
316
+ },
317
+ type: {
318
+ type: 'string',
319
+ enum: ['component', 'kit'],
320
+ description: 'Tipo do pacote: "component" ou "kit".',
321
+ },
322
+ },
323
+ required: ['name', 'type'],
324
+ },
325
+ },
326
+ {
327
+ name: 'generate_scaffold',
328
+ description:
329
+ 'Gera a estrutura inicial (boilerplate) de um novo componente Liquid para Shopify. ' +
330
+ 'Cria o arquivo com template padronizado já preenchido com LiquidDoc e Web Components.',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ name: {
335
+ type: 'string',
336
+ description: 'Nome do novo recurso em qualquer formato (ex: "hero-banner", "HeroBanner"). Será normalizado automaticamente.',
337
+ },
338
+ elementType: {
339
+ type: 'string',
340
+ enum: ['section', 'block', 'snippet', 'controller'],
341
+ description: 'Tipo de recurso Shopify: section, block, snippet ou controller (JS asset).',
342
+ },
343
+ },
344
+ required: ['name', 'elementType'],
345
+ },
346
+ },
347
+ ],
348
+ };
349
+ });
350
+
351
+ // --- Executar Tool solicitada pela IA ---
352
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
353
+ const { name, arguments: args } = request.params;
354
+
355
+ try {
356
+ switch (name) {
357
+ case 'list_components':
358
+ return await handleListComponents(args || {});
359
+
360
+ case 'install_component':
361
+ return await handleInstallComponent(args);
362
+
363
+ case 'generate_scaffold':
364
+ return await handleGenerateScaffold(args);
365
+
366
+ default:
367
+ throw new Error(`Tool desconhecida: "${name}". Tools disponíveis: list_components, install_component, generate_scaffold`);
368
+ }
369
+ } catch (erro) {
370
+ // Retorna erro estruturado para a IA
371
+ return {
372
+ isError: true,
373
+ content: [{
374
+ type: 'text',
375
+ text: JSON.stringify({
376
+ error: true,
377
+ tool: name,
378
+ message: erro.message,
379
+ }, null, 2),
380
+ }],
381
+ };
382
+ }
383
+ });
384
+
385
+ // --- Iniciar transporte Stdio ---
386
+ const transport = new StdioServerTransport();
387
+ await server.connect(transport);
@@ -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
  *
@@ -49,7 +49,7 @@ export class Logger {
49
49
  banner() {
50
50
  console.log('');
51
51
  console.log(pc.bold(pc.white(' ╔══════════════════════════════╗')));
52
- console.log(pc.bold(pc.white(' ║ ♟️ ROOK CLI v1.0.3 ║')));
52
+ console.log(pc.bold(pc.white(' ║ ♟️ ROOK CLI v1.1.1 ║')));
53
53
  console.log(pc.bold(pc.white(' ║ Shopify Component Tool ║')));
54
54
  console.log(pc.bold(pc.white(' ╚══════════════════════════════╝')));
55
55
  console.log('');
@@ -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
+ }