Dialecto Angular
@pbuilder/sdk/angular edita los archivos .ts de un proyecto Angular. Su handle tiene todas
las ops de @pbuilder/sdk/typescript más ops para componentes standalone, los providers raíz
de la aplicación, rutas, la metadata de @NgModule y miembros de clase como signals y
dependencias inyectadas. Todas las ops se encadenan sobre un handle y el archivo se escribe una
sola vez; ver Modify para cómo
funciona un handle.
Cada op encuentra su destino a partir del código del archivo, sin importar cómo se llame el
archivo, así que las ops funcionan con los dos estilos de nombres: app.ts y
app.component.ts.
import * as angular from "@pbuilder/sdk/angular";
export const run = async () => { await angular .find("src/app/app.config.ts") .addRootProvider({ expression: "provideHttpClient(withFetch())" }, [ { name: "provideHttpClient", from: "@angular/common/http" }, { name: "withFetch", from: "@angular/common/http" }, ]);};// antesimport { ApplicationConfig } from "@angular/core";import { provideRouter } from "@angular/router";import { routes } from "./app.routes";
export const appConfig: ApplicationConfig = { providers: [provideRouter(routes)],};
// despuésimport { ApplicationConfig } from "@angular/core";import { provideRouter } from "@angular/router";import { routes } from "./app.routes";import { provideHttpClient, withFetch } from "@angular/common/http";
export const appConfig: ApplicationConfig = { providers: [provideRouter(routes), provideHttpClient(withFetch())],};Ops heredadas de TypeScript
Sección titulada «Ops heredadas de TypeScript»El handle tiene todas las ops del dialecto TypeScript: addImport,
addDefaultImport, addNamespaceImport, addTypeImport, addSideEffectImport,
addReExport, addExportAll, addFunction, addVariable, addClass, removeImport,
removeExport, removeExportAll, addArrayElement y addClassProperty. Una op que el
dialecto TypeScript sume más adelante también queda disponible aquí.
El código y sus imports
Sección titulada «El código y sus imports»Las ops de componente, de providers raíz y de NgModule reciben (element, imports?).
elementes un identificador como"RouterOutlet", o{ expression }para código como{ expression: "provideHttpClient(withFetch())" }. Una expresión debe parsearse como exactamente una expresión. Fuera de eso, la op la escribe tal como se pasa.importslista los imports con nombre{ name, from }que el elemento necesita. Cada uno se agrega con las mismas reglas que eladdImportde TypeScript: un import existente denamedesdefromse reutiliza, y unnameque ya está ligado de otra forma en el archivo se rechaza.
Si la lista ya tiene el elemento, la op lo deja como está. Los elementos se comparan token por token, así que los espacios y los comentarios no cuentan.
Ops de componente
Sección titulada «Ops de componente»| Op | Agrega a |
|---|---|
addComponentImport(element, imports?) |
imports del único @Component del archivo |
addComponentProvider(element, imports?) |
providers del único @Component del archivo |
Las dos ops crean el objeto de metadata o el array cuando falta.
await angular .find("src/app/app.ts") .addComponentImport("RouterOutlet", [{ name: "RouterOutlet", from: "@angular/router" }]);// antesimport { Component } from "@angular/core";
@Component({ selector: "app-root", imports: [], templateUrl: "./app.html",})export class App {}
// despuésimport { Component } from "@angular/core";import { RouterOutlet } from "@angular/router";
@Component({ selector: "app-root", imports: [RouterOutlet], templateUrl: "./app.html",})export class App {}Providers raíz
Sección titulada «Providers raíz»addRootProvider(element, imports?) agrega al final de los providers raíz de la
aplicación. Los busca en este orden:
- Los
providersde la única variable de nivel superior del archivo con tipoApplicationConfig, por anotación (const appConfig: ApplicationConfig = …) osatisfies, sin importar su nombre. - Los
providersdel objeto de configuración que recibe la única llamada abootstrapApplication(...)del archivo. Si la llamada todavía no tiene configuración, la op la crea.
// src/main.ts antesimport { bootstrapApplication } from "@angular/platform-browser";import { App } from "./app/app";
bootstrapApplication(App);
// después de addRootProvider({ expression: "provideHttpClient()" }, [...])import { bootstrapApplication } from "@angular/platform-browser";import { App } from "./app/app";import { provideHttpClient } from "@angular/common/http";
bootstrapApplication(App, { providers: [provideHttpClient()] });Cuando bootstrapApplication recibe la configuración por nombre, como un appConfig
importado desde ./app/app.config, la op se rechaza y su mensaje nombra ese módulo. Corre la
op sobre ese archivo.
| Op | Agrega |
|---|---|
addRoute({ path, component, from }) |
Una ruta eager. Importa component desde from. |
addLazyRoute({ path, loadComponent | loadChildren, export? }) |
Una ruta lazy que carga el módulo indicado por loadComponent o loadChildren (exactamente uno de los dos). Con export, la ruta lee ese export con nombre; sin él, el export default del módulo. No agrega ningún import. |
Las dos ops agregan a la única variable de nivel superior del archivo con tipo Routes o
Route[], por anotación o satisfies. Angular usa la primera ruta que coincide, así que una
ruta nueva va antes de una ruta { path: "**" } de nivel superior cuando existe, y al final
en caso contrario.
await angular .find("src/app/app.routes.ts") .addRoute({ path: "about", component: "About", from: "./about/about" }) .addLazyRoute({ path: "settings", loadComponent: "./settings/settings", export: "Settings" }) .addLazyRoute({ path: "admin", loadChildren: "./admin/admin.routes" });// antesimport { Routes } from "@angular/router";import { Home } from "./home/home";
export const routes: Routes = [ { path: "", component: Home }, { path: "**", redirectTo: "" },];
// despuésimport { Routes } from "@angular/router";import { Home } from "./home/home";import { About } from "./about/about";
export const routes: Routes = [ { path: "", component: Home }, { path: "about", component: About }, { path: "settings", loadComponent: () => import("./settings/settings").then((m) => m.Settings) }, { path: "admin", loadChildren: () => import("./admin/admin.routes") }, { path: "**", redirectTo: "" },];Una ruta igual se deja como está. Una ruta distinta con el mismo path se rechaza, así que un
schematic que corre dos veces con otras entradas nunca agrega una segunda.
Rutas en una app con NgModule
Sección titulada «Rutas en una app con NgModule»En un archivo de routing de NgModule sin variable Routes, las ops agregan a las rutas de la
única llamada a RouterModule.forRoot(...) o RouterModule.forChild(...): su array inline, o
una variable del mismo archivo que la llamada nombra.
await angular .find("src/app/app-routing.module.ts") .addRoute({ path: "about", component: "About", from: "./about/about" });// antesimport { NgModule } from "@angular/core";import { RouterModule, Routes } from "@angular/router";import { Home } from "./home/home";
const routes = [ { path: "", component: Home }, { path: "**", redirectTo: "" },];
@NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule],})export class AppRoutingModule {}
// despuésimport { NgModule } from "@angular/core";import { RouterModule, Routes } from "@angular/router";import { Home } from "./home/home";import { About } from "./about/about";
const routes = [ { path: "", component: Home }, { path: "about", component: About }, { path: "**", redirectTo: "" },];
@NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule],})export class AppRoutingModule {}Las ops reconocen RouterModule por ese nombre. Un import con alias (RouterModule as RM) no
se encuentra, y la op se rechaza.
Limitación conocida: comparación de rutas
Sección titulada «Limitación conocida: comparación de rutas»Las rutas se comparan token por token, y las ops escriben comillas dobles. Una ruta escrita
como { path: 'about', component: About } es una ruta distinta de la que escribe addRoute,
así que volver a agregarla se rechaza porque el path ya está ocupado.
NgModule
Sección titulada «NgModule»Angular recomienda componentes standalone para el código nuevo. Estas ops mantienen
aplicaciones que todavía usan @NgModule. Cada una recibe (element, imports?), igual que las
ops de componente, y agrega al final de un array del único @NgModule del archivo, creando el
objeto de metadata o el array cuando falta.
| Op | Agrega a |
|---|---|
addNgModuleImport(element, imports?) |
imports, como otros NgModules o StoreModule.forRoot({}) |
addDeclaration(element, imports?) |
declarations. Angular exige que esos componentes, directivas y pipes sean standalone: false. |
addNgModuleProvider(element, imports?) |
providers |
addNgModuleExport(element, imports?) |
exports |
addBootstrap(element, imports?) |
bootstrap |
Los nombres evitan addImport, addProvider y addExport porque addImport ya es la op
heredada de TypeScript que agrega una sentencia import.
await angular .find("src/app/app.module.ts") .addDeclaration("UserCard", [{ name: "UserCard", from: "./user-card" }]);// antesimport { NgModule } from "@angular/core";import { BrowserModule } from "@angular/platform-browser";import { App } from "./app";
@NgModule({ declarations: [App], imports: [BrowserModule], bootstrap: [App] })export class AppModule {}
// despuésimport { NgModule } from "@angular/core";import { BrowserModule } from "@angular/platform-browser";import { App } from "./app";import { UserCard } from "./user-card";
@NgModule({ declarations: [App, UserCard], imports: [BrowserModule], bootstrap: [App] })export class AppModule {}Signals e inyección
Sección titulada «Signals e inyección»Estas ops agregan una propiedad de clase mediante el addClassProperty de TypeScript e
importan desde @angular/core la función de Angular que esa propiedad llama. La propiedad va
después de la última propiedad de la clase, lo que mantiene inputs, outputs y dependencias
inyectadas por encima del constructor y de los métodos, como pide la guía de estilo de
Angular. Las ops encuentran la clase por su decorador, así que no reciben un nombre de clase.
Si la clase ya tiene la misma propiedad, la op no hace nada. Otro miembro con el mismo nombre
se rechaza.
| Op | Agrega | Clase |
|---|---|---|
addInjection(name, token, options?) |
private readonly name = inject(token); |
@Component, @Directive, @Pipe, @Injectable o @Service |
addInput(name, options?) |
readonly name = input<type>(initial);, o input.required<type>() con required: true |
@Component o @Directive |
addModel(name, options?) |
readonly name = model<type>(initial);, para un binding bidireccional |
@Component o @Directive |
addOutput(name, options?) |
readonly name = output<type>(); |
@Component o @Directive |
addSignal(name, initial, options?) |
protected readonly name = signal<type>(initial); |
@Component o @Directive |
addComputed(name, expression, options?) |
protected readonly name = computed<type>(() => expression); |
@Component o @Directive |
Los modificadores siguen la guía de estilo de Angular: las propiedades input, model y
output son readonly, y las propiedades signal y computed son protected readonly
porque las lee el template. El archivo debe tener exactamente una clase a la que apunte la op.
Las opciones:
typees el argumento genérico y debe parsearse como exactamente un tipo.initiales el valor y debe parsearse como exactamente una expresión.addInputyaddModelrechazanrequired: truejunto coninitial.scopereemplaza aprivateenaddInjectiony aprotectedenaddSignalyaddComputed.importslista los imports{ name, from }que esos valores necesitan, como en El código y sus imports.- En
addInjection,tokenes un identificador o{ expression }. - En
addComputed, una expresión que es un object literal se envuelve entre paréntesis para que la arrow function la devuelva.
await angular .find("src/app/user-card.ts") .addInput("user", { type: "User", required: true, imports: [{ name: "User", from: "./user" }] }) .addInput("size", { type: '"sm" | "lg"', initial: '"sm"' }) .addModel("expanded", { type: "boolean", initial: "false" }) .addOutput("selected", { type: "User" });// antesimport { Component } from "@angular/core";
@Component({ selector: "app-user-card", template: "" })export class UserCard {}
// despuésimport { Component, input, model, output } from "@angular/core";import { User } from "./user";
@Component({ selector: "app-user-card", template: "" })export class UserCard { readonly user = input.required<User>(); readonly size = input<"sm" | "lg">("sm"); readonly expanded = model<boolean>(false); readonly selected = output<User>();}await angular .find("src/app/counter.ts") .addSignal("count", "0") .addComputed("double", "this.count() * 2");// -> protected readonly count = signal(0);// -> protected readonly double = computed(() => this.count() * 2);await angular .find("src/app/users.ts") .addInjection("http", "HttpClient", { imports: [{ name: "HttpClient", from: "@angular/common/http" }] });// antesimport { Injectable } from "@angular/core";
@Injectable({ providedIn: "root" })export class Users { constructor() {}}
// despuésimport { Injectable, inject } from "@angular/core";import { HttpClient } from "@angular/common/http";
@Injectable({ providedIn: "root" })export class Users { private readonly http = inject(HttpClient); constructor() {}}Preservación del formato
Sección titulada «Preservación del formato»Una entrada nueva sigue el formato de la lista a la que se suma. Una lista de una línea queda en una línea, una lista multilínea recibe una línea nueva con la indentación de su última entrada, y una coma final se mantiene. Todo lo que está fuera del texto insertado queda idéntico byte a byte.
Rechazos
Sección titulada «Rechazos»Una op se rechaza, y el archivo queda sin cambios, cuando:
- el archivo no tiene ningún
@Component,@NgModule, clase decorada, variableApplicationConfig, llamada abootstrapApplicationo rutas para la op, o tiene más de uno; - la clase ya tiene un miembro distinto con el nombre de la propiedad;
- la metadata, la configuración o la lista de rutas no es un literal (por ejemplo
providers: PROVIDERS); bootstrapApplicationrecibe su configuración por nombre;- el
pathde una ruta empieza con/, o una ruta distinta ya usa esepath; - un
element, unnamede import, uncomponent, unexport, untypeo uninitialno es válido, orequiredviene junto coninitial.
Igual que el dialecto TypeScript, find(path) acepta cualquier ruta .ts. Un archivo sin el
destino solo falla cuando corre una de estas ops.
Ediciones a medida con .modify()
Sección titulada «Ediciones a medida con .modify()»Usa .modify(fn) para las ediciones que estas ops no cubren, como una ruta hija o
viewProviders, y para los archivos que las ops rechazan. El callback recibe el SourceFile
vivo de ts-morph, y el export astLibrary del módulo es ts-morph:
import * as angular from "@pbuilder/sdk/angular";
await angular .find("src/app/user-card.ts") .addImport("CardState", "./card-state") .modify((ast) => { const viewProviders = ast .getClassOrThrow("UserCard") .getDecoratorOrThrow("Component") .getArguments()[0]! .asKindOrThrow(angular.astLibrary.SyntaxKind.ObjectLiteralExpression) .getPropertyOrThrow("viewProviders") .asKindOrThrow(angular.astLibrary.SyntaxKind.PropertyAssignment) .getInitializerIfKindOrThrow(angular.astLibrary.SyntaxKind.ArrayLiteralExpression); viewProviders.addElement("CardState"); });Próximos pasos
Sección titulada «Próximos pasos»- Modify: los dialectos TypeScript y React y cómo funciona un handle.
- Dialecto NestJS: el mismo enfoque para un
@Modulede NestJS. - Librerías AST: qué librería y qué versión respalda cada dialecto.