Browse Source

Merge pull request #22645 from abpframework/feature/#19594-standalone-migration

Refactoring schematics for standalone migration
pull/22807/head
Gizem Mutu Kurt 9 months ago
committed by GitHub
parent
commit
22ec02b356
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 398
      npm/ng-packs/packages/schematics/src/commands/change-theme/index.ts
  2. 101
      npm/ng-packs/packages/schematics/src/commands/change-theme/style-map.ts
  3. 44
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/.eslintrc.json.template
  4. 7
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/ng-package.json.template
  5. 1
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/enums/index.ts.template
  6. 3
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/enums/route-names.ts.template
  7. 1
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/providers/index.ts.template
  8. 30
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/providers/route.provider.ts.template
  9. 2
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/public-api.ts.template
  10. 44
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/karma.conf.js.template
  11. 7
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/ng-package.json.template
  12. 11
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/package.json.template
  13. 11
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/lib/__libraryName@kebab__.component.ts.template
  14. 9
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/lib/__libraryName@kebab__.routes.ts.template
  15. 1
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/lib/index.ts.template
  16. 4
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/public-api.ts.template
  17. 26
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/test.ts.template
  18. 20
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/tsconfig.lib.json.template
  19. 10
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/tsconfig.lib.prod.json.template
  20. 17
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/tsconfig.spec.json.template
  21. 6
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/ng-package.json.template
  22. 7
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/src/lib/__target@kebab__-__libraryName@kebab__.ts.template
  23. 1
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/src/lib/index.ts.template
  24. 1
      npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/src/public-api.ts.template
  25. 307
      npm/ng-packs/packages/schematics/src/commands/create-lib/index.ts
  26. 11
      npm/ng-packs/packages/schematics/src/commands/create-lib/models/generate-lib-schema.ts
  27. 22
      npm/ng-packs/packages/schematics/src/commands/create-lib/schema.json
  28. 11
      npm/ng-packs/packages/schematics/src/utils/angular/add-declaration-to-ng-module.ts
  29. 141
      npm/ng-packs/packages/schematics/src/utils/angular/ast-utils.ts
  30. 20
      npm/ng-packs/packages/schematics/src/utils/angular/change.ts
  31. 2
      npm/ng-packs/packages/schematics/src/utils/angular/dependencies.ts
  32. 10
      npm/ng-packs/packages/schematics/src/utils/angular/dependency.ts
  33. 25
      npm/ng-packs/packages/schematics/src/utils/angular/eol.ts
  34. 12
      npm/ng-packs/packages/schematics/src/utils/angular/find-module.ts
  35. 22
      npm/ng-packs/packages/schematics/src/utils/angular/generate-from-files.ts
  36. 1
      npm/ng-packs/packages/schematics/src/utils/angular/index.ts
  37. 18
      npm/ng-packs/packages/schematics/src/utils/angular/json-file.ts
  38. 24
      npm/ng-packs/packages/schematics/src/utils/angular/ng-ast-utils.ts
  39. 2
      npm/ng-packs/packages/schematics/src/utils/angular/parse-name.ts
  40. 12
      npm/ng-packs/packages/schematics/src/utils/angular/paths.ts
  41. 12
      npm/ng-packs/packages/schematics/src/utils/angular/project-targets.ts
  42. 148
      npm/ng-packs/packages/schematics/src/utils/angular/standalone/app_component.ts
  43. 127
      npm/ng-packs/packages/schematics/src/utils/angular/standalone/app_config.ts
  44. 115
      npm/ng-packs/packages/schematics/src/utils/angular/standalone/code_block.ts
  45. 10
      npm/ng-packs/packages/schematics/src/utils/angular/standalone/index.ts
  46. 258
      npm/ng-packs/packages/schematics/src/utils/angular/standalone/rules.ts
  47. 171
      npm/ng-packs/packages/schematics/src/utils/angular/standalone/util.ts
  48. 5
      npm/ng-packs/packages/schematics/src/utils/angular/validation.ts
  49. 20
      npm/ng-packs/packages/schematics/src/utils/angular/workspace-models.ts
  50. 12
      npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts
  51. 7
      npm/ng-packs/packages/schematics/src/utils/ast.ts
  52. 2
      npm/ng-packs/packages/schematics/src/utils/index.ts
  53. 159
      npm/ng-packs/packages/schematics/src/utils/ng-module.ts
  54. 180
      npm/ng-packs/packages/schematics/src/utils/standalone.ts
  55. 2
      npm/ng-packs/packages/schematics/src/utils/workspace.ts
  56. 5
      npm/ng-packs/scripts/build-schematics.ts

398
npm/ng-packs/packages/schematics/src/commands/change-theme/index.ts

