This commit adds the basic building blocks for linking partial declarations.
In particular it provides a generic `FileLinker` class that delegates to
a set of (not yet implemented) `PartialLinker` classes.
The Babel plugin makes use of this `FileLinker` providing concrete classes
for `AstHost` and `AstFactory` that work with Babel AST. It can be created
with the following code:
```ts
const plugin = createEs2015LinkerPlugin({ /* options */ });
```
PR Close #39116
		
	
			
		
			
				
	
	
		
			257 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			257 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * @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 * as o from '@angular/compiler/src/output/output_ast';
 | |
| import {NodePath, PluginObj, transformSync} from '@babel/core';
 | |
| import generate from '@babel/generator';
 | |
| import * as t from '@babel/types';
 | |
| 
 | |
| import {FileLinker} from '../../../linker';
 | |
| import {PartialDirectiveLinkerVersion1} from '../../src/file_linker/partial_linkers/partial_directive_linker_1';
 | |
| import {createEs2015LinkerPlugin} from '../src/es2015_linker_plugin';
 | |
| 
 | |
| describe('createEs2015LinkerPlugin()', () => {
 | |
|   it('should return a Babel plugin visitor that handles Program (enter/exit) and CallExpression nodes',
 | |
|      () => {
 | |
|        const plugin = createEs2015LinkerPlugin();
 | |
|        expect(plugin.visitor).toEqual({
 | |
|          Program: {
 | |
|            enter: jasmine.any(Function),
 | |
|            exit: jasmine.any(Function),
 | |
|          },
 | |
|          CallExpression: jasmine.any(Function),
 | |
|        });
 | |
|      });
 | |
| 
 | |
|   it('should return a Babel plugin that calls FileLinker.isPartialDeclaration() on each call expression',
 | |
|      () => {
 | |
|        const isPartialDeclarationSpy = spyOn(FileLinker.prototype, 'isPartialDeclaration');
 | |
| 
 | |
|        transformSync(
 | |
|            [
 | |
|              'var core;', `fn1()`, 'fn2({prop: () => fn3({})});', `x.method(() => fn4());`,
 | |
|              'spread(...x);'
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [createEs2015LinkerPlugin()],
 | |
|              filename: '/test.js',
 | |
|              parserOpts: {sourceType: 'unambiguous'},
 | |
|            });
 | |
|        expect(isPartialDeclarationSpy.calls.allArgs()).toEqual([
 | |
|          ['fn1'],
 | |
|          ['fn2'],
 | |
|          ['fn3'],
 | |
|          ['method'],
 | |
|          ['fn4'],
 | |
|          ['spread'],
 | |
|        ]);
 | |
|      });
 | |
| 
 | |
|   it('should return a Babel plugin that calls FileLinker.linkPartialDeclaration() on each matching declaration',
 | |
|      () => {
 | |
|        const linkSpy = spyOn(FileLinker.prototype, 'linkPartialDeclaration')
 | |
|                            .and.returnValue(t.identifier('REPLACEMENT'));
 | |
| 
 | |
|        transformSync(
 | |
|            [
 | |
|              'var core;',
 | |
|              `$ngDeclareDirective({version: 1, ngImport: core, x: 1});`,
 | |
|              `$ngDeclareComponent({version: 1, ngImport: core, foo: () => $ngDeclareDirective({version: 1, ngImport: core, x: 2})});`,
 | |
|              `x.qux(() => $ngDeclareDirective({version: 1, ngImport: core, x: 3}));`,
 | |
|              'spread(...x);',
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [createEs2015LinkerPlugin()],
 | |
|              filename: '/test.js',
 | |
|              parserOpts: {sourceType: 'unambiguous'},
 | |
|            });
 | |
| 
 | |
|        expect(humanizeLinkerCalls(linkSpy.calls)).toEqual([
 | |
|          ['$ngDeclareDirective', '{version:1,ngImport:core,x:1}'],
 | |
|          [
 | |
|            '$ngDeclareComponent',
 | |
|            '{version:1,ngImport:core,foo:()=>$ngDeclareDirective({version:1,ngImport:core,x:2})}'
 | |
|          ],
 | |
|          // Note we do not process `x:2` declaration since it is nested within another declaration
 | |
|          ['$ngDeclareDirective', '{version:1,ngImport:core,x:3}']
 | |
|        ]);
 | |
|      });
 | |
| 
 | |
|   it('should return a Babel plugin that replaces call expressions with the return value from FileLinker.linkPartialDeclaration()',
 | |
|      () => {
 | |
|        let replaceCount = 0;
 | |
|        spyOn(FileLinker.prototype, 'linkPartialDeclaration')
 | |
|            .and.callFake(() => t.identifier('REPLACEMENT_' + ++replaceCount));
 | |
|        const result = transformSync(
 | |
|            [
 | |
|              'var core;',
 | |
|              '$ngDeclareDirective({version: 1, ngImport: core});',
 | |
|              '$ngDeclareDirective({version: 1, ngImport: core, foo: () => bar({})});',
 | |
|              'x.qux();',
 | |
|              'spread(...x);',
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [createEs2015LinkerPlugin()],
 | |
|              filename: '/test.js',
 | |
|              parserOpts: {sourceType: 'unambiguous'},
 | |
|              generatorOpts: {compact: true},
 | |
|            });
 | |
|        expect(result!.code).toEqual('var core;REPLACEMENT_1;REPLACEMENT_2;x.qux();spread(...x);');
 | |
|      });
 | |
| 
 | |
|   it('should return a Babel plugin that adds shared statements after any imports', () => {
 | |
|     spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT'));
 | |
|     const result = transformSync(
 | |
|         [
 | |
|           'import * as core from \'some-module\';',
 | |
|           'import {id} from \'other-module\';',
 | |
|           `$ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|           `$ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|           `$ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|         ].join('\n'),
 | |
|         {
 | |
|           plugins: [createEs2015LinkerPlugin()],
 | |
|           filename: '/test.js',
 | |
|           parserOpts: {sourceType: 'unambiguous'},
 | |
|           generatorOpts: {compact: true},
 | |
|         });
 | |
|     expect(result!.code)
 | |
|         .toEqual(
 | |
|             'import*as core from\'some-module\';import{id}from\'other-module\';const _c0=[1];const _c1=[2];const _c2=[3];"REPLACEMENT";"REPLACEMENT";"REPLACEMENT";');
 | |
|   });
 | |
| 
 | |
|   it('should return a Babel plugin that adds shared statements at the start of the program if it is an ECMAScript Module and there are no imports',
 | |
|      () => {
 | |
|        spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT'));
 | |
|        const result = transformSync(
 | |
|            [
 | |
|              'var core;',
 | |
|              `$ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              `$ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              `$ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [createEs2015LinkerPlugin()],
 | |
|              filename: '/test.js',
 | |
|              // We declare the file as a module because this cannot be inferred from the source
 | |
|              parserOpts: {sourceType: 'module'},
 | |
|              generatorOpts: {compact: true},
 | |
|            });
 | |
|        expect(result!.code)
 | |
|            .toEqual(
 | |
|                'const _c0=[1];const _c1=[2];const _c2=[3];var core;"REPLACEMENT";"REPLACEMENT";"REPLACEMENT";');
 | |
|      });
 | |
| 
 | |
|   it('should return a Babel plugin that adds shared statements at the start of the function body if the ngImport is from a function parameter',
 | |
|      () => {
 | |
|        spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT'));
 | |
|        const result = transformSync(
 | |
|            [
 | |
|              'function run(core) {', `  $ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              `  $ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              `  $ngDeclareDirective({version: 1, ngImport: core})`, '}'
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [createEs2015LinkerPlugin()],
 | |
|              filename: '/test.js',
 | |
|              parserOpts: {sourceType: 'unambiguous'},
 | |
|              generatorOpts: {compact: true},
 | |
|            });
 | |
|        expect(result!.code)
 | |
|            .toEqual(
 | |
|                'function run(core){const _c0=[1];const _c1=[2];const _c2=[3];"REPLACEMENT";"REPLACEMENT";"REPLACEMENT";}');
 | |
|      });
 | |
| 
 | |
|   it('should return a Babel plugin that adds shared statements into an IIFE if no scope could not be derived for the ngImport',
 | |
|      () => {
 | |
|        spyOnLinkPartialDeclarationWithConstants(o.literal('REPLACEMENT'));
 | |
|        const result = transformSync(
 | |
|            [
 | |
|              'function run() {',
 | |
|              `  $ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              `  $ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              `  $ngDeclareDirective({version: 1, ngImport: core})`,
 | |
|              '}',
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [createEs2015LinkerPlugin()],
 | |
|              filename: '/test.js',
 | |
|              parserOpts: {sourceType: 'unambiguous'},
 | |
|              generatorOpts: {compact: true},
 | |
|            });
 | |
|        expect(result!.code).toEqual([
 | |
|          `function run(){`,
 | |
|          `(function(){const _c0=[1];return"REPLACEMENT";})();`,
 | |
|          `(function(){const _c0=[2];return"REPLACEMENT";})();`,
 | |
|          `(function(){const _c0=[3];return"REPLACEMENT";})();`,
 | |
|          `}`,
 | |
|        ].join(''));
 | |
|      });
 | |
| 
 | |
|   it('should still execute other plugins that match AST nodes inside the result of the replacement',
 | |
|      () => {
 | |
|        spyOnLinkPartialDeclarationWithConstants(o.fn([], [], null, null, 'FOO'));
 | |
|        const result = transformSync(
 | |
|            [
 | |
|              `$ngDeclareDirective({version: 1, ngImport: core}); FOO;`,
 | |
|            ].join('\n'),
 | |
|            {
 | |
|              plugins: [
 | |
|                createEs2015LinkerPlugin(),
 | |
|                createIdentifierMapperPlugin('FOO', 'BAR'),
 | |
|                createIdentifierMapperPlugin('_c0', 'x1'),
 | |
|              ],
 | |
|              filename: '/test.js',
 | |
|              parserOpts: {sourceType: 'module'},
 | |
|              generatorOpts: {compact: true},
 | |
|            });
 | |
|        expect(result!.code).toEqual([
 | |
|          `(function(){const x1=[1];return function BAR(){};})();BAR;`,
 | |
|        ].join(''));
 | |
|      });
 | |
| });
 | |
