diff --git a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts index f0c8342791d6..8640c7cbee20 100644 --- a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts @@ -18,13 +18,14 @@ import { } from './virtual_file_system_decorator'; import { resolveEntryModuleFromMain } from './entry_resolver'; import { + createTransformerFactory, + ComponentResourceTransformer, replaceBootstrap, exportNgFactory, exportLazyModuleMap, removeDecorators, registerLocaleData, findResources, - replaceResources, } from './transformers'; import { time, timeEnd } from './benchmark'; import { InitMessage, UpdateMessage } from './type_checker'; @@ -642,7 +643,13 @@ export class AngularCompilerPlugin implements Tapable { if (this._JitMode) { // Replace resources in JIT. - this._transformers.push(replaceResources(isAppPath)); + this._transformers.push(createTransformerFactory( + new ComponentResourceTransformer(), + { + getTypeChecker, + exclude: node => !isAppPath(node.fileName), + }, + )); } else { // Remove unneeded angular decorators. this._transformers.push(removeDecorators(isAppPath, getTypeChecker)); diff --git a/packages/@ngtools/webpack/src/transformers/component-resource-replacer.spec.ts b/packages/@ngtools/webpack/src/transformers/component-resource-replacer.spec.ts new file mode 100644 index 000000000000..ff7df9c93e61 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/component-resource-replacer.spec.ts @@ -0,0 +1,505 @@ +import { oneLine, stripIndent } from 'common-tags'; +import * as ts from 'typescript'; +import { + ComponentResourceTransformer, + ComponentResourceReplacerOptions, + ReplacerMode +} from './component-resource-replacer'; +import { createTransformerFactory, Transformer } from './transformer'; + + +function transform( + content: string, + transformer?: Transformer, +): string | undefined { + let result: string | undefined; + const source = ts.createSourceFile('temp.ts', content, ts.ScriptTarget.Latest); + const compilerOptions: ts.CompilerOptions = { + isolatedModules: true, + noLib: true, + noResolve: true, + target: ts.ScriptTarget.Latest, + importHelpers: true, + }; + const compilerHost: ts.CompilerHost = { + getSourceFile: (fileName) => { + if (fileName === source.fileName) { + return source; + } + throw new Error(); + }, + getDefaultLibFileName: () => 'lib.d.ts', + writeFile: (fileName, data) => { + if (fileName === 'temp.js') { + result = data; + } + }, + getCurrentDirectory: () => '', + getDirectories: () => [], + getCanonicalFileName: (fileName) => fileName, + useCaseSensitiveFileNames: () => false, + getNewLine: () => '\n', + fileExists: (fileName) => fileName === source.fileName, + readFile: (_fileName) => '', + }; + + const program = ts.createProgram([source.fileName], compilerOptions, compilerHost); + + let transformers = undefined; + if (transformer) { + transformers = { + before: [ + createTransformerFactory( + transformer, + { + getTypeChecker: () => program.getTypeChecker() + } + ) + ], + }; + } + + program.emit(undefined, undefined, undefined, undefined, transformers); + + return result; +} + +function expectTransformation( + input: string, + output: string, + options?: Partial +): void { + const inputResult = transform(input, new ComponentResourceTransformer(options)); + const outputResult = transform(output); + + expect(oneLine`${inputResult}`).toEqual(oneLine`${outputResult}`); +} + +function createComponentSource(metadataText: string): string { + const content = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + ${metadataText} + }) + export class TestComponent {} + `; + + return content; +} + +describe('component-resource-replacer', () => { + it('converts templateUrl to template require()', () => { + const input = createComponentSource(` + templateUrl: './test.component.html', + `); + const output = createComponentSource(` + template: require('./test.component.html'), + `); + + expectTransformation(input, output); + }); + + it('normalizes a template resource path', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: 'test.component.html', + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + template: require("./test.component.html"), + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('converts single element styleUrls to styles require()', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styleUrls: ['./test.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: [require('./test.component.css')], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('normalizes a styleUrl resource path', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styleUrls: ['test.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: [require("./test.component.css")], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('converts multi element styleUrls to styles require()', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: [require('./test-1.component.css'), require('./test-2.component.css')], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('removes empty styleUrls array', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styleUrls: [], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('maintains inline styles with no styleUrls', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: ['h1 { color: blue; }'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: ['h1 { color: blue; }'], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('combines inline styles with styleUrls', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: ['h1 { color: blue; }'], + styleUrls: ['./test.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: ['h1 { color: blue; }', require('./test.component.css')], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('removes empty styles array', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: [], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('removes empty styles array elements', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: ['', 'h1 { color: blue; }', ''], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + styles: ['h1 { color: blue; }'], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('removes moduleId', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + moduleId: 'test-module-id', + selector: 'test', + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('converts both templateUrl and styleUrls', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + template: require('./test.component.html'), + styles: [require('./test-1.component.css'), require('./test-2.component.css')], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('only modifies Angular component metadata', () => { + const input = stripIndent` + import { Component } from 'xyz'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from 'xyz'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + + expectTransformation(input, output); + }); + + it('reports all resources in require mode', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + template: require('./test.component.html'), + styles: [require('./test-1.component.css'), require('./test-2.component.css')], + }) + export class TestComponent {} + `; + + const resources: string[] = []; + expectTransformation(input, output, { onResourceFound: r => resources.push(r) }); + + expect(resources.length).toEqual(3); + expect(resources.includes('./test.component.html')).toBeTruthy(); + expect(resources.includes('./test-1.component.css')).toBeTruthy(); + expect(resources.includes('./test-2.component.css')).toBeTruthy(); + }); + + it('makes no changes in analyze only mode', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + + expectTransformation(input, output, { urlMode: ReplacerMode.Analyze }); + }); + + it('reports all resources in analyze only mode', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + + const resources: string[] = []; + expectTransformation(input, output, { + urlMode: ReplacerMode.Analyze, + onResourceFound: r => resources.push(r), + }); + + expect(resources.length).toEqual(3); + expect(resources.includes('./test.component.html')).toBeTruthy(); + expect(resources.includes('./test-1.component.css')).toBeTruthy(); + expect(resources.includes('./test-2.component.css')).toBeTruthy(); + }); + + it('fetches all resources in inline mode', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css', './test-2.component.css'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + template: "

hi

", + styles: [":host { }", "p { color: blue; }"], + }) + export class TestComponent {} + `; + + const contents: { [resource: string]: string } = { + './test.component.html': '

hi

', + './test-1.component.css': ':host { }', + './test-2.component.css': 'p { color: blue; }', + }; + + expectTransformation(input, output, { + urlMode: ReplacerMode.Inline, + fetchResource: r => contents[r], + }); + }); + + it('transforms inline content in require mode', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + templateUrl: './test.component.html', + styleUrls: ['./test-1.component.css'], + styles: ['p { color: blue; }'], + }) + export class TestComponent {} + `; + const output = stripIndent` + import { Component } from '@angular/core'; + @Component({ + selector: 'test', + template: require('./test.component.html'), + styles: ["p { color: red; }", require('./test-1.component.css')], + }) + export class TestComponent {} + `; + + expectTransformation(input, output, { + urlMode: ReplacerMode.Require, + transformContent: (content, inline) => { + expect(content).toBe('p { color: blue; }'); + expect(inline).toBe(true); + + return 'p { color: red; }'; + }, + }); + }); + +}); diff --git a/packages/@ngtools/webpack/src/transformers/component-resource-replacer.ts b/packages/@ngtools/webpack/src/transformers/component-resource-replacer.ts new file mode 100644 index 000000000000..5f6c48c52a48 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/component-resource-replacer.ts @@ -0,0 +1,270 @@ +import * as ts from 'typescript'; +import { AbstractTransformer } from './transformer'; + +export enum ReplacerMode { + Analyze, + Inline, + Require +} + +export class ComponentResourceReplacerOptions { + fullAstWalk = false; + urlMode = ReplacerMode.Require; + onResourceFound?: (resource: string, sourceFilename: string) => void; + fetchResource?: (resource: string, sourceFilename: string) => string | undefined; + transformContent?: (content: string, inline: boolean) => string; +} + +export class ComponentResourceTransformer extends AbstractTransformer { + private _currentSourceFile: ts.SourceFile | null; + private _componentStyles: Array; + + constructor( + private readonly options: Partial = {}, + ) { + super(); + this.options = { ...new ComponentResourceReplacerOptions(), ...options }; + + if (options.urlMode === ReplacerMode.Inline && !options.fetchResource) { + throw new Error('inline mode requires the fetch resource option to be defined'); + } + } + + reset() { + this._currentSourceFile = null; + } + + transform(file: ts.SourceFile): ts.SourceFile { + this._currentSourceFile = file; + + const visitor: ts.Visitor = node => { + if (ts.isClassDeclaration(node)) { + node.decorators = ts.visitNodes( + node.decorators, + (node: ts.Decorator) => this._replaceResources(node), + ); + } + + return this.options.fullAstWalk ? this.visitEachChild(node, visitor) : node; + }; + + return this.visitEachChild(file, visitor); + } + + private _isComponentDecorator(node: ts.Node): node is ts.Decorator { + if (!ts.isDecorator(node)) { + return false; + } + + const origin = this.getDecoratorOrigin(node); + if (origin && origin.module === '@angular/core' && origin.name === 'Component') { + return true; + } + + return false; + } + + private _normalizeUrl(url: string): string { + return `${/^\.?\.\//.test(url) ? '' : './'}${url}`; + } + + private _analyzeUrlExpression(node: ts.Expression): ts.Expression { + // only analyze strings + if (!ts.isStringLiteral(node)) { + return node; + } + + const rawUrl = node.text; + const normalizedUrl = this._normalizeUrl(rawUrl); + + if (this.options.onResourceFound) { + this.options.onResourceFound(normalizedUrl, this._currentSourceFile.fileName); + } + + if (rawUrl === normalizedUrl) { + return node; + } + + const updatedNode = ts.createLiteral(normalizedUrl); + + return updatedNode; + } + + private _transformUrlExpression(node: ts.Expression) { + const requireArgument = this._analyzeUrlExpression(node); + + if (this.options.urlMode === ReplacerMode.Require) { + const requireCall = ts.createCall( + ts.createIdentifier('require'), + undefined, + [requireArgument] + ); + + return requireCall; + } else if (this.options.urlMode === ReplacerMode.Inline && ts.isStringLiteral(node)) { + let content = this.options.fetchResource(node.text, this._currentSourceFile.fileName); + if (!content) { + return undefined; + } + + if (this.options.transformContent) { + content = this.options.transformContent(content, false); + } + + if (!content || content.trim().length === 0) { + return undefined; + } + + return ts.createLiteral(content); + } + + // unsupported node - return directly + return node; + } + + private _visitInlineStyle(node: ts.Expression): ts.VisitResult { + // TODO: fold constants + + // leave nodes other than string literals as is + if (!ts.isStringLiteral(node)) { + return node; + } + + // transform content if option is configured + if (this.options.transformContent) { + const content = this.options.transformContent(node.text, true); + if (!content || content.trim().length === 0) { + return undefined; + } + + return ts.createLiteral(content); + } + + // filter out string values that lack content + if (node.text.trim().length === 0) { + return undefined; + } + + return node; + } + + private _replaceResources(node: ts.Decorator): ts.Decorator { + if (!this._isComponentDecorator(node)) { + // not a component decorator + return node; + } + if (!ts.isCallExpression(node.expression)) { + // Error: unsupported syntax + return node; + } + + const decoratorFactory = node.expression; + const args = decoratorFactory.arguments; + if (args.length !== 1 || args[0].kind !== ts.SyntaxKind.ObjectLiteralExpression) { + // Error: unsupported component metadata + return node; + } + + this._componentStyles = []; + const objectExpression = args[0] as ts.ObjectLiteralExpression; + const properties = ts.visitNodes( + objectExpression.properties, + (node: ts.ObjectLiteralElementLike) => this._visitComponentMetaData(node), + ); + if (this._componentStyles.length > 0) { + const styleAssignment = ts.createPropertyAssignment( + ts.createIdentifier('styles'), + ts.createArrayLiteral(this._componentStyles) + ); + properties.push(styleAssignment); + } + args[0] = ts.updateObjectLiteral( + objectExpression, + properties + ); + + return ts.updateDecorator( + node, + ts.updateCall( + decoratorFactory, + decoratorFactory.expression, + decoratorFactory.typeArguments, + args + ) + ); + } + + private _visitComponentMetaData( + node: ts.ObjectLiteralElementLike + ): ts.VisitResult { + if (!ts.isPropertyAssignment(node)) { + // Error: unsupported + return node; + } + + if (ts.isComputedPropertyName(node.name)) { + // computed names are not supported + return node; + } + + const name = node.name.text; + switch (name) { + case 'moduleId': + // remove + return undefined; + case 'styleUrls': + // update + if (!ts.isArrayLiteralExpression(node.initializer)) { + // Error: unsupported + return node; + } + const styleUrls = node.initializer.elements; + if (styleUrls.length === 0) { + return undefined; + } + if (this.options.urlMode === ReplacerMode.Analyze) { + styleUrls.forEach(element => this._analyzeUrlExpression(element)); + return node; + } + const transformedUrls = ts.visitNodes( + styleUrls, + (node: ts.Expression) => this._transformUrlExpression(node) + ); + this._componentStyles = this._componentStyles.concat(transformedUrls); + + // combined styles are added after visitor + return undefined; + case 'templateUrl': + // update + if (this.options.urlMode === ReplacerMode.Analyze) { + this._analyzeUrlExpression(node.initializer); + return node; + } + return ts.updatePropertyAssignment( + node, + ts.createIdentifier('template'), + this._transformUrlExpression(node.initializer) + ); + case 'styles': + // combine with styleUrls; styles (this) first + if (!ts.isArrayLiteralExpression(node.initializer)) { + // Error: unsupported + return node; + } + + const inlineStyles = ts.visitNodes( + node.initializer.elements, + (node: ts.Expression) => this._visitInlineStyle(node), + ); + this._componentStyles.unshift(...inlineStyles); + + // combined styles are added after visitor + return undefined; + case 'template': + // TODO: Warn about both inline & url; also, priority? + default: + return node; + } + } + +} diff --git a/packages/@ngtools/webpack/src/transformers/index.ts b/packages/@ngtools/webpack/src/transformers/index.ts index 4ee76368978c..a53e70aadc30 100644 --- a/packages/@ngtools/webpack/src/transformers/index.ts +++ b/packages/@ngtools/webpack/src/transformers/index.ts @@ -1,3 +1,4 @@ +export { createTransformerFactory } from './transformer'; export * from './interfaces'; export * from './ast_helpers'; export * from './make_transform'; @@ -7,5 +8,7 @@ export * from './replace_bootstrap'; export * from './export_ngfactory'; export * from './export_lazy_module_map'; export * from './register_locale_data'; +export * from './component-resource-replacer'; export * from './replace_resources'; export * from './remove_decorators'; + diff --git a/packages/@ngtools/webpack/src/transformers/transformer.ts b/packages/@ngtools/webpack/src/transformers/transformer.ts new file mode 100644 index 000000000000..8df9453f1c51 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/transformer.ts @@ -0,0 +1,150 @@ +import * as ts from 'typescript'; +import { satisfies } from 'semver'; + +const needsNodeSymbolFix = satisfies(ts.version, '< 2.5.2'); + +export interface Transformer { + initialize( + transformationContext: ts.TransformationContext, + programContext: ProgramContext, + ): void; + reset?(): void; + transform?(node: T): T; +} + +export abstract class AbstractTransformer implements Transformer { + private _transformationContext: ts.TransformationContext | null = null; + private _programContext: ProgramContext | null = null; + + protected get transformationContext(): ts.TransformationContext { + if (!this._transformationContext) { + throw new Error('transformer is not initialized'); + } + return this._transformationContext; + } + + protected get programContext(): ProgramContext { + if (!this._programContext) { + throw new Error('transformer is not initialized'); + } + return this._programContext; + } + + initialize(transformationContext: ts.TransformationContext, programContext: ProgramContext) { + this._transformationContext = transformationContext; + this._programContext = programContext; + } + + protected visitEachChild(node: T, visitor: ts.Visitor): T { + return ts.visitEachChild(node, visitor, this.transformationContext); + } + + protected getDecoratorOrigin(decorator: ts.Decorator): DecoratorOrigin { + return getDecoratorOrigin(decorator, this.programContext.getTypeChecker()); + } +} + +export interface ProgramContext { + getTypeChecker(): ts.TypeChecker; +} + +export interface TransformerFactoryOptions { + getTypeChecker?: () => ts.TypeChecker; + exclude?: (node: TNode) => boolean; +} + +export function createTransformerFactory( + transformer: Transformer, + options: TransformerFactoryOptions = {}, +): ts.TransformerFactory { + const programContext: ProgramContext = { + getTypeChecker: () => { + if (!options.getTypeChecker) { + throw new Error('type checker is not available'); + } + return options.getTypeChecker(); + }, + }; + + const factory: ts.TransformerFactory = transformationContext => { + transformer.initialize(transformationContext, programContext); + + if (transformer.reset) { + transformer.reset(); + } + + if (!transformer.transform) { + return node => node; + } + + return node => { + if (options.exclude && options.exclude(node)) { + return node; + } + + const result = transformer.transform(node); + + if (transformer.reset) { + transformer.reset(); + } + + if (result && needsNodeSymbolFix) { + const original = ts.getParseTreeNode(result); + // tslint:disable-next-line:no-any - 'symbol' is internal + (result as any).symbol = (result as any).symbol || (original as any).symbol; + } + + return result; + }; + }; + + return factory; +} + +export interface DecoratorOrigin { + name: string; + module: string; +} + +function getDecoratorOrigin( + decorator: ts.Decorator, + typeChecker: ts.TypeChecker +): DecoratorOrigin | null { + if (!ts.isCallExpression(decorator.expression)) { + return null; + } + + let identifier: ts.Node; + let name: string; + if (ts.isPropertyAccessExpression(decorator.expression.expression)) { + identifier = decorator.expression.expression.expression; + name = decorator.expression.expression.name.text; + } else if (ts.isIdentifier(decorator.expression.expression)) { + identifier = decorator.expression.expression; + } else { + return null; + } + + // NOTE: resolver.getReferencedImportDeclaration would work as well but is internal + const symbol = typeChecker.getSymbolAtLocation(identifier); + if (symbol && symbol.declarations && symbol.declarations.length > 0) { + const declaration = symbol.declarations[0]; + let module: string; + if (ts.isImportSpecifier(declaration)) { + name = (declaration.propertyName || declaration.name).text; + module = (declaration.parent.parent.parent.moduleSpecifier as ts.StringLiteral).text; + } else if (ts.isNamespaceImport(declaration)) { + // Use the name from the decorator namespace property access + module = (declaration.parent.parent.moduleSpecifier as ts.StringLiteral).text; + } else if (ts.isImportClause(declaration)) { + name = declaration.name.text; + module = (declaration.parent.moduleSpecifier as ts.StringLiteral).text; + } else { + return null; + } + + return { name, module }; + } + + return null; +}