@ -5,21 +5,20 @@ import * as ts from 'typescript';
import { allStyles, importMap, styleMap } from './style-map';
import { ChangeThemeOptions } from './model';
import {
Change,
createDefaultPath,
InsertChange,
addRootImport,
addRootProvider,
getAppModulePath,
isLibrary,
isStandaloneApp,
updateWorkspace,
WorkspaceDefinition,
getAppConfigPath,
cleanEmptyExprFromModule,
cleanEmptyExprFromProviders,
} from '../../utils';
import { ThemeOptionsEnum } from './theme-options.enum';
import {
addImportToModule,
addProviderToModule,
findNodes,
getDecoratorMetadata,
getMetadataField,
} from '../../utils/angular/ast-utils';
import { findNodes, getDecoratorMetadata, getMetadataField } from '../../utils/angular/ast-utils';
import { getMainFilePath } from '../../utils/angular/standalone/util';
export default function (_options: ChangeThemeOptions): Rule {
return async () => {
@ -68,46 +67,60 @@ function updateProjectStyle(
function updateAppModule(selectedProject: string, targetThemeName: ThemeOptionsEnum): Rule {
return async (host: Tree) => {
const appModulePath = (await createDefaultPath(host, selectedProject)) + '/app.module.ts';
const mainFilePath = await getMainFilePath(host, selectedProject);
const isStandalone = isStandaloneApp(host, mainFilePath);
const appModulePath = isStandalone
? getAppConfigPath(host, mainFilePath)
: getAppModulePath(host, mainFilePath);
return chain([
removeImportPath(appModulePath, targetThemeName),
removeImportFromNgModuleMetadata(appModulePath, targetThemeName),
removeProviderFromNgModuleMetadata(appModulePath, targetThemeName),
insertImports(appModulePath, targetThemeName),
insertProviders(appModulePath, targetThemeName),
...(!isStandalone ? [removeImportFromNgModuleMetadata(appModulePath, targetThemeName)] : []),
isStandalone
? removeImportsFromStandaloneProviders(appModulePath, targetThemeName)
: removeProviderFromNgModuleMetadata(appModulePath, targetThemeName),
insertImports(selectedProject, targetThemeName),
insertProviders(selectedProject, targetThemeName),
adjustProvideAbpThemeShared(appModulePath, targetThemeName),
formatFile(appModulePath),
cleanEmptyExpressions(appModulePath, isStandalone),
]);
};
}
export function removeImportPath(appModulePath: string, selectedTheme: ThemeOptionsEnum): Rule {
export function removeImportPath(filePath: string, selectedTheme: ThemeOptionsEnum): Rule {
return (host: Tree) => {
const recorder = host.beginUpdate(appModulePath);
const source = createSourceFile(host, appModulePath);
const buffer = host.read(filePath);
if (!buffer) return host;
const sourceText = buffer.toString('utf-8');
const source = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
const recorder = host.beginUpdate(filePath);
const impMap = getImportPaths(selectedTheme, true);
const nodes = findNodes(source, ts.isImportDeclaration);
const filteredNodes = nodes.filter(node =>
impMap.some(({ path, importName }) => {
const sourceModule = node.getFullText();
const moduleName = importName.split('.')[0];
const filteredNodes = nodes.filter(node => {
const importPath = (node.moduleSpecifier as ts.StringLiteral).text;
const namedBindings = node.importClause?.namedBindings;
if (path && sourceModule.match(path)) {
return true;
}
return impMap.some(({ path, importName }) => {
const symbol = importName.split('.')[0];
const matchesPath = !!path && importPath === path;
return !!(moduleName && sourceModule.match(moduleName));
}),
);
const matchesSymbol =
!!namedBindings &&
ts.isNamedImports(namedBindings) &&
namedBindings.elements.some(e => e.name.text === symbol);
if (filteredNodes?.length < 1) {
return;
}
return matchesPath || matchesSymbol;
});
});
filteredNodes.map(importPath =>
recorder.remove(importPath.getStart(), importPath.getWidth() + 1),
);
for (const node of filteredNodes) {
recorder.remove(node.getStart(), node.getWidth());
}
host.commitUpdate(recorder);
return host;
@ -153,100 +166,188 @@ export function removeImportFromNgModuleMetadata(
};
}
export function removeProviderFromNgModuleMetadata(
appModulePath: string,
export function removeImportsFromStandaloneProviders(
mainPath: string,
selectedTheme: ThemeOptionsEnum,
): Rule {
return (host: Tree) => {
const recorder = host.beginUpdate(appModulePath);
const source = createSourceFile(host, appModulePath);
const buffer = host.read(mainPath);
if (!buffer) return host;
const sourceText = buffer.toString('utf-8');
const source = ts.createSourceFile(mainPath, sourceText, ts.ScriptTarget.Latest, true);
const recorder = host.beginUpdate(mainPath);
const impMap = getImportPaths(selectedTheme, true);
const callExpressions = findNodes(source, ts.isCallExpression);
const node = getDecoratorMetadata(source, 'NgModule', '@angular/core')[0] || {};
if (!node) {
throw new SchematicsException('The app module does not found');
}
for (const expr of callExpressions) {
const exprText = expr.getText();
const matchingProperties = getMetadataField(node as ts.ObjectLiteralExpression, 'providers');
const assignment = matchingProperties[0] as ts.PropertyAssignment;
const assignmentInit = assignment.initializer as ts.ArrayLiteralExpression;
if (expr.expression.getText() === 'importProvidersFrom') {
const args = expr.arguments;
const elements = assignmentInit.elements;
if (!elements || elements.length < 1) {
throw new SchematicsException(`Elements could not found: ${elements}`);
}
let modules: readonly ts.Expression[] = [];
const filteredElements = elements.filter(f =>
impMap.filter(f => !!f.provider).some(s => f.getText().match(s.provider!)),
);
if (args.length === 1 && ts.isArrayLiteralExpression(args[0])) {
modules = (args[0] as ts.ArrayLiteralExpression).elements;
} else {
modules = args;
}
if (!filteredElements || filteredElements.length < 1) {
return;
const elementsToRemove = modules.filter(el =>
impMap.some(({ importName }) => el.getText().includes(importName)),
);
if (elementsToRemove.length) {
for (const removeEl of elementsToRemove) {
const start = removeEl.getFullStart();
const end = removeEl.getEnd();
const nextChar = sourceText.slice(end, end + 1);
const prevChar = sourceText.slice(start - 1, start);
if (nextChar === ',') {
recorder.remove(start, end - start + 1);
} else if (prevChar === ',') {
recorder.remove(start - 1, end - start + 1);
} else {
recorder.remove(start, end - start);
}
}
}
const remaining = modules.filter(el => !elementsToRemove.includes(el));
if (remaining.length === 0) {
const start = expr.getFullStart();
const end = expr.getEnd();
const nextChar = sourceText.slice(end, end + 1);
const prevChar = sourceText.slice(start - 1, start);
if (nextChar === ',') {
recorder.remove(start, end - start + 1);
} else if (prevChar === ',') {
recorder.remove(start - 1, end - start + 1);
} else {
recorder.remove(start, end - start);
}
}
} else {
const match = impMap.find(({ importName, provider }) => {
const moduleSymbol = importName?.split('.')[0];
return (
(moduleSymbol && exprText.includes(moduleSymbol)) ||
(provider && exprText.includes(provider))
);
});
if (match) {
const start = expr.getFullStart();
const end = expr.getEnd();
const nextChar = sourceText.slice(end, end + 1);
const prevChar = sourceText.slice(start - 1, start);
if (nextChar === ',') {
recorder.remove(start, end - start + 1);
} else if (prevChar === ',') {
recorder.remove(start - 1, end - start + 1);
} else {
recorder.remove(start, end - start);
}
}
}
}
filteredElements.map(willRemoveModule => {
recorder.remove(willRemoveModule.getStart(), willRemoveModule.getWidth());
});
host.commitUpdate(recorder);
return host;
};
}
export function insertImports(appModulePath: string, selectedTheme: ThemeOptionsEnum): Rule {
export function removeProviderFromNgModuleMetadata(
appModulePath: string,
selectedTheme: ThemeOptionsEnum,
): Rule {
return (host: Tree) => {
const recorder = host.beginUpdate(appModulePath);
const source = createSourceFile(host, appModulePath);
const selected = importMap.get(selectedTheme);
const impMap = getImportPaths(selectedTheme, true);
if (!selected) {
return host;
const node = getDecoratorMetadata(source, 'NgModule', '@angular/core')[0];
if (!node) {
throw new SchematicsException('The app module does not found');
}
const changes: Change[] = [];
const providersProperty = getMetadataField(
node as ts.ObjectLiteralExpression,
'providers',
)[0] as ts.PropertyAssignment;
selected.map(({ importName, path }) =>
changes.push(...addImportToModule(source, appModulePath, importName, path)),
);
const providersArray = providersProperty.initializer as ts.ArrayLiteralExpression;
if (!providersArray.elements.length) return host;
if (changes.length > 0) {
for (const change of changes) {
if (change instanceof InsertChange) {
recorder.insertLeft(change.order, change.toAdd);
for (const element of providersArray.elements) {
const elementText = element.getText();
const match = impMap.find(({ provider }) => {
if (!provider) return false;
const providerName = provider.replace(/\(\s*\)$/, '').trim();
return provider && elementText.includes(providerName);
});
if (match) {
const start = element.getFullStart();
const end = element.getEnd();
const nextChar = source.text.slice(end, end + 1);
const prevChar = source.text.slice(start - 1, start);
if (nextChar === ',') {
recorder.remove(start, end - start + 1);
} else if (prevChar === ',') {
recorder.remove(start - 1, end - start + 1);
} else {
recorder.remove(start, end - start);
}
}
}
host.commitUpdate(recorder);
return host;
};
}
export function insertProviders(appModulePath: string, selectedTheme: ThemeOptionsEnum): Rule {
return (host: Tree) => {
const recorder = host.beginUpdate(appModulePath);
const source = createSourceFile(host, appModulePath);
export function insertImports(projectName: string, selectedTheme: ThemeOptionsEnum): Rule {
return addRootImport(projectName, code => {
const selected = importMap.get(selectedTheme);
if (!selected?.length) return code.code``;
if (!selected) {
return host;
}
const changes: Change[] = [];
const expressions: string[] = [];
selected.map(({ path, provider }) => {
if (provider) {
changes.push(...addProviderToModule(source, appModulePath, provider + '()', path));
for (const { importName, path, expression } of selected) {
if (importName && path) {
code.external(importName, path);
}
});
for (const change of changes) {
if (change instanceof InsertChange) {
recorder.insertLeft(change.order, change.toAdd);
if (expression) {
expressions.push(expression.trim());
}
}
return code.code`${expressions}`;
});
}
export function insertProviders(projectName: string, selectedTheme: ThemeOptionsEnum): Rule {
return addRootProvider(projectName, code => {
const selected = importMap.get(selectedTheme);
if (!selected || selected.length === 0) return code.code``;
host.commitUpdate(recorder);
return host;
};
const providers = selected
.filter(s => !!s.provider)
.map(({ provider, path, importName }) => {
code.external(importName, path);
return `${provider}`;
});
return code.code`${providers}`;
});
}
export function createSourceFile(host: Tree, appModulePath: string): ts.SourceFile {
@ -271,7 +372,7 @@ export function createSourceFile(host: Tree, appModulePath: string): ts.SourceFi
* @param selectedTheme The selected theme
* @param getAll If true, returns all import paths
*/
export function getImportPaths(selectedTheme: ThemeOptionsEnum, getAll: boolean = false) {
export function getImportPaths(selectedTheme: ThemeOptionsEnum, getAll = false) {
if (getAll) {
return Array.from(importMap.values()).reduce((acc, val) => [...acc, ...val], []);
}
@ -316,3 +417,120 @@ export const styleCompareFn = (item1: string | object, item2: string | object) =
return o1.bundleName && o2.bundleName && o1.bundleName == o2.bundleName;
};
export const formatFile = (filePath: string): Rule => {
return (tree: Tree) => {
const buffer = tree.read(filePath);
if (!buffer) return tree;
const source = ts.createSourceFile(filePath, buffer.toString(), ts.ScriptTarget.Latest, true);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const formatted = printer.printFile(source);
tree.overwrite(filePath, formatted);
return tree;
};
};
export function cleanEmptyExpressions(modulePath: string, isStandalone: boolean): Rule {
return (host: Tree) => {
const buffer = host.read(modulePath);
if (!buffer) throw new SchematicsException(`Cannot read ${modulePath}`);
const source = ts.createSourceFile(
modulePath,
buffer.toString('utf-8'),
ts.ScriptTarget.Latest,
true,
);
const recorder = host.beginUpdate(modulePath);
if (isStandalone) {
cleanEmptyExprFromProviders(source, recorder);
} else {
cleanEmptyExprFromModule(source, recorder);
}
host.commitUpdate(recorder);
return host;
};
}
export function adjustProvideAbpThemeShared(
appModulePath: string,
selectedTheme: ThemeOptionsEnum,
): Rule {
return (host: Tree) => {
const source = createSourceFile(host, appModulePath);
const recorder = host.beginUpdate(appModulePath);
const sourceText = source.getText();
const callExpressions = findProvideAbpThemeSharedCalls(source);
for (const expr of callExpressions) {
const exprStart = expr.getStart();
const exprEnd = expr.getEnd();
const originalText = sourceText.substring(exprStart, exprEnd);
let newText = originalText;
const hasHttpErrorConfig = originalText.includes('withHttpErrorConfig');
const hasValidationBluePrint = originalText.includes('withValidationBluePrint');
if (selectedTheme === ThemeOptionsEnum.LeptonX) {
if (!hasHttpErrorConfig) {
newText = newText.replace(
'(',
`(
withHttpErrorConfig({
errorScreen: {
component: HttpErrorComponent,
forWhichErrors: [401, 403, 404, 500],
hideCloseIcon: true
}
}),`,
);
}
} else {
if (hasHttpErrorConfig) {
newText = newText.replace(/withHttpErrorConfig\([^)]*\),?/, '');
}
}
if (!hasValidationBluePrint) {
newText = newText.replace(
'(',
`(
withValidationBluePrint({
wrongPassword: 'Please choose 1q2w3E*'
}),`,
);
}
if (newText && newText !== originalText) {
recorder.remove(exprStart, exprEnd - exprStart);
recorder.insertLeft(exprStart, newText);
}
}
host.commitUpdate(recorder);
return host;
};
}
function findProvideAbpThemeSharedCalls(source: ts.SourceFile): ts.CallExpression[] {
const result: ts.CallExpression[] = [];
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const expressionText = node.expression.getText();
if (expressionText.includes('provideAbpThemeShared')) {
result.push(node);
}
}
ts.forEachChild(node, visit);
};
visit(source);
return result;
}

101
npm/ng-packs/packages/schematics/src/commands/change-theme/style-map.ts

@ -12,6 +12,7 @@ export type ImportDefinition = {
path: string;
importName: string;
provider?: string;
expression?: string;
};
export const styleMap = new Map<ThemeOptionsEnum, StyleDefinition[]>();
@ -260,7 +261,7 @@ styleMap.set(ThemeOptionsEnum.LeptonXLite, [
bundleName: 'bootstrap-icons',
},
]);
// the code written by Github co-pilot. thank go-pilot. You are the best sidekick.
export const allStyles = Array.from(styleMap.values()).reduce((acc, val) => [...acc, ...val], []);
export const importMap = new Map<ThemeOptionsEnum, ImportDefinition[]>();
@ -269,40 +270,122 @@ importMap.set(ThemeOptionsEnum.Basic, [
{
path: '@abp/ng.theme.basic',
importName: 'ThemeBasicModule',
provider: 'provideThemeBasicConfig',
expression: 'ThemeBasicModule',
},
{
path: '@abp/ng.theme.basic',
importName: 'provideThemeBasicConfig',
provider: 'provideThemeBasicConfig()',
},
{
path: '@abp/ng.theme.shared',
importName: 'ThemeSharedModule',
expression: 'ThemeSharedModule',
},
{
path: '@abp/ng.theme.shared',
importName: 'withValidationBluePrint',
},
{
path: '@abp/ng.theme.shared',
importName: 'provideAbpThemeShared',
provider: 'provideAbpThemeShared()',
},
]);
importMap.set(ThemeOptionsEnum.Lepton, [
{
path: '@volo/abp.ng.theme.lepton',
importName: 'ThemeLeptonModule',
provider: 'provideThemeLepton',
importName: 'provideThemeLepton',
provider: 'provideThemeLepton()',
},
{
path: '@abp/ng.theme.shared',
importName: 'ThemeSharedModule',
expression: 'ThemeSharedModule',
},
{
path: '@abp/ng.theme.shared',
importName: 'withHttpErrorConfig',
},
{
path: '@abp/ng.theme.shared',
importName: 'withValidationBluePrint',
},
{
path: '@abp/ng.theme.shared',
importName: 'provideAbpThemeShared',
provider: 'provideAbpThemeShared()',
},
]);
importMap.set(ThemeOptionsEnum.LeptonXLite, [
{
path: '@abp/ng.theme.lepton-x',
importName: 'ThemeLeptonXModule.forRoot()',
importName: 'ThemeLeptonXModule',
expression: 'ThemeLeptonXModule.forRoot()',
},
{
path: '@abp/ng.theme.lepton-x/layouts',
importName: 'SideMenuLayoutModule.forRoot()',
importName: 'SideMenuLayoutModule',
expression: 'SideMenuLayoutModule.forRoot()',
},
{
path: '@abp/ng.theme.lepton-x/account',
importName: 'AccountLayoutModule.forRoot()',
importName: 'AccountLayoutModule',
expression: 'AccountLayoutModule.forRoot()',
},
{
path: '@abp/ng.theme.shared',
importName: 'ThemeSharedModule',
expression: 'ThemeSharedModule',
},
{
path: '@abp/ng.theme.shared',
importName: 'withHttpErrorConfig',
},
{
path: '@abp/ng.theme.shared',
importName: 'withValidationBluePrint',
},
{
path: '@abp/ng.theme.shared',
importName: 'provideAbpThemeShared',
provider: 'provideAbpThemeShared()',
},
]);
importMap.set(ThemeOptionsEnum.LeptonX, [
{
path: '@volosoft/abp.ng.theme.lepton-x',
importName: 'ThemeLeptonXModule.forRoot()',
importName: 'ThemeLeptonXModule',
expression: 'ThemeLeptonXModule.forRoot()',
},
{
path: '@volosoft/abp.ng.theme.lepton-x/layouts',
importName: 'SideMenuLayoutModule.forRoot()',
importName: 'SideMenuLayoutModule',
expression: 'SideMenuLayoutModule.forRoot()',
},
{
path: '@abp/ng.theme.shared',
importName: 'ThemeSharedModule',
expression: 'ThemeSharedModule',
},
{
path: '@volosoft/abp.ng.theme.lepton-x',
importName: 'HttpErrorComponent',
},
{
path: '@abp/ng.theme.shared',
importName: 'withHttpErrorConfig',
},
{
path: '@abp/ng.theme.shared',
importName: 'withValidationBluePrint',
},
{
path: '@abp/ng.theme.shared',
importName: 'provideAbpThemeShared',
provider: 'provideAbpThemeShared()',
},
]);

44
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/.eslintrc.json.template

@ -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": {}
}
]
}

7
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/ng-package.json.template

@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"dest": "../../dist/<%= kebab(libraryName) %>/config",
"lib": {
"entryFile": "src/public-api.ts"
}
}

1
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/enums/index.ts.template

@ -0,0 +1 @@
export * from './route-names';

3
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/enums/route-names.ts.template

@ -0,0 +1,3 @@
export const enum e<%= pascal(libraryName) %>RouteNames {
<%= pascal(libraryName) %> = '<%= pascal(libraryName) %>',
}

1
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/providers/index.ts.template

@ -0,0 +1 @@
export * from './route.provider';

30
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/providers/route.provider.ts.template

@ -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);
}

2
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/config/src/public-api.ts.template

@ -0,0 +1,2 @@
export * from './enums';
export * from './providers';

44
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/karma.conf.js.template

@ -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
});
};

7
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/ng-package.json.template

@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/<%= kebab(libraryName) %>",
"lib": {
"entryFile": "src/public-api.ts"
}
}

11
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/package.json.template

@ -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"
}
}

11
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/lib/__libraryName@kebab__.component.ts.template

@ -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 {}

9
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/lib/__libraryName@kebab__.routes.ts.template

@ -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),
},
];

