mirror of https://github.com/abpframework/abp.git
committed by
GitHub
56 changed files with 2340 additions and 295 deletions
@ -0,0 +1,44 @@ |
|||
{ |
|||
"extends": "../../.eslintrc.json", |
|||
"ignorePatterns": [ |
|||
"!**/*" |
|||
], |
|||
"overrides": [ |
|||
{ |
|||
"files": [ |
|||
"*.ts" |
|||
], |
|||
"parserOptions": { |
|||
"project": [ |
|||
"projects/<%= kebab(libraryName) %>/tsconfig.lib.json", |
|||
"projects/<%= kebab(libraryName) %>/tsconfig.spec.json" |
|||
], |
|||
"createDefaultProgram": true |
|||
}, |
|||
"rules": { |
|||
"@angular-eslint/directive-selector": [ |
|||
"error", |
|||
{ |
|||
"type": "attribute", |
|||
"prefix": "lib", |
|||
"style": "camelCase" |
|||
} |
|||
], |
|||
"@angular-eslint/component-selector": [ |
|||
"error", |
|||
{ |
|||
"type": "element", |
|||
"prefix": "lib", |
|||
"style": "kebab-case" |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
{ |
|||
"files": [ |
|||
"*.html" |
|||
], |
|||
"rules": {} |
|||
} |
|||
] |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
{ |
|||
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", |
|||
"dest": "../../dist/<%= kebab(libraryName) %>/config", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './route-names'; |
|||
@ -0,0 +1,3 @@ |
|||
export const enum e<%= pascal(libraryName) %>RouteNames { |
|||
<%= pascal(libraryName) %> = '<%= pascal(libraryName) %>', |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './route.provider'; |
|||
@ -0,0 +1,30 @@ |
|||
import { eLayoutType, RoutesService } from '@abp/ng.core'; |
|||
import { e<%= pascal(libraryName) %>RouteNames } from '../enums/route-names'; |
|||
import { makeEnvironmentProviders, provideAppInitializer, inject, EnvironmentProviders } from '@angular/core'; |
|||
|
|||
export const <%= macro(libraryName) %>_ROUTE_PROVIDERS = [ |
|||
provideAppInitializer(() => { |
|||
configureRoutes(); |
|||
}), |
|||
]; |
|||
|
|||
export function configureRoutes() { |
|||
const routes = inject(RoutesService); |
|||
routes.add([ |
|||
{ |
|||
path: '/<%= kebab(libraryName) %>', |
|||
name: e<%= pascal(libraryName) %>RouteNames.<%= pascal(libraryName) %>, |
|||
iconClass: 'fas fa-book', |
|||
layout: eLayoutType.application, |
|||
order: 3, |
|||
}, |
|||
]); |
|||
} |
|||
|
|||
export const <%= macro(libraryName) %>_PROVIDERS: EnvironmentProviders[] = [ |
|||
...<%= macro(libraryName) %>_ROUTE_PROVIDERS, |
|||
]; |
|||
|
|||
export function provide<%= pascal(libraryName) %>Config() { |
|||
return makeEnvironmentProviders(<%= macro(libraryName) %>_PROVIDERS); |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export * from './enums'; |
|||
export * from './providers'; |
|||
@ -0,0 +1,44 @@ |
|||
// Karma configuration file, see link for more information |
|||
// https://karma-runner.github.io/1.0/config/configuration-file.html |
|||
|
|||
module.exports = function (config) { |
|||
config.set({ |
|||
basePath: '', |
|||
frameworks: ['jasmine', '@angular-devkit/build-angular'], |
|||
plugins: [ |
|||
require('karma-jasmine'), |
|||
require('karma-chrome-launcher'), |
|||
require('karma-jasmine-html-reporter'), |
|||
require('karma-coverage'), |
|||
require('@angular-devkit/build-angular/plugins/karma') |
|||
], |
|||
client: { |
|||
jasmine: { |
|||
// you can add configuration options for Jasmine here |
|||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html |
|||
// for example, you can disable the random execution with `random: false` |
|||
// or set a specific seed with `seed: 4321` |
|||
}, |
|||
clearContext: false // leave Jasmine Spec Runner output visible in browser |
|||
}, |
|||
jasmineHtmlReporter: { |
|||
suppressAll: true // removes the duplicated traces |
|||
}, |
|||
coverageReporter: { |
|||
dir: require('path').join(__dirname, '../../coverage/<%= kebab(libraryName) %>'), |
|||
subdir: '.', |
|||
reporters: [ |
|||
{ type: 'html' }, |
|||
{ type: 'text-summary' } |
|||
] |
|||
}, |
|||
reporters: ['progress', 'kjhtml'], |
|||
port: 9876, |
|||
colors: true, |
|||
logLevel: config.LOG_INFO, |
|||
autoWatch: true, |
|||
browsers: ['Chrome'], |
|||
singleRun: false, |
|||
restartOnFileChange: true |
|||
}); |
|||
}; |
|||
@ -0,0 +1,7 @@ |
|||
{ |
|||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json", |
|||
"dest": "../../dist/<%= kebab(libraryName) %>", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"name": "@<%= kebab(libraryName) %>/<%= kebab(libraryName) %>", |
|||
"version": "0.0.1", |
|||
"peerDependencies": { |
|||
"@abp/ng.core": "<%= abpVersion %>", |
|||
"@abp/ng.theme.shared": "<%= abpVersion %>" |
|||
}, |
|||
"dependencies": { |
|||
"tslib": "^2.1.0" |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { Component } from '@angular/core'; |
|||
import {CoreModule} from "@abp/ng.core"; |
|||
import {ThemeSharedModule} from "@abp/ng.theme.shared"; |
|||
|
|||
@Component({ |
|||
standalone: true, |
|||
selector: '<%= camel(libraryName) %>-home', |
|||
template: `<h1>Lazy Loaded Test Component</h1>`, |
|||
imports: [CoreModule, ThemeSharedModule], |
|||
}) |
|||
export class <%= pascal(libraryName) %>Component {} |
|||
@ -0,0 +1,9 @@ |
|||
import { Routes } from '@angular/router'; |
|||
|
|||
export const <%= macro(libraryName) %>_ROUTES: Routes = [ |
|||
{ |
|||
path: '', |
|||
loadComponent: () => |
|||
import('./<%= kebab(libraryName) %>.component').then(m => m.<%= pascal(libraryName) %>Component), |
|||
}, |
|||
]; |
|||
@ -0,0 +1 @@ |
|||
export * from './<%= kebab(libraryName) %>.routes'; |
|||
@ -0,0 +1,4 @@ |
|||
/* |
|||
* Public API Surface of my-project-name |
|||
*/ |
|||
export * from './lib'; |
|||
@ -0,0 +1,26 @@ |
|||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files |
|||
|
|||
import 'zone.js'; |
|||
import 'zone.js/testing'; |
|||
import { getTestBed } from '@angular/core/testing'; |
|||
import { |
|||
BrowserDynamicTestingModule, |
|||
platformBrowserDynamicTesting |
|||
} from '@angular/platform-browser-dynamic/testing'; |
|||
|
|||
declare const require: { |
|||
context(path: string, deep?: boolean, filter?: RegExp): { |
|||
keys(): string[]; |
|||
<T>(id: string): T; |
|||
}; |
|||
}; |
|||
|
|||
// First, initialize the Angular testing environment. |
|||
getTestBed().initTestEnvironment( |
|||
BrowserDynamicTestingModule, |
|||
platformBrowserDynamicTesting() |
|||
); |
|||
// Then we find all the tests. |
|||
const context = require.context('./', true, /\.spec\.ts$/); |
|||
// And load the modules. |
|||
context.keys().map(context); |
|||
@ -0,0 +1,20 @@ |
|||
/* To learn more about this file see: https://angular.io/config/tsconfig. */ |
|||
{ |
|||
"extends": "../../tsconfig.json", |
|||
"compilerOptions": { |
|||
"outDir": "../../out-tsc/lib", |
|||
"target": "es2020", |
|||
"declaration": true, |
|||
"declarationMap": true, |
|||
"inlineSources": true, |
|||
"types": [], |
|||
"lib": [ |
|||
"dom", |
|||
"es2018" |
|||
] |
|||
}, |
|||
"exclude": [ |
|||
"src/test.ts", |
|||
"**/*.spec.ts" |
|||
] |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
/* To learn more about this file see: https://angular.io/config/tsconfig. */ |
|||
{ |
|||
"extends": "./tsconfig.lib.json", |
|||
"compilerOptions": { |
|||
"declarationMap": false |
|||
}, |
|||
"angularCompilerOptions": { |
|||
"compilationMode": "partial" |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
/* To learn more about this file see: https://angular.io/config/tsconfig. */ |
|||
{ |
|||
"extends": "../../tsconfig.json", |
|||
"compilerOptions": { |
|||
"outDir": "../../out-tsc/spec", |
|||
"types": [ |
|||
"jasmine" |
|||
] |
|||
}, |
|||
"files": [ |
|||
"src/test.ts" |
|||
], |
|||
"include": [ |
|||
"**/*.spec.ts", |
|||
"**/*.d.ts" |
|||
] |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
import { Provider } from '@angular/core'; |
|||
|
|||
export function provide<%= pascal(target) %><%= pascal(libraryName) %>(): Provider[] { |
|||
return [ |
|||
// Add your providers here |
|||
]; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './<%= kebab(target) %>-<%= kebab(libraryName) %>'; |
|||
@ -0,0 +1 @@ |
|||
export * from './lib/index'; |
|||
@ -0,0 +1,25 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
import { EOL } from 'node:os'; |
|||
|
|||
const CRLF = '\r\n'; |
|||
const LF = '\n'; |
|||
|
|||
export function getEOL(content: string): string { |
|||
const newlines = content.match(/(?:\r?\n)/g); |
|||
|
|||
if (newlines?.length) { |
|||
const crlf = newlines.filter(l => l === CRLF).length; |
|||
const lf = newlines.length - crlf; |
|||
|
|||
return crlf > lf ? CRLF : LF; |
|||
} |
|||
|
|||
return EOL; |
|||
} |
|||
@ -0,0 +1,148 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
import { SchematicsException, Tree } from '@angular-devkit/schematics'; |
|||
import * as ts from 'typescript'; |
|||
import { getDecoratorMetadata, getMetadataField } from '../ast-utils'; |
|||
import { findBootstrapModuleCall, getAppModulePath } from '../ng-ast-utils'; |
|||
import { findBootstrapApplicationCall, getSourceFile } from './util'; |
|||
|
|||
/** Data resolved for a bootstrapped component. */ |
|||
interface BootstrappedComponentData { |
|||
/** Original name of the component class. */ |
|||
componentName: string; |
|||
|
|||
/** Path under which the component was imported in the main entrypoint. */ |
|||
componentImportPathInSameFile: string; |
|||
|
|||
/** Original name of the NgModule being bootstrapped, null if the app isn't module-based. */ |
|||
moduleName: string | null; |
|||
|
|||
/** |
|||
* Path under which the module was imported in the main entrypoint, |
|||
* null if the app isn't module-based. |
|||
*/ |
|||
moduleImportPathInSameFile: string | null; |
|||
} |
|||
|
|||
/** |
|||
* Finds the original name and path relative to the `main.ts` of the bootrstrapped app component. |
|||
* @param tree File tree in which to look for the component. |
|||
* @param mainFilePath Path of the `main` file. |
|||
*/ |
|||
export function resolveBootstrappedComponentData( |
|||
tree: Tree, |
|||
mainFilePath: string, |
|||
): BootstrappedComponentData | null { |
|||
// First try to resolve for a standalone app.
|
|||
try { |
|||
const call = findBootstrapApplicationCall(tree, mainFilePath); |
|||
|
|||
if (call.arguments.length > 0 && ts.isIdentifier(call.arguments[0])) { |
|||
const resolved = resolveIdentifier(call.arguments[0]); |
|||
|
|||
if (resolved) { |
|||
return { |
|||
componentName: resolved.name, |
|||
componentImportPathInSameFile: resolved.path, |
|||
moduleName: null, |
|||
moduleImportPathInSameFile: null, |
|||
}; |
|||
} |
|||
} |
|||
} catch (e) { |
|||
// `findBootstrapApplicationCall` will throw if it can't find the `bootrstrapApplication` call.
|
|||
// Catch so we can continue to the fallback logic.
|
|||
if (!(e instanceof SchematicsException)) { |
|||
throw e; |
|||
} |
|||
} |
|||
|
|||
// Otherwise fall back to resolving an NgModule-based app.
|
|||
return resolveNgModuleBasedData(tree, mainFilePath); |
|||
} |
|||
|
|||
/** Resolves the bootstrap data for a NgModule-based app. */ |
|||
function resolveNgModuleBasedData( |
|||
tree: Tree, |
|||
mainFilePath: string, |
|||
): BootstrappedComponentData | null { |
|||
const appModulePath = getAppModulePath(tree, mainFilePath); |
|||
const appModuleFile = getSourceFile(tree, appModulePath); |
|||
const metadataNodes = getDecoratorMetadata(appModuleFile, 'NgModule', '@angular/core'); |
|||
|
|||
for (const node of metadataNodes) { |
|||
if (!ts.isObjectLiteralExpression(node)) { |
|||
continue; |
|||
} |
|||
|
|||
const bootstrapProp = getMetadataField(node, 'bootstrap').find(prop => { |
|||
return ( |
|||
ts.isArrayLiteralExpression(prop.initializer) && |
|||
prop.initializer.elements.length > 0 && |
|||
ts.isIdentifier(prop.initializer.elements[0]) |
|||
); |
|||
}); |
|||
|
|||
const componentIdentifier = (bootstrapProp?.initializer as ts.ArrayLiteralExpression) |
|||
.elements[0] as ts.Identifier | undefined; |
|||
const componentResult = componentIdentifier ? resolveIdentifier(componentIdentifier) : null; |
|||
const bootstrapCall = findBootstrapModuleCall(tree, mainFilePath); |
|||
|
|||
if ( |
|||
componentResult && |
|||
bootstrapCall && |
|||
bootstrapCall.arguments.length > 0 && |
|||
ts.isIdentifier(bootstrapCall.arguments[0]) |
|||
) { |
|||
const moduleResult = resolveIdentifier(bootstrapCall.arguments[0]); |
|||
|
|||
if (moduleResult) { |
|||
return { |
|||
componentName: componentResult.name, |
|||
componentImportPathInSameFile: componentResult.path, |
|||
moduleName: moduleResult.name, |
|||
moduleImportPathInSameFile: moduleResult.path, |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** Resolves an identifier to its original name and path that it was imported from. */ |
|||
function resolveIdentifier(identifier: ts.Identifier): { name: string; path: string } | null { |
|||
const sourceFile = identifier.getSourceFile(); |
|||
|
|||
// Try to resolve the import path by looking at the top-level named imports of the file.
|
|||
for (const node of sourceFile.statements) { |
|||
if ( |
|||
!ts.isImportDeclaration(node) || |
|||
!ts.isStringLiteral(node.moduleSpecifier) || |
|||
!node.importClause || |
|||
!node.importClause.namedBindings || |
|||
!ts.isNamedImports(node.importClause.namedBindings) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
for (const element of node.importClause.namedBindings.elements) { |
|||
if (element.name.text === identifier.text) { |
|||
return { |
|||
// Note that we use `propertyName` if available, because it contains
|
|||
// the real name in the case where the import is aliased.
|
|||
name: (element.propertyName || element.name).text, |
|||
path: node.moduleSpecifier.text, |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
import { Tree } from '@angular-devkit/schematics'; |
|||
import { dirname, join } from 'node:path'; |
|||
import * as ts from 'typescript'; |
|||
import { getSourceFile } from './util'; |
|||
|
|||
/** App config that was resolved to its source node. */ |
|||
export interface ResolvedAppConfig { |
|||
/** Tree-relative path of the file containing the app config. */ |
|||
filePath: string; |
|||
|
|||
/** Node defining the app config. */ |
|||
node: ts.ObjectLiteralExpression; |
|||
} |
|||
|
|||
/** |
|||
* Resolves the node that defines the app config from a bootstrap call. |
|||
* @param bootstrapCall Call for which to resolve the config. |
|||
* @param tree File tree of the project. |
|||
* @param filePath File path of the bootstrap call. |
|||
*/ |
|||
export function findAppConfig( |
|||
bootstrapCall: ts.CallExpression, |
|||
tree: Tree, |
|||
filePath: string, |
|||
): ResolvedAppConfig | null { |
|||
if (bootstrapCall.arguments.length > 1) { |
|||
const config = bootstrapCall.arguments[1]; |
|||
|
|||
if (ts.isObjectLiteralExpression(config)) { |
|||
return { filePath, node: config }; |
|||
} |
|||
|
|||
if (ts.isIdentifier(config)) { |
|||
return resolveAppConfigFromIdentifier(config, tree, filePath); |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Resolves the app config from an identifier referring to it. |
|||
* @param identifier Identifier referring to the app config. |
|||
* @param tree File tree of the project. |
|||
* @param bootstapFilePath Path of the bootstrap call. |
|||
*/ |
|||
function resolveAppConfigFromIdentifier( |
|||
identifier: ts.Identifier, |
|||
tree: Tree, |
|||
bootstapFilePath: string, |
|||
): ResolvedAppConfig | null { |
|||
const sourceFile = identifier.getSourceFile(); |
|||
|
|||
for (const node of sourceFile.statements) { |
|||
// Only look at relative imports. This will break if the app uses a path
|
|||
// mapping to refer to the import, but in order to resolve those, we would
|
|||
// need knowledge about the entire program.
|
|||
if ( |
|||
!ts.isImportDeclaration(node) || |
|||
!node.importClause?.namedBindings || |
|||
!ts.isNamedImports(node.importClause.namedBindings) || |
|||
!ts.isStringLiteralLike(node.moduleSpecifier) || |
|||
!node.moduleSpecifier.text.startsWith('.') |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
for (const specifier of node.importClause.namedBindings.elements) { |
|||
if (specifier.name.text !== identifier.text) { |
|||
continue; |
|||
} |
|||
|
|||
// Look for a variable with the imported name in the file. Note that ideally we would use
|
|||
// the type checker to resolve this, but we can't because these utilities are set up to
|
|||
// operate on individual files, not the entire program.
|
|||
const filePath = join(dirname(bootstapFilePath), node.moduleSpecifier.text + '.ts'); |
|||
const importedSourceFile = getSourceFile(tree, filePath); |
|||
const resolvedVariable = findAppConfigFromVariableName( |
|||
importedSourceFile, |
|||
(specifier.propertyName || specifier.name).text, |
|||
); |
|||
|
|||
if (resolvedVariable) { |
|||
return { filePath, node: resolvedVariable }; |
|||
} |
|||
} |
|||
} |
|||
|
|||
const variableInSameFile = findAppConfigFromVariableName(sourceFile, identifier.text); |
|||
|
|||
return variableInSameFile ? { filePath: bootstapFilePath, node: variableInSameFile } : null; |
|||
} |
|||
|
|||
/** |
|||
* Finds an app config within the top-level variables of a file. |
|||
* @param sourceFile File in which to search for the config. |
|||
* @param variableName Name of the variable containing the config. |
|||
*/ |
|||
function findAppConfigFromVariableName( |
|||
sourceFile: ts.SourceFile, |
|||
variableName: string, |
|||
): ts.ObjectLiteralExpression | null { |
|||
for (const node of sourceFile.statements) { |
|||
if (ts.isVariableStatement(node)) { |
|||
for (const decl of node.declarationList.declarations) { |
|||
if ( |
|||
ts.isIdentifier(decl.name) && |
|||
decl.name.text === variableName && |
|||
decl.initializer && |
|||
ts.isObjectLiteralExpression(decl.initializer) |
|||
) { |
|||
return decl.initializer; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
import { Rule, Tree } from '@angular-devkit/schematics'; |
|||
import * as ts from 'typescript'; |
|||
import { hasTopLevelIdentifier, insertImport } from '../ast-utils'; |
|||
import { applyToUpdateRecorder } from '../change'; |
|||
|
|||
/** Generated code that hasn't been interpolated yet. */ |
|||
export interface PendingCode { |
|||
/** Code that will be inserted. */ |
|||
expression: string; |
|||
|
|||
/** Imports that need to be added to the file in which the code is inserted. */ |
|||
imports: PendingImports; |
|||
} |
|||
|
|||
/** Map keeping track of imports and aliases under which they're referred to in an expression. */ |
|||
type PendingImports = Map<string, Map<string, string>>; |
|||
|
|||
/** Counter used to generate unique IDs. */ |
|||
let uniqueIdCounter = 0; |
|||
|
|||
/** |
|||
* Callback invoked by a Rule that produces the code |
|||
* that needs to be inserted somewhere in the app. |
|||
*/ |
|||
export type CodeBlockCallback = (block: CodeBlock) => PendingCode; |
|||
|
|||
/** |
|||
* Utility class used to generate blocks of code that |
|||
* can be inserted by the devkit into a user's app. |
|||
*/ |
|||
export class CodeBlock { |
|||
private _imports: PendingImports = new Map<string, Map<string, string>>(); |
|||
|
|||
// Note: the methods here are defined as arrow function so that they can be destructured by
|
|||
// consumers without losing their context. This makes the API more concise.
|
|||
|
|||
/** Function used to tag a code block in order to produce a `PendingCode` object. */ |
|||
code = (strings: TemplateStringsArray, ...params: unknown[]): PendingCode => { |
|||
return { |
|||
expression: strings.map((part, index) => part + (params[index] || '')).join(''), |
|||
imports: this._imports, |
|||
}; |
|||
}; |
|||
|
|||
/** |
|||
* Used inside of a code block to mark external symbols and which module they should be imported |
|||
* from. When the code is inserted, the required import statements will be produced automatically. |
|||
* @param symbolName Name of the external symbol. |
|||
* @param moduleName Module from which the symbol should be imported. |
|||
*/ |
|||
external = (symbolName: string, moduleName: string): string => { |
|||
if (!this._imports.has(moduleName)) { |
|||
this._imports.set(moduleName, new Map()); |
|||
} |
|||
|
|||
const symbolsPerModule = this._imports.get(moduleName) as Map<string, string>; |
|||
|
|||
if (!symbolsPerModule.has(symbolName)) { |
|||
symbolsPerModule.set(symbolName, `@@__SCHEMATIC_PLACEHOLDER_${uniqueIdCounter++}__@@`); |
|||
} |
|||
|
|||
return symbolsPerModule.get(symbolName) as string; |
|||
}; |
|||
|
|||
/** |
|||
* Produces the necessary rules to transform a `PendingCode` object into valid code. |
|||
* @param initialCode Code pending transformed. |
|||
* @param filePath Path of the file in which the code will be inserted. |
|||
*/ |
|||
static transformPendingCode(initialCode: PendingCode, filePath: string) { |
|||
const code = { ...initialCode }; |
|||
const rules: Rule[] = []; |
|||
|
|||
code.imports.forEach((symbols, moduleName) => { |
|||
symbols.forEach((placeholder, symbolName) => { |
|||
rules.push((tree: Tree) => { |
|||
const recorder = tree.beginUpdate(filePath); |
|||
const sourceFile = ts.createSourceFile( |
|||
filePath, |
|||
tree.readText(filePath), |
|||
ts.ScriptTarget.Latest, |
|||
true, |
|||
); |
|||
|
|||
// Note that this could still technically clash if there's a top-level symbol called
|
|||
// `${symbolName}_alias`, however this is unlikely. We can revisit this if it becomes
|
|||
// a problem.
|
|||
const alias = hasTopLevelIdentifier(sourceFile, symbolName, moduleName) |
|||
? symbolName + '_alias' |
|||
: undefined; |
|||
|
|||
code.expression = code.expression.replace( |
|||
new RegExp(placeholder, 'g'), |
|||
alias || symbolName, |
|||
); |
|||
|
|||
applyToUpdateRecorder(recorder, [ |
|||
insertImport(sourceFile, filePath, symbolName, moduleName, false, alias), |
|||
]); |
|||
tree.commitUpdate(recorder); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
return { code, rules }; |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
export { addRootImport, addRootProvider } from './rules'; |
|||
export type { PendingCode, CodeBlockCallback, CodeBlock } from './code_block'; |
|||
@ -0,0 +1,258 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
import { tags } from '@angular-devkit/core'; |
|||
import { Rule, SchematicsException, Tree, chain } from '@angular-devkit/schematics'; |
|||
import * as ts from 'typescript'; |
|||
import { addSymbolToNgModuleMetadata, insertAfterLastOccurrence } from '../ast-utils'; |
|||
import { InsertChange } from '../change'; |
|||
import { getAppModulePath, isStandaloneApp } from '../ng-ast-utils'; |
|||
import { ResolvedAppConfig, findAppConfig } from './app_config'; |
|||
import { CodeBlock, CodeBlockCallback, PendingCode } from './code_block'; |
|||
import { |
|||
applyChangesToFile, |
|||
findBootstrapApplicationCall, |
|||
findProvidersLiteral, |
|||
getMainFilePath, |
|||
getSourceFile, |
|||
isMergeAppConfigCall, |
|||
} from './util'; |
|||
|
|||
/** |
|||
* Adds an import to the root of the project. |
|||
* @param project Name of the project to which to add the import. |
|||
* @param callback Function that generates the code block which should be inserted. |
|||
* @example |
|||
* |
|||
* ```ts
|
|||
* import { Rule } from '@angular-devkit/schematics'; |
|||
* import { addRootImport } from '@schematics/angular/utility'; |
|||
* |
|||
* export default function(): Rule { |
|||
* return addRootImport('default', ({code, external}) => { |
|||
* return code`${external('MyModule', '@my/module')}.forRoot({})`; |
|||
* }); |
|||
* } |
|||
* ``` |
|||
*/ |
|||
export function addRootImport(project: string, callback: CodeBlockCallback): Rule { |
|||
return getRootInsertionRule(project, callback, 'imports', { |
|||
name: 'importProvidersFrom', |
|||
module: '@angular/core', |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Adds a provider to the root of the project. |
|||
* @param project Name of the project to which to add the import. |
|||
* @param callback Function that generates the code block which should be inserted. |
|||
* @example |
|||
* |
|||
* ```ts
|
|||
* import { Rule } from '@angular-devkit/schematics'; |
|||
* import { addRootProvider } from '@schematics/angular/utility'; |
|||
* |
|||
* export default function(): Rule { |
|||
* return addRootProvider('default', ({code, external}) => { |
|||
* return code`${external('provideLibrary', '@my/library')}({})`; |
|||
* }); |
|||
* } |
|||
* ``` |
|||
*/ |
|||
export function addRootProvider(project: string, callback: CodeBlockCallback): Rule { |
|||
return getRootInsertionRule(project, callback, 'providers'); |
|||
} |
|||
|
|||
/** |
|||
* Creates a rule that inserts code at the root of either a standalone or NgModule-based project. |
|||
* @param project Name of the project into which to inser tthe code. |
|||
* @param callback Function that generates the code block which should be inserted. |
|||
* @param ngModuleField Field of the root NgModule into which the code should be inserted, if the |
|||
* app is based on NgModule |
|||
* @param standaloneWrapperFunction Function with which to wrap the code if the app is standalone. |
|||
*/ |
|||
function getRootInsertionRule( |
|||
project: string, |
|||
callback: CodeBlockCallback, |
|||
ngModuleField: string, |
|||
standaloneWrapperFunction?: { name: string; module: string }, |
|||
): Rule { |
|||
return async host => { |
|||
const mainFilePath = await getMainFilePath(host, project); |
|||
const codeBlock = new CodeBlock(); |
|||
|
|||
if (isStandaloneApp(host, mainFilePath)) { |
|||
return tree => |
|||
addProviderToStandaloneBootstrap( |
|||
tree, |
|||
callback(codeBlock), |
|||
mainFilePath, |
|||
standaloneWrapperFunction, |
|||
); |
|||
} |
|||
|
|||
const modulePath = getAppModulePath(host, mainFilePath); |
|||
const pendingCode = CodeBlock.transformPendingCode(callback(codeBlock), modulePath); |
|||
|
|||
return chain([ |
|||
...pendingCode.rules, |
|||
tree => { |
|||
const changes = addSymbolToNgModuleMetadata( |
|||
getSourceFile(tree, modulePath), |
|||
modulePath, |
|||
ngModuleField, |
|||
pendingCode.code.expression, |
|||
// Explicitly set the import path to null since we deal with imports here separately.
|
|||
null, |
|||
); |
|||
|
|||
applyChangesToFile(tree, modulePath, changes); |
|||
}, |
|||
]); |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Adds a provider to the root of a standalone project. |
|||
* @param host Tree of the root rule. |
|||
* @param pendingCode Code that should be inserted. |
|||
* @param mainFilePath Path to the project's main file. |
|||
* @param wrapperFunction Optional function with which to wrap the provider. |
|||
*/ |
|||
function addProviderToStandaloneBootstrap( |
|||
host: Tree, |
|||
pendingCode: PendingCode, |
|||
mainFilePath: string, |
|||
wrapperFunction?: { name: string; module: string }, |
|||
): Rule { |
|||
const bootstrapCall = findBootstrapApplicationCall(host, mainFilePath); |
|||
const fileToEdit = findAppConfig(bootstrapCall, host, mainFilePath)?.filePath || mainFilePath; |
|||
const { code, rules } = CodeBlock.transformPendingCode(pendingCode, fileToEdit); |
|||
|
|||
return chain([ |
|||
...rules, |
|||
() => { |
|||
let wrapped: PendingCode; |
|||
let additionalRules: Rule[]; |
|||
|
|||
if (wrapperFunction) { |
|||
const block = new CodeBlock(); |
|||
const result = CodeBlock.transformPendingCode( |
|||
block.code`${block.external(wrapperFunction.name, wrapperFunction.module)}(${ |
|||
code.expression |
|||
})`,
|
|||
fileToEdit, |
|||
); |
|||
|
|||
wrapped = result.code; |
|||
additionalRules = result.rules; |
|||
} else { |
|||
wrapped = code; |
|||
additionalRules = []; |
|||
} |
|||
|
|||
return chain([ |
|||
...additionalRules, |
|||
tree => insertStandaloneRootProvider(tree, mainFilePath, wrapped.expression), |
|||
]); |
|||
}, |
|||
]); |
|||
} |
|||
|
|||
/** |
|||
* Inserts a string expression into the root of a standalone project. |
|||
* @param tree File tree used to modify the project. |
|||
* @param mainFilePath Path to the main file of the project. |
|||
* @param expression Code expression to be inserted. |
|||
*/ |
|||
function insertStandaloneRootProvider(tree: Tree, mainFilePath: string, expression: string): void { |
|||
const bootstrapCall = findBootstrapApplicationCall(tree, mainFilePath); |
|||
const appConfig = findAppConfig(bootstrapCall, tree, mainFilePath); |
|||
|
|||
if (bootstrapCall.arguments.length === 0) { |
|||
throw new SchematicsException( |
|||
`Cannot add provider to invalid bootstrapApplication call in ${ |
|||
bootstrapCall.getSourceFile().fileName |
|||
}`,
|
|||
); |
|||
} |
|||
|
|||
if (appConfig) { |
|||
addProvidersExpressionToAppConfig(tree, appConfig, expression); |
|||
|
|||
return; |
|||
} |
|||
|
|||
const newAppConfig = `, {\n${tags.indentBy(2)`providers: [${expression}]`}\n}`; |
|||
let targetCall: ts.CallExpression; |
|||
|
|||
if (bootstrapCall.arguments.length === 1) { |
|||
targetCall = bootstrapCall; |
|||
} else if (isMergeAppConfigCall(bootstrapCall.arguments[1])) { |
|||
targetCall = bootstrapCall.arguments[1]; |
|||
} else { |
|||
throw new SchematicsException( |
|||
`Cannot statically analyze bootstrapApplication call in ${ |
|||
bootstrapCall.getSourceFile().fileName |
|||
}`,
|
|||
); |
|||
} |
|||
|
|||
applyChangesToFile(tree, mainFilePath, [ |
|||
insertAfterLastOccurrence( |
|||
targetCall.arguments, |
|||
newAppConfig, |
|||
mainFilePath, |
|||
targetCall.getEnd() - 1, |
|||
), |
|||
]); |
|||
} |
|||
|
|||
/** |
|||
* Adds a string expression to an app config object. |
|||
* @param tree File tree used to modify the project. |
|||
* @param appConfig Resolved configuration object of the project. |
|||
* @param expression Code expression to be inserted. |
|||
*/ |
|||
function addProvidersExpressionToAppConfig( |
|||
tree: Tree, |
|||
appConfig: ResolvedAppConfig, |
|||
expression: string, |
|||
): void { |
|||
const { node, filePath } = appConfig; |
|||
const configProps = node.properties; |
|||
const providersLiteral = findProvidersLiteral(node); |
|||
|
|||
// If there's a `providers` property, we can add the provider
|
|||
// to it, otherwise we need to declare it ourselves.
|
|||
if (providersLiteral) { |
|||
applyChangesToFile(tree, filePath, [ |
|||
insertAfterLastOccurrence( |
|||
providersLiteral.elements, |
|||
(providersLiteral.elements.length === 0 ? '' : ', ') + expression, |
|||
filePath, |
|||
providersLiteral.getStart() + 1, |
|||
), |
|||
]); |
|||
} else { |
|||
const prop = tags.indentBy(2)`providers: [${expression}]`; |
|||
let toInsert: string; |
|||
let insertPosition: number; |
|||
|
|||
if (configProps.length === 0) { |
|||
toInsert = '\n' + prop + '\n'; |
|||
insertPosition = node.getEnd() - 1; |
|||
} else { |
|||
const hasTrailingComma = configProps.hasTrailingComma; |
|||
toInsert = (hasTrailingComma ? '' : ',') + '\n' + prop; |
|||
insertPosition = configProps[configProps.length - 1].getEnd() + (hasTrailingComma ? 1 : 0); |
|||
} |
|||
|
|||
applyChangesToFile(tree, filePath, [new InsertChange(filePath, insertPosition, toInsert)]); |
|||
} |
|||
} |
|||
@ -0,0 +1,171 @@ |
|||
/** |
|||
* @license |
|||
* Copyright Google LLC 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.dev/license
|
|||
*/ |
|||
|
|||
import { SchematicsException, Tree } from '@angular-devkit/schematics'; |
|||
import * as ts from 'typescript'; |
|||
import { Change, applyToUpdateRecorder } from '../change'; |
|||
import { targetBuildNotFoundError } from '../project-targets'; |
|||
import { getWorkspace } from '../workspace'; |
|||
import { Builders } from '../workspace-models'; |
|||
|
|||
/** |
|||
* Finds the main file of a project. |
|||
* @param tree File tree for the project. |
|||
* @param projectName Name of the project in which to search. |
|||
*/ |
|||
export async function getMainFilePath(tree: Tree, projectName: string): Promise<string> { |
|||
const workspace = await getWorkspace(tree); |
|||
const project = workspace.projects.get(projectName); |
|||
const buildTarget = project?.targets.get('build'); |
|||
|
|||
if (!buildTarget) { |
|||
throw targetBuildNotFoundError(); |
|||
} |
|||
|
|||
const options = buildTarget.options as Record<string, string>; |
|||
|
|||
return buildTarget.builder === Builders.Application || |
|||
buildTarget.builder === Builders.BuildApplication |
|||
? options.browser |
|||
: options.main; |
|||
} |
|||
|
|||
/** |
|||
* Gets a TypeScript source file at a specific path. |
|||
* @param tree File tree of a project. |
|||
* @param path Path to the file. |
|||
*/ |
|||
export function getSourceFile(tree: Tree, path: string): ts.SourceFile { |
|||
const content = tree.readText(path); |
|||
const source = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); |
|||
|
|||
return source; |
|||
} |
|||
|
|||
/** Finds the call to `bootstrapApplication` within a file. */ |
|||
export function findBootstrapApplicationCall(tree: Tree, mainFilePath: string): ts.CallExpression { |
|||
const sourceFile = getSourceFile(tree, mainFilePath); |
|||
const localName = findImportLocalName( |
|||
sourceFile, |
|||
'bootstrapApplication', |
|||
'@angular/platform-browser', |
|||
); |
|||
|
|||
if (localName) { |
|||
let result: ts.CallExpression | null = null; |
|||
|
|||
sourceFile.forEachChild(function walk(node) { |
|||
if ( |
|||
ts.isCallExpression(node) && |
|||
ts.isIdentifier(node.expression) && |
|||
node.expression.text === localName |
|||
) { |
|||
result = node; |
|||
} |
|||
|
|||
if (!result) { |
|||
node.forEachChild(walk); |
|||
} |
|||
}); |
|||
|
|||
if (result) { |
|||
return result; |
|||
} |
|||
} |
|||
|
|||
throw new SchematicsException(`Could not find bootstrapApplication call in ${mainFilePath}`); |
|||
} |
|||
|
|||
/** |
|||
* Finds the local name of an imported symbol. Could be the symbol name itself or its alias. |
|||
* @param sourceFile File within which to search for the import. |
|||
* @param name Actual name of the import, not its local alias. |
|||
* @param moduleName Name of the module from which the symbol is imported. |
|||
*/ |
|||
function findImportLocalName( |
|||
sourceFile: ts.SourceFile, |
|||
name: string, |
|||
moduleName: string, |
|||
): string | null { |
|||
for (const node of sourceFile.statements) { |
|||
// Only look for top-level imports.
|
|||
if ( |
|||
!ts.isImportDeclaration(node) || |
|||
!ts.isStringLiteral(node.moduleSpecifier) || |
|||
node.moduleSpecifier.text !== moduleName |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
// Filter out imports that don't have the right shape.
|
|||
if ( |
|||
!node.importClause || |
|||
!node.importClause.namedBindings || |
|||
!ts.isNamedImports(node.importClause.namedBindings) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
// Look through the elements of the declaration for the specific import.
|
|||
for (const element of node.importClause.namedBindings.elements) { |
|||
if ((element.propertyName || element.name).text === name) { |
|||
// The local name is always in `name`.
|
|||
return element.name.text; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Applies a set of changes to a file. |
|||
* @param tree File tree of the project. |
|||
* @param path Path to the file that is being changed. |
|||
* @param changes Changes that should be applied to the file. |
|||
*/ |
|||
export function applyChangesToFile(tree: Tree, path: string, changes: Change[]) { |
|||
if (changes.length > 0) { |
|||
const recorder = tree.beginUpdate(path); |
|||
applyToUpdateRecorder(recorder, changes); |
|||
tree.commitUpdate(recorder); |
|||
} |
|||
} |
|||
|
|||
/** Checks whether a node is a call to `mergeApplicationConfig`. */ |
|||
export function isMergeAppConfigCall(node: ts.Node): node is ts.CallExpression { |
|||
if (!ts.isCallExpression(node)) { |
|||
return false; |
|||
} |
|||
|
|||
const localName = findImportLocalName( |
|||
node.getSourceFile(), |
|||
'mergeApplicationConfig', |
|||
'@angular/core', |
|||
); |
|||
|
|||
return !!localName && ts.isIdentifier(node.expression) && node.expression.text === localName; |
|||
} |
|||
|
|||
/** Finds the `providers` array literal within an application config. */ |
|||
export function findProvidersLiteral( |
|||
config: ts.ObjectLiteralExpression, |
|||
): ts.ArrayLiteralExpression | null { |
|||
for (const prop of config.properties) { |
|||
if ( |
|||
ts.isPropertyAssignment(prop) && |
|||
ts.isIdentifier(prop.name) && |
|||
prop.name.text === 'providers' && |
|||
ts.isArrayLiteralExpression(prop.initializer) |
|||
) { |
|||
return prop.initializer; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
@ -0,0 +1,159 @@ |
|||
import { SchematicsException, Tree, UpdateRecorder } from '@angular-devkit/schematics'; |
|||
import { getMainFilePath } from './angular/standalone/util'; |
|||
import * as ts from 'typescript'; |
|||
import { getAppModulePath, getDecoratorMetadata, getMetadataField } from './angular'; |
|||
import { createSourceFile } from '../commands/change-theme/index'; |
|||
import { normalize, Path } from '@angular-devkit/core'; |
|||
import * as path from 'path'; |
|||
import { removeEmptyElementsFromArrayLiteral } from './ast'; |
|||
|
|||
/** |
|||
* Checks whether a specific import or provider exists in the specified metadata |
|||
* array (`imports`, `providers`, etc.) of the `NgModule` decorator in the AppModule. |
|||
* |
|||
* This function locates the AppModule file of the given Angular project, |
|||
* parses its AST, and inspects the specified metadata array to determine |
|||
* if it includes an element matching the provided string (e.g., `CommonModule`, `HttpClientModule`). |
|||
* |
|||
* @param host - The virtual file system tree used by Angular schematics. |
|||
* @param projectName - The name of the Angular project. |
|||
* @param metadataFn - The name (string) to match against the elements of the metadata array. |
|||
* @param metadataName - The metadata field to search in (e.g., 'imports', 'providers'). Defaults to 'imports'. |
|||
* @returns A promise that resolves to `true` if the metadata function is found, or `false` otherwise. |
|||
* @throws SchematicsException if the AppModule file or expected metadata is not found or malformed. |
|||
*/ |
|||
export const hasImportInNgModule = async ( |
|||
host: Tree, |
|||
projectName: string, |
|||
metadataFn: string, |
|||
metadataName = 'imports', |
|||
): Promise<boolean> => { |
|||
const mainFilePath = await getMainFilePath(host, projectName); |
|||
const appModulePath = getAppModulePath(host, mainFilePath); |
|||
const buffer = host.read(appModulePath); |
|||
|
|||
if (!buffer) { |
|||
throw new SchematicsException(`Could not read file: ${appModulePath}`); |
|||
} |
|||
|
|||
const source = createSourceFile(host, appModulePath); |
|||
|
|||
// Get the NgModule decorator metadata
|
|||
const ngModuleDecorator = getDecoratorMetadata(source, 'NgModule', '@angular/core')[0]; |
|||
|
|||
if (!ngModuleDecorator) { |
|||
throw new SchematicsException('The app module does not found'); |
|||
} |
|||
|
|||
const matchingProperties = getMetadataField( |
|||
ngModuleDecorator as ts.ObjectLiteralExpression, |
|||
metadataName, |
|||
); |
|||
const assignment = matchingProperties[0] as ts.PropertyAssignment; |
|||
const assignmentInit = assignment.initializer as ts.ArrayLiteralExpression; |
|||
|
|||
const elements = assignmentInit.elements; |
|||
if (!elements || elements.length < 1) { |
|||
throw new SchematicsException(`Elements could not found: ${elements}`); |
|||
} |
|||
|
|||
return elements.some(f => f.getText().match(metadataFn)); |
|||
}; |
|||
|
|||
/** |
|||
* Attempts to locate the path of the `AppRoutingModule` file that is imported |
|||
* within the root AppModule file of an Angular application. |
|||
* |
|||
* This function reads the AppModule file (resolved from the main file path), |
|||
* parses its AST, and searches for an import declaration that imports |
|||
* `AppRoutingModule`. Once found, it resolves the import path to a normalized |
|||
* file path relative to the workspace root. |
|||
* |
|||
* @param tree - The virtual file system tree used by Angular schematics. |
|||
* @param mainFilePath - The path to the main entry file of the Angular application (typically `main.ts`). |
|||
* @returns A normalized workspace-relative path to the AppRoutingModule file if found, or `null` otherwise. |
|||
* @throws If the route file path is resolved but the file does not exist in the tree. |
|||
*/ |
|||
export async function findAppRoutesModulePath( |
|||
tree: Tree, |
|||
mainFilePath: string, |
|||
): Promise<Path | null> { |
|||
const appModulePath = getAppModulePath(tree, mainFilePath); |
|||
if (!appModulePath || !tree.exists(appModulePath)) return null; |
|||
|
|||
const buffer = tree.read(appModulePath); |
|||
if (!buffer) return null; |
|||
|
|||
const source = ts.createSourceFile( |
|||
appModulePath, |
|||
buffer.toString('utf-8'), |
|||
ts.ScriptTarget.Latest, |
|||
true, |
|||
); |
|||
|
|||
for (const stmt of source.statements) { |
|||
if (!ts.isImportDeclaration(stmt)) continue; |
|||
|
|||
const importClause = stmt.importClause; |
|||
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) continue; |
|||
|
|||
const isRoutesImport = importClause.namedBindings.elements.some( |
|||
el => el.name.getText() === 'AppRoutingModule', |
|||
); |
|||
if (!isRoutesImport || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; |
|||
|
|||
let importPath = stmt.moduleSpecifier.text; |
|||
|
|||
if (!importPath.endsWith('.ts')) { |
|||
importPath += '.ts'; |
|||
} |
|||
|
|||
const configDir = path.dirname(appModulePath); |
|||
const resolvedFsPath = path.resolve(configDir, importPath); |
|||
const workspaceRelativePath = path.relative(process.cwd(), resolvedFsPath).replace(/\\/g, '/'); |
|||
|
|||
const normalizedPath = normalize(workspaceRelativePath); |
|||
|
|||
if (!tree.exists(normalizedPath)) { |
|||
throw new Error(`Cannot find routes file: ${normalizedPath}`); |
|||
} |
|||
|
|||
return normalizedPath; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Cleans up empty or invalid expressions (e.g., extra commas) from the `imports` and `providers` |
|||
* arrays in the NgModule decorator of an Angular module file. |
|||
* |
|||
* This function parses the source file's AST, locates the `NgModule` decorator, and processes |
|||
* the `imports` and `providers` metadata fields. If these fields contain array literals with |
|||
* empty slots (such as trailing or double commas), they are removed and the array is rewritten. |
|||
* |
|||
* @param source - The TypeScript source file containing the Angular module. |
|||
* @param recorder - The recorder used to apply changes to the source file. |
|||
*/ |
|||
export function cleanEmptyExprFromModule(source: ts.SourceFile, recorder: UpdateRecorder): void { |
|||
const ngModuleNode = getDecoratorMetadata(source, 'NgModule', '@angular/core')[0]; |
|||
if (!ngModuleNode) return; |
|||
|
|||
const printer = ts.createPrinter(); |
|||
const metadataKeys = ['imports', 'providers']; |
|||
for (const key of metadataKeys) { |
|||
const metadataField = getMetadataField(ngModuleNode as ts.ObjectLiteralExpression, key); |
|||
if (!metadataField.length) continue; |
|||
|
|||
const assignment = metadataField[0] as ts.PropertyAssignment; |
|||
const arrayLiteral = assignment.initializer as ts.ArrayLiteralExpression; |
|||
|
|||
const cleanedArray = removeEmptyElementsFromArrayLiteral(arrayLiteral); |
|||
|
|||
recorder.remove(arrayLiteral.getStart(), arrayLiteral.getWidth()); |
|||
recorder.insertLeft( |
|||
arrayLiteral.getStart(), |
|||
printer.printNode(ts.EmitHint.Expression, cleanedArray, source), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,180 @@ |
|||
import { SchematicsException, Tree, UpdateRecorder } from '@angular-devkit/schematics'; |
|||
import { findBootstrapApplicationCall, getMainFilePath } from './angular/standalone/util'; |
|||
import { findAppConfig } from './angular/standalone/app_config'; |
|||
import * as ts from 'typescript'; |
|||
import { normalize, Path } from '@angular-devkit/core'; |
|||
import * as path from 'path'; |
|||
import { findNodes } from './angular'; |
|||
import { removeEmptyElementsFromArrayLiteral } from './ast'; |
|||
|
|||
/** |
|||
* Retrieves the file path of the application's configuration used in a standalone |
|||
* Angular application setup. |
|||
* |
|||
* This function locates the `bootstrapApplication` call in the main entry file and |
|||
* resolves the path to the configuration object passed to it (typically `appConfig`). |
|||
* |
|||
* @param host - The virtual file system tree used by Angular schematics. |
|||
* @param mainFilePath - The path to the main entry file of the Angular application (e.g., `main.ts`). |
|||
* @returns The resolved file path of the application's configuration, or an empty string if not found. |
|||
*/ |
|||
export const getAppConfigPath = (host: Tree, mainFilePath: string): string => { |
|||
const bootstrapCall = findBootstrapApplicationCall(host, mainFilePath); |
|||
const appConfig = findAppConfig(bootstrapCall, host, mainFilePath); |
|||
return appConfig?.filePath || ''; |
|||
}; |
|||
|
|||
/** |
|||
* Attempts to locate the file path of the `routes` array used in a standalone |
|||
* Angular application configuration. |
|||
* |
|||
* This function resolves the application's config file (typically where `routes` is defined or imported), |
|||
* parses the file, and inspects its import declarations to find the import associated with `routes`. |
|||
* It then resolves and normalizes the file path of the `routes` definition and returns it. |
|||
* |
|||
* @param tree - The virtual file system tree used by Angular schematics. |
|||
* @param mainFilePath - The path to the main entry file of the Angular application (e.g., `main.ts`). |
|||
* @returns The normalized workspace-relative path to the file where `routes` is defined, or `null` if not found. |
|||
* @throws If the `routes` import path is found but the file does not exist in the tree. |
|||
*/ |
|||
export function findAppRoutesPath(tree: Tree, mainFilePath: string): Path | null { |
|||
const appConfigPath = getAppConfigPath(tree, mainFilePath); |
|||
if (!appConfigPath || !tree.exists(appConfigPath)) return null; |
|||
|
|||
const buffer = tree.read(appConfigPath); |
|||
if (!buffer) return null; |
|||
|
|||
const source = ts.createSourceFile( |
|||
appConfigPath, |
|||
buffer.toString('utf-8'), |
|||
ts.ScriptTarget.Latest, |
|||
true, |
|||
); |
|||
|
|||
for (const stmt of source.statements) { |
|||
if (!ts.isImportDeclaration(stmt)) continue; |
|||
|
|||
const importClause = stmt.importClause; |
|||
if (!importClause?.namedBindings || !ts.isNamedImports(importClause.namedBindings)) continue; |
|||
|
|||
const isRoutesImport = importClause.namedBindings.elements.some( |
|||
el => el.name.getText() === 'routes', |
|||
); |
|||
if (!isRoutesImport || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; |
|||
|
|||
let importPath = stmt.moduleSpecifier.text; |
|||
|
|||
if (!importPath.endsWith('.ts')) { |
|||
importPath += '.ts'; |
|||
} |
|||
|
|||
const configDir = path.dirname(appConfigPath); |
|||
const resolvedFsPath = path.resolve(configDir, importPath); |
|||
const workspaceRelativePath = path.relative(process.cwd(), resolvedFsPath).replace(/\\/g, '/'); |
|||
|
|||
const normalizedPath = normalize(workspaceRelativePath); |
|||
|
|||
if (!tree.exists(normalizedPath)) { |
|||
throw new Error(`Cannot find routes file: ${normalizedPath}`); |
|||
} |
|||
|
|||
return normalizedPath; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Checks whether a specific provider is registered in the `providers` array of the |
|||
* standalone application configuration (typically within `app.config.ts`) in an Angular project. |
|||
* |
|||
* This function reads and parses the application configuration file, looks for the |
|||
* `providers` property in the configuration object, and checks whether it includes |
|||
* the specified provider name. |
|||
* |
|||
* @param host - The virtual file system tree used by Angular schematics. |
|||
* @param projectName - The name of the Angular project. |
|||
* @param providerName - The name of the provider to search for (as a string match). |
|||
* @returns A promise that resolves to `true` if the provider is found, otherwise `false`. |
|||
* @throws SchematicsException if the app config file cannot be read. |
|||
*/ |
|||
export const hasProviderInStandaloneAppConfig = async ( |
|||
host: Tree, |
|||
projectName: string, |
|||
providerName: string, |
|||
): Promise<boolean> => { |
|||
const mainFilePath = await getMainFilePath(host, projectName); |
|||
const appConfigPath = getAppConfigPath(host, mainFilePath); |
|||
const buffer = host.read(appConfigPath); |
|||
|
|||
if (!buffer) { |
|||
throw new SchematicsException(`Could not read file: ${appConfigPath}`); |
|||
} |
|||
|
|||
const source = ts.createSourceFile( |
|||
appConfigPath, |
|||
buffer.toString('utf-8'), |
|||
ts.ScriptTarget.Latest, |
|||
true, |
|||
); |
|||
const callExpressions = source.statements |
|||
.flatMap(stmt => (ts.isVariableStatement(stmt) ? stmt.declarationList.declarations : [])) |
|||
.flatMap(decl => |
|||
decl.initializer && ts.isObjectLiteralExpression(decl.initializer) |
|||
? decl.initializer.properties |
|||
: [], |
|||
) |
|||
.filter(ts.isPropertyAssignment) |
|||
.filter(prop => prop.name.getText() === 'providers'); |
|||
|
|||
if (callExpressions.length === 0) return false; |
|||
|
|||
const providersArray = callExpressions[0].initializer as ts.ArrayLiteralExpression; |
|||
return providersArray.elements.some(el => el.getText().includes(providerName)); |
|||
}; |
|||
|
|||
/** |
|||
* Cleans up empty or invalid expressions (e.g., extra or trailing commas) from the |
|||
* `providers` array within a standalone Angular application configuration object. |
|||
* |
|||
* This function parses the source file's AST to locate variable declarations that |
|||
* define an object literal. It then searches for a `providers` property and removes |
|||
* any empty elements from its array literal, replacing it with a cleaned version. |
|||
* |
|||
* Typically used in Angular schematics to ensure the `providers` array in `app.config.ts` |
|||
* is free of empty slots after modifications. |
|||
* |
|||
* @param source - The TypeScript source file containing the app configuration. |
|||
* @param recorder - The recorder used to apply changes to the source file. |
|||
*/ |
|||
export function cleanEmptyExprFromProviders(source: ts.SourceFile, recorder: UpdateRecorder): void { |
|||
const varStatements = findNodes(source, ts.isVariableStatement); |
|||
const printer = ts.createPrinter(); |
|||
|
|||
for (const stmt of varStatements) { |
|||
const declList = stmt.declarationList; |
|||
for (const decl of declList.declarations) { |
|||
if (!decl.initializer || !ts.isObjectLiteralExpression(decl.initializer)) continue; |
|||
|
|||
const obj = decl.initializer; |
|||
|
|||
const providersProp = obj.properties.find( |
|||
prop => |
|||
ts.isPropertyAssignment(prop) && |
|||
ts.isIdentifier(prop.name) && |
|||
prop.name.text === 'providers', |
|||
) as ts.PropertyAssignment; |
|||
|
|||
if (!providersProp || !ts.isArrayLiteralExpression(providersProp.initializer)) continue; |
|||
|
|||
const arrayLiteral = providersProp.initializer; |
|||
const cleanedArray = removeEmptyElementsFromArrayLiteral(arrayLiteral); |
|||
|
|||
recorder.remove(arrayLiteral.getStart(), arrayLiteral.getWidth()); |
|||
recorder.insertLeft( |
|||
arrayLiteral.getStart(), |
|||
printer.printNode(ts.EmitHint.Expression, cleanedArray, source), |
|||
); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue