diff --git a/modules/angular2/src/core/compiler/style_inliner.js b/modules/angular2/src/core/compiler/style_inliner.js new file mode 100644 index 0000000000..dce2761299 --- /dev/null +++ b/modules/angular2/src/core/compiler/style_inliner.js @@ -0,0 +1,115 @@ +import {XHR} from 'angular2/src/core/compiler/xhr/xhr'; + +import {ListWrapper} from 'angular2/src/facade/collection'; +import { + isBlank, + RegExp, + RegExpWrapper, + StringWrapper, + normalizeBlank, +} from 'angular2/src/facade/lang'; +import { + Promise, + PromiseWrapper, +} from 'angular2/src/facade/async'; + +export class StyleInliner { + _xhr: XHR; + + constructor(xhr: XHR) { + this._xhr = xhr; + } + + // TODO(vicb): handle base url + // TODO(vicb): Union types: returns either a Promise or a string + inlineImports(cssText: string) { + return this._inlineImports(cssText, []); + } + + _inlineImports(cssText: string, inlinedUrls: List) { + var partIndex = 0; + var parts = StringWrapper.split(cssText, _importRe); + + if (parts.length === 1) { + // no @import rule found, return the original css + return cssText; + } + + var promises = []; + + while (partIndex < parts.length - 1) { + var prefix = parts[partIndex]; + var rule = parts[partIndex + 1]; + var url = _extractUrl(rule); + var mediaQuery = _extractMediaQuery(rule); + + var promise; + if (isBlank(url) || ListWrapper.contains(inlinedUrls, url)) { + // The current import rule has already been inlined, return the prefix only + // Importing again might cause a circular dependency + promise = PromiseWrapper.resolve(prefix); + } else { + ListWrapper.push(inlinedUrls, url); + promise = PromiseWrapper.then( + this._xhr.get(url), + (css) => { + // resolve nested @import rules + css = this._inlineImports(css, inlinedUrls); + if (PromiseWrapper.isPromise(css)) { + // wait until nested @import are inlined + return css.then((css) => prefix + _wrapInMediaRule(css, mediaQuery)+ '\n') ; + } else { + // there are no nested @import, return the css + return prefix + _wrapInMediaRule(css, mediaQuery) + '\n'; + } + }, + (error) => `/* failed to import ${url} */\n` + ); + } + ListWrapper.push(promises, promise); + partIndex += 2; + } + + return PromiseWrapper.then( + PromiseWrapper.all(promises), + function (cssParts) { + var cssText = cssParts.join(''); + if (partIndex < parts.length) { + // append whatever css located after the last @import rule + cssText += parts[partIndex]; + } + return cssText; + }, + function(e) { + throw 'error'; + } + ); + } +} + +// Extracts the url from an import rule, supported formats: +// - 'url' / "url", +// - url('url') / url("url") +function _extractUrl(importRule: string): string { + var match = RegExpWrapper.firstMatch(_urlRe, importRule); + if (isBlank(match)) return null; + return match[1]; +} + +// Extracts the media query from an import rule. +// Returns null when there is no media query. +function _extractMediaQuery(importRule: string): string { + var match = RegExpWrapper.firstMatch(_mediaQueryRe, importRule); + if (isBlank(match)) return null; + var mediaQuery = match[1].trim(); + return (mediaQuery.length > 0) ? mediaQuery: null; +} + +// Wraps the css in a media rule when the media query is not null +function _wrapInMediaRule(css: string, query: string) { + return (isBlank(query)) ? css : `@media ${query} {\n${css}\n}`; +} + +var _importRe = RegExpWrapper.create('@import\\s+([^;]+);'); +var _urlRe = RegExpWrapper.create('(?:url\\(\\s*)?[\'"]([^\'"]+)[\'"]'); +var _mediaQueryRe = RegExpWrapper.create('[\'"][^\'"]+[\'"]\\s*\\)?\\s*(.*)'); diff --git a/modules/angular2/test/core/compiler/style_inliner_spec.js b/modules/angular2/test/core/compiler/style_inliner_spec.js new file mode 100644 index 0000000000..7e3ca2ccdf --- /dev/null +++ b/modules/angular2/test/core/compiler/style_inliner_spec.js @@ -0,0 +1,165 @@ +import {describe, it, expect, beforeEach, ddescribe, iit, xit, el} from 'angular2/test_lib'; +import {StyleInliner} from 'angular2/src/core/compiler/style_inliner'; + +import {isBlank} from 'angular2/src/facade/lang'; +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; +import {Map, MapWrapper} from 'angular2/src/facade/collection'; + +import {XHR} from 'angular2/src/core/compiler/xhr/xhr'; + +export function main() { + describe('StyleInliner', () => { + describe('loading', () => { + it('should return a string when there is no import statement', () => { + var css = '.main {}'; + var loader = new StyleInliner(null); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).not.toBePromise(); + expect(loadedCss).toEqual(css); + }); + + it('should inline @import rules', (done) => { + var xhr = new FakeXHR(); + xhr.reply('one.css', '.one {}'); + var css = '@import "one.css";.main {}'; + var loader = new StyleInliner(xhr); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).toBePromise(); + PromiseWrapper.then( + loadedCss, + function(css) { + expect(css).toEqual('.one {}\n.main {}'); + done(); + }, + function(e) { + throw 'fail;' + } + ); + }); + + it('should handle @import error gracefuly', (done) => { + var xhr = new FakeXHR(); + var css = '@import "one.css";.main {}'; + var loader = new StyleInliner(xhr); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).toBePromise(); + PromiseWrapper.then( + loadedCss, + function(css) { + expect(css).toEqual('/* failed to import one.css */\n.main {}'); + done(); + }, + function(e) { + throw 'fail;' + } + ); + }); + + it('should inline multiple @import rules', (done) => { + var xhr = new FakeXHR(); + xhr.reply('one.css', '.one {}'); + xhr.reply('two.css', '.two {}'); + var css = '@import "one.css";@import "two.css";.main {}'; + var loader = new StyleInliner(xhr); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).toBePromise(); + PromiseWrapper.then( + loadedCss, + function(css) { + expect(css).toEqual('.one {}\n.two {}\n.main {}'); + done(); + }, + function(e) { + throw 'fail;' + } + ); + }); + + it('should inline nested @import rules', (done) => { + var xhr = new FakeXHR(); + xhr.reply('one.css', '@import "two.css";.one {}'); + xhr.reply('two.css', '.two {}'); + var css = '@import "one.css";.main {}'; + var loader = new StyleInliner(xhr); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).toBePromise(); + PromiseWrapper.then( + loadedCss, + function(css) { + expect(css).toEqual('.two {}\n.one {}\n.main {}'); + done(); + }, + function(e) { + throw 'fail;' + } + ); + }); + + it('should handle circular dependencies gracefuly', (done) => { + var xhr = new FakeXHR(); + xhr.reply('one.css', '@import "two.css";.one {}'); + xhr.reply('two.css', '@import "one.css";.two {}'); + var css = '@import "one.css";.main {}'; + var loader = new StyleInliner(xhr); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).toBePromise(); + PromiseWrapper.then( + loadedCss, + function(css) { + expect(css).toEqual('.two {}\n.one {}\n.main {}'); + done(); + }, + function(e) { + throw 'fail;' + } + ); + }); + + }); + + describe('media query', () => { + it('should wrap inlined content in media query', (done) => { + var xhr = new FakeXHR(); + xhr.reply('one.css', '.one {}'); + var css = '@import "one.css" (min-width: 700px) and (orientation: landscape);'; + var loader = new StyleInliner(xhr); + var loadedCss = loader.inlineImports(css); + expect(loadedCss).toBePromise(); + PromiseWrapper.then( + loadedCss, + function(css) { + expect(css).toEqual('@media (min-width: 700px) and (orientation: landscape) {\n.one {}\n}\n'); + done(); + }, + function(e) { + throw 'fail;' + } + ); + }); + + }); + + }); +} + +class FakeXHR extends XHR { + _responses: Map; + + constructor() { + super(); + this._responses = MapWrapper.create(); + } + + get(url: string): Promise { + var response = MapWrapper.get(this._responses, url); + if (isBlank(response)) { + return PromiseWrapper.reject('xhr error'); + } + + return PromiseWrapper.resolve(response); + } + + reply(url: string, response: string) { + MapWrapper.set(this._responses, url, response); + } +}