| 
 | |
| /**
 | |
|  * Convert the arguments of the spied-on `calls` into a human readable array.
 | |
|  */
 | |
| function humanizeLinkerCalls(
 | |
|     calls: jasmine.Calls<typeof FileLinker.prototype.linkPartialDeclaration>) {
 | |
|   return calls.all().map(({args: [fn, args]}) => [fn, generate(args[0], {compact: true}).code]);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Spy on the `PartialDirectiveLinkerVersion1.linkPartialDeclaration()` method, triggering
 | |
|  * shared constants to be created.
 | |
|  */
 | |
| function spyOnLinkPartialDeclarationWithConstants(replacement: o.Expression) {
 | |
|   let callCount = 0;
 | |
|   spyOn(PartialDirectiveLinkerVersion1.prototype, 'linkPartialDeclaration')
 | |
|       .and.callFake(((sourceUrl, code, constantPool) => {
 | |
|                       const constArray = o.literalArr([o.literal(++callCount)]);
 | |
|                       // We have to add the constant twice or it will not create a shared statement
 | |
|                       constantPool.getConstLiteral(constArray);
 | |
|                       constantPool.getConstLiteral(constArray);
 | |
|                       return replacement;
 | |
|                     }) as typeof PartialDirectiveLinkerVersion1.prototype.linkPartialDeclaration);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * A simple Babel plugin that will replace all identifiers that match `<src>` with identifiers
 | |
|  * called `<dest>`.
 | |
|  */
 | |
| function createIdentifierMapperPlugin(src: string, dest: string): PluginObj {
 | |
|   return {
 | |
|     visitor: {
 | |
|       Identifier(path: NodePath<t.Identifier>) {
 | |
|         if (path.node.name === src) {
 | |
|           path.replaceWith(t.identifier(dest));
 | |
|         }
 | |
|       }
 | |
|     },
 | |
|   };
 | |
| }
 |