1
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/lib/index.ts.template

@ -0,0 +1 @@
export * from './<%= kebab(libraryName) %>.routes';

4
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/public-api.ts.template

@ -0,0 +1,4 @@
/*
* Public API Surface of my-project-name
*/
export * from './lib';

26
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/src/test.ts.template

@ -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);

20
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/tsconfig.lib.json.template

@ -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"
]
}

10
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/tsconfig.lib.prod.json.template

@ -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"
}
}

17
npm/ng-packs/packages/schematics/src/commands/create-lib/files-package-standalone/__libraryName@kebab__/tsconfig.spec.json.template

@ -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"
]
}

6
npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/ng-package.json.template

@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}

7
npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/src/lib/__target@kebab__-__libraryName@kebab__.ts.template

@ -0,0 +1,7 @@
import { Provider } from '@angular/core';
export function provide<%= pascal(target) %><%= pascal(libraryName) %>(): Provider[] {
return [
// Add your providers here
];
}

1
npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/src/lib/index.ts.template

@ -0,0 +1 @@
export * from './<%= kebab(target) %>-<%= kebab(libraryName) %>';

1
npm/ng-packs/packages/schematics/src/commands/create-lib/files-secondary-entrypoint-standalone/__libraryName@kebab__/src/public-api.ts.template

