rook-cli 1.0.3 → 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.3",
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 () => {
@@ -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
+ }
@@ -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
  *
@@ -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
+ }