mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-15 09:34:19 +08:00
fix(angular): ng add @ionic/angular in standalone projects (#28523)
Issue number: Resolves #28514 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> When using the `@ionic/angular` schematic in an Angular 17 project (`ng add @ionic/angular`), developers will receive an error preventing the schematic from running. Additionally, the previous implementations of the schematic are out of sync with the current state of the Ionic starters: - `variables.css` is empty and missing Ionic's defaults - `ionic.config.json` is not created - Schematic does not have support for module vs. standalone projects. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - `ng add @ionic/angular` works with Angular 17 projects - `ng add @ionic/angular` has fallback behavior for Angular 16 projects using `AppModule` - Schematics now includes the proper `variables.css` from Ionic starters - Ionicons assets will no longer be copied when being added to a standalone project - Refactors a majority of the implementation to use the utilities that come directly from `@angular-devkit/schematics` and `@schematics/angular`. - Sets the `@ionic/angular-toolkit` CLI configuration and schematics configuration in the `angular.json` - Creates missing `ionic.config.json` ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Dev-build: `7.5.5-dev.11700239837.1925bbdb` To test this PR: 1. Install Angular CLI v17 - `npm install -g @angular/cli@17` 2. Create a new project - `ng new angular-17` 3. Use the dev-build: - `ng add @ionic/angular@7.5.5-dev.11700239837.1925bbdb` 4. Confirm the prompts 5. Validate that `provideIonicAngular({})` is added to the `app.config.ts` 6. Validate that `ionic.config.json` was created 7. Validate that `angular.json` was updated with the `@ionic/angular-devkit` configurations Now verify legacy behavior: 1. Install Angular CLI v16 - `npm install -g @angular/cli@16` 2. Create a new project - `ng new angular-16` 3. Use the dev-build - `ng add @ionic/angular@7.5.5-dev.11700239837.1925bbdb` 4. Confirm the prompts 5. Validate that `IonicModule.forRoot({})` is added to the `app.module.ts` 8. Validate the ionicons glob pattern is added to the `angular.json` 9. Validate the `ionic.config.json` was created 10. Validate the `angular.json` was updated with the `@ionic/angular-devkit` configurations
This commit is contained in:
@ -4,3 +4,244 @@
|
||||
|
||||
/* To quickly generate your own theme, check out the color generator */
|
||||
/* https://ionicframework.com/docs/theming/color-generator */
|
||||
|
||||
/** Ionic CSS Variables **/
|
||||
:root {
|
||||
/** primary **/
|
||||
--ion-color-primary: #3880ff;
|
||||
--ion-color-primary-rgb: 56, 128, 255;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #3171e0;
|
||||
--ion-color-primary-tint: #4c8dff;
|
||||
|
||||
/** secondary **/
|
||||
--ion-color-secondary: #3dc2ff;
|
||||
--ion-color-secondary-rgb: 61, 194, 255;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #36abe0;
|
||||
--ion-color-secondary-tint: #50c8ff;
|
||||
|
||||
/** tertiary **/
|
||||
--ion-color-tertiary: #5260ff;
|
||||
--ion-color-tertiary-rgb: 82, 96, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #4854e0;
|
||||
--ion-color-tertiary-tint: #6370ff;
|
||||
|
||||
/** success **/
|
||||
--ion-color-success: #2dd36f;
|
||||
--ion-color-success-rgb: 45, 211, 111;
|
||||
--ion-color-success-contrast: #ffffff;
|
||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-success-shade: #28ba62;
|
||||
--ion-color-success-tint: #42d77d;
|
||||
|
||||
/** warning **/
|
||||
--ion-color-warning: #ffc409;
|
||||
--ion-color-warning-rgb: 255, 196, 9;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0ac08;
|
||||
--ion-color-warning-tint: #ffca22;
|
||||
|
||||
/** danger **/
|
||||
--ion-color-danger: #eb445a;
|
||||
--ion-color-danger-rgb: 235, 68, 90;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #cf3c4f;
|
||||
--ion-color-danger-tint: #ed576b;
|
||||
|
||||
/** dark **/
|
||||
--ion-color-dark: #222428;
|
||||
--ion-color-dark-rgb: 34, 36, 40;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-dark-shade: #1e2023;
|
||||
--ion-color-dark-tint: #383a3e;
|
||||
|
||||
/** medium **/
|
||||
--ion-color-medium: #92949c;
|
||||
--ion-color-medium-rgb: 146, 148, 156;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-medium-shade: #808289;
|
||||
--ion-color-medium-tint: #9d9fa6;
|
||||
|
||||
/** light **/
|
||||
--ion-color-light: #f4f5f8;
|
||||
--ion-color-light-rgb: 244, 245, 248;
|
||||
--ion-color-light-contrast: #000000;
|
||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-light-shade: #d7d8da;
|
||||
--ion-color-light-tint: #f5f6f9;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/*
|
||||
* Dark Colors
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
body {
|
||||
--ion-color-primary: #428cff;
|
||||
--ion-color-primary-rgb: 66, 140, 255;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #3a7be0;
|
||||
--ion-color-primary-tint: #5598ff;
|
||||
|
||||
--ion-color-secondary: #50c8ff;
|
||||
--ion-color-secondary-rgb: 80, 200, 255;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #46b0e0;
|
||||
--ion-color-secondary-tint: #62ceff;
|
||||
|
||||
--ion-color-tertiary: #6a64ff;
|
||||
--ion-color-tertiary-rgb: 106, 100, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #5d58e0;
|
||||
--ion-color-tertiary-tint: #7974ff;
|
||||
|
||||
--ion-color-success: #2fdf75;
|
||||
--ion-color-success-rgb: 47, 223, 117;
|
||||
--ion-color-success-contrast: #000000;
|
||||
--ion-color-success-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-success-shade: #29c467;
|
||||
--ion-color-success-tint: #44e283;
|
||||
|
||||
--ion-color-warning: #ffd534;
|
||||
--ion-color-warning-rgb: 255, 213, 52;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0bb2e;
|
||||
--ion-color-warning-tint: #ffd948;
|
||||
|
||||
--ion-color-danger: #ff4961;
|
||||
--ion-color-danger-rgb: 255, 73, 97;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #e04055;
|
||||
--ion-color-danger-tint: #ff5b71;
|
||||
|
||||
--ion-color-dark: #f4f5f8;
|
||||
--ion-color-dark-rgb: 244, 245, 248;
|
||||
--ion-color-dark-contrast: #000000;
|
||||
--ion-color-dark-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-dark-shade: #d7d8da;
|
||||
--ion-color-dark-tint: #f5f6f9;
|
||||
|
||||
--ion-color-medium: #989aa2;
|
||||
--ion-color-medium-rgb: 152, 154, 162;
|
||||
--ion-color-medium-contrast: #000000;
|
||||
--ion-color-medium-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-medium-shade: #86888f;
|
||||
--ion-color-medium-tint: #a2a4ab;
|
||||
|
||||
--ion-color-light: #222428;
|
||||
--ion-color-light-rgb: 34, 36, 40;
|
||||
--ion-color-light-contrast: #ffffff;
|
||||
--ion-color-light-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-light-shade: #1e2023;
|
||||
--ion-color-light-tint: #383a3e;
|
||||
}
|
||||
|
||||
/*
|
||||
* iOS Dark Theme
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
.ios body {
|
||||
--ion-background-color: #000000;
|
||||
--ion-background-color-rgb: 0, 0, 0;
|
||||
|
||||
--ion-text-color: #ffffff;
|
||||
--ion-text-color-rgb: 255, 255, 255;
|
||||
|
||||
--ion-color-step-50: #0d0d0d;
|
||||
--ion-color-step-100: #1a1a1a;
|
||||
--ion-color-step-150: #262626;
|
||||
--ion-color-step-200: #333333;
|
||||
--ion-color-step-250: #404040;
|
||||
--ion-color-step-300: #4d4d4d;
|
||||
--ion-color-step-350: #595959;
|
||||
--ion-color-step-400: #666666;
|
||||
--ion-color-step-450: #737373;
|
||||
--ion-color-step-500: #808080;
|
||||
--ion-color-step-550: #8c8c8c;
|
||||
--ion-color-step-600: #999999;
|
||||
--ion-color-step-650: #a6a6a6;
|
||||
--ion-color-step-700: #b3b3b3;
|
||||
--ion-color-step-750: #bfbfbf;
|
||||
--ion-color-step-800: #cccccc;
|
||||
--ion-color-step-850: #d9d9d9;
|
||||
--ion-color-step-900: #e6e6e6;
|
||||
--ion-color-step-950: #f2f2f2;
|
||||
|
||||
--ion-item-background: #000000;
|
||||
|
||||
--ion-card-background: #1c1c1d;
|
||||
}
|
||||
|
||||
.ios ion-modal {
|
||||
--ion-background-color: var(--ion-color-step-100);
|
||||
--ion-toolbar-background: var(--ion-color-step-150);
|
||||
--ion-toolbar-border-color: var(--ion-color-step-250);
|
||||
}
|
||||
|
||||
/*
|
||||
* Material Design Dark Theme
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
.md body {
|
||||
--ion-background-color: #121212;
|
||||
--ion-background-color-rgb: 18, 18, 18;
|
||||
|
||||
--ion-text-color: #ffffff;
|
||||
--ion-text-color-rgb: 255, 255, 255;
|
||||
|
||||
--ion-border-color: #222222;
|
||||
|
||||
--ion-color-step-50: #1e1e1e;
|
||||
--ion-color-step-100: #2a2a2a;
|
||||
--ion-color-step-150: #363636;
|
||||
--ion-color-step-200: #414141;
|
||||
--ion-color-step-250: #4d4d4d;
|
||||
--ion-color-step-300: #595959;
|
||||
--ion-color-step-350: #656565;
|
||||
--ion-color-step-400: #717171;
|
||||
--ion-color-step-450: #7d7d7d;
|
||||
--ion-color-step-500: #898989;
|
||||
--ion-color-step-550: #949494;
|
||||
--ion-color-step-600: #a0a0a0;
|
||||
--ion-color-step-650: #acacac;
|
||||
--ion-color-step-700: #b8b8b8;
|
||||
--ion-color-step-750: #c4c4c4;
|
||||
--ion-color-step-800: #d0d0d0;
|
||||
--ion-color-step-850: #dbdbdb;
|
||||
--ion-color-step-900: #e7e7e7;
|
||||
--ion-color-step-950: #f3f3f3;
|
||||
|
||||
--ion-item-background: #1e1e1e;
|
||||
|
||||
--ion-toolbar-background: #1f1f1f;
|
||||
|
||||
--ion-tab-bar-background: #1f1f1f;
|
||||
|
||||
--ion-card-background: #1e1e1e;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
/*
|
||||
* For more information on dynamic font scaling, visit the documentation:
|
||||
* https://ionicframework.com/docs/layout/dynamic-font-scaling
|
||||
*/
|
||||
--ion-dynamic-font: var(--ion-default-dynamic-font);
|
||||
}
|
||||
|
@ -12,10 +12,19 @@ import {
|
||||
url,
|
||||
} from '@angular-devkit/schematics';
|
||||
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
|
||||
import { addRootProvider } from '@schematics/angular/utility';
|
||||
import { getWorkspace } from '@schematics/angular/utility/workspace';
|
||||
|
||||
import { addModuleImportToRootModule } from './../utils/ast';
|
||||
import { addArchitectBuilder, addAsset, addStyle, getDefaultAngularAppName } from './../utils/config';
|
||||
import { addIonicModuleImportToNgModule } from '../utils/ast';
|
||||
|
||||
import {
|
||||
addArchitectBuilder,
|
||||
addAsset,
|
||||
addCli,
|
||||
addSchematics,
|
||||
addStyle,
|
||||
getDefaultAngularAppName,
|
||||
} from './../utils/config';
|
||||
import { addPackageToPackageJson } from './../utils/package';
|
||||
import { Schema as IonAddOptions } from './schema';
|
||||
|
||||
@ -33,9 +42,53 @@ function addIonicAngularToolkitToPackageJson(): Rule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the @ionic/angular-toolkit schematics and cli configuration to the project's `angular.json` file.
|
||||
* @param projectName The name of the project.
|
||||
*/
|
||||
function addIonicAngularToolkitToAngularJson(): Rule {
|
||||
return (host: Tree) => {
|
||||
addCli(host, '@ionic/angular-toolkit');
|
||||
addSchematics(host, '@ionic/angular-toolkit:component', {
|
||||
styleext: 'scss',
|
||||
});
|
||||
addSchematics(host, '@ionic/angular-toolkit:page', {
|
||||
styleext: 'scss',
|
||||
});
|
||||
return host;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `IonicModule.forRoot()` usage to the project's `AppModule`.
|
||||
* If the project does not use modules this will operate as a noop.
|
||||
* @param projectSourceRoot The source root path of the project.
|
||||
*/
|
||||
function addIonicAngularModuleToAppModule(projectSourceRoot: Path): Rule {
|
||||
return (host: Tree) => {
|
||||
addModuleImportToRootModule(host, projectSourceRoot, 'IonicModule.forRoot()', '@ionic/angular');
|
||||
const appModulePath = `${projectSourceRoot}/app/app.module.ts`;
|
||||
if (host.exists(appModulePath)) {
|
||||
addIonicModuleImportToNgModule(host, appModulePath);
|
||||
}
|
||||
return host;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `provideIonicAngular` usage to the project's app config.
|
||||
* If the project does not use an app config this will operate as a noop.
|
||||
* @param projectName The name of the project.
|
||||
* @param projectSourceRoot The source root path of the project.
|
||||
*/
|
||||
function addProvideIonicAngular(projectName: string, projectSourceRoot: Path): Rule {
|
||||
return (host: Tree) => {
|
||||
const appConfig = `${projectSourceRoot}/app/app.config.ts`;
|
||||
if (host.exists(appConfig)) {
|
||||
return addRootProvider(
|
||||
projectName,
|
||||
({ code, external }) => code`${external('provideIonicAngular', '@ionic/angular/standalone')}({})`
|
||||
);
|
||||
}
|
||||
return host;
|
||||
};
|
||||
}
|
||||
@ -63,15 +116,49 @@ function addIonicStyles(projectName: string, projectSourceRoot: Path): Rule {
|
||||
};
|
||||
}
|
||||
|
||||
function addIonicons(projectName: string): Rule {
|
||||
function addIonicons(projectName: string, projectSourceRoot: Path): Rule {
|
||||
return (host: Tree) => {
|
||||
const ioniconsGlob = {
|
||||
glob: '**/*.svg',
|
||||
input: 'node_modules/ionicons/dist/ionicons/svg',
|
||||
output: './svg',
|
||||
};
|
||||
addAsset(host, projectName, 'build', ioniconsGlob);
|
||||
addAsset(host, projectName, 'test', ioniconsGlob);
|
||||
const hasAppModule = host.exists(`${projectSourceRoot}/app/app.module.ts`);
|
||||
|
||||
if (hasAppModule) {
|
||||
/**
|
||||
* Add Ionicons to the `angular.json` file only if the project
|
||||
* is using the lazy build of `@ionic/angular` with modules.
|
||||
*/
|
||||
const ioniconsGlob = {
|
||||
glob: '**/*.svg',
|
||||
input: 'node_modules/ionicons/dist/ionicons/svg',
|
||||
output: './svg',
|
||||
};
|
||||
addAsset(host, projectName, 'build', ioniconsGlob);
|
||||
addAsset(host, projectName, 'test', ioniconsGlob);
|
||||
}
|
||||
|
||||
return host;
|
||||
};
|
||||
}
|
||||
|
||||
function addIonicConfig(projectSourceRoot: string): Rule {
|
||||
return (host: Tree) => {
|
||||
const ionicConfig = 'ionic.config.json';
|
||||
if (!host.exists(ionicConfig)) {
|
||||
const hasAppModule = host.exists(`${projectSourceRoot}/app/app.module.ts`);
|
||||
const type = hasAppModule ? 'angular' : 'angular-standalone';
|
||||
|
||||
host.create(
|
||||
ionicConfig,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'ionic-app',
|
||||
app_id: '',
|
||||
type,
|
||||
integrations: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
return host;
|
||||
};
|
||||
}
|
||||
@ -129,10 +216,13 @@ export default function ngAdd(options: IonAddOptions): Rule {
|
||||
// @ionic/angular
|
||||
addIonicAngularToPackageJson(),
|
||||
addIonicAngularToolkitToPackageJson(),
|
||||
addIonicAngularToolkitToAngularJson(),
|
||||
addIonicAngularModuleToAppModule(sourcePath),
|
||||
addProvideIonicAngular(options.project, sourcePath),
|
||||
addIonicBuilder(options.project),
|
||||
addIonicStyles(options.project, sourcePath),
|
||||
addIonicons(options.project),
|
||||
addIonicons(options.project, sourcePath),
|
||||
addIonicConfig(sourcePath),
|
||||
mergeWith(rootTemplateSource),
|
||||
// install freshly added dependencies
|
||||
installNodeDeps(),
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { normalize } from '@angular-devkit/core';
|
||||
import { Tree, SchematicsException } from '@angular-devkit/schematics';
|
||||
import type { Tree } from '@angular-devkit/schematics';
|
||||
import { SchematicsException } from '@angular-devkit/schematics';
|
||||
import { addSymbolToNgModuleMetadata, insertImport } from '@schematics/angular/utility/ast-utils';
|
||||
import { applyToUpdateRecorder } from '@schematics/angular/utility/change';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import { addImportToModule } from './devkit-utils/ast-utils';
|
||||
import { InsertChange } from './devkit-utils/change';
|
||||
|
||||
/**
|
||||
* Reads file given path and returns TypeScript source file.
|
||||
*/
|
||||
export function getSourceFile(host: Tree, path: string): ts.SourceFile {
|
||||
function getSourceFile(host: Tree, path: string): ts.SourceFile {
|
||||
const buffer = host.read(path);
|
||||
if (!buffer) {
|
||||
throw new SchematicsException(`Could not find file for path: ${path}`);
|
||||
@ -21,32 +20,17 @@ export function getSourceFile(host: Tree, path: string): ts.SourceFile {
|
||||
/**
|
||||
* Import and add module to root app module.
|
||||
*/
|
||||
export function addModuleImportToRootModule(
|
||||
host: Tree,
|
||||
projectSourceRoot: string,
|
||||
moduleName: string,
|
||||
importSrc: string
|
||||
): void {
|
||||
addModuleImportToModule(host, normalize(`${projectSourceRoot}/app/app.module.ts`), moduleName, importSrc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import and add module to specific module path.
|
||||
* @param host the tree we are updating
|
||||
* @param modulePath src location of the module to import
|
||||
* @param moduleName name of module to import
|
||||
* @param src src location to import
|
||||
*/
|
||||
export function addModuleImportToModule(host: Tree, modulePath: string, moduleName: string, src: string): void {
|
||||
const moduleSource = getSourceFile(host, modulePath);
|
||||
const changes = addImportToModule(moduleSource, modulePath, moduleName, src);
|
||||
export function addIonicModuleImportToNgModule(host: Tree, modulePath: string): void {
|
||||
const recorder = host.beginUpdate(modulePath);
|
||||
const moduleSource = getSourceFile(host, modulePath) as any;
|
||||
|
||||
changes.forEach((change) => {
|
||||
if (change instanceof InsertChange) {
|
||||
recorder.insertLeft(change.pos, change.toAdd);
|
||||
}
|
||||
});
|
||||
const ionicModuleChange = insertImport(moduleSource, modulePath, 'IonicModule', '@ionic/angular');
|
||||
|
||||
applyToUpdateRecorder(recorder, [ionicModuleChange]);
|
||||
|
||||
const metadataChange = addSymbolToNgModuleMetadata(moduleSource, modulePath, 'imports', 'IonicModule.forRoot({})');
|
||||
|
||||
applyToUpdateRecorder(recorder, metadataChange);
|
||||
|
||||
host.commitUpdate(recorder);
|
||||
}
|
||||
|
@ -1,24 +1,28 @@
|
||||
import type { JsonObject } from '@angular-devkit/core';
|
||||
import { WorkspaceDefinition } from '@angular-devkit/core/src/workspace';
|
||||
import { Tree, SchematicsException } from '@angular-devkit/schematics';
|
||||
import type { SchematicOptions } from '@angular/cli/lib/config/workspace-schema';
|
||||
import { parse } from 'jsonc-parser';
|
||||
|
||||
const CONFIG_PATH = 'angular.json';
|
||||
const ANGULAR_JSON_PATH = 'angular.json';
|
||||
|
||||
// TODO(FW-2827): types
|
||||
|
||||
export function readConfig(host: Tree): any {
|
||||
const sourceText = host.read(CONFIG_PATH)?.toString('utf-8');
|
||||
return JSON.parse(sourceText);
|
||||
export function readConfig<T extends JsonObject = JsonObject>(host: Tree): T {
|
||||
return host.readJson(ANGULAR_JSON_PATH) as T;
|
||||
}
|
||||
|
||||
export function writeConfig(host: Tree, config: JSON): void {
|
||||
host.overwrite(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
export function writeConfig(host: Tree, config: JsonObject): void {
|
||||
host.overwrite(ANGULAR_JSON_PATH, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function isAngularBrowserProject(projectConfig: any): boolean {
|
||||
if (projectConfig.projectType === 'application') {
|
||||
const buildConfig = projectConfig.architect.build;
|
||||
return buildConfig.builder === '@angular-devkit/build-angular:browser';
|
||||
// Angular 16 and lower
|
||||
const legacyAngularBuilder = buildConfig.builder === '@angular-devkit/build-angular:browser';
|
||||
// Angular 17+
|
||||
const modernAngularBuilder = buildConfig.builder === '@angular-devkit/build-angular:application';
|
||||
|
||||
return legacyAngularBuilder || modernAngularBuilder;
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -38,7 +42,7 @@ export function getDefaultAngularAppName(config: any): string {
|
||||
return projectNames[0];
|
||||
}
|
||||
|
||||
export function getAngularAppConfig(config: any, projectName: string): any | never {
|
||||
function getAngularJson(config: any, projectName: string): any | never {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (!config.projects.hasOwnProperty(projectName)) {
|
||||
throw new SchematicsException(`Could not find project: ${projectName}`);
|
||||
@ -59,8 +63,8 @@ export function getAngularAppConfig(config: any, projectName: string): any | nev
|
||||
|
||||
export function addStyle(host: Tree, projectName: string, stylePath: string): void {
|
||||
const config = readConfig(host);
|
||||
const appConfig = getAngularAppConfig(config, projectName);
|
||||
appConfig.architect.build.options.styles.push({
|
||||
const angularJson = getAngularJson(config, projectName);
|
||||
angularJson.architect.build.options.styles.push({
|
||||
input: stylePath,
|
||||
});
|
||||
writeConfig(host, config);
|
||||
@ -73,8 +77,8 @@ export function addAsset(
|
||||
asset: string | { glob: string; input: string; output: string }
|
||||
): void {
|
||||
const config = readConfig(host);
|
||||
const appConfig = getAngularAppConfig(config, projectName);
|
||||
const target = appConfig.architect[architect];
|
||||
const angularJson = getAngularJson(config, projectName);
|
||||
const target = angularJson.architect[architect];
|
||||
if (target) {
|
||||
target.options.assets.push(asset);
|
||||
writeConfig(host, config);
|
||||
@ -88,11 +92,48 @@ export function addArchitectBuilder(
|
||||
builderOpts: any
|
||||
): void | never {
|
||||
const config = readConfig(host);
|
||||
const appConfig = getAngularAppConfig(config, projectName);
|
||||
appConfig.architect[builderName] = builderOpts;
|
||||
const angularJson = getAngularJson(config, projectName);
|
||||
angularJson.architect[builderName] = builderOpts;
|
||||
writeConfig(host, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the angular.json to add an additional schematic collection
|
||||
* to the CLI configuration.
|
||||
*/
|
||||
export function addCli(host: Tree, collectionName: string): void | never {
|
||||
const angularJson = readConfig<any>(host);
|
||||
|
||||
if (angularJson.cli === undefined) {
|
||||
angularJson.cli = {};
|
||||
}
|
||||
|
||||
if (angularJson.cli.schematicCollections === undefined) {
|
||||
angularJson.cli.schematicCollections = [];
|
||||
}
|
||||
|
||||
angularJson.cli.schematicCollections.push(collectionName);
|
||||
|
||||
writeConfig(host, angularJson);
|
||||
}
|
||||
|
||||
// TODO(FW-5639): can remove [property: string]: any; when upgrading @angular/cli dev-dep to v16 or later
|
||||
export function addSchematics(
|
||||
host: Tree,
|
||||
schematicName: string,
|
||||
schematicOpts: SchematicOptions & { [property: string]: any }
|
||||
): void | never {
|
||||
const angularJson = readConfig<any>(host);
|
||||
|
||||
if (angularJson.schematics === undefined) {
|
||||
angularJson.schematics = {};
|
||||
}
|
||||
|
||||
angularJson.schematics[schematicName] = schematicOpts;
|
||||
|
||||
writeConfig(host, angularJson);
|
||||
}
|
||||
|
||||
export function getWorkspacePath(host: Tree): string {
|
||||
const possibleFiles = ['/angular.json', '/.angular.json'];
|
||||
const path = possibleFiles.filter((path) => host.exists(path))[0];
|
||||
|
@ -1,579 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import { Change, InsertChange, NoopChange } from './change';
|
||||
|
||||
/**
|
||||
* Add Import `import { symbolName } from fileName` if the import doesn't exit
|
||||
* already. Assumes fileToEdit can be resolved and accessed.
|
||||
* @param fileToEdit (file we want to add import to)
|
||||
* @param symbolName (item to import)
|
||||
* @param fileName (path to the file)
|
||||
* @param isDefault (if true, import follows style for importing default exports)
|
||||
* @return Change
|
||||
*/
|
||||
export function insertImport(
|
||||
source: ts.SourceFile,
|
||||
fileToEdit: string,
|
||||
symbolName: string,
|
||||
fileName: string,
|
||||
isDefault = false
|
||||
): Change {
|
||||
const rootNode = source;
|
||||
const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
|
||||
|
||||
// get nodes that map to import statements from the file fileName
|
||||
const relevantImports = allImports.filter((node) => {
|
||||
// StringLiteral of the ImportDeclaration is the import file (fileName in this case).
|
||||
const importFiles = node
|
||||
.getChildren()
|
||||
.filter((child) => child.kind === ts.SyntaxKind.StringLiteral)
|
||||
.map((n) => (n as ts.StringLiteral).text);
|
||||
|
||||
return importFiles.filter((file) => file === fileName).length === 1;
|
||||
});
|
||||
|
||||
if (relevantImports.length > 0) {
|
||||
let importsAsterisk = false;
|
||||
// imports from import file
|
||||
const imports: ts.Node[] = [];
|
||||
relevantImports.forEach((n) => {
|
||||
Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier));
|
||||
if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) {
|
||||
importsAsterisk = true;
|
||||
}
|
||||
});
|
||||
|
||||
// if imports * from fileName, don't add symbolName
|
||||
if (importsAsterisk) {
|
||||
return new NoopChange();
|
||||
}
|
||||
|
||||
const importTextNodes = imports.filter((n) => (n as ts.Identifier).text === symbolName);
|
||||
|
||||
// insert import if it's not there
|
||||
if (importTextNodes.length === 0) {
|
||||
const fallbackPos =
|
||||
findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].getStart() ||
|
||||
findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart();
|
||||
|
||||
return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos);
|
||||
}
|
||||
|
||||
return new NoopChange();
|
||||
}
|
||||
|
||||
// no such import declaration exists
|
||||
const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral).filter(
|
||||
(n: ts.StringLiteral) => n.text === 'use strict'
|
||||
);
|
||||
let fallbackPos = 0;
|
||||
if (useStrict.length > 0) {
|
||||
fallbackPos = useStrict[0].end;
|
||||
}
|
||||
const open = isDefault ? '' : '{ ';
|
||||
const close = isDefault ? '' : ' }';
|
||||
// if there are no imports or 'use strict' statement, insert import at beginning of file
|
||||
const insertAtBeginning = allImports.length === 0 && useStrict.length === 0;
|
||||
const separator = insertAtBeginning ? '' : ';\n';
|
||||
const toInsert =
|
||||
`${separator}import ${open}${symbolName}${close}` + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
|
||||
|
||||
return insertAfterLastOccurrence(allImports, toInsert, fileToEdit, fallbackPos, ts.SyntaxKind.StringLiteral);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all nodes from the AST in the subtree of node of SyntaxKind kind.
|
||||
* @param node
|
||||
* @param kind
|
||||
* @param max The maximum number of items to return.
|
||||
* @return all nodes of kind, or [] if none is found
|
||||
*/
|
||||
export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max = Infinity): ts.Node[] {
|
||||
if (!node || max == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const arr: ts.Node[] = [];
|
||||
if (node.kind === kind) {
|
||||
arr.push(node);
|
||||
max--;
|
||||
}
|
||||
if (max > 0) {
|
||||
for (const child of node.getChildren()) {
|
||||
findNodes(child, kind, max).forEach((node) => {
|
||||
if (max > 0) {
|
||||
arr.push(node);
|
||||
}
|
||||
max--;
|
||||
});
|
||||
|
||||
if (max <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the nodes from a source.
|
||||
* @param sourceFile The source file object.
|
||||
* @returns {Observable<ts.Node>} An observable of all the nodes in the source.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function findNode(node: ts.Node, kind: ts.SyntaxKind, text: string): ts.Node | null {
|
||||
if (node.kind === kind && node.getText() === text) {
|
||||
// throw new Error(node.getText());
|
||||
return node;
|
||||
}
|
||||
|
||||
let foundNode: ts.Node | null = null;
|
||||
ts.forEachChild(node, (childNode) => {
|
||||
foundNode = foundNode || findNode(childNode, kind, text);
|
||||
});
|
||||
|
||||
return foundNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for sorting nodes.
|
||||
* @return function to sort nodes in increasing order of position in sourceFile
|
||||
*/
|
||||
function nodesByPosition(first: ts.Node, second: ts.Node): number {
|
||||
return first.getStart() - second.getStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]`
|
||||
* or after the last of occurence of `syntaxKind` if the last occurence is a sub child
|
||||
* of ts.SyntaxKind[nodes[i].kind] and save the changes in file.
|
||||
*
|
||||
* @param nodes insert after the last occurence of nodes
|
||||
* @param toInsert string to insert
|
||||
* @param file file to insert changes into
|
||||
* @param fallbackPos position to insert if toInsert happens to be the first occurence
|
||||
* @param syntaxKind the ts.SyntaxKind of the subchildren to insert after
|
||||
* @return Change instance
|
||||
* @throw Error if toInsert is first occurence but fall back is not set
|
||||
*/
|
||||
export function insertAfterLastOccurrence(
|
||||
nodes: ts.Node[],
|
||||
toInsert: string,
|
||||
file: string,
|
||||
fallbackPos: number,
|
||||
syntaxKind?: ts.SyntaxKind
|
||||
): Change {
|
||||
// sort() has a side effect, so make a copy so that we won't overwrite the parent's object.
|
||||
let lastItem = [...nodes].sort(nodesByPosition).pop();
|
||||
if (!lastItem) {
|
||||
throw new Error();
|
||||
}
|
||||
if (syntaxKind) {
|
||||
lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop();
|
||||
}
|
||||
if (!lastItem && fallbackPos == undefined) {
|
||||
throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`);
|
||||
}
|
||||
const lastItemPosition: number = lastItem ? lastItem.getEnd() : fallbackPos;
|
||||
|
||||
return new InsertChange(file, lastItemPosition, toInsert);
|
||||
}
|
||||
|
||||
export function getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null {
|
||||
if (node.kind == ts.SyntaxKind.Identifier) {
|
||||
return (node as ts.Identifier).text;
|
||||
} else if (node.kind == ts.SyntaxKind.StringLiteral) {
|
||||
return (node as ts.StringLiteral).text;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _angularImportsFromNode(
|
||||
node: ts.ImportDeclaration,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_sourceFile: ts.SourceFile
|
||||
): { [name: string]: string } {
|
||||
const ms = node.moduleSpecifier;
|
||||
let modulePath: string;
|
||||
switch (ms.kind) {
|
||||
case ts.SyntaxKind.StringLiteral:
|
||||
modulePath = (ms as ts.StringLiteral).text;
|
||||
break;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!modulePath.startsWith('@angular/')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (node.importClause) {
|
||||
if (node.importClause.name) {
|
||||
// This is of the form `import Name from 'path'`. Ignore.
|
||||
return {};
|
||||
} else if (node.importClause.namedBindings) {
|
||||
const nb = node.importClause.namedBindings;
|
||||
if (nb.kind == ts.SyntaxKind.NamespaceImport) {
|
||||
// This is of the form `import * as name from 'path'`. Return `name.`.
|
||||
return {
|
||||
[(nb as ts.NamespaceImport).name.text + '.']: modulePath,
|
||||
};
|
||||
} else {
|
||||
// This is of the form `import {a,b,c} from 'path'`
|
||||
const namedImports = nb as ts.NamedImports;
|
||||
|
||||
return namedImports.elements
|
||||
.map((is: ts.ImportSpecifier) => (is.propertyName ? is.propertyName.text : is.name.text))
|
||||
.reduce((acc: { [name: string]: string }, curr: string) => {
|
||||
acc[curr] = modulePath;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
} else {
|
||||
// This is of the form `import 'path';`. Nothing to do.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, module: string): ts.Node[] {
|
||||
const angularImports: { [name: string]: string } = findNodes(source, ts.SyntaxKind.ImportDeclaration)
|
||||
.map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, source))
|
||||
.reduce((acc: { [name: string]: string }, current: { [name: string]: string }) => {
|
||||
for (const key of Object.keys(current)) {
|
||||
acc[key] = current[key];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return getSourceNodes(source)
|
||||
.filter((node) => {
|
||||
return (
|
||||
node.kind == ts.SyntaxKind.Decorator && (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression
|
||||
);
|
||||
})
|
||||
.map((node) => (node as ts.Decorator).expression as ts.CallExpression)
|
||||
.filter((expr) => {
|
||||
if (expr.expression.kind == ts.SyntaxKind.Identifier) {
|
||||
const id = expr.expression as ts.Identifier;
|
||||
|
||||
return id.getFullText(source) == identifier && angularImports[id.getFullText(source)] === module;
|
||||
} else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) {
|
||||
// This covers foo.NgModule when importing * as foo.
|
||||
const paExpr = expr.expression as ts.PropertyAccessExpression;
|
||||
// If the left expression is not an identifier, just give up at that point.
|
||||
if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = paExpr.name.text;
|
||||
const moduleId = (paExpr.expression as ts.Identifier).getText(source);
|
||||
|
||||
return id === identifier && angularImports[moduleId + '.'] === module;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.filter((expr) => expr.arguments[0] && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression)
|
||||
.map((expr) => expr.arguments[0] as ts.ObjectLiteralExpression);
|
||||
}
|
||||
|
||||
function findClassDeclarationParent(node: ts.Node): ts.ClassDeclaration | undefined {
|
||||
if (ts.isClassDeclaration(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return node.parent && findClassDeclarationParent(node.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a source file with @NgModule class(es), find the name of the first @NgModule class.
|
||||
*
|
||||
* @param source source file containing one or more @NgModule
|
||||
* @returns the name of the first @NgModule, or `undefined` if none is found
|
||||
*/
|
||||
export function getFirstNgModuleName(source: ts.SourceFile): string | undefined {
|
||||
// First, find the @NgModule decorators.
|
||||
const ngModulesMetadata = getDecoratorMetadata(source, 'NgModule', '@angular/core');
|
||||
if (ngModulesMetadata.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Then walk parent pointers up the AST, looking for the ClassDeclaration parent of the NgModule
|
||||
// metadata.
|
||||
const moduleClass = findClassDeclarationParent(ngModulesMetadata[0]);
|
||||
if (!moduleClass?.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the class name of the module ClassDeclaration.
|
||||
return moduleClass.name.text;
|
||||
}
|
||||
|
||||
export function addSymbolToNgModuleMetadata(
|
||||
source: ts.SourceFile,
|
||||
ngModulePath: string,
|
||||
metadataField: string,
|
||||
symbolName: string,
|
||||
importPath: string | null = null
|
||||
): Change[] {
|
||||
const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core');
|
||||
let node: any = nodes[0]; // tslint:disable-line:no-any
|
||||
|
||||
// Find the decorator declaration.
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all the children property assignment of object literals.
|
||||
const matchingProperties: ts.ObjectLiteralElement[] = (node as ts.ObjectLiteralExpression).properties
|
||||
.filter((prop) => prop.kind == ts.SyntaxKind.PropertyAssignment)
|
||||
// Filter out every fields that's not "metadataField". Also handles string literals
|
||||
// (but not expressions).
|
||||
.filter((prop: ts.PropertyAssignment) => {
|
||||
const name = prop.name;
|
||||
switch (name.kind) {
|
||||
case ts.SyntaxKind.Identifier:
|
||||
return (name as ts.Identifier).getText(source) == metadataField;
|
||||
case ts.SyntaxKind.StringLiteral:
|
||||
return (name as ts.StringLiteral).text == metadataField;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Get the last node of the array literal.
|
||||
if (!matchingProperties) {
|
||||
return [];
|
||||
}
|
||||
if (matchingProperties.length == 0) {
|
||||
// We haven't found the field in the metadata declaration. Insert a new field.
|
||||
const expr = node as ts.ObjectLiteralExpression;
|
||||
let position: number;
|
||||
let toInsert: string;
|
||||
if (expr.properties.length == 0) {
|
||||
position = expr.getEnd() - 1;
|
||||
toInsert = ` ${metadataField}: [${symbolName}]\n`;
|
||||
} else {
|
||||
node = expr.properties[expr.properties.length - 1];
|
||||
position = node.getEnd();
|
||||
// Get the indentation of the last element, if any.
|
||||
const text = node.getFullText(source);
|
||||
const matches = text.match(/^\r?\n\s*/);
|
||||
if (matches.length > 0) {
|
||||
toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`;
|
||||
} else {
|
||||
toInsert = `, ${metadataField}: [${symbolName}]`;
|
||||
}
|
||||
}
|
||||
if (importPath !== null) {
|
||||
return [
|
||||
new InsertChange(ngModulePath, position, toInsert),
|
||||
insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath),
|
||||
];
|
||||
} else {
|
||||
return [new InsertChange(ngModulePath, position, toInsert)];
|
||||
}
|
||||
}
|
||||
const assignment = matchingProperties[0] as ts.PropertyAssignment;
|
||||
|
||||
// If it's not an array, nothing we can do really.
|
||||
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression;
|
||||
if (arrLiteral.elements.length == 0) {
|
||||
// Forward the property.
|
||||
node = arrLiteral;
|
||||
} else {
|
||||
node = arrLiteral.elements;
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
console.log('No app module found. Please add your new class to your component.');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const nodeArray = node as {} as ts.Node[];
|
||||
const symbolsArray = nodeArray.map((node) => node.getText());
|
||||
if (symbolsArray.includes(symbolName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
node = node[node.length - 1];
|
||||
}
|
||||
|
||||
let toInsert: string;
|
||||
let position = node.getEnd();
|
||||
if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) {
|
||||
// We haven't found the field in the metadata declaration. Insert a new
|
||||
// field.
|
||||
const expr = node as ts.ObjectLiteralExpression;
|
||||
if (expr.properties.length == 0) {
|
||||
position = expr.getEnd() - 1;
|
||||
toInsert = ` ${metadataField}: [${symbolName}]\n`;
|
||||
} else {
|
||||
node = expr.properties[expr.properties.length - 1];
|
||||
position = node.getEnd();
|
||||
// Get the indentation of the last element, if any.
|
||||
const text = node.getFullText(source);
|
||||
if (text.match('^\r?\r?\n')) {
|
||||
toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${symbolName}]`;
|
||||
} else {
|
||||
toInsert = `, ${metadataField}: [${symbolName}]`;
|
||||
}
|
||||
}
|
||||
} else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) {
|
||||
// We found the field but it's empty. Insert it just before the `]`.
|
||||
position--;
|
||||
toInsert = `${symbolName}`;
|
||||
} else {
|
||||
// Get the indentation of the last element, if any.
|
||||
const text = node.getFullText(source);
|
||||
if (text.match(/^\r?\n/)) {
|
||||
toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${symbolName}`;
|
||||
} else {
|
||||
toInsert = `, ${symbolName}`;
|
||||
}
|
||||
}
|
||||
if (importPath !== null) {
|
||||
return [
|
||||
new InsertChange(ngModulePath, position, toInsert),
|
||||
insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath),
|
||||
];
|
||||
}
|
||||
|
||||
return [new InsertChange(ngModulePath, position, toInsert)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function to insert a declaration (component, pipe, directive)
|
||||
* into NgModule declarations. It also imports the component.
|
||||
*/
|
||||
export function addDeclarationToModule(
|
||||
source: ts.SourceFile,
|
||||
modulePath: string,
|
||||
classifiedName: string,
|
||||
importPath: string
|
||||
): Change[] {
|
||||
return addSymbolToNgModuleMetadata(source, modulePath, 'declarations', classifiedName, importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function to insert an NgModule into NgModule imports. It also imports the module.
|
||||
*/
|
||||
export function addImportToModule(
|
||||
source: ts.SourceFile,
|
||||
modulePath: string,
|
||||
classifiedName: string,
|
||||
importPath: string
|
||||
): Change[] {
|
||||
return addSymbolToNgModuleMetadata(source, modulePath, 'imports', classifiedName, importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function to insert a provider into NgModule. It also imports it.
|
||||
*/
|
||||
export function addProviderToModule(
|
||||
source: ts.SourceFile,
|
||||
modulePath: string,
|
||||
classifiedName: string,
|
||||
importPath: string
|
||||
): Change[] {
|
||||
return addSymbolToNgModuleMetadata(source, modulePath, 'providers', classifiedName, importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function to insert an export into NgModule. It also imports it.
|
||||
*/
|
||||
export function addExportToModule(
|
||||
source: ts.SourceFile,
|
||||
modulePath: string,
|
||||
classifiedName: string,
|
||||
importPath: string
|
||||
): Change[] {
|
||||
return addSymbolToNgModuleMetadata(source, modulePath, 'exports', classifiedName, importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function to insert an export into NgModule. It also imports it.
|
||||
*/
|
||||
export function addBootstrapToModule(
|
||||
source: ts.SourceFile,
|
||||
modulePath: string,
|
||||
classifiedName: string,
|
||||
importPath: string
|
||||
): Change[] {
|
||||
return addSymbolToNgModuleMetadata(source, modulePath, 'bootstrap', classifiedName, importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom function to insert an entryComponent into NgModule. It also imports it.
|
||||
*/
|
||||
export function addEntryComponentToModule(
|
||||
source: ts.SourceFile,
|
||||
modulePath: string,
|
||||
classifiedName: string,
|
||||
importPath: string
|
||||
): Change[] {
|
||||
return addSymbolToNgModuleMetadata(source, modulePath, 'entryComponents', classifiedName, importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an import already exists.
|
||||
*/
|
||||
export function isImported(source: ts.SourceFile, classifiedName: string, importPath: string): boolean {
|
||||
const allNodes = getSourceNodes(source);
|
||||
const matchingNodes = allNodes
|
||||
.filter((node) => node.kind === ts.SyntaxKind.ImportDeclaration)
|
||||
.filter((imp: ts.ImportDeclaration) => imp.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral)
|
||||
.filter((imp: ts.ImportDeclaration) => {
|
||||
return (imp.moduleSpecifier as ts.StringLiteral).text === importPath;
|
||||
})
|
||||
.filter((imp: ts.ImportDeclaration) => {
|
||||
if (!imp.importClause) {
|
||||
return false;
|
||||
}
|
||||
const nodes = findNodes(imp.importClause, ts.SyntaxKind.ImportSpecifier).filter(
|
||||
(n) => n.getText() === classifiedName
|
||||
);
|
||||
|
||||
return nodes.length > 0;
|
||||
});
|
||||
|
||||
return matchingNodes.length > 0;
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
export interface Host {
|
||||
write(path: string, content: string): Promise<void>;
|
||||
read(path: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface Change {
|
||||
apply(host: Host): Promise<void>;
|
||||
|
||||
// The file this change should be applied to. Some changes might not apply to
|
||||
// a file (maybe the config).
|
||||
readonly path: string | null;
|
||||
|
||||
// The order this change should be applied. Normally the position inside the file.
|
||||
// Changes are applied from the bottom of a file to the top.
|
||||
readonly order: number;
|
||||
|
||||
// The description of this change. This will be outputted in a dry or verbose run.
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An operation that does nothing.
|
||||
*/
|
||||
export class NoopChange implements Change {
|
||||
description = 'No operation.';
|
||||
order = Infinity;
|
||||
path = null;
|
||||
apply(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will add text to the source code.
|
||||
*/
|
||||
export class InsertChange implements Change {
|
||||
order: number;
|
||||
description: string;
|
||||
|
||||
constructor(public path: string, public pos: number, public toAdd: string) {
|
||||
if (pos < 0) {
|
||||
throw new Error('Negative positions are invalid');
|
||||
}
|
||||
this.description = `Inserted ${toAdd} into position ${pos} of ${path}`;
|
||||
this.order = pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method does not insert spaces if there is none in the original string.
|
||||
*/
|
||||
apply(host: Host): Promise<void> {
|
||||
return host.read(this.path).then((content) => {
|
||||
const prefix = content.substring(0, this.pos);
|
||||
const suffix = content.substring(this.pos);
|
||||
|
||||
return host.write(this.path, `${prefix}${this.toAdd}${suffix}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will remove text from the source code.
|
||||
*/
|
||||
export class RemoveChange implements Change {
|
||||
order: number;
|
||||
description: string;
|
||||
|
||||
constructor(public path: string, private pos: number, private toRemove: string) {
|
||||
if (pos < 0) {
|
||||
throw new Error('Negative positions are invalid');
|
||||
}
|
||||
this.description = `Removed ${toRemove} into position ${pos} of ${path}`;
|
||||
this.order = pos;
|
||||
}
|
||||
|
||||
apply(host: Host): Promise<void> {
|
||||
return host.read(this.path).then((content) => {
|
||||
const prefix = content.substring(0, this.pos);
|
||||
const suffix = content.substring(this.pos + this.toRemove.length);
|
||||
|
||||
// TODO: throw error if toRemove doesn't match removed string.
|
||||
return host.write(this.path, `${prefix}${suffix}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will replace text from the source code.
|
||||
*/
|
||||
export class ReplaceChange implements Change {
|
||||
order: number;
|
||||
description: string;
|
||||
|
||||
constructor(public path: string, private pos: number, private oldText: string, private newText: string) {
|
||||
if (pos < 0) {
|
||||
throw new Error('Negative positions are invalid');
|
||||
}
|
||||
this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`;
|
||||
this.order = pos;
|
||||
}
|
||||
|
||||
apply(host: Host): Promise<void> {
|
||||
return host.read(this.path).then((content) => {
|
||||
const prefix = content.substring(0, this.pos);
|
||||
const suffix = content.substring(this.pos + this.oldText.length);
|
||||
const text = content.substring(this.pos, this.pos + this.oldText.length);
|
||||
|
||||
if (text !== this.oldText) {
|
||||
return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`));
|
||||
}
|
||||
|
||||
// TODO: throw error if oldText doesn't match removed string.
|
||||
return host.write(this.path, `${prefix}${this.newText}${suffix}`);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
### Devkit Utils
|
||||
|
||||
These are utility files copied over from `@angular-devkit`.
|
||||
They are not exported so they need to be manually copied over.
|
||||
Please do not edit directly.
|
@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import { findNodes, insertAfterLastOccurrence } from './ast-utils';
|
||||
import { Change, NoopChange } from './change';
|
||||
|
||||
/**
|
||||
* Add Import `import { symbolName } from fileName` if the import doesn't exit
|
||||
* already. Assumes fileToEdit can be resolved and accessed.
|
||||
* @param fileToEdit (file we want to add import to)
|
||||
* @param symbolName (item to import)
|
||||
* @param fileName (path to the file)
|
||||
* @param isDefault (if true, import follows style for importing default exports)
|
||||
* @return Change
|
||||
*/
|
||||
|
||||
export function insertImport(
|
||||
source: ts.SourceFile,
|
||||
fileToEdit: string,
|
||||
symbolName: string,
|
||||
fileName: string,
|
||||
isDefault = false
|
||||
): Change {
|
||||
const rootNode = source;
|
||||
const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
|
||||
|
||||
// get nodes that map to import statements from the file fileName
|
||||
const relevantImports = allImports.filter((node) => {
|
||||
// StringLiteral of the ImportDeclaration is the import file (fileName in this case).
|
||||
const importFiles = node
|
||||
.getChildren()
|
||||
.filter((child) => child.kind === ts.SyntaxKind.StringLiteral)
|
||||
.map((n) => (n as ts.StringLiteral).text);
|
||||
|
||||
return importFiles.filter((file) => file === fileName).length === 1;
|
||||
});
|
||||
|
||||
if (relevantImports.length > 0) {
|
||||
let importsAsterisk = false;
|
||||
// imports from import file
|
||||
const imports: ts.Node[] = [];
|
||||
relevantImports.forEach((n) => {
|
||||
Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier));
|
||||
if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) {
|
||||
importsAsterisk = true;
|
||||
}
|
||||
});
|
||||
|
||||
// if imports * from fileName, don't add symbolName
|
||||
if (importsAsterisk) {
|
||||
return new NoopChange();
|
||||
}
|
||||
|
||||
const importTextNodes = imports.filter((n) => (n as ts.Identifier).text === symbolName);
|
||||
|
||||
// insert import if it's not there
|
||||
if (importTextNodes.length === 0) {
|
||||
const fallbackPos =
|
||||
findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].getStart() ||
|
||||
findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart();
|
||||
|
||||
return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos);
|
||||
}
|
||||
|
||||
return new NoopChange();
|
||||
}
|
||||
|
||||
// no such import declaration exists
|
||||
const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral).filter(
|
||||
(n: ts.StringLiteral) => n.text === 'use strict'
|
||||
);
|
||||
let fallbackPos = 0;
|
||||
if (useStrict.length > 0) {
|
||||
fallbackPos = useStrict[0].end;
|
||||
}
|
||||
const open = isDefault ? '' : '{ ';
|
||||
const close = isDefault ? '' : ' }';
|
||||
// if there are no imports or 'use strict' statement, insert import at beginning of file
|
||||
const insertAtBeginning = allImports.length === 0 && useStrict.length === 0;
|
||||
const separator = insertAtBeginning ? '' : ';\n';
|
||||
const toInsert =
|
||||
`${separator}import ${open}${symbolName}${close}` + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
|
||||
|
||||
return insertAfterLastOccurrence(allImports, toInsert, fileToEdit, fallbackPos, ts.SyntaxKind.StringLiteral);
|
||||
}
|
Reference in New Issue
Block a user