@ -0,0 +1 @@
export * from './lib/index';

307
npm/ng-packs/packages/schematics/src/commands/create-lib/index.ts

@ -12,19 +12,24 @@ import * as ts from 'typescript';
import { join, normalize } from '@angular-devkit/core';
import {
addImportToModule,
addRootImport,
addRootProvider,
addRouteDeclarationToModule,
applyWithOverwrite,
camel,
findAppRoutesModulePath,
findAppRoutesPath,
getFirstApplication,
getWorkspace,
hasImportInNgModule,
hasProviderInStandaloneAppConfig,
InsertChange,
interpolate,
isLibrary,
isStandaloneApp,
JSONFile,
kebab,
macro,
pascal,
readWorkspaceSchema,
resolveProject,
updateWorkspace,
} from '../../utils';
@ -32,7 +37,8 @@ import { ProjectDefinition, WorkspaceDefinition } from '../../utils/angular/work
import { addLibToWorkspaceFile } from '../../utils/angular-schematic/generate-lib';
import * as cases from '../../utils/text';
import { Exception } from '../../enums/exception';
import { GenerateLibSchema } from './models/generate-lib-schema';
import { GenerateLibSchema, GenerateLibTemplateType } from './models/generate-lib-schema';
import { getMainFilePath } from '../../utils/angular/standalone/util';
export default function (schema: GenerateLibSchema) {
return async (tree: Tree) => {
@ -67,11 +73,17 @@ function createLibrary(options: GenerateLibSchema): Rule {
return async (tree: Tree) => {
const target = await resolveProject(tree, options.packageName, null);
if (!target || options.override) {
if (options.isModuleTemplate) {
return createLibFromModuleTemplate(tree, options);
}
if (options.isSecondaryEntrypoint) {
return createLibSecondaryEntry(tree, options);
if (options.templateType === GenerateLibTemplateType.Standalone) {
return createLibSecondaryEntryWithStandaloneTemplate(tree, options);
} else {
return createLibSecondaryEntry(tree, options);
}
}
if (options.templateType === GenerateLibTemplateType.Module) {
return createLibFromModuleTemplate(tree, options);
} else {
return createLibFromModuleStandaloneTemplate(tree, options);
}
} else {
throw new SchematicsException(
@ -109,14 +121,32 @@ async function createLibFromModuleTemplate(tree: Tree, options: GenerateLibSchem
}),
move(normalize(packagesDir)),
]),
addLibToWorkspaceIfNotExist(options.packageName, packagesDir),
addLibToWorkspaceIfNotExist(options, packagesDir),
]);
}
export function addLibToWorkspaceIfNotExist(name: string, packagesDir: string): Rule {
async function createLibFromModuleStandaloneTemplate(tree: Tree, options: GenerateLibSchema) {
const packagesDir = await resolvePackagesDirFromAngularJson(tree);
const packageJson = JSON.parse(tree.read('./package.json')!.toString());
const abpVersion = packageJson.dependencies['@abp/ng.core'];
return chain([
applyWithOverwrite(url('./files-package-standalone'), [
applyTemplates({
...cases,
libraryName: options.packageName,
abpVersion,
}),
move(normalize(packagesDir)),
]),
addLibToWorkspaceIfNotExist(options, packagesDir),
]);
}
export function addLibToWorkspaceIfNotExist(options: GenerateLibSchema, packagesDir: string): Rule {
return async (tree: Tree) => {
const workspace = await getWorkspace(tree);
const packageName = kebab(name);
const packageName = kebab(options.packageName);
const isProjectExist = workspace.projects.has(packageName);
const projectRoot = join(normalize(packagesDir), packageName);
@ -130,8 +160,8 @@ export function addLibToWorkspaceIfNotExist(name: string, packagesDir: string):
: noop(),
addLibToWorkspaceFile(projectRoot, packageName),
updateTsConfig(packageName, pathImportLib),
importConfigModuleToDefaultProjectAppModule(workspace, packageName),
addRoutingToAppRoutingModule(workspace, packageName),
importConfigModuleToDefaultProjectAppModule(packageName, options),
addRoutingToAppRoutingModule(workspace, packageName, options),
]);
};
}
@ -169,84 +199,211 @@ export async function createLibSecondaryEntry(tree: Tree, options: GenerateLibSc
]);
}
export async function createLibSecondaryEntryWithStandaloneTemplate(
tree: Tree,
options: GenerateLibSchema,
) {
const targetLib = await resolveProject(tree, options.target);
const packageName = `${kebab(targetLib.name)}/${kebab(options.packageName)}`;
const importPath = `${targetLib.definition.root}/${kebab(options.packageName)}`;
return chain([
applyWithOverwrite(url('./files-secondary-entrypoint-standalone'), [
applyTemplates({
...cases,
libraryName: options.packageName,
target: targetLib.name,
}),
move(normalize(targetLib.definition.root)),
updateTsConfig(packageName, importPath),
]),
]);
}
export function importConfigModuleToDefaultProjectAppModule(
workspace: WorkspaceDefinition,
packageName: string,
options: GenerateLibSchema,
) {
return (tree: Tree) => {
const projectName = readWorkspaceSchema(tree).defaultProject || getFirstApplication(tree).name!;
const project = workspace.projects.get(projectName);
const appModulePath = `${project?.sourceRoot}/app/app.module.ts`;
const appModule = tree.read(appModulePath);
if (!appModule) {
return;
}
const appModuleContent = appModule.toString();
if (appModuleContent.includes(`${camel(packageName)}ConfigModule`)) {
return;
}
return async (tree: Tree) => {
const projectName = getFirstApplication(tree).name!;
const mainFilePath = await getMainFilePath(tree, projectName);
const isSourceStandalone = isStandaloneApp(tree, mainFilePath);
const rules: Rule[] = [];
const forRootStatement = `${pascal(packageName)}ConfigModule.forRoot()`;
const text = tree.read(appModulePath);
if (!text) {
const providerAlreadyExists = isSourceStandalone
? await hasProviderInStandaloneAppConfig(
tree,
projectName,
`provide${pascal(packageName)}Config`,
)
: await hasImportInNgModule(
tree,
projectName,
options.templateType === GenerateLibTemplateType.Standalone
? `provide${pascal(packageName)}Config`
: `${pascal(packageName)}ConfigModule`,
options.templateType === GenerateLibTemplateType.Standalone ? 'providers' : 'imports',
);
if (providerAlreadyExists) {
return;
}
const sourceText = text.toString();
if (sourceText.includes(forRootStatement)) {
return;
if (options.templateType === GenerateLibTemplateType.Standalone) {
rules.push(
addRootProvider(projectName, code => {
const configFn = code.external(
`provide${pascal(packageName)}Config`,
`${kebab(packageName)}/config`,
);
return code.code`${configFn}()`;
}),
);
} else {
rules.push(
addRootImport(projectName, code => {
const configFn = code.external(
`${pascal(packageName)}ConfigModule`,
`${kebab(packageName)}/config`,
);
return code.code`${configFn}.forRoot()`;
}),
);
}
const source = ts.createSourceFile(appModulePath, sourceText, ts.ScriptTarget.Latest, true);
const changes = addImportToModule(
source,
appModulePath,
forRootStatement,
`${kebab(packageName)}/config`,
);
const recorder = tree.beginUpdate(appModulePath);
for (const change of changes) {
return chain(rules);
};
}
export function addRoutingToAppRoutingModule(
workspace: WorkspaceDefinition,
packageName: string,
options: GenerateLibSchema,
): Rule {
return async (tree: Tree) => {
const projectName = getFirstApplication(tree).name!;
const project = workspace.projects.get(projectName);
const mainFilePath = await getMainFilePath(tree, projectName);
const isSourceStandalone = isStandaloneApp(tree, mainFilePath);
const pascalName = pascal(packageName);
const macroName = macro(packageName);
const routePath = `${kebab(packageName)}`;
const moduleName = `${pascalName}Module`;
if (isSourceStandalone) {
const appRoutesPath =
findAppRoutesPath(tree, mainFilePath) || `${project?.sourceRoot}/app/app.routes.ts`;
const buffer = tree.read(appRoutesPath);
if (!buffer) {
throw new SchematicsException(`Cannot find routes file: ${appRoutesPath}`);
}
const content = buffer.toString();
const source = ts.createSourceFile(appRoutesPath, content, ts.ScriptTarget.Latest, true);
const routeExpr =
options.templateType === GenerateLibTemplateType.Standalone
? `() => import('${routePath}').then(m => m.${macroName}_ROUTES)`
: `() => import('${routePath}').then(m => m.${moduleName}.forLazy())`;
const routeToAdd = `{ path: '${routePath}', loadChildren: ${routeExpr} }`;
const change = addRouteToRoutesArray(source, 'APP_ROUTES', routeToAdd);
if (change instanceof InsertChange) {
const recorder = tree.beginUpdate(appRoutesPath);
recorder.insertLeft(change.pos, change.toAdd);
tree.commitUpdate(recorder);
}
}
tree.commitUpdate(recorder);
} else {
const appRoutingModulePath = await findAppRoutesModulePath(tree, mainFilePath);
if (!appRoutingModulePath) {
throw new SchematicsException(`Cannot find routing module: ${appRoutingModulePath}`);
}
const appRoutingModule = tree.read(appRoutingModulePath);
if (!appRoutingModule) {
return;
}
const appRoutingModuleContent = appRoutingModule.toString();
const routeExpr =
options.templateType === GenerateLibTemplateType.Standalone
? `${macroName}_ROUTES`
: moduleName;
if (appRoutingModuleContent.includes(routeExpr)) {
return;
}
const source = ts.createSourceFile(
appRoutingModulePath,
appRoutingModuleContent,
ts.ScriptTarget.Latest,
true,
);
const importStatement =
options.templateType === GenerateLibTemplateType.Standalone
? `() => import('${routePath}').then(m => m.${macroName}_ROUTES)`
: `() => import('${routePath}').then(m => m.${moduleName}.forLazy())`;
const routeDefinition = `{ path: '${routePath}', loadChildren: ${importStatement} }`;
const change = addRouteDeclarationToModule(source, routePath, routeDefinition);
const recorder = tree.beginUpdate(appRoutingModulePath);
if (change instanceof InsertChange) {
recorder.insertLeft(change.pos, change.toAdd);
}
tree.commitUpdate(recorder);
}
return;
};
}
export function addRoutingToAppRoutingModule(workspace: WorkspaceDefinition, packageName: string) {
return (tree: Tree) => {
const projectName = readWorkspaceSchema(tree).defaultProject || getFirstApplication(tree).name!;
const project = workspace.projects.get(projectName);
const appRoutingModulePath = `${project?.sourceRoot}/app/app-routing.module.ts`;
const appRoutingModule = tree.read(appRoutingModulePath);
if (!appRoutingModule) {
return;
}
const appRoutingModuleContent = appRoutingModule.toString();
const moduleName = `${pascal(packageName)}Module`;
if (appRoutingModuleContent.includes(moduleName)) {
return;
}
export function addRouteToRoutesArray(
source: ts.SourceFile,
arrayName: string,
routeToAdd: string,
): InsertChange | null {
const routesVar = source.statements.find(
stmt =>
ts.isVariableStatement(stmt) &&
stmt.declarationList.declarations.some(
decl =>
ts.isVariableDeclaration(decl) &&
(decl.name.getText() === arrayName || decl.name.getText() === arrayName.toUpperCase()) &&
decl.initializer !== undefined &&
ts.isArrayLiteralExpression(decl.initializer),
),
);
const source = ts.createSourceFile(
appRoutingModulePath,
appRoutingModuleContent,
ts.ScriptTarget.Latest,
true,
);
const importPath = `${kebab(packageName)}`;
const importStatement = `() => import('${importPath}').then(m => m.${moduleName}.forLazy())`;
const routeDefinition = `{ path: '${kebab(packageName)}', loadChildren: ${importStatement} }`;
const change = addRouteDeclarationToModule(source, `${kebab(packageName)}`, routeDefinition);
const recorder = tree.beginUpdate(appRoutingModulePath);
if (change instanceof InsertChange) {
recorder.insertLeft(change.pos, change.toAdd);
}
tree.commitUpdate(recorder);
if (!routesVar || !ts.isVariableStatement(routesVar)) {
throw new Error(`Could not find routes array named "${arrayName}".`);
}
return;
const declaration = routesVar.declarationList.declarations.find(
decl => decl.name.getText() === arrayName,
) as ts.VariableDeclaration;
const arrayLiteral = declaration.initializer as ts.ArrayLiteralExpression;
const getPathValue = (routeText: string): string | null => {
const match = routeText.match(/path:\s*['"`](.+?)['"`]/);
return match?.[1] ?? null;
};
const newPath = getPathValue(routeToAdd);
const alreadyExists = arrayLiteral.elements.some(el => {
const existingPath = getPathValue(el.getText());
return existingPath === newPath;
});
if (alreadyExists) {
return null;
}
const hasTrailingComma = arrayLiteral.elements.hasTrailingComma ?? false;
const insertPos =
hasTrailingComma || arrayLiteral.elements.length === 0
? arrayLiteral.getEnd() - 1
: arrayLiteral.elements[arrayLiteral.elements.length - 1].getEnd();
const prefix = arrayLiteral.elements.length > 0 && !hasTrailingComma ? ',\n ' : ' ';
const toAdd = `${prefix}${routeToAdd}`;
return new InsertChange(source.fileName, insertPos, toAdd);
}

11
npm/ng-packs/packages/schematics/src/commands/create-lib/models/generate-lib-schema.ts

@ -1,3 +1,8 @@
export enum GenerateLibTemplateType {
Standalone = 'standalone',
Module = 'module',
}
export interface GenerateLibSchema {
/**
* Angular package name will create
@ -8,8 +13,10 @@ export interface GenerateLibSchema {
* İs the package a library or a library module
*/
isSecondaryEntrypoint: boolean;
isModuleTemplate: boolean;
/**
* İs the package has standalone template
*/
templateType: GenerateLibTemplateType;
override: boolean;

22
npm/ng-packs/packages/schematics/src/commands/create-lib/schema.json

@ -22,21 +22,25 @@
},
"x-prompt": "Is secondary entrypoint?"
},
"isModuleTemplate": {
"description": "Is module template",
"type": "boolean",
"$default": {
"$source": "argv",
"index": 2
},
"x-prompt": "Is module template?"
"templateType": {
"type": "string",
"description": "Type of the template",
"enum": ["module", "standalone"],
"x-prompt": {
"message": "Select the type of template to generate:",
"type": "list",
"items": [
{ "value": "module", "label": "Module Template" },
{ "value": "standalone", "label": "Standalone Template" }
]
}
},
"override": {
"description": "Override existing files",
"type": "boolean",
"$default": {
"$source": "argv",
"index": 3
"index": 4
},
"x-prompt": "Override existing files?"
}

11
npm/ng-packs/packages/schematics/src/utils/angular/add-declaration-to-ng-module.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { Rule, Tree, strings } from '@angular-devkit/schematics';
@ -19,6 +19,7 @@ export interface DeclarationToNgModuleOptions {
flat?: boolean;
export?: boolean;
type: string;
typeSeparator?: '.' | '-';
skipImport?: boolean;
standalone?: boolean;
}
@ -30,6 +31,8 @@ export function addDeclarationToNgModule(options: DeclarationToNgModuleOptions):
return host;
}
const typeSeparator = options.typeSeparator ?? '.';
const sourceText = host.readText(modulePath);
const source = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);
@ -37,11 +40,11 @@ export function addDeclarationToNgModule(options: DeclarationToNgModuleOptions):
`/${options.path}/` +
(options.flat ? '' : strings.dasherize(options.name) + '/') +
strings.dasherize(options.name) +
(options.type ? '.' : '') +
strings.dasherize(options.type);
(options.type ? typeSeparator + strings.dasherize(options.type) : '');
const importPath = buildRelativePath(modulePath, filePath);
const classifiedName = strings.classify(options.name) + strings.classify(options.type);
const classifiedName =
strings.classify(options.name) + (options.type ? strings.classify(options.type) : '');
const changes = addDeclarationToModule(source, modulePath, classifiedName, importPath);
if (options.export) {

141
npm/ng-packs/packages/schematics/src/utils/angular/ast-utils.ts

@ -3,20 +3,22 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { tags } from '@angular-devkit/core';
import * as ts from 'typescript';
import { Change, InsertChange, NoopChange } from './change';
import { getEOL } from './eol';
/**
* 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)
* @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.
* @param alias Alias that the symbol should be inserted under.
* @return Change
*/
export function insertImport(
@ -25,46 +27,40 @@ export function insertImport(
symbolName: string,
fileName: string,
isDefault = false,
alias?: string,
): Change {
const rootNode = source;
const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
const allImports = findNodes(rootNode, ts.isImportDeclaration);
const importExpression = alias ? `${symbolName} as ${alias}` : symbolName;
// 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(ts.isStringLiteral)
.map(n => n.text);
return importFiles.filter(file => file === fileName).length === 1;
return ts.isStringLiteralLike(node.moduleSpecifier) && node.moduleSpecifier.text === fileName;
});
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;
}
const hasNamespaceImport = relevantImports.some(node => {
return node.importClause?.namedBindings?.kind === ts.SyntaxKind.NamespaceImport;
});
// if imports * from fileName, don't add symbolName
if (importsAsterisk) {
if (hasNamespaceImport) {
return new NoopChange();
}
const importTextNodes = imports.filter(n => (n as ts.Identifier).text === symbolName);
const imports = relevantImports.flatMap(node => {
return node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)
? node.importClause.namedBindings.elements
: [];
});
// insert import if it's not there
if (importTextNodes.length === 0) {
if (!imports.some(node => (node.propertyName || node.name).text === symbolName)) {
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 insertAfterLastOccurrence(imports, `, ${importExpression}`, fileToEdit, fallbackPos);
}
return new NoopChange();
@ -78,12 +74,13 @@ export function insertImport(
}
const open = isDefault ? '' : '{ ';
const close = isDefault ? '' : ' }';
const eol = getEOL(rootNode.getText());
// 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 separator = insertAtBeginning ? '' : `;${eol}`;
const toInsert =
`${separator}import ${open}${symbolName}${close}` +
` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
`${separator}import ${open}${importExpression}${close}` +
` from '${fileName}'${insertAtBeginning ? `;${eol}` : ''}`;
return insertAfterLastOccurrence(
allImports,
@ -222,7 +219,7 @@ function nodesByPosition(first: ts.Node, second: ts.Node): number {
* @throw Error if toInsert is first occurence but fall back is not set
*/
export function insertAfterLastOccurrence(
nodes: ts.Node[],
nodes: ts.Node[] | ts.NodeArray<ts.Node>,
toInsert: string,
file: string,
fallbackPos: number,
@ -345,7 +342,7 @@ export function getDecoratorMetadata(
export function getMetadataField(
node: ts.ObjectLiteralExpression,
metadataField: string,
): ts.ObjectLiteralElement[] {
): ts.PropertyAssignment[] {
return (
node.properties
.filter(ts.isPropertyAssignment)
@ -399,12 +396,7 @@ export function addSymbolToNgModuleMetadata(
if (importPath !== null) {
return [
new InsertChange(ngModulePath, position, toInsert),
insertImport(
source,
ngModulePath,
symbolName.replace(/\..*$/, '').replace(/\(\)/, ''),
importPath,
),
insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath),
];
} else {
return [new InsertChange(ngModulePath, position, toInsert)];
@ -420,7 +412,7 @@ export function addSymbolToNgModuleMetadata(
return [];
}
let expresssion: ts.Expression | ts.ArrayLiteralExpression;
let expression: ts.Expression | ts.ArrayLiteralExpression;
const assignmentInit = assignment.initializer;
const elements = assignmentInit.elements;
@ -430,20 +422,20 @@ export function addSymbolToNgModuleMetadata(
return [];
}
expresssion = elements[elements.length - 1];
expression = elements[elements.length - 1];
} else {
expresssion = assignmentInit;
expression = assignmentInit;
}
let toInsert: string;
let position = expresssion.getEnd();
if (ts.isArrayLiteralExpression(expresssion)) {
let position = expression.getEnd();
if (ts.isArrayLiteralExpression(expression)) {
// We found the field but it's empty. Insert it just before the `]`.
position--;
toInsert = `\n${tags.indentBy(4)`${symbolName}`}\n `;
} else {
// Get the indentation of the last element, if any.
const text = expresssion.getFullText(source);
const text = expression.getFullText(source);
const matches = text.match(/^(\r?\n)(\s*)/);
if (matches) {
toInsert = `,${matches[1]}${tags.indentBy(matches[2].length)`${symbolName}`}`;
@ -451,15 +443,11 @@ export function addSymbolToNgModuleMetadata(
toInsert = `, ${symbolName}`;
}
}
if (importPath !== null) {
return [
new InsertChange(ngModulePath, position, toInsert),
insertImport(
source,
ngModulePath,
symbolName.replace(/\..*$/, '').replace(/\(\)/, ''),
importPath,
),
insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath),
];
}
@ -572,13 +560,9 @@ export function getRouterModuleDeclaration(source: ts.SourceFile): ts.Expression
}
const matchingProperties = getMetadataField(node, 'imports');
if (!matchingProperties) {
return;
}
const assignment = matchingProperties[0] as ts.PropertyAssignment;
const assignment = matchingProperties[0];
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
if (!assignment || assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
return;
}
@ -678,3 +662,52 @@ export function addRouteDeclarationToModule(
return new InsertChange(fileToAdd, insertPos, route);
}
/** Asserts if the specified node is a named declaration (e.g. class, interface). */
function isNamedNode(
node: ts.Node & { name?: ts.Node },
): node is ts.Node & { name: ts.Identifier } {
return !!node.name && ts.isIdentifier(node.name);
}
/**
* Determines if a SourceFile has a top-level declaration whose name matches a specific symbol.
* Can be used to avoid conflicts when inserting new imports into a file.
* @param sourceFile File in which to search.
* @param symbolName Name of the symbol to search for.
* @param skipModule Path of the module that the symbol may have been imported from. Used to
* avoid false positives where the same symbol we're looking for may have been imported.
*/
export function hasTopLevelIdentifier(
sourceFile: ts.SourceFile,
symbolName: string,
skipModule: string | null = null,
): boolean {
for (const node of sourceFile.statements) {
if (isNamedNode(node) && node.name.text === symbolName) {
return true;
}
if (
ts.isVariableStatement(node) &&
node.declarationList.declarations.some(decl => {
return isNamedNode(decl) && decl.name.text === symbolName;
})
) {
return true;
}
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteralLike(node.moduleSpecifier) &&
node.moduleSpecifier.text !== skipModule &&
node.importClause?.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings) &&
node.importClause.namedBindings.elements.some(el => el.name.text === symbolName)
) {
return true;
}
}
return false;
}

20
npm/ng-packs/packages/schematics/src/utils/angular/change.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { UpdateRecorder } from '@angular-devkit/schematics';
@ -47,7 +47,11 @@ export class InsertChange implements Change {
order: number;
description: string;
constructor(public path: string, public pos: number, public toAdd: string) {
constructor(
public path: string,
public pos: number,
public toAdd: string,
) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
@ -59,7 +63,7 @@ export class InsertChange implements Change {
* This method does not insert spaces if there is none in the original string.
*/
apply(host: Host) {
return host.read(this.path).then((content) => {
return host.read(this.path).then(content => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos);
@ -75,7 +79,11 @@ export class RemoveChange implements Change {
order: number;
description: string;
constructor(public path: string, private pos: number, public toRemove: string) {
constructor(
public path: string,
private pos: number,
public toRemove: string,
) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
@ -84,7 +92,7 @@ export class RemoveChange implements Change {
}
apply(host: Host): Promise<void> {
return host.read(this.path).then((content) => {
return host.read(this.path).then(content => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos + this.toRemove.length);
@ -115,7 +123,7 @@ export class ReplaceChange implements Change {
}
apply(host: Host): Promise<void> {
return host.read(this.path).then((content) => {
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);

2
npm/ng-packs/packages/schematics/src/utils/angular/dependencies.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { Tree } from '@angular-devkit/schematics';

10
npm/ng-packs/packages/schematics/src/utils/angular/dependency.ts

@ -3,12 +3,12 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { Rule, SchematicContext } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import * as path from 'path';
import * as path from 'node:path';
const installTasks = new WeakMap<SchematicContext, Set<string>>();
@ -41,11 +41,13 @@ export enum InstallBehavior {
* which may install the dependency.
*/
None,
/**
* Automatically determine the need to schedule a {@link NodePackageInstallTask} based on
* previous usage of the {@link addDependency} within the schematic.
*/
Auto,
/**
* Always schedule a {@link NodePackageInstallTask} when the rule is executed.
*/
@ -62,6 +64,7 @@ export enum ExistingBehavior {
* The dependency will not be added or otherwise changed if it already exists.
*/
Skip,
/**
* The dependency's existing specifier will be replaced with the specifier provided in the
* {@link addDependency} call. A warning will also be shown during schematic execution to
@ -95,17 +98,20 @@ export function addDependency(
* dependency will be added. Defaults to {@link DependencyType.Default} (`dependencies`).
*/
type?: DependencyType;
/**
* The path of the package manifest file (`package.json`) that will be modified.
* Defaults to `/package.json`.
*/
packageJsonPath?: string;
/**
* The dependency installation behavior to use to determine whether a
* {@link NodePackageInstallTask} should be scheduled after adding the dependency.
* Defaults to {@link InstallBehavior.Auto}.
*/
install?: InstallBehavior;
/**
* The behavior to use when the dependency already exists within the `package.json`.
* Defaults to {@link ExistingBehavior.Replace}.

25
npm/ng-packs/packages/schematics/src/utils/angular/eol.ts

@ -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;
}

12
npm/ng-packs/packages/schematics/src/utils/angular/find-module.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { NormalizedRoot, Path, dirname, join, normalize, relative } from '@angular-devkit/core';
@ -54,12 +54,12 @@ export function findModuleFromOptions(host: Tree, options: ModuleOptions): Path
const candidatesDirs = [...candidateSet].sort((a, b) => b.length - a.length);
for (const c of candidatesDirs) {
const candidateFiles = ['', `${moduleBaseName}.ts`, `${moduleBaseName}${moduleExt}`].map(
(x) => join(c, x),
const candidateFiles = ['', `${moduleBaseName}.ts`, `${moduleBaseName}${moduleExt}`].map(x =>
join(c, x),
);
for (const sc of candidateFiles) {
if (host.exists(sc)) {
if (host.exists(sc) && host.readText(sc).includes('@NgModule')) {
return normalize(sc);
}
}
@ -85,8 +85,8 @@ export function findModule(
let foundRoutingModule = false;
while (dir) {
const allMatches = dir.subfiles.filter((p) => p.endsWith(moduleExt));
const filteredMatches = allMatches.filter((p) => !p.endsWith(routingModuleExt));
const allMatches = dir.subfiles.filter(p => p.endsWith(moduleExt));
const filteredMatches = allMatches.filter(p => !p.endsWith(routingModuleExt));
foundRoutingModule = foundRoutingModule || allMatches.length !== filteredMatches.length;

22
npm/ng-packs/packages/schematics/src/utils/angular/generate-from-files.ts

@ -3,16 +3,18 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import {
FileOperator,
Rule,
Tree,
apply,
applyTemplates,
chain,
filter,
forEach,
mergeWith,
move,
noop,
@ -30,6 +32,8 @@ export interface GenerateFromFilesOptions {
prefix?: string;
project: string;
skipTests?: boolean;
templateFilesDirectory?: string;
type?: string;
}
export function generateFromFiles(
@ -41,19 +45,33 @@ export function generateFromFiles(
options.prefix ??= '';
options.flat ??= true;
// Schematic templates require a defined type value
options.type ??= '';
const parsedPath = parseName(options.path, options.name);
options.name = parsedPath.name;
options.path = parsedPath.path;
validateClassName(strings.classify(options.name));
const templateSource = apply(url('./files'), [
const templateFilesDirectory = options.templateFilesDirectory ?? './files';
const templateSource = apply(url(templateFilesDirectory), [
options.skipTests ? filter(path => !path.endsWith('.spec.ts.template')) : noop(),
applyTemplates({
...strings,
...options,
...extraTemplateValues,
}),
!options.type
? forEach((file => {
return file.path.includes('..')
? {
content: file.content,
path: file.path.replace('..', '.'),
}
: file;
}) as FileOperator)
: noop(),
move(parsedPath.path + (options.flat ? '' : '/' + strings.dasherize(options.name))),
]);

1
npm/ng-packs/packages/schematics/src/utils/angular/index.ts

@ -11,3 +11,4 @@ export * from './project-targets';
export * from './validation';
export * from './workspace';
export * from './workspace-models';
export * from './standalone';

18
npm/ng-packs/packages/schematics/src/utils/angular/json-file.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { JsonValue } from '@angular-devkit/core';
@ -18,16 +18,22 @@ import {
parseTree,
printParseErrorCode,
} from 'jsonc-parser';
import { getEOL } from './eol';
export type InsertionIndex = (properties: string[]) => number;
export type JSONPath = (string | number)[];
/** @internal */
/** @private */
export class JSONFile {
content: string;
private eol: string;
constructor(private readonly host: Tree, private readonly path: string) {
constructor(
private readonly host: Tree,
private readonly path: string,
) {
this.content = this.host.readText(this.path);
this.eol = getEOL(this.content);
}
private _jsonAst: Node | undefined;
@ -73,15 +79,17 @@ export class JSONFile {
let getInsertionIndex: InsertionIndex | undefined;
if (insertInOrder === undefined) {
const property = jsonPath.slice(-1)[0];
getInsertionIndex = (properties) =>
[...properties, property].sort().findIndex((p) => p === property);
getInsertionIndex = properties =>
[...properties, property].sort().findIndex(p => p === property);
} else if (insertInOrder !== false) {
getInsertionIndex = insertInOrder;
}
const edits = modify(this.content, jsonPath, value, {
getInsertionIndex,
formattingOptions: {
eol: this.eol,
insertSpaces: true,
tabSize: 2,
},

24
npm/ng-packs/packages/schematics/src/utils/angular/ng-ast-utils.ts

@ -3,14 +3,14 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { normalize } from '@angular-devkit/core';
import { SchematicsException, Tree } from '@angular-devkit/schematics';
import { dirname } from 'path';
import { dirname, join } from 'node:path/posix';
import * as ts from 'typescript';
import { findNode, getSourceNodes } from './ast-utils';
import { findBootstrapApplicationCall } from './standalone/util';
export function findBootstrapModuleCall(host: Tree, mainPath: string): ts.CallExpression | null {
const mainText = host.readText(mainPath);
@ -46,7 +46,7 @@ export function findBootstrapModuleCall(host: Tree, mainPath: string): ts.CallEx
return bootstrapCall;
}
export function findBootstrapModulePath(host: Tree, mainPath: string): string {
function findBootstrapModulePath(host: Tree, mainPath: string): string {
const bootstrapCall = findBootstrapModuleCall(host, mainPath);
if (!bootstrapCall) {
throw new SchematicsException('Bootstrap call not found');
@ -74,7 +74,21 @@ export function findBootstrapModulePath(host: Tree, mainPath: string): string {
export function getAppModulePath(host: Tree, mainPath: string): string {
const moduleRelativePath = findBootstrapModulePath(host, mainPath);
const mainDir = dirname(mainPath);
const modulePath = normalize(`/${mainDir}/${moduleRelativePath}.ts`);
const modulePath = join(mainDir, `${moduleRelativePath}.ts`);
return modulePath;
}
export function isStandaloneApp(host: Tree, mainPath: string): boolean {
try {
findBootstrapApplicationCall(host, mainPath);
return true;
} catch (error) {
if (error instanceof SchematicsException) {
return false;
}
throw error;
}
}

2
npm/ng-packs/packages/schematics/src/utils/angular/parse-name.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { Path, basename, dirname, join, normalize } from '@angular-devkit/core';

12
npm/ng-packs/packages/schematics/src/utils/angular/paths.ts

@ -3,17 +3,15 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { normalize, split } from '@angular-devkit/core';
import { join, relative } from 'node:path/posix';
export function relativePathToWorkspaceRoot(projectRoot: string | undefined): string {
const normalizedPath = split(normalize(projectRoot || ''));
if (normalizedPath.length === 0 || !normalizedPath[0]) {
if (!projectRoot) {
return '.';
} else {
return normalizedPath.map(() => '..').join('/');
}
return relative(join('/', projectRoot), '/') || '.';
}

12
npm/ng-packs/packages/schematics/src/utils/angular/project-targets.ts

@ -3,11 +3,21 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { SchematicsException } from '@angular-devkit/schematics';
import { ProjectDefinition } from './workspace';
import { Builders } from './workspace-models';
export function targetBuildNotFoundError(): SchematicsException {
return new SchematicsException(`Project target "build" not found.`);
}
export function isUsingApplicationBuilder(project: ProjectDefinition): boolean {
const buildBuilder = project.targets.get('build')?.builder;
const isUsingApplicationBuilder =
buildBuilder === Builders.Application || buildBuilder === Builders.BuildApplication;
return isUsingApplicationBuilder;
}

148
npm/ng-packs/packages/schematics/src/utils/angular/standalone/app_component.ts

@ -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;
}

127
npm/ng-packs/packages/schematics/src/utils/angular/standalone/app_config.ts

@ -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;
}

115
npm/ng-packs/packages/schematics/src/utils/angular/standalone/code_block.ts

@ -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 };
}
}

10
npm/ng-packs/packages/schematics/src/utils/angular/standalone/index.ts

@ -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';

258
npm/ng-packs/packages/schematics/src/utils/angular/standalone/rules.ts

@ -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)]);
}
}

171
npm/ng-packs/packages/schematics/src/utils/angular/standalone/util.ts

@ -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;
}

5
npm/ng-packs/packages/schematics/src/utils/angular/validation.ts

@ -3,14 +3,15 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { SchematicsException } from '@angular-devkit/schematics';
// Must start with a letter, and must contain only alphanumeric characters or dashes.
// When adding a dash the segment after the dash must also start with a letter.
export const htmlSelectorRe = /^[a-zA-Z][.0-9a-zA-Z]*(:?-[a-zA-Z][.0-9a-zA-Z]*)*$/;
export const htmlSelectorRe =
/^[a-zA-Z][.0-9a-zA-Z]*((:?-[0-9]+)*|(:?-[a-zA-Z][.0-9a-zA-Z]*(:?-[0-9]+)*)*)$/;
// See: https://github.com/tc39/proposal-regexp-unicode-property-escapes/blob/fe6d07fad74cd0192d154966baa1e95e7cda78a1/README.md#other-examples
const ecmaIdentifierNameRegExp = /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u;

20
npm/ng-packs/packages/schematics/src/utils/angular/workspace-models.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
export enum ProjectType {
@ -18,16 +18,24 @@ export enum ProjectType {
* `angular.json` workspace file.
*/
export enum Builders {
Application = '@angular-devkit/build-angular:application',
AppShell = '@angular-devkit/build-angular:app-shell',
Server = '@angular-devkit/build-angular:server',
Browser = '@angular-devkit/build-angular:browser',
SsrDevServer = '@angular-devkit/build-angular:ssr-dev-server',
Prerender = '@angular-devkit/build-angular:prerender',
BrowserEsbuild = '@angular-devkit/build-angular:browser-esbuild',
Karma = '@angular-devkit/build-angular:karma',
BuildKarma = '@angular/build:karma',
TsLint = '@angular-devkit/build-angular:tslint',
DeprecatedNgPackagr = '@angular-devkit/build-ng-packagr:build',
NgPackagr = '@angular-devkit/build-angular:ng-packagr',
BuildNgPackagr = '@angular/build:ng-packagr',
DevServer = '@angular-devkit/build-angular:dev-server',
BuildDevServer = '@angular/build:dev-server',
ExtractI18n = '@angular-devkit/build-angular:extract-i18n',
Protractor = '@angular-devkit/build-angular:protractor',
BuildExtractI18n = '@angular/build:extract-i18n',
Protractor = '@angular-devkit/build-angular:private-protractor',
BuildApplication = '@angular/build:application',
}
export interface FileReplacements {
@ -70,8 +78,9 @@ export interface BrowserBuilderOptions extends BrowserBuilderBaseOptions {
}
export interface ServeBuilderOptions {
browserTarget: string;
buildTarget: string;
}
export interface LibraryBuilderOptions {
tsConfig: string;
project: string;
@ -138,11 +147,9 @@ export type E2EBuilderTarget = BuilderTarget<Builders.Protractor, E2EOptions>;
interface WorkspaceCLISchema {
warnings?: Record<string, boolean>;
schematicCollections?: string[];
defaultCollection?: string;
}
export interface WorkspaceSchema {
version: 1;
defaultProject?: string;
cli?: WorkspaceCLISchema;
projects: {
[key: string]: WorkspaceProject<ProjectType.Application | ProjectType.Library>;
@ -165,6 +172,7 @@ export interface WorkspaceProject<TProjectType extends ProjectType = ProjectType
* Tool options.
*/
architect?: WorkspaceTargets<TProjectType>;
/**
* Tool options.
*/

12
npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts

@ -3,7 +3,7 @@
* 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.io/license
* found in the LICENSE file at https://angular.dev/license
*/
import { json, workspaces } from '@angular-devkit/core';
@ -20,7 +20,7 @@ export type TargetDefinition = workspaces.TargetDefinition;
/**
* A {@link workspaces.WorkspaceHost} backed by a Schematics {@link Tree} instance.
*/
class TreeWorkspaceHost implements workspaces.WorkspaceHost {
export class TreeWorkspaceHost implements workspaces.WorkspaceHost {
constructor(private readonly tree: Tree) {}
async readFile(path: string): Promise<string> {
@ -58,14 +58,12 @@ class TreeWorkspaceHost implements workspaces.WorkspaceHost {
export function updateWorkspace(
updater: (workspace: WorkspaceDefinition) => void | Rule | PromiseLike<void | Rule>,
): Rule {
return async (tree: Tree) => {
const host = new TreeWorkspaceHost(tree);
const { workspace } = await workspaces.readWorkspace(DEFAULT_WORKSPACE_PATH, host);
return async (host: Tree) => {
const workspace = await getWorkspace(host);
const result = await updater(workspace);
await workspaces.writeWorkspace(workspace, host);
await workspaces.writeWorkspace(workspace, new TreeWorkspaceHost(host));
return result || noop;
};

7
npm/ng-packs/packages/schematics/src/utils/ast.ts

@ -35,3 +35,10 @@ export function isBooleanStringOrNumberLiteral(
node.kind === ts.SyntaxKind.FalseKeyword
);
}
export function removeEmptyElementsFromArrayLiteral(
array: ts.ArrayLiteralExpression,
): ts.ArrayLiteralExpression {
const cleaned = array.elements.filter(el => el.kind !== ts.SyntaxKind.OmittedExpression);
return ts.factory.updateArrayLiteralExpression(array, ts.factory.createNodeArray(cleaned));
}

2
npm/ng-packs/packages/schematics/src/utils/index.ts

@ -18,3 +18,5 @@ export * from './text';
export * from './tree';
export * from './type';
export * from './workspace';
export * from './standalone';
export * from './ng-module';

159
npm/ng-packs/packages/schematics/src/utils/ng-module.ts

@ -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),
);
}
}

180
npm/ng-packs/packages/schematics/src/utils/standalone.ts

@ -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),
);
}
}
}

2
npm/ng-packs/packages/schematics/src/utils/workspace.ts

@ -52,7 +52,7 @@ export async function resolveProject<T = any>(
// @typescript-eslint/no-explicit-any
notFoundValue: T = NOT_FOUND_VALUE as unknown as any,
): Promise<Project | T> {
name = name || readWorkspaceSchema(tree).defaultProject || getFirstApplication(tree).name!;
name = name || getFirstApplication(tree).name!;
const workspace = await getWorkspace(tree);
let definition: Project['definition'] | undefined;

5
npm/ng-packs/scripts/build-schematics.ts

@ -23,10 +23,15 @@ const FILES_TO_COPY_AFTER_BUILD: (FileCopy | string)[] = [
{ src: 'src/commands/create-lib/schema.json', dest: 'commands/create-lib/schema.json' },
{ src: 'src/commands/change-theme/schema.json', dest: 'commands/change-theme/schema.json' },
{ src: 'src/commands/create-lib/files-package', dest: 'commands/create-lib/files-package' },
{ src: 'src/commands/create-lib/files-package-standalone', dest: 'commands/create-lib/files-package-standalone' },
{
src: 'src/commands/create-lib/files-secondary-entrypoint',
dest: 'commands/create-lib/files-secondary-entrypoint',
},
{
src: 'src/commands/create-lib/files-secondary-entrypoint-standalone',
dest: 'commands/create-lib/files-secondary-entrypoint-standalone',
},
{ src: 'src/commands/proxy-add/schema.json', dest: 'commands/proxy-add/schema.json' },
{ src: 'src/commands/proxy-index/schema.json', dest: 'commands/proxy-index/schema.json' },
{ src: 'src/commands/proxy-refresh/schema.json', dest: 'commands/proxy-refresh/schema.json' },

Loading…
Cancel
Save