diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 073bdfed27e7..9b828f5232e6 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -115,6 +115,11 @@ "factory": "./web-worker", "schema": "./web-worker/schema.json", "description": "Create a Web Worker." + }, + "environments": { + "factory": "./environments", + "schema": "./environments/schema.json", + "description": "Generate project environment files." } } } diff --git a/packages/schematics/angular/environments/index.ts b/packages/schematics/angular/environments/index.ts new file mode 100644 index 000000000000..1ce9aa4287a9 --- /dev/null +++ b/packages/schematics/angular/environments/index.ts @@ -0,0 +1,147 @@ +/** + * @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.io/license + */ + +import { Rule, SchematicsException, chain } from '@angular-devkit/schematics'; +import { AngularBuilder, TargetDefinition, updateWorkspace } from '@schematics/angular/utility'; +import { posix as path } from 'path'; +import { Schema as EnvironmentOptions } from './schema'; + +const ENVIRONMENTS_DIRECTORY = 'environments'; +const ENVIRONMENT_FILE_CONTENT = 'export const environment = {};\n'; + +export default function (options: EnvironmentOptions): Rule { + return updateWorkspace((workspace) => { + const project = workspace.projects.get(options.project); + if (!project) { + throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`); + } + + const type = project.extensions['projectType']; + if (type !== 'application') { + return log( + 'error', + 'Only application project types are support by this schematic.' + type + ? ` Project "${options.project}" has a "projectType" of "${type}".` + : ` Project "${options.project}" has no "projectType" defined.`, + ); + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + return log( + 'error', + `No "build" target found for project "${options.project}".` + + ' A "build" target is required to generate environment files.', + ); + } + + const serverTarget = project.targets.get('server'); + + const sourceRoot = project.sourceRoot ?? path.join(project.root, 'src'); + + // The generator needs to be iterated prior to returning to ensure all workspace changes that occur + // within the generator are present for `updateWorkspace` when it writes the workspace file. + return chain([ + ...generateConfigurationEnvironments(buildTarget, serverTarget, sourceRoot, options.project), + ]); + }); +} + +function createIfMissing(path: string): Rule { + return (tree, context) => { + if (tree.exists(path)) { + context.logger.info(`Skipping creation of already existing environment file "${path}".`); + } else { + tree.create(path, ENVIRONMENT_FILE_CONTENT); + } + }; +} + +function log(type: 'info' | 'warn' | 'error', text: string): Rule { + return (_, context) => context.logger[type](text); +} + +function* generateConfigurationEnvironments( + buildTarget: TargetDefinition, + serverTarget: TargetDefinition | undefined, + sourceRoot: string, + projectName: string, +): Iterable { + if (!buildTarget.builder.startsWith(AngularBuilder.Browser)) { + yield log( + 'warn', + `"build" target found for project "${projectName}" has a third-party builder "${buildTarget.builder}".` + + ' The generated project options may not be compatible with this builder.', + ); + } + + if (serverTarget && !serverTarget.builder.startsWith(AngularBuilder.Server)) { + yield log( + 'warn', + `"server" target found for project "${projectName}" has a third-party builder "${buildTarget.builder}".` + + ' The generated project options may not be compatible with this builder.', + ); + } + + // Create default environment file + const defaultFilePath = path.join(sourceRoot, ENVIRONMENTS_DIRECTORY, 'environment.ts'); + yield createIfMissing(defaultFilePath); + + const configurationEntries = [ + ...Object.entries(buildTarget.configurations ?? {}), + ...Object.entries(serverTarget?.configurations ?? {}), + ]; + + const addedFiles = new Set(); + for (const [name, configurationOptions] of configurationEntries) { + if (!configurationOptions) { + // Invalid configuration + continue; + } + + // Default configuration will use the default environment file + if (name === buildTarget.defaultConfiguration) { + continue; + } + + const configurationFilePath = path.join( + sourceRoot, + ENVIRONMENTS_DIRECTORY, + `environment.${name}.ts`, + ); + + // Add file replacement option entry for the configuration environment file + const replacements = (configurationOptions['fileReplacements'] ??= []) as { + replace: string; + with: string; + }[]; + const existing = replacements.find((value) => value.replace === defaultFilePath); + if (existing) { + if (existing.with === configurationFilePath) { + yield log( + 'info', + `Skipping addition of already existing file replacements option for "${defaultFilePath}" to "${configurationFilePath}".`, + ); + } else { + yield log( + 'warn', + `Configuration "${name}" has a file replacements option for "${defaultFilePath}" but with a different replacement.` + + ` Expected "${configurationFilePath}" but found "${existing.with}". This may result in unexpected build behavior.`, + ); + } + } else { + replacements.push({ replace: defaultFilePath, with: configurationFilePath }); + } + + // Create configuration specific environment file if not already added + if (!addedFiles.has(configurationFilePath)) { + addedFiles.add(configurationFilePath); + yield createIfMissing(configurationFilePath); + } + } +} diff --git a/packages/schematics/angular/environments/index_spec.ts b/packages/schematics/angular/environments/index_spec.ts new file mode 100644 index 000000000000..180374c9097d --- /dev/null +++ b/packages/schematics/angular/environments/index_spec.ts @@ -0,0 +1,174 @@ +/** + * @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.io/license + */ + +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { Schema as ApplicationOptions } from '../application/schema'; +import { Schema as WorkspaceOptions } from '../workspace/schema'; +import { Schema as EnvironmentOptions } from './schema'; + +describe('Application Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@schematics/angular', + require.resolve('../collection.json'), + ); + + const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + version: '15.0.0', + }; + + const defaultOptions: EnvironmentOptions = { + project: 'foo', + }; + + const defaultAppOptions: ApplicationOptions = { + name: 'foo', + inlineStyle: true, + inlineTemplate: true, + routing: false, + skipPackageJson: false, + minimal: true, + }; + + let applicationTree: UnitTestTree; + const messages: string[] = []; + schematicRunner.logger.subscribe((x) => messages.push(x.message)); + + function runEnvironmentsSchematic(): Promise { + return schematicRunner.runSchematic('environments', defaultOptions, applicationTree); + } + + beforeEach(async () => { + messages.length = 0; + const workspaceTree = await schematicRunner.runSchematic('workspace', workspaceOptions); + applicationTree = await schematicRunner.runSchematic( + 'application', + defaultAppOptions, + workspaceTree, + ); + }); + + it('should create a default environment typescript file', async () => { + const tree = await runEnvironmentsSchematic(); + expect(tree.readText('projects/foo/src/environments/environment.ts')).toEqual( + 'export const environment = {};\n', + ); + }); + + it('should create a development configuration environment typescript file', async () => { + const tree = await runEnvironmentsSchematic(); + expect(tree.readText('projects/foo/src/environments/environment.development.ts')).toEqual( + 'export const environment = {};\n', + ); + }); + + it('should create environment typescript files for additional configurations', async () => { + const initialWorkspace = JSON.parse(applicationTree.readContent('/angular.json')); + initialWorkspace.projects.foo.architect.build.configurations.staging = {}; + applicationTree.overwrite('/angular.json', JSON.stringify(initialWorkspace)); + + const tree = await runEnvironmentsSchematic(); + expect(tree.readText('projects/foo/src/environments/environment.development.ts')).toEqual( + 'export const environment = {};\n', + ); + + expect(tree.readText('projects/foo/src/environments/environment.staging.ts')).toEqual( + 'export const environment = {};\n', + ); + }); + + it('should update the angular.json file replacements option for the development configuration', async () => { + const tree = await runEnvironmentsSchematic(); + const workspace = JSON.parse(tree.readContent('/angular.json')); + + const developmentConfiguration = + workspace.projects.foo.architect.build.configurations.development; + expect(developmentConfiguration).toEqual( + jasmine.objectContaining({ + fileReplacements: [ + { + replace: 'projects/foo/src/environments/environment.ts', + with: 'projects/foo/src/environments/environment.development.ts', + }, + ], + }), + ); + }); + + it('should update the angular.json file replacements option for additional configurations', async () => { + const initialWorkspace = JSON.parse(applicationTree.readContent('/angular.json')); + initialWorkspace.projects.foo.architect.build.configurations.staging = {}; + applicationTree.overwrite('/angular.json', JSON.stringify(initialWorkspace)); + + const tree = await runEnvironmentsSchematic(); + const workspace = JSON.parse(tree.readContent('/angular.json')); + + const developmentConfiguration = + workspace.projects.foo.architect.build.configurations.development; + expect(developmentConfiguration).toEqual( + jasmine.objectContaining({ + fileReplacements: [ + { + replace: 'projects/foo/src/environments/environment.ts', + with: 'projects/foo/src/environments/environment.development.ts', + }, + ], + }), + ); + + const stagingConfiguration = workspace.projects.foo.architect.build.configurations.staging; + expect(stagingConfiguration).toEqual( + jasmine.objectContaining({ + fileReplacements: [ + { + replace: 'projects/foo/src/environments/environment.ts', + with: 'projects/foo/src/environments/environment.staging.ts', + }, + ], + }), + ); + }); + + it('should update the angular.json file replacements option for server configurations', async () => { + await schematicRunner.runSchematic( + 'universal', + { project: 'foo', skipInstall: true }, + applicationTree, + ); + + const tree = await runEnvironmentsSchematic(); + const workspace = JSON.parse(tree.readContent('/angular.json')); + + const developmentConfiguration = + workspace.projects.foo.architect.build.configurations.development; + expect(developmentConfiguration).toEqual( + jasmine.objectContaining({ + fileReplacements: [ + { + replace: 'projects/foo/src/environments/environment.ts', + with: 'projects/foo/src/environments/environment.development.ts', + }, + ], + }), + ); + + const serverDevelopmentConfiguration = + workspace.projects.foo.architect.server.configurations.development; + expect(serverDevelopmentConfiguration).toEqual( + jasmine.objectContaining({ + fileReplacements: [ + { + replace: 'projects/foo/src/environments/environment.ts', + with: 'projects/foo/src/environments/environment.development.ts', + }, + ], + }), + ); + }); +}); diff --git a/packages/schematics/angular/environments/schema.json b/packages/schematics/angular/environments/schema.json new file mode 100644 index 000000000000..6aea81664e04 --- /dev/null +++ b/packages/schematics/angular/environments/schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsAngularEnvironment", + "title": "Angular Environments Options Schema", + "type": "object", + "additionalProperties": false, + "description": "Generates and configures environment files for a project.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + } + }, + "required": ["project"] +}