Crea un generador de proyectos con React 🚀

En este pequeño tutorial crearemos un CLI el cual nos permita crear proyectos en la ruta donde estemos.
Para realizar esto usaremos una base de plantillas y un archivo de configuración.
Una de las cosas interesantes es que usaremos React para definir opciones mas dinámicas y para ellos nos estaremos apoyando de la librería React Ink. Comencemos! 😁

Configurando el proyecto

Primero se instalará las siguientes dependencias.

# dependencias
$ yarn add ink ink-select-input ink-spinner ink-text-input react yaml fs-extra @babel/runtime

# dependencias de desarrollo
$ yarn add @babel/cli @babel/core @babel/node @babel/preset-env @babel/preset-react @babel/plugin-transform-runtime babel-loader nodemon --dev

Una vez instalado, añadimos en el archivo package.json los siguientes scripts, para poder usar en desarrollo y para generar nuestro código listo para producción.

{
  "scripts": {
    "build": "babel src -d dist",
    "dev": "nodemon --no-stdin --exec babel-node src/index.js",
    "start": "node ./dist/index.js"
  }
}

Y ahora que sigue? Creamos un archivo .babelrc donde solo añadiremos la configuración de los presets y plugins necesarios.

{
  "presets": ["@babel/preset-env", "@babel/preset-react"],
  "plugins": ["@babel/plugin-transform-runtime"]
}

Estructurando los archivos

La estructura final quedaría de la siguiente forma la cual veremos para que sirve cada uno de los archivos.

src

Aquí irá nuestro código para crear el CLI 👋.

templates.generator.yaml

Archivo de configuración para definir nuestros proyectos que podremos generar. Como se puede ver en la imagen también existe una carpeta templates.generator la cual contiene el mismo nombre que el archivo yaml. Aquí se encontrará nuestros proyectos base. Por ejemplo:

version: 1.0
templates:
 - name: angular project
   path: /angular 
 - name: react project
   path: /react
 - name: vue project
   path: /vue

Aquí tendríamos una lista de plantillas, cada uno con su nombre y la ruta donde se encuentra, no es necesario añadir la carpeta de templates.generator ya que automaticamente lo detectaría.

Dentro de la carpeta se tendría la siguiente estructura:

templates.generator
  ├── angular
  ├── react
  └── vue

Creando el CLI

Creando las constantes necesarios

Usaremos 4 constantes principales:

  • currentDirectory: para ubicarnos en el directorio actual.
  • templateDirectory: Directorio donde se tendrá las plantillas.
  • templateName: Nombre del archivo de configuración.
  • STEPS: Pasos que se irán mostrando en el CLI.
//src/constants.js

export const currentDirectory = process.cwd();
export const templateDirectory = "templates.generator"
export const templateName = `${templateDirectory}.yaml`

export const STEPS = {
    "NAME" : 1,
    "SELECT" : 2,
    "LOADING" : 3,
    "END" : 4
}

Definiendo funciones principales

Usaremos 3 funciones principales, para obtener el archivo de configuración YAML como json, formatear el json con rutas absolutas y la última para copiar una carpeta o archivo a otro directorio.

//src/utils.js

import { currentDirectory, templateDirectory, templateName } from "./constants";
import fs from "fs";
import Yaml from "yaml";
import path from "path";
import fsExtra from "fs-extra"

export async function getTemplateGenerator() {
  const file = fs.readFileSync(
    path.join(currentDirectory, templateName),
    "utf8"
  );
  const parseFile = Yaml.parse(file);
  return formatPathsInTemplate(parseFile);
}

export function formatPathsInTemplate(json) {
  const generator = { ...json };
  generator.templates = generator.templates.map((template) => {
    return {
      ...template,
      path: path.join(currentDirectory,templateDirectory, template.path),
    };
  });
  return generator.templates;
}


export function copyTemplateToCurrentDirectory({from,to}) {
  return fsExtra.copy(from,path.join(currentDirectory,to))
}

Creando al archivo principal

Por el momento solo crearemos un simple mensaje para poder ver su uso.

//src/index.js

import React from "react";
import { render, Box, Text } from "ink";

const App = () => {
  return(
    <Box>
     <Text>Hello world</Text>
    </Box>
  )
}

render(<App/>)

Si ahora ejecutamos el script yarn dev verémos en consola lo siguiente:

$ Hello world

Definiendo el state

Creamos un estado inicial para los siguientes casos: el paso en el que se encuentra, la lista de plantillas y el directorio en donde se creará el proyecto.

//src/core/state.js
import { STEPS } from "../constants";

export const state = {
    step : STEPS.NAME,
    templates: [],
    directory: '.'
}

Añadiendo el reducer

//src/core/reducer.js

export const ACTIONS = {
  SET_TEMPLATES: "SET_TEMPLATES",
  SET_STEP: "SET_STEP",
  SET_NAME_DIRECTORY: "SET_NAME_DIRECTORY",
};

