Skip to content

Instantly share code, notes, and snippets.

@LayZeeDK
Last active April 28, 2022 08:04
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save LayZeeDK/46479a1ed6b8005e7e50c6fd89716deb to your computer and use it in GitHub Desktop.
Save LayZeeDK/46479a1ed6b8005e7e50c6fd89716deb to your computer and use it in GitHub Desktop.
Angular CLI workspace tool: Generate project.
const childProcess = require('child_process');
const fs = require('fs');
const yargs = require('yargs');
function addScopeToLibraryProjectName({ name, scope }) {
runCommand('npx json -I -f angular.json '
+ `-e "this.projects['${scope}-${name}'] = this.projects['${name}']"`);
runCommand(`npx json -I -f angular.json -e "delete this.projects['${name}']"`);
}
function adjustAppComponentSpecToAppComponentTemplate({
groupingFolder,
name,
}) {
const filePath =
`${appFolderPath({ groupingFolder, name })}/app.component.spec.ts`;
const search =
`expect(compiled.querySelector('.content span').textContent).toContain('${name} app is running!');`;
const replacement =
`expect(compiled.querySelector('h1').textContent).toContain('${name}');`;
searchAndReplaceInFile({ filePath, replacement, search });
}
function adjustAppEndToEndTestSuite({ groupingFolder, name }) {
searchAndReplaceInFile({
filePath:
`${endToEndSrcFolderPath({ groupingFolder, name })}/app.e2e-spec.ts`,
search: `${name} app is running!`,
replacement: name,
});
}
function adjustAppPageObject({ groupingFolder, name }) {
searchAndReplaceInFile({
filePath: `${endToEndSrcFolderPath({ groupingFolder, name })}/app.po.ts`,
search: /(.*?-root) .content span/,
replacement: '$1 h1',
});
}
function appComponentWithFeatureShellTemplate() {
return `<h1>
{{title}}
</h1>
<router-outlet></router-outlet>
`;
}
function appFolderPath({ groupingFolder, name }) {
return ['apps', groupingFolder, name, 'src', 'app']
.filter(x => !!x)
.join('/');
}
function cleanUpDefaultLibraryFiles({ pathPrefix, name }) {
runCommand(`npx rimraf '${pathPrefix}/${name}/*package.json'`);
runCommand(`npx rimraf ${pathPrefix}/${name}/tsconfig.lib.prod.json`);
runCommand(`npx rimraf '${pathPrefix}/${name}/src/lib/*.*'`);
}
function configureApplicationArchitects({ name, pathPrefix }) {
runCommand(`ng config projects["${name}-e2e"].root ${pathPrefix}/${name}-e2e`);
runCommand(`ng config projects["${name}-e2e"].architect.lint.options.tsConfig `
+ `${pathPrefix}/${name}-e2e/tsconfig.json`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}'].architect.e2e"`);
runCommand(`npx json -I -f angular.json -e `
+ `"this.projects['${name}'].architect.lint.options.tsConfig.pop()"`);
runCommand(`ng config projects["${name}"].architect.lint.options.exclude[1] `
+ `'!${pathPrefix}/${name}/**'`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].architect.build"`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].architect['extract-i18n']"`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].architect.serve"`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].architect.test"`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].prefix"`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].sourceRoot"`);
runCommand(`npx json -I -f angular.json -e `
+ `"delete this.projects['${name}-e2e'].schematics"`);
runCommand(
`ng config projects["${name}-e2e"].architect.lint.options.exclude[1] `
+ `'!${pathPrefix}/${name}-e2e/**'`);
}
function configureKarmaConfig({ groupingFolder, name, pathPrefix }) {
const coveragePath = `${pathPrefix}/${name}`;
const directoryUpNavigationCount = groupingFolder.split('/').length + 2;
const directoryUpNavigations =
Array(directoryUpNavigationCount).fill('..').join('/');
const karmaConfig = `const path = require('path');
const getBaseKarmaConfig = require('${directoryUpNavigations}/karma.conf');
module.exports = (config) => {
const baseConfig = getBaseKarmaConfig();
config.set({
...baseConfig,
coverageIstanbulReporter: {
...baseConfig.coverageIstanbulReporter,
dir: path.join(__dirname, '${directoryUpNavigations}/coverage/${coveragePath}'),
},
});
};
`;
writeFile(`${pathPrefix}/${name}/karma.conf.js`, karmaConfig);
}
function configureLibraryArchitect({ name, pathPrefix, scope }) {
runCommand('npx json -I -f angular.json '
+ `-e "delete this.projects['${scope}-${name}'].architect.build"`);
runCommand(
`ng config projects["${scope}-${name}"].architect.lint.options.exclude[1] `
+ `'!libs/${scope}/${name}/**'`);
runCommand(`npx json -I -f ${pathPrefix}/${name}/tslint.json `
+ `-e "this.linterOptions = { exclude: ['!**/*'] }"`);
}
function configurePathMapping({
groupingFolder,
name,
npmScope,
pathPrefix,
scope,
}) {
runCommand(`npx json -I -f tsconfig.json -e `
+ `"delete this.compilerOptions.paths['${name}']"`);
runCommand(`npx json -I -f tsconfig.json `
+ `-e "this.compilerOptions.paths['@${npmScope}/${scope}/${name}'] = `
+ `['${pathPrefix}/${name}/src/index.ts']"`);
}
function deleteEnvironmentsFolder({ projectPath }) {
runCommand(`npx rimraf ${projectPath}/src/environments`);
}
function endToEndSrcFolderPath({ groupingFolder, name }) {
return ['apps', groupingFolder, `${name}-e2e`, 'src']
.filter(x => !!x)
.join('/');
}
function extractEndToEndTestingProject({ name, pathPrefix }) {
moveDirectory({
from: `${pathPrefix}/${name}/e2e`,
to: `${pathPrefix}/${name}-e2e`,
});
runCommand(`npx json -I -f ${pathPrefix}/${name}-e2e/tsconfig.json -e `
+ `"this.extends = '../../../tsconfig.json'"`);
runCommand(`npx json -I -f ${pathPrefix}/${name}-e2e/tsconfig.json -e `
+ `"this.compilerOptions.outDir = '../../../out-tsc/e2e'"`);
runCommand(
`ng config projects["${name}"].architect.e2e.options.protractorConfig `
+ `${pathPrefix}/${name}-e2e/protractor.conf.js`);
runCommand(`npx json -I -f angular.json -e `
+ `"this.projects['${name}-e2e'] = this.projects['${name}']"`);
}
function importFeatureShellModuleInAppModule({ groupingFolder, name, scope }) {
const filePath = `${appFolderPath({ groupingFolder, name })}/app.module.ts`;
const importsSearch = `imports: [
BrowserModule
],`;
const featureShellModuleClassName =
toPascalCase(`${scope}-feature-shell-module`);
const importsReplacement = `imports: [
BrowserModule,
${featureShellModuleClassName},
],`;
searchAndReplaceInFile({
filePath,
replacement: importsReplacement,
search: importsSearch,
});
const componentImportSearch =
"import { AppComponent } from './app.component';";
const featureShellLibraryImportPath = libraryImportPath({
npmScope,
scope,
name: 'feature-shell',
});
const componentImportReplacement = `import {
${featureShellModuleClassName},
} from '${featureShellLibraryImportPath}';
${componentImportSearch}`;
searchAndReplaceInFile({
filePath,
replacement: componentImportReplacement,
search: componentImportSearch,
});
}
function importRouterModuleInAppComponentSpec({ groupingFolder, name }) {
const filePath =
`${appFolderPath({ groupingFolder, name })}/app.component.spec.ts`;
const importsSearch = /declarations:\s*\[\s*AppComponent\s*\],/;
const importsReplacement = `declarations: [AppComponent],
imports: [
RouterModule.forRoot([]),
],`
searchAndReplaceInFile({
filePath,
replacement: importsReplacement,
search: importsSearch,
});
const componentImportSearch = "import { AppComponent } from './app.component';";
const componentImportReplacement = `import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';`;
searchAndReplaceInFile({
filePath,
replacement: componentImportReplacement,
search: componentImportSearch,
});
}
function featureShellModule({ componentName, name, scope }) {
const featureShellModuleClassName = toPascalCase(`${scope}-${name}-module`);
const shellComponentClassName = toPascalCase(`${componentName}-component`);
return `import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ${shellComponentClassName} } from './${componentName}/${componentName}.component';
const routes: Routes = [
{
path: '',
component: ${shellComponentClassName},
children: [],
},
];
@NgModule({
declarations: [${shellComponentClassName}],
exports: [RouterModule],
imports: [
RouterModule.forRoot(routes),
],
})
export class ${featureShellModuleClassName} {}
`;
}
function generateApplication({ groupingFolder, name, npmScope, scope }) {
const projectRoot = 'apps';
const pathPrefix = [projectRoot, groupingFolder].join('/');
generateApplicationProject({ name, pathPrefix, scope });
if (hasSharedEnvironmentsLibary({ scope })) {
const sharedEnvironmentsLibraryName =
readMaybeSharedEnvironmentsLibraryName({ scope });
useSharedEnvironmentsLibraryInMainFile({
groupingFolder,
name,
npmScope,
projectRoot,
sharedEnvironmentsLibraryName,
});
useSharedEnvironmentsLibraryInFileReplacements({
name,
sharedEnvironmentsLibraryName,
});
const projectPath = `${pathPrefix}/${name}`;
deleteEnvironmentsFolder({ projectPath });
}
function useSharedEnvironmentsLibraryInFileReplacements({
name,
sharedEnvironmentsLibraryName,
}) {
const sharedEnvironmentsLibraryRoot = readAngularJson()
.projects[sharedEnvironmentsLibraryName]
.root;
const libFolderPath =
[sharedEnvironmentsLibraryRoot, 'src', 'lib'].join('/');
const environmentsFilePath = [libFolderPath, 'environment.ts'].join('/');
const environmentsProdFilePath =
[libFolderPath, 'environment.prod.ts'].join('/');
runCommand(
'npx json -I -f angular.json -e '
+ `"this.projects['${name}'].architect.build.configurations.production.`
+ `fileReplacements[0].replace = '${environmentsFilePath}'"`);
runCommand(
'npx json -I -f angular.json -e '
+ `"this.projects['${name}'].architect.build.configurations.production.`
+ `fileReplacements[0].with = '${environmentsProdFilePath}'"`);
}
extractEndToEndTestingProject({ name, pathPrefix });
configureApplicationArchitects({ name, pathPrefix });
configureKarmaConfig({ groupingFolder, name, pathPrefix });
if (hasFeatureShellLibrary({ scope })) {
useFeatureShell({ groupingFolder, name, scope });
}
}
function generateApplicationProject({ name, pathPrefix, scope }) {
runCommand(`ng generate application ${name} --prefix=${scope} `
+ `--project-root=${pathPrefix}/${name} --style=css --routing=false`);
}
function generateFeatureState({ name, scope }) {
runCommand(
`ng generate @ngrx/schematics:feature +state/${scope} `
+ `--project=${scope}-${name} --module=${scope}-${name}.module.ts `
+ '--creators=true --api=false');
}
function generateLibraryAngularModule({
isPresentationLayer,
name,
pathPrefix,
scope,
}) {
runCommand(`ng generate module ${scope}-${name} --project=${scope}-${name} `
+ `--flat ${isPresentationLayer ? '' : '--no-common-module'}`);
writeFile(
`${pathPrefix}/${name}/src/lib/${scope}-${name}.module.spec.ts`,
libraryModuleSpec({ name, scope }));
}
function generateLibraryComponent({ name, scope }) {
const isFeatureShell = name.endsWith('feature-shell');
const componentName = isFeatureShell ? 'shell' : name.replace(/^.*?-/, '');
runCommand(`ng generate component ${componentName} `
+ `--project=${scope}-${name} --module=${scope}-${name}.module.ts `
+ `--display-block`);
if (!isFeatureShell) {
return;
}
writeFile(
`libs/${scope}/${name}/src/lib/${componentName}/${componentName}.component.html`,
shellComponentTemplate());
writeFile(
`libs/${scope}/${name}/src/lib/${componentName}/${componentName}.component.spec.ts`,
shellComponentSpec({ componentName }));
writeFile(
`libs/${scope}/${name}/src/lib/${scope}-${name}.module.ts`,
featureShellModule({ componentName, name, scope }));
}
function generateLibraryProject({ name, npmScope, pathPrefix, scope }) {
const prefix =
(scope === defaultScope)
? npmScope
: scope;
runCommand(`ng config newProjectRoot ${pathPrefix}`);
runCommand(`ng generate library ${name} --prefix=${prefix} `
+ '--entry-file=index --skip-install --skip-package-json');
}
function generateLibraryPublicApi({ name, pathPrefix, scope }) {
writeFile(
`${pathPrefix}/${name}/src/index.ts`,
libraryPublicApi({ name, scope }));
}
function generateWorkspaceLibrary({ groupingFolder = '', name, npmScope, scope, type }) {
const isDataAccess = type === 'data-access';
const isPresentationLayer = ['feature', 'ui'].includes(type);
const pathPrefix = ['libs', (groupingFolder || scope)].join('/');
generateLibraryProject({ name, npmScope, pathPrefix, scope });
addScopeToLibraryProjectName({ name, scope });
configureLibraryArchitect({ name, pathPrefix, scope });
cleanUpDefaultLibraryFiles({ name, pathPrefix });
generateLibraryAngularModule({
isPresentationLayer,
name,
pathPrefix,
scope,
});
if (isPresentationLayer) {
generateLibraryComponent({ name, scope });
}
if (isDataAccess && withState) {
generateFeatureState({ name, scope });
}
generateLibraryPublicApi({ name, pathPrefix, scope });
configurePathMapping({
groupingFolder,
name,
npmScope,
pathPrefix,
scope,
});
configureKarmaConfig({
groupingFolder,
name,
pathPrefix,
projectRoot: 'libs',
});
}
function hasFeatureShellLibrary({ scope }) {
return Object.keys(readAngularJson().projects).find(projectName =>
projectName === `${scope}-feature-shell`)
!== undefined;
}
function hasSharedEnvironmentsLibary({ scope }) {
return readMaybeSharedEnvironmentsLibraryName({ scope }) !== undefined;
}
function libraryImportPath({ groupingFolder, npmScope, scope, name }) {
return [`@${npmScope}`, scope, groupingFolder, name]
.filter(x => !!x)
.join('/');
}
function libraryModuleSpec({ name, scope }) {
const moduleName = toPascalCase(`${scope}-${name}-module`);
return `import { TestBed } from '@angular/core/testing';
import { ${moduleName} } from './${scope}-${name}.module';
describe('${moduleName}', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [${moduleName}],
});
await TestBed.compileComponents();
});
it('should create', () => {
expect(${moduleName}).toBeDefined();
});
});
`;
}
function libraryPublicApi({ name, scope }) {
return `/*
* Public API Surface of ${scope}-${name}
*/
export * from './lib/${scope}-${name}.module';
`;
}
function moveDirectory({ from, to }) {
runCommand(`npx copy '${from}/**/*' '${to}'`);
runCommand(`npx rimraf ${from}`)
}
function readAngularJson() {
return JSON.parse(readFile('angular.json'));
}
function readFile(filePath) {
return fs.readFileSync(`${cwd}/${filePath}`, { encoding: 'utf8' });
}
function readMaybeSharedEnvironmentsLibraryName({ scope }) {
const scopedSharedEnvironmentsLibraryName = `${scope}-shared-environments`;
return Object.keys(readAngularJson().projects)
.find(projectName => [
'shared-environments',
scopedSharedEnvironmentsLibraryName,
].includes(projectName));
}
function runCommand(command) {
return childProcess.execSync(command, { stdio: 'inherit' });
}
function searchAndReplaceInFile({ filePath, search, replacement }) {
const fileContent = readFile(filePath);
writeFile(filePath, fileContent.replace(search, replacement));
}
function setAppComponentTemplateToTitleAndRouterOutlet({ groupingFolder, name }) {
writeFile(
`${appFolderPath({ groupingFolder, name })}/app.component.html`,
appComponentWithFeatureShellTemplate());
}
function shellComponentSpec({ componentName }) {
const shellComponentClassName = toPascalCase(`${componentName}-component`);
return `import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterModule } from '@angular/router';
import { ${shellComponentClassName} } from './${componentName}.component';
describe('${shellComponentClassName}', () => {
let component: ${shellComponentClassName};
let fixture: ComponentFixture<${shellComponentClassName}>;
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [${shellComponentClassName}],
imports: [
RouterModule.forRoot([]),
],
});
await TestBed.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(${shellComponentClassName});
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
`;
}
function shellComponentTemplate() {
return '<router-outlet></router-outlet>';
}
function toPascalCase(kebabCase) {
return kebabCase
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
function useFeatureShell({ groupingFolder, name, scope }) {
setAppComponentTemplateToTitleAndRouterOutlet({ groupingFolder, name });
adjustAppComponentSpecToAppComponentTemplate({ groupingFolder, name });
importFeatureShellModuleInAppModule({ groupingFolder, name, scope });
importRouterModuleInAppComponentSpec({ groupingFolder, name });
adjustAppPageObject({ groupingFolder, name });
adjustAppEndToEndTestSuite({ groupingFolder, name });
}
function useSharedEnvironmentsLibraryInMainFile({
groupingFolder,
name,
npmScope,
projectRoot,
sharedEnvironmentsLibraryName,
}) {
const filePath =
[projectRoot, groupingFolder, name, 'src', 'main.ts'].join('/');
const search = "import { environment } from './environments/environment';";
const sharedEnvironmentsLibraryRoot = readAngularJson()
.projects[sharedEnvironmentsLibraryName]
.root;
const sharedEnvironmentsLibraryImportPath = `@${npmScope}/`
+ sharedEnvironmentsLibraryRoot.replace(/^libs\//, '');
const replacement =
`import { environment } from '${sharedEnvironmentsLibraryImportPath}';`;
searchAndReplaceInFile({ filePath, search, replacement });
}
function writeFile(filePath, fileContent) {
fs.writeFileSync(`${cwd}/${filePath}`, fileContent, { encoding: 'utf8' });
}
const cwd = process.cwd();
if (!fs.existsSync(`angular.json`)) {
console.error(
'ERROR: Must be run from the workspace root folder that contains '
+ 'angular.json.');
process.exit(1);
}
const defaultScope = 'shared';
const argv = yargs
.scriptName('generate-project')
.usage('Usage: $0 <command> <args>')
.command({
command: 'application <name>',
description: 'Generate application',
aliases: ['app', 'a'],
builder: yargs => {
yargs.positional('name', {
description: 'Application name, for example "booking-desktop" or '
+ '"check-in-mobile"',
type: 'string',
});
},
handler: _argv => {
setImmediate(() => {
if (scope === 'shared') {
console.error(
'ERROR: "scope" parameter cannot be "shared" for application '
+ 'projects');
process.exit(1);
}
generateApplication({ groupingFolder, name, npmScope, scope });
});
},
})
.command({
command: 'library <type> [name]',
description: 'Generate workspace library',
aliases: ['lib', 'l'],
builder: yargs => {
yargs.positional('type', {
description:
'The library type, for example "data-access", "feature", or "ui"',
type: 'string',
});
yargs.positional('name', {
description: 'Library name, for example "feature-shell"',
type: 'string',
});
yargs.option('with-state', {
default: false,
description: 'Whether to include NgRx +state folder',
type: 'boolean',
});
},
handler: _argv => {
setImmediate(() => {
generateWorkspaceLibrary({
groupingFolder,
name,
npmScope,
scope,
type,
});
});
},
})
.option('grouping-folder', {
alias: 'g',
default: '',
description: 'Name of project grouping folder, for example "booking", '
+ '"check-in", or "seatmap"',
type: 'string',
})
.option('npm-scope', {
alias: 'p',
default: 'workspace',
description: 'Workspace path mapping scope, for example "workspace", '
+ 'or "nrwl-airlines"',
type: 'string',
})
.option('scope', {
alias: 's',
default: defaultScope,
description:
'Project scope, for example "shared", "booking", or "check-in"',
type: 'string',
})
.demandCommand()
.help().alias('help', 'h')
.version('1.0.0').alias('version', 'v')
.argv;
const { groupingFolder, npmScope, scope, type, withState, name = type } = argv;
{
"scripts": {
"generate-project": "node ./tools/generate-project.js",
"example-generate-shared-data-access-library": "npm run generate-project -- library data-access",
"example-generate-feature-shell-library": "npm run generate-project -- library feature feature-shell --scope=booking --npm-scope=nrwl-airlines",
"example-generate-application": "npm run generate-project -- application booking-mobile --scope=booking --npm-scope=nrwl-airlines",
"example-generate-application-in-grouping-folder": "npm run generate-project -- application check-in-desktop --scope=check-in --grouping-folder=check-in --npm-scope=nrwl-airlines"
},
"devDependencies": {
"json": "^9.0.6",
"rimraf": "^3.0.2",
"yargs": "^15.3.1"
}
}
@amarchino
Copy link

A couple points:

  • even if it is not in the @angular/cli best practices (and I cannot fathom why, since not doing it always gave me problems down the line), it may be useful to use the npx pre-command even with the ng commands. Given that not everyone may like this structure, may I propose the definition of a "global" variable const ngPrefix = ''; that may be used to prepend npx to the commands? As an example, transforming line 64 into
    runCommand(`${ngPrefix}ng config projects["${name}-e2e"].root ${pathPrefix}/${name}-e2e`);
  • the moveDirectory command should be
    function moveDirectory({ from, to }) {
      runCommand(`npx copy '${from}/**/*' ${to}`);
      runCommand(`npx rimraf ${from}`)
    }
    to prevent accidental glob expansion by the command line (had problems with it when using ZSH)
  • every command that contains an exclamation mark or an asterisk should be enclosed in single quotes so as to prevent history/glob expansion by the command line. Eg: lines 71-72 should be
      runCommand(`ng config projects["${name}"].architect.lint.options.exclude[1] `
        + `'!${pathPrefix}/${name}/**'`);

@LayZeeDK
Copy link
Author

LayZeeDK commented May 29, 2020

Thanks, @amarchino

I've made those updates to better support Zsh.

About npx ng, the global Angular CLI will now correctly use the local version in projects. For ng update, it will use the latest version.

npx ng <command> doesn't allow us to use the Angular CLI if it isn't installed either globally or locally as the package name is @angular/cli. We would have to use npx -p @angular/cli ng <command>, but then it would not use the local or global instance.

In the article, I mention that I assume Angular CLI to be installed globally. I guess we could use npm run ng -- <command> or yarn ng <command> to use the local version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment