diff --git a/packages/localize/schematics/ng-add/index.ts b/packages/localize/schematics/ng-add/index.ts index 0ec9c11792e..bbc6a49260c 100644 --- a/packages/localize/schematics/ng-add/index.ts +++ b/packages/localize/schematics/ng-add/index.ts @@ -8,102 +8,96 @@ * @fileoverview Schematics for ng-new project that builds with Bazel. */ -import {virtualFs, workspaces} from '@angular-devkit/core'; -import {chain, noop, Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {tags} from '@angular-devkit/core'; +import {chain, noop, Rule, SchematicContext, SchematicsException, Tree,} from '@angular-devkit/schematics'; import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks'; -import {addPackageJsonDependency, NodeDependencyType, removePackageJsonDependency} from '@schematics/angular/utility/dependencies'; -import {getWorkspace} from '@schematics/angular/utility/workspace'; +import {addPackageJsonDependency, NodeDependencyType, removePackageJsonDependency,} from '@schematics/angular/utility/dependencies'; +import {allTargetOptions, getWorkspace, updateWorkspace,} from '@schematics/angular/utility/workspace'; import {Builders} from '@schematics/angular/utility/workspace-models'; import {Schema} from './schema'; -export const localizePolyfill = `import '@angular/localize/init';`; +export const localizePolyfill = `@angular/localize/init`; -function getRelevantTargetDefinitions( - project: workspaces.ProjectDefinition, builderName: Builders): workspaces.TargetDefinition[] { - const definitions: workspaces.TargetDefinition[] = []; - project.targets.forEach((target: workspaces.TargetDefinition): void => { - if (target.builder === builderName) { - definitions.push(target); +function prependToMainFiles(projectName: string): Rule { + return async (host: Tree) => { + const workspace = await getWorkspace(host); + const project = workspace.projects.get(projectName); + if (!project) { + throw new SchematicsException(`Invalid project name (${projectName})`); } - }); - return definitions; -} -function getOptionValuesForTargetDefinition( - definition: workspaces.TargetDefinition, optionName: string): string[] { - const optionValues: string[] = []; - if (definition.options && optionName in definition.options) { - let optionValue: unknown = definition.options[optionName]; - if (typeof optionValue === 'string') { - optionValues.push(optionValue); - } - } - if (!definition.configurations) { - return optionValues; - } - Object.values(definition.configurations) - .forEach((configuration: Record|undefined): void => { - if (configuration && optionName in configuration) { - const optionValue: unknown = configuration[optionName]; - if (typeof optionValue === 'string') { - optionValues.push(optionValue); - } + const fileList = new Set(); + for (const target of project.targets.values()) { + if (target.builder !== Builders.Server) { + continue; + } + + for (const [, options] of allTargetOptions(target)) { + const value = options['main']; + if (typeof value === 'string') { + fileList.add(value); } - }); - return optionValues; -} - -function getFileListForRelevantTargetDefinitions( - project: workspaces.ProjectDefinition, builderName: Builders, optionName: string): string[] { - const fileList: string[] = []; - const definitions = getRelevantTargetDefinitions(project, builderName); - definitions.forEach((definition: workspaces.TargetDefinition): void => { - const optionValues = getOptionValuesForTargetDefinition(definition, optionName); - optionValues.forEach((filePath: string): void => { - if (fileList.indexOf(filePath) === -1) { - fileList.push(filePath); } - }); - }); - return fileList; -} + } -function prependToTargetFiles( - project: workspaces.ProjectDefinition, builderName: Builders, optionName: string, str: string) { - return (host: Tree) => { - const fileList = getFileListForRelevantTargetDefinitions(project, builderName, optionName); - - fileList.forEach((path: string): void => { - const data = host.read(path); - if (!data) { - // If the file doesn't exist, just ignore it. - return; - } - - const content = virtualFs.fileBufferToString(data); - if (content.includes(localizePolyfill) || - content.includes(localizePolyfill.replace(/'/g, '"'))) { + for (const path of fileList) { + const content = host.readText(path); + if (content.includes(localizePolyfill)) { // If the file already contains the polyfill (or variations), ignore it too. - return; + continue; } // Add string at the start of the file. const recorder = host.beginUpdate(path); - recorder.insertLeft(0, str); + + const localizeStr = + tags.stripIndents`/*************************************************************************************************** + * Load \`$localize\` onto the global scope - used if i18n tags appear in Angular templates. + */ + import '${localizePolyfill}'; + `; + recorder.insertLeft(0, localizeStr); host.commitUpdate(recorder); - }); + } }; } -function moveToDependencies(host: Tree, context: SchematicContext) { +function addToPolyfillsOption(projectName: string): Rule { + return updateWorkspace((workspace) => { + const project = workspace.projects.get(projectName); + if (!project) { + throw new SchematicsException(`Invalid project name (${projectName})`); + } + + for (const target of project.targets.values()) { + if (target.builder !== Builders.Browser && target.builder !== Builders.Karma) { + continue; + } + + target.options ??= {}; + target.options['polyfills'] ??= [localizePolyfill]; + + for (const [, options] of allTargetOptions(target)) { + // Convert polyfills option to array. + const polyfillsValue = typeof options['polyfills'] === 'string' ? [options['polyfills']] : + options['polyfills']; + if (Array.isArray(polyfillsValue) && !polyfillsValue.includes(localizePolyfill)) { + options['polyfills'] = [...polyfillsValue, localizePolyfill]; + } + } + } + }); +} + +function moveToDependencies(host: Tree, context: SchematicContext): void { if (host.exists('package.json')) { // Remove the previous dependency and add in a new one under the desired type. removePackageJsonDependency(host, '@angular/localize'); addPackageJsonDependency(host, { name: '@angular/localize', type: NodeDependencyType.Default, - version: `~0.0.0-PLACEHOLDER` + version: `~0.0.0-PLACEHOLDER`, }); // Add a task to run the package manager. This is necessary because we updated @@ -113,7 +107,7 @@ function moveToDependencies(host: Tree, context: SchematicContext) { } export default function(options: Schema): Rule { - return async (host: Tree) => { + return () => { // We favor the name option because the project option has a // smart default which can be populated even when unspecified by the user. const projectName = options.name ?? options.project; @@ -122,22 +116,9 @@ export default function(options: Schema): Rule { throw new SchematicsException('Option "project" is required.'); } - const workspace = await getWorkspace(host); - const project: workspaces.ProjectDefinition|undefined = workspace.projects.get(projectName); - if (!project) { - throw new SchematicsException(`Invalid project name (${projectName})`); - } - - const localizeStr = - `/*************************************************************************************************** - * Load \`$localize\` onto the global scope - used if i18n tags appear in Angular templates. - */ -${localizePolyfill} -`; - return chain([ - prependToTargetFiles(project, Builders.Browser, 'polyfills', localizeStr), - prependToTargetFiles(project, Builders.Server, 'main', localizeStr), + prependToMainFiles(projectName), + addToPolyfillsOption(projectName), // If `$localize` will be used at runtime then must install `@angular/localize` // into `dependencies`, rather than the default of `devDependencies`. options.useAtRuntime ? moveToDependencies : noop(), diff --git a/packages/localize/schematics/ng-add/index_spec.ts b/packages/localize/schematics/ng-add/index_spec.ts index b1793379d8a..6d967f6fb85 100644 --- a/packages/localize/schematics/ng-add/index_spec.ts +++ b/packages/localize/schematics/ng-add/index_spec.ts @@ -11,18 +11,11 @@ import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/test import {localizePolyfill} from './index'; - describe('ng-add schematic', () => { - const countInstances = (str: string, substr: string) => str.split(substr).length - 1; const defaultOptions = {project: 'demo'}; let host: UnitTestTree; let schematicRunner: SchematicTestRunner; - // The real polyfills file is bigger than this, but for the test it shouldn't matter. - const polyfillsContent = - `/*************************************************************************************************** - * Zone JS is required by default for Angular itself. - */ -import 'zone.js';`; + const polyfillsContent = ''; const mainServerContent = `import { enableProdMode } from '@angular/core'; import { environment } from './environments/environment'; if (environment.production) { @@ -38,7 +31,7 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; // The default (according to `ng-add` in its package.json) is for `@angular/localize` to be // saved to `devDependencies`. '@angular/localize': '~0.0.0-PLACEHOLDER', - } + }, })); host.create('src/polyfills.ts', polyfillsContent); host.create('src/another-polyfills.ts', polyfillsContent); @@ -62,19 +55,26 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; configurations: { production: { polyfills: 'src/another-polyfills.ts', - } - } + }, + }, }, - 'another-build': { - builder: '@angular-devkit/build-angular:browser', + test: { + builder: '@angular-devkit/build-angular:karma', options: { - polyfills: 'src/polyfills.ts', + polyfills: ['src/polyfills.ts'], }, configurations: { production: { - polyfills: 'src/another-polyfills.ts', - } - } + polyfills: ['src/another-polyfills.ts'], + }, + dev: { + polyfills: [localizePolyfill], + }, + }, + }, + 'another-test': { + builder: '@angular-devkit/build-angular:karma', + options: {}, }, server: { builder: '@angular-devkit/build-angular:server', @@ -84,8 +84,8 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; configurations: { production: { main: 'src/another-main.server.ts', - } - } + }, + }, }, 'another-server': { builder: '@angular-devkit/build-angular:server', @@ -95,8 +95,8 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; configurations: { production: { main: 'src/another-main.server.ts', - } - } + }, + }, }, 'not-browser-or-server': { builder: '@angular-devkit/build-angular:something-else', @@ -106,24 +106,62 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; }, configurations: { production: { - polyfills: 'src/other-unrelated-polyfills.ts', + polyfills: ['src/other-unrelated-polyfills.ts'], main: 'src/another-unrelated-main.server.ts', - } - } + }, + }, }, }, - } + }, }, - defaultProject: 'demo', })); schematicRunner = new SchematicTestRunner('@angular/localize', require.resolve('../collection.json')); }); - it('should add localize polyfill to polyfill files', async () => { + it(`should add localize polyfill to polyfill option when it's a string`, async () => { host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); - expect(host.readContent('/src/polyfills.ts')).toContain(localizePolyfill); - expect(host.readContent('/src/another-polyfills.ts')).toContain(localizePolyfill); + const demoProjectBuild = + (host.readJson('angular.json') as any)['projects']['demo']['architect']['build']; + expect(demoProjectBuild['options']['polyfills']).toEqual([ + 'src/polyfills.ts', + localizePolyfill, + ]); + expect(demoProjectBuild['configurations']['production']['polyfills']).toEqual([ + 'src/another-polyfills.ts', + localizePolyfill, + ]); + }); + + it(`should add localize polyfill to polyfill option when it's a array`, async () => { + host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); + const demoProjectAnotherTest = + (host.readJson('angular.json') as any)['projects']['demo']['architect']['test']; + expect(demoProjectAnotherTest['options']['polyfills']).toEqual([ + 'src/polyfills.ts', + localizePolyfill, + ]); + expect(demoProjectAnotherTest['configurations']['production']['polyfills']).toEqual([ + 'src/another-polyfills.ts', + localizePolyfill, + ]); + }); + + it(`should not add localize polyfill to polyfill option when it's already set`, async () => { + host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); + const demoProjectAnotherTest = + (host.readJson('angular.json') as any)['projects']['demo']['architect']['test']; + expect(demoProjectAnotherTest['configurations']['dev']['polyfills']).toEqual([ + localizePolyfill, + ]); + }); + it(`should add localize polyfill when polyfills options is not set`, async () => { + host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); + const demoProjectAnotherTest = + (host.readJson('angular.json') as any)['projects']['demo']['architect']['another-test']; + expect(demoProjectAnotherTest['options']['polyfills']).toEqual([ + localizePolyfill, + ]); }); it('should add localize polyfill to server main files', async () => { @@ -132,12 +170,6 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; expect(host.readContent('/src/another-main.server.ts')).toContain(localizePolyfill); }); - it('should add localize polyfill at the start of file', async () => { - host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); - const content = host.readContent('/src/polyfills.ts'); - expect(content.indexOf(localizePolyfill)).toBeLessThan(content.indexOf(polyfillsContent)); - }); - it('should not add localize polyfill to files referenced in other targets files', async () => { host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); expect(host.readContent('/src/unrelated-polyfills.ts')).not.toContain(localizePolyfill); @@ -145,21 +177,14 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; expect(host.readContent('/src/unrelated-main.server.ts')).not.toContain(localizePolyfill); expect(host.readContent('/src/another-unrelated-main.server.ts')) .not.toContain(localizePolyfill); - }); - it('should only add localize polyfill once if multiple builds reference it', async () => { - host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); - const content = host.readContent('/src/polyfills.ts'); - expect(countInstances(content, localizePolyfill)).toBe(1); - }); - - it('should not add localize polyfill if it\'s already there', async () => { - const polyfillVariation = localizePolyfill.replace(/'/g, '"'); - host.overwrite('/src/polyfills.ts', `${localizePolyfill}\n${polyfillsContent}`); - host.overwrite('/src/another-polyfills.ts', `${polyfillVariation}\n${polyfillsContent}`); - host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); - expect(countInstances(host.readContent('/src/polyfills.ts'), localizePolyfill)).toBe(1); - expect(countInstances(host.readContent('/src/another-polyfills.ts'), localizePolyfill)).toBe(0); + const demoProjectBuild = + (host.readJson('angular.json') as + any)['projects']['demo']['architect']['not-browser-or-server']; + expect(demoProjectBuild['options']['polyfills']).toBe('src/unrelated-polyfills.ts'); + expect(demoProjectBuild['configurations']['production']['polyfills']).toEqual([ + 'src/other-unrelated-polyfills.ts', + ]); }); it('should not break when there are no polyfills', async () => { @@ -169,7 +194,7 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; 'demo': { root: '', architect: {}, - } + }, }, defaultProject: 'demo', })); @@ -179,8 +204,10 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; it('should add package to `devDependencies` by default', async () => { host = await schematicRunner.runSchematicAsync('ng-add', defaultOptions, host).toPromise(); const packageJsonText = host.readContent('/package.json'); - const packageJsonObj = JSON.parse(packageJsonText) as - {devDependencies: {[key: string]: string}, dependencies: {[key: string]: string}}; + const packageJsonObj = JSON.parse(packageJsonText) as { + devDependencies: {[key: string]: string}; + dependencies: {[key: string]: string}; + }; expect(packageJsonObj.devDependencies?.['@angular/localize']).toBe('~0.0.0-PLACEHOLDER'); expect(packageJsonObj.dependencies?.['@angular/localize']).toBeUndefined(); }); @@ -190,8 +217,10 @@ export { renderModule, renderModuleFactory } from '@angular/platform-server';`; .runSchematicAsync('ng-add', {...defaultOptions, useAtRuntime: true}, host) .toPromise(); const packageJsonText = host.readContent('/package.json'); - const packageJsonObj = JSON.parse(packageJsonText) as - {devDependencies: {[key: string]: string}, dependencies: {[key: string]: string}}; + const packageJsonObj = JSON.parse(packageJsonText) as { + devDependencies: {[key: string]: string}; + dependencies: {[key: string]: string}; + }; expect(packageJsonObj.dependencies?.['@angular/localize']).toBe('~0.0.0-PLACEHOLDER'); expect(packageJsonObj.devDependencies?.['@angular/localize']).toBeUndefined(); }); diff --git a/packages/localize/schematics/ng-add/tsconfig-build.json b/packages/localize/schematics/ng-add/tsconfig-build.json index 1e587c427c0..0d9f54b2c3c 100644 --- a/packages/localize/schematics/ng-add/tsconfig-build.json +++ b/packages/localize/schematics/ng-add/tsconfig-build.json @@ -5,8 +5,7 @@ "stripInternal": false, "target": "es2015", "lib": [ - "es2015", - "es2017.object", + "es2017", ], }, "bazelOptions": {