Introducción
Una de las herramientas más poderosas que el ecosistema Angular nos provee como desarrolladores es su CLI. ¿Alguna vez se han preguntado como es que los componentes, servicios y otros se crean cada vez que corremos el comando ng generate
?
La línea de comandos hace uso por detrás de Schematics
, específicamente la colección de schematics por defecto: @schematics/angular
.
En este post, exploraremos algunos conceptos clave cuando trabajamos con schematics, entenderemos sus operaciones básicas y construiremos nuestra propia colección de schematics desde cero usando su propio CLI.
El Tree
Schematics nos permite operar en un sistema de archivos virtual, llamado Tree
. Cuando ejecutemos un schematic podremos preparar una serie de transformaciones a èl (Crear, actualizar o eliminar archivos), y finalmente aplicar (o no) esos cambios.
Creando nuestro primer schematic
Para crear nuestro primer schematic, comenzaros por instalar el CLI de Schematics , el cual nos ayudará a construir nuestra colección.
// instalar CLI
npm install -g @angular-devkit/schematics-cli
//crear colección
schematics blank my-collection // or schematics blank --name=my-collection
El comando schematics
funciona de dos maneras dependiendo de dónde es usado.
Si no estamos en un directorio de un projecto de schematics, creará uno nuevo con la estructura básica e instalará sus dependencias, en caso contrario, añadirá un nuevo schematic
a la colección.
Demos una mirada a la estructura de proyecto.
collection.json
contiene la información de nuestra colección, expone los schematics que contendrá y los enlaza con los métodos apropiados. Hay más configuración que se puede realizar en este archivo, como añadir un alias, pero eso lo veremos más adelante.
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"my-collection": {
"description": "A blank schematic.",
"factory": "./my-collection/index#myCollection"
}
}
}
El schematic por defecto cuando se crea una nueva colección tendrá el mismo nombre que la colección. Un schematic debe exportar al menos una función que nos retorne una regla, de tipo Rule
. Nuestro nuevo schematic tiene una pequeña descripción, y una ruta al archivo que lo contiene, seguido del nombre de la función que usará cuando sea llamado.
// src/my-collection/index.ts
import { Rule, SchematicContext, Tree } from "@angular-devkit/schematics";
export function myCollection(_options: any): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
index.ts
exporta una única función, una función que nos retorna una regla (Rule). Rule
es una función que dados un Tree
y un contexto (SchematicContext
), nos retornará un nuevo Tree
En el ejemplo anterior, ninguna transformación fue aplicada.
Puedes exportar más de una función de un sólo archivo. También puedes elegir exportar una funciòn
default
y omitir la última parte del path al método encollection.json
.
Build y ejecución
Antes de poder ejecutar nuestro schematic tenemos que crear un nuevo build de nuestra colección. Para esto utilizaremos dos comandos:
// build
npm run build
// ejecutar
schematics .:my-collection
Primero, construimos nuestra colección usando npm run build
y luego los ejecutamos usando el CLI de schematics. Le indicamos al CLI que ejecute el schematic my-coleccion
en el directorio de la colección actual(schematics \<ruta\>:\<nombre-schematic\>
).
Recuerda ejecutar un build de tu colección antes de testearla. Mi recomendación es ejecutar el comando con el flag
watch
mientras desarrollamos.(npm run build -- --watch
)
Logs
Hasta el momento nuestro schematic no hace nada. Mostrar información en pantalla puede entregar información valiosa al usuario o ayudarnos mientras depuramos nuestra colleción (debug
). Cambiemos nuestro schematic para que muestre información.
// src/my-collection/index.ts
export function myCollection(_options: any): Rule {
return (tree: Tree, context: SchematicContext) => {
context.logger.info('Info message');
context.logger.warn('Warn message');
context.logger.error('Error message');
return tree;
};
}
Si corremos nuestro schematic ahora(recuerda hacer un build antes), podremos ver nuestros mensajes coloreados según el tipo de log.
Creando archivos
Ahora que sabemos como imprimir información en pantalla, podemos comenzar a modificar nuestro tree. Comenzaremos por crear un nuevo schematic que añadirá un nuevo archivo.
schematics blank create-file
Nuestro nuevo schematic fue añadido a la colección, notarás que un nuevo directorio fue creado y collection.json
fue modificado para incluir este nuevo schematic. Podemos ahora editar nuestro método createFile
para modificar nuestro tree.
// src/create-file/index.ts
import { Rule, SchematicContext, Tree } from "@angular-devkit/schematics";
export function createFile(_options: any): Rule {
return (tree: Tree, _context: SchematicContext) => {
tree.create("test.ts", "File created from schematic!");
return tree;
};
}
Nuestra regla o Rule tomará nuestro tree, añadirá un archivo llamado test.ts
en la raíz de éste, y retornará este nuevo tree modificado.
Corremos el Build y ejecutamos.
schematics .:create-file
¿Éxito? Si miramos dentro de nuestro de nuestro directorio no seremos capaces de encontrar el archivo supuestamente creado. Eso es porque nuestro schematic correrá en modo debug cuando es llamado desde una ruta relativa. Esto significa que no hará modificaciones reales al sistema de archivos. Para aplicar los cambios, debemos agregar la opción --debug=false
al comando. Si lo intentamos nuevamente, test.js
será finalmente creado con el contenido deseado en él. Elimina el archivo antes de continuar.
Si ejecutamos el comando nuevamente, fallará porque
create
no sobreescribirá un archivo que ya existe.
Argumentos y schemas
Nuestro schematic es muy limitado hasta el momento. Siempre creará el mismo archivo sin importar lo que hagamos. ¿No sería mejor si pudieramos pasar ciertos argumentos? Para lograr esto definiremos un schema. Creemos un archivo llamado schema.json
dentro de la carpeta create-file
. También crearemos una interfaz en schema.ts
que equivalga a los argumentos declarados en el archivo .json
{
"$schema": "http://json-schema.org/schema",
"id": "my-collection-create-file",
"title": "Creates a file using the given path",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path of the file to create."
}
},
"required": ["path"]
}
// src/create-file/schema.ts
export interface CreateFileOptions{
path:string;
}
Declaramos un nuevo argumento llamado path
y lo marcamos como required
(requerido) en schema.json
.
Agreguemos el schema y la interfaz a nuestra función y al schema de la colección.
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { CreateFileOptions } from './schema';
export function createFile(options: CreateFileOptions): Rule {
return (tree: Tree, _context: SchematicContext) => {
tree.create(options.path, "File created from schematic!");
return tree;
};
}
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"my-collection": {
"description": "A blank schematic.",
"factory": "./my-collection/index#myCollection"
},
"create-file": {
"description": "A blank schematic.",
"factory": "./create-file/index#createFile",
"schema": "./create-file/schema.json"
}
}
}
Al crear una interfaz hemos agregado inferencia de tipo al argumento options
de nuestra función. La propiedad schema
debe ser agregada a la declaracion del schematic en collection.json
con la ruta al archivo del schema.
Este schema hará que el CLI falle en caso de que el argumento path
nos es indicado.
Hacemos un build y ejecutamos.
schematics .:create-file
Si ejecutamos el schematic sin el argumento path
, fallará.
Ejecutémoslo nuevamente pasando un argumento.
schematics .:create-file --path=test-path.ts
Aunque el schema y la interfaz no son requeridas, nos proveen de validacion y type checking. El comando ng generate
leerá este schema y nos mostrarà los argumentos disponibles cuando lo ejecutemos con el flag --help
.
Consultando al usuario y alias
Puede ser difícil recordar todos los argumentos que un schematic puede recibir. Podemos hacer nuestro schematic más amigable con el usuario preguntándole por los argumentos requeridos. Por otro lado, podemos hacer nuestros comandos mas cortos o legibles añadiéndole un alias a nuestro schematic. Esto lo haremos modificando algunas proiedades de nuestro schema.
Primero, añadiremos la entrada x-prompt
a la propiedad que lo requiere en schema.json
. El usuario será consultado si el argumento no se provee.
{
"$schema": "http://json-schema.org/schema",
"id": "my-collection-create-file",
"title": "Creates a file using the given path",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path of the file to create.",
"x-prompt": "Enter the file path:",
}
},
"required": ["path"]
}
Para crear un alias, agregaremos la propiedad aliases
al schematic en el archivo collection.json
.
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"my-collection": {
"description": "A blank schematic.",
"factory": "./my-collection/index#myCollection"
},
"create-file": {
"description": "A blank schematic.",
"factory": "./create-file/index#createFile",
"schema": "./create-file/schema.json",
"aliases": ["cf"]
}
}
}
Ahora podemos ejecutar nuestro schemtic usando el alias cf
y si llegamos a olvidarnos del argumento path
, se nos requerirá por el CLI.
schematics .:cf
Templates
Pasar un argumento para cambiar el archivo está bien, pero nuestro contenido es siempre el mismo. Pasar el contenido como un argumento no sería práctico ya que podría ser complejo. Afortunadamente, podemos crear templates cuando tengamos que lidiar con este tipo de contenido. Los templates no son mas que archivos o plantillas que pueden ser copiads, movidos y modificados en nuestro tree.
Creemos un nuevo schematic aplicando los conceptos que hemos visto anteriormente.
schematics blank create-from-template
collection.json
(partial)
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"create-from-template": {
"description": "A blank schematic.",
"factory": "./create-from-template/index#createFromTemplate",
"schema": "./create-from-template/schema.json",
"aliases": ["cft"]
}
}
}
// src/create-from-template/schema.ts
export interface CreateFromTemplateOptions {
folder: string;
}
// src/create-from-template/schema.json
{
"$schema": "http://json-schema.org/schema",
"id": "my-collection-create-from-template",
"title": "Creates files in the given folder",
"type": "object",
"properties": {
"folder": {
"type": "string",
"description": "The destination folder of the files to create.",
"x-prompt":"Enter the destination folder:"
}
},
"required": ["folder"]
}
Para crear los templates, haremos un directorio /files
dentro de la carpeta del schematic y ubicaremos los archivos que seran copiados.
Puedes usar cualquier nombre de carpeta, mientras sea ignorado por el compilador.
/files
es ignorado por defecto.
Hemos añadido dos archivos dentro de nuestra carpeta. Ahora podremos hacer uso de ellos dentro de nuestro schematic.
// src/create-from-template/index.ts
import {
Rule,
SchematicContext,
Tree,
Source,
url,
mergeWith,
move,
apply
} from "@angular-devkit/schematics";
import { CreateFromTemplateOptions } from "./schema";
import { normalize } from "@angular-devkit/core";
export function createFromTemplate(options: CreateFromTemplateOptions): Rule {
return (tree: Tree, context: SchematicContext) => {
const source: Source = url("./files");
const transformedSource: Source = apply(source, [
move(normalize(options.folder))
]);
return mergeWith(transformedSource)(tree, context);
};
}
Hay varias cosas ocurriendo aquí. Primero, estamos leyendo desde el directorio de nuestros templates usando la funcion url
que retornará un Source
. Luego aplicamos una serie de reglas a cada uno de los archivos de origen. En este ejemplo estamos moviendo los archivos desde la raìz hasta la carpeta dada como argumento. Finalmente, unimos la fuente modificada con nuestro tree original.
Build y ejecutamos.
Nuestros archivos fueron copiados desde el directorio /files
de nuestro schematic a /my-folder
en nuestro proyecto.
Contenido dinámico
Tomemos un paso hacia a atrás y pensemos en ejemplos reales de schematics. Cuando creamos un componente usando el CLI de Angular, un grupo de archivos y un directorio son creados. Esos archivos cambian su nombre y contenido dependiendo del input del usuario. ¿Cómo podemos conseguir algo similar?
Haremos uso de la función template
que nos provee angular-devkit/schematics
y la aplicaremos a nuestros archivos fuente.
// ...imports
export function createFromTemplate(options: CreateFromTemplateOptions): Rule {
return (tree: Tree, context: SchematicContext) => {
// ...
const transformedSource: Source = apply(source, [
template({
filaname: options.folder,
...strings // dasherize, classify, camelize, etc
}),
move(normalize(folder))
]);
return mergeWith(transformedSource)(tree, context);
};
}
template
tomará un objeto como argumento y permitirá que todas sus propiedades esten disponibles para ser utilizadas por los nombres de archivos y sus contenidos. En este caso estamos pasándole un objecto con el nombre de directorio sin formatear y un set de métodos para transformar strings.
Para probar su funcionamiento crearemos dos archivos.
// files/__filename@dasherize__.ts
export class <%= classify(filename) %> {
constructor(){}
}
<!-- files/__filename@dasherize__.html -->
<ul>
<li><%= dasherize(filename) %></li>
<li><%= camelize(filename) %></li>
<li><%= capitalize(filename) %></li>
<li><%= underscore(filename) %></li>
</ul>
¡Mira esos nombres de archivo!. __
es el delimitador de inicio y fin por defecto, @
pasará el argumento (antes del símbolo) a la función (despues del símbolo). En este ejemplo, el nombre de archivo será paasado como un argumento a la función dasherize
y el valor retornado será seguido de la extension de archivo.
Dentro de nuestro templates, seguiremos usando la funciones para manipular strings y el nombre de archivo
Una vez más, hacemos un build y ejecutamos.
schematics .:cft --folder=very-complexFolder_name
Los nombres de archivo fueron formateados de la forma que queríamos (dasherized o separando palabras con guiones). Revisemos el contenido de los archivos. (Recuerda ejecutar el comado con la opcion --debug=false para poder verlos)
<!-- very-complex-flder-name.html -->
<ul>
<li>very-complex-folder-name</li>
<li>veryComplexFolderName</li>
<li>Very-complexFolder_name</li>
<li>very_complex_folder_name</li>
</ul
// very-complex-folder-name.ts
export class VeryComplexFolderName {
constructor(){}
}
Nuestro schematic comienza a verse mucho más útil!
Eliminando archivos
Siguiente en la lista se encuentra eliminar archivos
Creemos un nuevo schematic.
schematics blank --name=remove-file
collection.json
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"remove-file": {
"description": "Removes a file",
"factory": "./remove-file/index#removeFile",
"schema": "./remove-file/schema.json",
"aliases": ["rm"]
}
}
}
schema.json
{
"$schema": "http://json-schema.org/schema",
"id": "my-collection-remove-file",
"title": "Deletes a file using the given path",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path of the file to remove.",
"x-prompt":"Enter the file path:"
}
},
"required": ["path"]
}
// src/remove-file/schema.ts
export interface RemoveFileOptions {
path: string;
}
// src/remove-file/index.ts
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { RemoveFileOptions } from './schema';
export function removeFile(options: RemoveFileOptions): Rule {
return (tree: Tree, _context: SchematicContext) => {
tree.delete(options.path);
return tree;
};
}
Antes de correr el schematic asegúrate que el archivo a eliminar existe. Recuerda que por defecto estamos ejecutándo en modo debug así que los cambios no se aplicarán realmente.
schematics .:rm --path=src/collection.json
Actualizando archivos
Borrar archivos pareció mucho más simple que crearlos. Dejé la actualización de archivos para el último ya que (en mi opinión) involucra las operaciones más complejas, dependiendo del tipo de archivo y como estamos modificándolos. Puede ser tan simple como añadir unas cuantas líneas de código al inicio (o al final)de un archivo o tan complejo como haciendo uso del AST (abstract syntax tree) de Typescript para determinar donde y como ejecutar la actualización.
Creemos un nuevo schematic.
schematics blank overwrite-file
// src/overwrite-file
export function overwriteFile(options: OverwriteFileOptions): Rule {
return (tree: Tree, _context: SchematicContext) => {
const buffer = tree.read(options.path);
const content = buffer ? buffer.toString() : '';
const comment = `// ¯\_(ツ)_/¯\n`;
if(!content.includes(comment)){
tree.overwrite(options.path, comment + content)
}
return tree;
};
}
Omití la creación del schema y la actualizaciín de la colección ya que es similar a lo que hemos hecho con el resto.
Nuestra función lee un archivo desde la ruta dada por el usuario. Convierte ese buffer a un string, chequea si un comentario ya fue añadido al archivo y lo agrega al inicio del archivo si no está presente. Luego sobreescribimos el archivo con el contenido actualizado. Podrímaos hacer otro tipo de verificaoines para evitar, por ejemplo, agregar un comentario a un archivo .json
, invalidándolo, pero esta fuera de alcance de este tutorial.
Esta no es la unica forma de actualizar un archivo.
schematics blank update-recorder
export function updateRecorder(options: RecorderOptions): Rule {
return (tree: Tree, _context: SchematicContext) => {
const comment = '// ᕙ(⇀‸↼‶)ᕗ\n';
const updateRecorder: UpdateRecorder = tree.beginUpdate(options.path);
updateRecorder.insertLeft(0, comment);
updateRecorder.insertLeft(0, comment);
updateRecorder.insertLeft(0, comment);
updateRecorder.insertLeft(0, comment);
tree.commitUpdate(updateRecorder);
return tree;
};
}
Esto es similar a lo que hicimos en el schematic anterior, pero funciona un poco diferente. Primero, leemos nuestro archivo y luego comenzamos a insertar valores (string
o Buffer
) a la izquierda o derecha de una posición dada. Los cambios son aplicados al tree sólo después de llamar a la funcion commitUpdate
.
La parte interesante es la posición para insertar los valores y cómo determinaremos dónde queremos hacer modificaciones. En este ejemplo, la posición no importa mucho ya que estamos insertando al inicio del contenido, pero ahora veremos un escenario más complejo. No olvidemos de hacer el build y ejecutar!
Usando el AST de Typescript
Digamos que queremos leer un archivo Typescript, deseamos encontrar la primera interfaz declarada en él y agregar una propiedad al principio y otra al final. Podríamos tratar de leer todo como texto y encontrar los caracteres apropiados o, mejor aún, podríamos usar el AST de Typescript para navegar nuestros archivos, sin pensar en caracteres, sino en nodos que tienen un significado.
schematics blank ts-ast
import { Rule, SchematicContext, Tree, SchematicsException } from '@angular-devkit/schematics';
import * as ts from 'typescript';
export function tsAst(options: TsAstOptions): Rule {
return (tree: Tree, _context: SchematicContext) => {
const buffer = tree.read(options.path);
if(!buffer){
throw new SchematicsException(`File ${options.path} not found.`);
}
const source = ts.createSourceFile(options.path, buffer.toString(), ts.ScriptTarget.Latest, true);
const nodes = getSourceNodes(source);
const interfaceDeclaration = nodes.find(n=>n.kind === ts.SyntaxKind.InterfaceDeclaration);
if(!interfaceDeclaration){
throw new SchematicsException(`No Interface found`);
}
const [openBrace, closeBrace] = [
interfaceDeclaration!.getChildren().find(n=>n.kind===ts.SyntaxKind.OpenBraceToken),
interfaceDeclaration!.getChildren().slice().reverse().find(n=>n.kind===ts.SyntaxKind.CloseBraceToken),
]
const text = interfaceDeclaration!.getText();
let indentation;
const matches = text.match(/\r?\n\s*/);
if (matches && matches.length > 0) {
indentation = matches[0]
} else {
indentation= ''
}
const recorder = tree.beginUpdate(options.path);
recorder.insertRight(openBrace!.end, `${indentation}first: string;`);
recorder.insertLeft(closeBrace!.pos, `${indentation}last: string;`);
tree.commitUpdate(recorder);
return tree;
};
}
// tomado de los schematics de angular. Retorna un array de Nodes
function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] {
const nodes: ts.Node[] = [sourceFile];
const result = [];
while (nodes.length > 0) {
const node = nodes.shift();
if (node) {
result.push(node);
if (node.getChildCount(sourceFile) >= 0) {
nodes.unshift(...node.getChildren());
}
}
}
return result;
}
Vayamos paso a paso a través de este archivo.
Primero nos aseguramos que el archivo existe. En caso contrario, arrojaremos un error. Luego, usando el compilador de typescript, leemos el archivo y obtenemos todos los nodos del AST. Luego buscaremos el primer nodo de tipo InterfaceDeclaration
. La lista de tipos de nodods es muy extensa. Éste tipo en particular retornara la declaración completa de la interfaz hasta la llave de cierre. Con cada nodo, obtendremos la posición inicial y final. (¿Recuerdan los métodos insertLeft
e insertRight
?)
Aún no llegamos donde queremos llegar, podemos obtener más información de este nodo aislado. Comenzaremos por obtener todos sus nodos hijo, y buscaremos la primera ocurrencia de una llave de apertura ({
) y la ultima llave de cierre (}
). Estos nodos también contienen una posición de inicio y fin.
El espacio en blanco no tiene 'significado' en nuestro árbol sintáctico, así que tomaremos una estrategia diferente. Leeremos el texto de nuestra declaración de interfaz y obtendremos el epacio en blanco para poder determinar la indentación. Esto es sólo por motivos estéticos, ya que una nueva linea bastaría.
Ahora es momento de comenzar nuestra modificación, insertaremos a la derecha de nuestra llave de apertura y a la izquierda de nuestra llave de cierre. Recordemos que nada ha cambiado hasta que llamemos el método commitUpdate
. Aunque hayamos insertado algo después de la llave inicial, la posición de la llave final sigue siendo la misma que antes, y podemos insertar con seguridad a la izquierda de ésta.
Crearemos un archivo de prueba y correremos nuestro schematic. (No olvidar el build
))
interface TestInterface {
aProperty: string;
}
Luego de correr nuestro schematic deberíamos obtener un archivo similar a este:
interface TestInterface {
first:string;
aProperty: string;
last:string;
}
!Èxito!
Palabras finales
Aunque los schematics son ampliamente usados en el ecosistema de Angular, no están limitados a éste. De hecho, todos lo ejemplos que hemos hecho hasta ahora han sido usados fuera de un proyecto de Angular.
En la próxima parte de esta serie, aprenderemos acerca de las tareas (tasks) y cómo testear schematics, extenderlos y correrlos en secuencia.
En la parte 3, crearemos un ejemplo 'real' de una colección de schematics que añadirán TailwindCSS a un proyecto Angular.
Pueden encontrar el código final en este repositorio
Referencias
Artículos/Libros relacionados
- Schematics: Generating custom Angular Code with the CLI
- https://blog.angular.io/schematics-an-introduction-dc1dfbc2a2b2
- https://medium.com/@tomastrajan/total-guide-to-custom-angular-schematics-5c50cf90cdb4
- https://brianflove.com/2018/12/11/angular-schematics-tutorial/
El post original se encuentra aquí
Este artículo fue escrito por Ignacio Falk, software engineer en This Dot.
Pueden seguirlo en Twitter como @flakolefluk.
¿Necesita consultoría, mentores o entrenamiento en JavaScript? Revise nuestra lista de servicios en This Dot Labs.
Top comments (1)
Gran artículo Ignacio, felicitaciones.