diff --git a/.vscode/settings.json b/.vscode/settings.json index 0c4a669..3a2ec3b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "vsicons.presets.angular": false + "vsicons.presets.angular": false, + "prettier.printWidth": 100 } \ No newline at end of file diff --git a/index.js b/index.js index 587cf75..8e6dc32 100644 --- a/index.js +++ b/index.js @@ -1,59 +1,178 @@ - var loaderUtils = require("loader-utils"); +var ts = require("typescript"); -// using: regex, capture groups, and capture group variables. -var templateUrlRegex = /templateUrl\s*:(\s*['"`](.*?)['"`]\s*([,}]))/gm; -var stylesRegex = /styleUrls *:(\s*\[[^\]]*?\])/g; -var stringRegex = /(['`"])((?:[^\\]\\\1|.)*?)\1/g; +function calculateTemplateUrlReplacement(propertyAssignment, replacedPropertyName) { + return [ + { + start: propertyAssignment.name.getStart(), + end: propertyAssignment.name.getEnd(), + replacement: replacedPropertyName + }, + constructUrlReplacement(propertyAssignment.initializer) + ]; +} -function replaceStringsWithRequires(string) { - return string.replace(stringRegex, function (match, quote, url) { - if (url.charAt(0) !== ".") { - url = "./" + url; +function calculateStyleUrlsReplacement(propertyAssignment, replacedPropertyName) { + var styleUrlsReplacements = []; + for (var i = 0; i < propertyAssignment.initializer.elements.length; i++) { + styleUrlsReplacements.push(constructUrlReplacement(propertyAssignment.initializer.elements[i])); + } + return [ + { + start: propertyAssignment.name.getStart(), + end: propertyAssignment.name.getEnd(), + replacement: replacedPropertyName } - return "require('" + url + "')"; - }); + ].concat(styleUrlsReplacements); } -module.exports = function(source, sourcemap) { +function constructUrlReplacement(element) { + var delimiter = "'"; + if (element.kind === ts.SyntaxKind.FirstTemplateToken) { + delimiter = "`"; + } + var url = element.text.replace(/(['"\\])/g, "\\$&"); + if (url.charAt(0) !== ".") { + url = "./" + url; + } + + return { + start: element.getStart(), + end: element.getEnd(), + replacement: "require(" + delimiter + url + delimiter + ")"; + }; +} + +function isTemplateUrlProperty(propertyAssignment) { + return ( + propertyAssignment.name.kind === ts.SyntaxKind.Identifier && + propertyAssignment.name.text === "templateUrl" && + (propertyAssignment.initializer.kind === ts.SyntaxKind.StringLiteral || + propertyAssignment.initializer.kind === ts.SyntaxKind.FirstTemplateToken) + ); +} + +function isStyleUrlsPropery(propertyAssignment) { + return ( + propertyAssignment.name.kind === ts.SyntaxKind.Identifier && + propertyAssignment.name.text === "styleUrls" && + propertyAssignment.initializer.kind === ts.SyntaxKind.ArrayLiteralExpression && + propertyAssignment.initializer.elements.every(function(element) { + return ( + element.kind === ts.SyntaxKind.StringLiteral || + element.kind === ts.SyntaxKind.FirstTemplateToken + ); + }) + ); +} + +function flatten(prev, current) { + return prev.concat(current); +} +function findeReplacements(node, templateReplacement, styleReplacement) { + var positions = []; + + calculateReplacements(node); + + function calculateReplacements(node) { + switch (node.kind) { + // search only for class declarations + case ts.SyntaxKind.ClassDeclaration: + if (node.decorators && node.decorators.length > 0) { + // filter for all decorators containing a component decorator + var replacementPositions = node.decorators + .filter(function(decorator) { + return ( + decorator.expression.kind === ts.SyntaxKind.CallExpression && + decorator.expression.expression.kind === ts.SyntaxKind.Identifier && + decorator.expression.expression.text === "Component" && + decorator.expression.arguments.length > 0 + ); + }) + .map(function(decorator) { + return decorator.expression.arguments[0]; + }) + // the argument must be a literal expression + .filter(function(argument) { + return (argument.kind = ts.SyntaxKind.ObjectLiteralExpression); + }) + .map(function(literalExpression) { + return literalExpression.properties; + }) + .reduce(flatten, []) + // filter for property assignments + .filter(function(properties) { + return properties.kind === ts.SyntaxKind.PropertyAssignment; + }) + // only filter for property assignments with the text templateUrl or styleUrls + .filter(function(propertyAssignment) { + return ( + isTemplateUrlProperty(propertyAssignment) || isStyleUrlsPropery(propertyAssignment) + ); + }) + .map(function(propertyAssignment) { + if (propertyAssignment.name.text === "templateUrl") { + return calculateTemplateUrlReplacement(propertyAssignment, templateReplacement); + } else { + return calculateStyleUrlsReplacement(propertyAssignment, styleReplacement); + } + }) + .reduce(flatten, []); + positions = positions.concat(replacementPositions); + } + break; + } + + return ts.forEachChild(node, calculateReplacements); + } + + return positions; +} + +module.exports = function(source, sourcemap) { var config = {}; var query = loaderUtils.parseQuery(this.query); - var styleProperty = 'styles'; - var templateProperty = 'template'; + var styleProperty = "styles"; + var templateProperty = "template"; if (this.options != null) { - Object.assign(config, this.options['angular2TemplateLoader']); + Object.assign(config, this.options["angular2TemplateLoader"]); } Object.assign(config, query); if (config.keepUrl === true) { - styleProperty = 'styleUrls'; - templateProperty = 'templateUrl'; + styleProperty = "styleUrls"; + templateProperty = "templateUrl"; } - // Not cacheable during unit tests; + // Not cacheable during unit tests; this.cacheable && this.cacheable(); - var newSource = source.replace(templateUrlRegex, function (match, url) { - // replace: templateUrl: './path/to/template.html' - // with: template: require('./path/to/template.html') - // or: templateUrl: require('./path/to/template.html') - // if `keepUrl` query parameter is set to true. - return templateProperty + ":" + replaceStringsWithRequires(url); - }) - .replace(stylesRegex, function (match, urls) { - // replace: stylesUrl: ['./foo.css', "./baz.css", "./index.component.css"] - // with: styles: [require('./foo.css'), require("./baz.css"), require("./index.component.css")] - // or: styleUrls: [require('./foo.css'), require("./baz.css"), require("./index.component.css")] - // if `keepUrl` query parameter is set to true. - return styleProperty + ":" + replaceStringsWithRequires(urls); - }); + var fileName = this.resourcePath; + + var sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.ES6, + /*setParentNodes */ true + ); + + var positions = findeReplacements(sourceFile, templateProperty, styleProperty); + + var newSource = source; + + for (var i = positions.length - 1; i >= 0; i--) { + var pos = positions[i]; + var prefix = newSource.substring(0, pos.start); + var postfix = newSource.substring(pos.end); + newSource = prefix + pos.replacement + postfix; + } // Support for tests if (this.callback) { - this.callback(null, newSource, sourcemap) + this.callback(null, newSource, sourcemap); } else { return newSource; } diff --git a/package.json b/package.json index 48a5795..2ed931b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "should": "^9.0.0" }, "dependencies": { - "loader-utils": "^0.2.15" + "loader-utils": "^0.2.15", + "typescript": "^2.3.4" } } diff --git a/test/fixtures/component_with_template_literals.js b/test/fixtures/component_with_template_literals.js new file mode 100644 index 0000000..e15be16 --- /dev/null +++ b/test/fixtures/component_with_template_literals.js @@ -0,0 +1,12 @@ +var componentWithTemplateLiterals = ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-component', + templateUrl: \`./some/path/to/file.html\`, + styleUrls: [\`./app/css/styles.css\`] + }) + export class TestComponent {} +`; + +module.exports = componentWithTemplateLiterals; diff --git a/test/fixtures/index.js b/test/fixtures/index.js index b45a94a..d95f86e 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -5,6 +5,7 @@ var componentWithoutRelPeriodSlash = require('./component_without_relative_perio var componentWithSpacing = require('./component_with_spacing.js'); var componentWithSingleLineDecorator = require('./component_with_single_line_decorator.js'); var componentWithTemplateUrlEndingBySpace = require('./component_with_template_url_ending_by_space.js'); +var componentWithTemplateLiterals = require('./component_with_template_literals.js'); exports.simpleAngularTestComponentFileStringSimple = sampleAngularComponentSimpleFixture; exports.componentWithQuoteInUrls = componentWithQuoteInUrls; @@ -13,3 +14,4 @@ exports.componentWithoutRelPeriodSlash = componentWithoutRelPeriodSlash; exports.componentWithSpacing = componentWithSpacing; exports.componentWithSingleLineDecorator = componentWithSingleLineDecorator; exports.componentWithTemplateUrlEndingBySpace = componentWithTemplateUrlEndingBySpace; +exports.componentWithTemplateLiterals = componentWithTemplateLiterals; diff --git a/test/loader.spec.js b/test/loader.spec.js index ea730ac..89a60e1 100644 --- a/test/loader.spec.js +++ b/test/loader.spec.js @@ -6,7 +6,7 @@ var fixtures = require("./fixtures"); describe("loader", function() { it("Should convert html and style file strings to require()s", function(){ - loader.call({}, fixtures.simpleAngularTestComponentFileStringSimple) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.simpleAngularTestComponentFileStringSimple) .should .be .eql(` @@ -25,7 +25,7 @@ describe("loader", function() { it("Should convert html and style file strings to require()s regardless of inner quotes", function(){ - loader.call({}, fixtures.componentWithQuoteInUrls) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithQuoteInUrls) .should .be .eql(String.raw` @@ -44,7 +44,7 @@ describe("loader", function() { it("Should convert html and multiple style file strings to require()s", function(){ - loader.call({}, fixtures.componentWithMultipleStyles) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithMultipleStyles) .should .be .eql(` @@ -65,21 +65,21 @@ describe("loader", function() { }); it("Should return original source if there are no matches", function() { - loader.call({}, 'foo') + loader.call({ resourcePath: '/path/file.ts' }, 'foo') .should .be .eql('foo'); }); - it("Should convert partial string match requires", function() { - loader.call({}, `{templateUrl: './index/app.html'}`) + it("Should not convert partial string match requires", function() { + loader.call({ resourcePath: '/path/file.ts' }, `{templateUrl: './index/app.html'}`) .should .be - .eql(`{template: require('./index/app.html')}`); + .eql(`{templateUrl: './index/app.html'}`); }); it("Should handle the absense of proper relative path notation", function() { - loader.call({}, fixtures.componentWithoutRelPeriodSlash) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithoutRelPeriodSlash) .should .be .eql(` @@ -97,7 +97,7 @@ describe("loader", function() { it("Should convert html and style file strings to require()s regardless of spacing", function(){ - loader.call({}, fixtures.componentWithSpacing) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithSpacing) .should .be .eql(` @@ -105,8 +105,8 @@ describe("loader", function() { @Component({ selector : 'test-component', - template: require('./some/path/to/file.html'), - styles: [require('./app/css/styles.css')] + template : require('./some/path/to/file.html'), + styles : [require('./app/css/styles.css')] }) export class TestComponent {} ` @@ -116,7 +116,7 @@ describe("loader", function() { it("Should keep templateUrl when asked for", function () { - loader.call({query: '?keepUrl=true'}, fixtures.componentWithSpacing) + loader.call({ resourcePath: '/path/file.ts', query: '?keepUrl=true'}, fixtures.componentWithSpacing) .should .be .eql(` @@ -124,8 +124,8 @@ describe("loader", function() { @Component({ selector : 'test-component', - templateUrl: require('./some/path/to/file.html'), - styleUrls: [require('./app/css/styles.css')] + templateUrl : require('./some/path/to/file.html'), + styleUrls : [require('./app/css/styles.css')] }) export class TestComponent {} ` @@ -142,6 +142,7 @@ describe("loader", function() { keepUrl: true } }; + self.resourcePath = '/path/file.ts'; loader.call(self, fixtures.componentWithSpacing) .should @@ -151,8 +152,8 @@ describe("loader", function() { @Component({ selector : 'test-component', - templateUrl: require('./some/path/to/file.html'), - styleUrls: [require('./app/css/styles.css')] + templateUrl : require('./some/path/to/file.html'), + styleUrls : [require('./app/css/styles.css')] }) export class TestComponent {} ` @@ -162,7 +163,7 @@ describe("loader", function() { it("Should convert html and style file strings to require()s in a single line component decorator", function() { - loader.call({}, fixtures.componentWithSingleLineDecorator) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithSingleLineDecorator) .should .be .eql(` @@ -177,7 +178,7 @@ describe("loader", function() { it("Should convert html and style file strings to require()s if line is ending by space character", function() { - loader.call({}, fixtures.componentWithTemplateUrlEndingBySpace) + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithTemplateUrlEndingBySpace) .should .be .eql(` @@ -194,4 +195,23 @@ describe("loader", function() { }); + it("Should convert html and style file template literals to require()s", function() { + + loader.call({ resourcePath: '/path/file.ts' }, fixtures.componentWithTemplateLiterals) + .should + .be + .eql(` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-component', + template: require(\`./some/path/to/file.html\`), + styles: [require(\`./app/css/styles.css\`)] + }) + export class TestComponent {} +` + ) + + }); + });