export function reducer(state, action) {
  switch (action.type) {
    case ACTIONS.SET_TEMPLATES:
      return {
        ...state,
        templates: action.payload,
      };
    case ACTIONS.SET_STEP:
      return {
        ...state,
        step: action.payload,
      };
    case ACTIONS.SET_NAME_DIRECTORY: 
    return {
        ...state,
        directory: action.payload
    }
    default:
      return state;
  }
}

Creando el hook useGenerator

Y ahora creamos el hook en el cuál estaremos encapsulando la lógica necesaria para generar proyectos, leer la lista de opciones que tenemos del archivo YAML y movernos a los siguientes o anteriores pasos.

//src/useGenerator.js

import { useReducer } from "react";
import { STEPS } from "./constants";
import { ACTIONS, reducer } from "./core/reducer";
import { state as initialState } from "./core/state";
import { copyTemplateToCurrentDirectory } from "./utils";

export default function useGenerator() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const setDirectory = (payload) => {
    dispatch({
      type: ACTIONS.SET_NAME_DIRECTORY,
      payload,
    });
  };

  const setStep = (payload) => {
    dispatch({
      type: ACTIONS.SET_STEP,
      payload,
    });
  };

  const setTemplates = (payload) => {
    dispatch({
      type: ACTIONS.SET_TEMPLATES,
      payload,
    });
  };

  const onSelectTemplate = async ({value}) => {
    try {
        setStep(STEPS.LOADING);
        await copyTemplateToCurrentDirectory({
          from: value,
          to: state.directory,
        });
        setStep(STEPS.END);
        process.exit();
      } catch (error) {
        console.log(error.message);
      }
  }

  const onCompleteTypingDirectory = () => {
    setStep(STEPS.SELECT);
  }

  return {
    onSelectTemplate,
    onCompleteTypingDirectory,
    state,
    setTemplates,
    setDirectory,
    setStep,
    dispatch
  };
}

Redefiniendo el componente principal

Es momento de actualizar el archivo donde se encontraba nuestro componente añadiendo los pasos y nuevos componentes creados con esta librería. Nos apoyaremos de 3 principales:

Importando lo necesario

Inicialmente importaremos todo lo que usaremos para crear el CLI.

//src/index.js
import React, { useEffect, useMemo } from "react";
import { render, Box, Text } from "ink";
import Select from "ink-select-input";
import Loading from "ink-spinner";
import { getTemplateGenerator } from "./utils";
import { STEPS } from "./constants";
import Input from "ink-text-input";
import useGenerator from "./useGenerator";
//...

Integrando el hook useGenerator

Primero daremos un formato a la lista de opciones para que el componente Select pueda aceptarlo. Asímismo vamos a traer la lista de las plantillas para poder elejir la que se requiera.

const App = () => {
  const {
    state,
    setTemplates,
    setDirectory,
    onCompleteTypingDirectory,
    onSelectTemplate,
  } = useGenerator();

  const templateItems = useMemo(
    () =>
      state.templates.map((template) => {
        return {
          label: template.name,
          value: template.path,
        };
      }),
    [state.templates]
  );

  useEffect(() => {
    getTemplateGenerator().then(setTemplates);
  }, []);

  return(
    <Box>
      <Text>hello</Text>
    </Box>
  )
}

Añadiendo los componentes con las interacciones

Finalmente añadimos los componentes usando el hook y los datos necesarios para mostrar cada paso y generar un proyecto.

const App = () => {
  /// ... 
  return (
    <Box>
      {state.step === STEPS.NAME && (
        <Box>
          <Text color="cyanBright">Name directory:</Text>
          <Input
            value={state.directory}
            onChange={setDirectory}
            onSubmit={onCompleteTypingDirectory}
          />
        </Box>
      )}
      {state.step === STEPS.SELECT && (
        <Box flexDirection="column">
          <Box marginTop={1}>
            <Text color="cyanBright">Select a template</Text>
          </Box>
          <Select  items={templateItems} onSelect={onSelectTemplate} />
        </Box>
      )}
      {state.step === STEPS.LOADING && (
        <Box>
          <Text color="yellowBright">
            <Loading type="dots" />
            <Loading type="dots" />
            <Loading type="dots" />
          </Text>
          <Text color="yellow">Creando proyecto...</Text>
        </Box>
      )}
      {state.step === STEPS.END && (
        <Box paddingY={2}>
          <Text color="rgb(50,220,230)">
          ====================== ✨ Proyecto creado!!! ✨ ======================
          </Text>
        </Box>
      )}
    </Box>
  );
};

render(<App />);

Uso final

Para este caso ejecutaremos el siguiente script yarn build y después yarn start para poder ver el funcionamiento. Y listo, lo logramos!! 😄🎉🎉.

En caso de querer más detalles te dejo el link del repositorio y el link de la librería 😊.

36