feat(core): provide support for relative assets for components

Assets defined for `templateUrl` and `styleUrls` can now be loaded
in relative to where the component file is placed so long as the
`moduleId` is set within the component annotation.

Closes #5634
This commit is contained in:
Matias Niemelä 2015-12-05 02:21:38 -08:00
parent 5f0ce30ee6
commit 28860d35b2
23 changed files with 328 additions and 23 deletions

View File

@ -5,4 +5,4 @@ export * from './platform/browser';
export * from './src/platform/dom/dom_adapter'; export * from './src/platform/dom/dom_adapter';
export * from './src/platform/dom/events/event_manager'; export * from './src/platform/dom/events/event_manager';
export * from './upgrade'; export * from './upgrade';
export {UrlResolver, AppRootUrl} from './compiler'; export {UrlResolver, AppRootUrl, getUrlScheme, DEFAULT_PACKAGE_URL_PROVIDER} from './compiler';

View File

@ -13,6 +13,7 @@ export {
APP_ID, APP_ID,
APP_COMPONENT, APP_COMPONENT,
APP_INITIALIZER, APP_INITIALIZER,
PACKAGE_ROOT_URL,
PLATFORM_INITIALIZER PLATFORM_INITIALIZER
} from './src/core/application_tokens'; } from './src/core/application_tokens';
export * from './src/core/zone'; export * from './src/core/zone';

View File

@ -24,7 +24,7 @@ import {Compiler} from 'angular2/src/core/linker/compiler';
import {RuntimeCompiler} from 'angular2/src/compiler/runtime_compiler'; import {RuntimeCompiler} from 'angular2/src/compiler/runtime_compiler';
import {ElementSchemaRegistry} from 'angular2/src/compiler/schema/element_schema_registry'; import {ElementSchemaRegistry} from 'angular2/src/compiler/schema/element_schema_registry';
import {DomElementSchemaRegistry} from 'angular2/src/compiler/schema/dom_element_schema_registry'; import {DomElementSchemaRegistry} from 'angular2/src/compiler/schema/dom_element_schema_registry';
import {UrlResolver} from 'angular2/src/compiler/url_resolver'; import {UrlResolver, DEFAULT_PACKAGE_URL_PROVIDER} from 'angular2/src/compiler/url_resolver';
import {AppRootUrl} from 'angular2/src/compiler/app_root_url'; import {AppRootUrl} from 'angular2/src/compiler/app_root_url';
import {AnchorBasedAppRootUrl} from 'angular2/src/compiler/anchor_based_app_root_url'; import {AnchorBasedAppRootUrl} from 'angular2/src/compiler/anchor_based_app_root_url';
import {Parser, Lexer} from 'angular2/src/core/change_detection/change_detection'; import {Parser, Lexer} from 'angular2/src/core/change_detection/change_detection';
@ -40,6 +40,7 @@ export const COMPILER_PROVIDERS: Array<Type | Provider | any[]> = CONST_EXPR([
TemplateParser, TemplateParser,
TemplateNormalizer, TemplateNormalizer,
RuntimeMetadataResolver, RuntimeMetadataResolver,
DEFAULT_PACKAGE_URL_PROVIDER,
StyleCompiler, StyleCompiler,
CommandCompiler, CommandCompiler,
ChangeDetectionCompiler, ChangeDetectionCompiler,

View File

@ -19,6 +19,7 @@ import {reflector} from 'angular2/src/core/reflection/reflection';
import {Injectable, Inject, Optional} from 'angular2/src/core/di'; import {Injectable, Inject, Optional} from 'angular2/src/core/di';
import {PLATFORM_DIRECTIVES} from 'angular2/src/core/platform_directives_and_pipes'; import {PLATFORM_DIRECTIVES} from 'angular2/src/core/platform_directives_and_pipes';
import {MODULE_SUFFIX} from './util'; import {MODULE_SUFFIX} from './util';
import {getUrlScheme} from 'angular2/src/compiler/url_resolver';
@Injectable() @Injectable()
export class RuntimeMetadataResolver { export class RuntimeMetadataResolver {
@ -107,8 +108,11 @@ function isValidDirective(value: Type): boolean {
} }
function calcModuleUrl(type: Type, dirMeta: md.DirectiveMetadata): string { function calcModuleUrl(type: Type, dirMeta: md.DirectiveMetadata): string {
if (isPresent(dirMeta.moduleId)) { var moduleId = dirMeta.moduleId;
return `package:${dirMeta.moduleId}${MODULE_SUFFIX}`; if (isPresent(moduleId)) {
var scheme = getUrlScheme(moduleId);
return isPresent(scheme) && scheme.length > 0 ? moduleId :
`package:${moduleId}${MODULE_SUFFIX}`;
} else { } else {
return reflector.importUri(type); return reflector.importUri(type);
} }

View File

@ -1,18 +1,22 @@
library angular2.src.services.url_resolver; library angular2.src.services.url_resolver;
import 'package:angular2/src/core/di.dart' show Injectable, Provider; import 'package:angular2/src/core/di.dart' show Injectable, Inject, Provider;
import 'package:angular2/src/facade/lang.dart' show isPresent, StringWrapper;
import 'package:angular2/src/core/application_tokens.dart' show PACKAGE_ROOT_URL;
UrlResolver createWithoutPackagePrefix() { UrlResolver createWithoutPackagePrefix() {
return new UrlResolver.withUrlPrefix(null); return new UrlResolver.withUrlPrefix(null);
} }
const DEFAULT_PACKAGE_URL_PROVIDER = const Provider(PACKAGE_ROOT_URL, useValue: "/packages");
@Injectable() @Injectable()
class UrlResolver { class UrlResolver {
/// This will be the location where 'package:' Urls will resolve. Default is /// This will be the location where 'package:' Urls will resolve. Default is
/// '/packages' /// '/packages'
final String _packagePrefix; final String _packagePrefix;
const UrlResolver() : _packagePrefix = '/packages'; UrlResolver([@Inject(PACKAGE_ROOT_URL) this._packagePrefix]);
/// Creates a UrlResolver that will resolve 'package:' Urls to a different /// Creates a UrlResolver that will resolve 'package:' Urls to a different
/// prefixed location. /// prefixed location.
@ -32,15 +36,23 @@ class UrlResolver {
*/ */
String resolve(String baseUrl, String url) { String resolve(String baseUrl, String url) {
Uri uri = Uri.parse(url); Uri uri = Uri.parse(url);
if (!uri.isAbsolute) {
if (isPresent(baseUrl) && baseUrl.length > 0) {
Uri baseUri = Uri.parse(baseUrl); Uri baseUri = Uri.parse(baseUrl);
uri = baseUri.resolveUri(uri); uri = baseUri.resolveUri(uri);
} }
if (_packagePrefix != null && uri.scheme == 'package') { var prefix = this._packagePrefix;
return '$_packagePrefix/${uri.path}'; if (prefix != null && uri.scheme == 'package') {
prefix = StringWrapper.stripRight(prefix, '/');
var path = StringWrapper.stripLeft(uri.path, '/');
return '$prefix/$path';
} else { } else {
return uri.toString(); return uri.toString();
} }
} }
} }
String getUrlScheme(String url) {
return Uri.parse(url).scheme;
}

View File

@ -1,12 +1,21 @@
import {Injectable} from 'angular2/src/core/di'; import {Injectable, Inject} from 'angular2/src/core/di';
import {isPresent, isBlank, RegExpWrapper, normalizeBlank} from 'angular2/src/facade/lang'; import {
StringWrapper,
isPresent,
isBlank,
RegExpWrapper,
normalizeBlank
} from 'angular2/src/facade/lang';
import {BaseException, WrappedException} from 'angular2/src/facade/exceptions'; import {BaseException, WrappedException} from 'angular2/src/facade/exceptions';
import {ListWrapper} from 'angular2/src/facade/collection'; import {ListWrapper} from 'angular2/src/facade/collection';
import {PACKAGE_ROOT_URL} from 'angular2/src/core/application_tokens';
import {Provider} from 'angular2/src/core/di';
export function createWithoutPackagePrefix(): UrlResolver { export function createWithoutPackagePrefix(): UrlResolver {
return new UrlResolver(); return new UrlResolver();
} }
export var DEFAULT_PACKAGE_URL_PROVIDER = new Provider(PACKAGE_ROOT_URL, {useValue: "/"});
/** /**
* Used by the {@link Compiler} when resolving HTML and CSS template URLs. * Used by the {@link Compiler} when resolving HTML and CSS template URLs.
@ -17,6 +26,14 @@ export function createWithoutPackagePrefix(): UrlResolver {
*/ */
@Injectable() @Injectable()
export class UrlResolver { export class UrlResolver {
private _packagePrefix: string;
constructor(@Inject(PACKAGE_ROOT_URL) packagePrefix: string = null) {
if (isPresent(packagePrefix)) {
this._packagePrefix = StringWrapper.stripRight(packagePrefix, "/") + "/";
}
}
/** /**
* Resolves the `url` given the `baseUrl`: * Resolves the `url` given the `baseUrl`:
* - when the `url` is null, the `baseUrl` is returned, * - when the `url` is null, the `baseUrl` is returned,
@ -29,7 +46,21 @@ export class UrlResolver {
* @param {string} url * @param {string} url
* @returns {string} the resolved URL * @returns {string} the resolved URL
*/ */
resolve(baseUrl: string, url: string): string { return _resolveUrl(baseUrl, url); } resolve(baseUrl: string, url: string): string {
var resolvedUrl = url;
if (isPresent(baseUrl) && baseUrl.length > 0) {
resolvedUrl = _resolveUrl(baseUrl, resolvedUrl);
}
if (isPresent(this._packagePrefix) && getUrlScheme(resolvedUrl) == "package") {
resolvedUrl = resolvedUrl.replace("package:", this._packagePrefix);
}
return resolvedUrl;
}
}
export function getUrlScheme(url: string): string {
var match = _split(url);
return (match && match[_ComponentIndex.Scheme]) || "";
} }
// The code below is adapted from Traceur: // The code below is adapted from Traceur:

View File

@ -59,3 +59,9 @@ export const PLATFORM_INITIALIZER: OpaqueToken =
* A function that will be executed when an application is initialized. * A function that will be executed when an application is initialized.
*/ */
export const APP_INITIALIZER: OpaqueToken = CONST_EXPR(new OpaqueToken("Application Initializer")); export const APP_INITIALIZER: OpaqueToken = CONST_EXPR(new OpaqueToken("Application Initializer"));
/**
* A token which indicates the root directory of the application
*/
export const PACKAGE_ROOT_URL: OpaqueToken =
CONST_EXPR(new OpaqueToken("Application Packages Root URL"));

View File

@ -72,6 +72,30 @@ class StringWrapper {
return s == s2; return s == s2;
} }
static String stripLeft(String s, String charVal) {
if (isPresent(s) && s.length > 0) {
var pos = 0;
for (var i = 0; i < s.length; i++) {
if (s[i] != charVal) break;
pos++;
}
s = s.substring(pos);
}
return s;
}
static String stripRight(String s, String charVal) {
if (isPresent(s) && s.length > 0) {
var pos = s.length;
for (var i = s.length - 1; i >= 0; i--) {
if (s[i] != charVal) break;
pos--;
}
s = s.substring(0, pos);
}
return s;
}
static String replace(String s, Pattern from, String replace) { static String replace(String s, Pattern from, String replace) {
return s.replaceFirst(from, replace); return s.replaceFirst(from, replace);
} }

View File

@ -166,6 +166,30 @@ export class StringWrapper {
static equals(s: string, s2: string): boolean { return s === s2; } static equals(s: string, s2: string): boolean { return s === s2; }
static stripLeft(s: string, charVal: string): string {
if (s && s.length) {
var pos = 0;
for (var i = 0; i < s.length; i++) {
if (s[i] != charVal) break;
pos++;
}
s = s.substring(pos);
}
return s;
}
static stripRight(s: string, charVal: string): string {
if (s && s.length) {
var pos = s.length;
for (var i = s.length - 1; i >= 0; i--) {
if (s[i] != charVal) break;
pos--;
}
s = s.substring(0, pos);
}
return s;
}
static replace(s: string, from: string, replace: string): string { static replace(s: string, from: string, replace: string): string {
return s.replace(from, replace); return s.replace(from, replace);
} }

View File

@ -1,4 +1,15 @@
import {describe, it, expect, beforeEach, ddescribe, iit, xit, el} from 'angular2/testing_internal'; import {
describe,
it,
expect,
beforeEach,
ddescribe,
iit,
xit,
el,
inject
} from 'angular2/testing_internal';
import {IS_DART} from 'angular2/src/facade/lang';
import {UrlResolver} from 'angular2/src/compiler/url_resolver'; import {UrlResolver} from 'angular2/src/compiler/url_resolver';
export function main() { export function main() {
@ -69,13 +80,49 @@ export function main() {
expect(resolver.resolve('foo/baz', '/bar')).toEqual('/bar'); expect(resolver.resolve('foo/baz', '/bar')).toEqual('/bar');
expect(resolver.resolve('foo/baz/', '/bar')).toEqual('/bar'); expect(resolver.resolve('foo/baz/', '/bar')).toEqual('/bar');
}); });
it('should not resolve urls against the baseUrl when the url contains a scheme', () => {
resolver = new UrlResolver('my_packages_dir');
expect(resolver.resolve("base/", 'package:file')).toEqual('my_packages_dir/file');
expect(resolver.resolve("base/", 'http:super_file')).toEqual('http:super_file');
expect(resolver.resolve("base/", './mega_file')).toEqual('base/mega_file');
}); });
});
describe('packages',
() => {
it('should resolve a url based on the application package', () => {
resolver = new UrlResolver('my_packages_dir');
expect(resolver.resolve(null, 'package:some/dir/file.txt'))
.toEqual('my_packages_dir/some/dir/file.txt');
expect(resolver.resolve(null, 'some/dir/file.txt')).toEqual('some/dir/file.txt');
});
it('should contain a default value of "/packages" when nothing is provided for DART',
inject([UrlResolver], (resolver) => {
if (IS_DART) {
expect(resolver.resolve(null, 'package:file')).toEqual('/packages/file');
}
}));
it('should contain a default value of "/" when nothing is provided for TS/ESM',
inject([UrlResolver], (resolver) => {
if (!IS_DART) {
expect(resolver.resolve(null, 'package:file')).toEqual('/file');
}
}));
it('should resolve a package value when present within the baseurl', () => {
resolver = new UrlResolver('/my_special_dir');
expect(resolver.resolve('package:some_dir/', 'matias.html'))
.toEqual('/my_special_dir/some_dir/matias.html');
});
})
describe('corner and error cases', () => { describe('corner and error cases', () => {
it('should encode URLs before resolving', () => { it('should encode URLs before resolving', () => {
expect(resolver.resolve('foo/baz', `<p #p>Hello expect(resolver.resolve('foo/baz', `<p #p>Hello
</p>`)) </p>`)).toEqual('foo/%3Cp%20#p%3EHello%0A%20%20%20%20%20%20%20%20%3C/p%3E');
.toEqual('foo/%3Cp%20#p%3EHello%0A%20%20%20%20%20%20%20%20%3C/p%3E');
}); });
}); });
}); });

View File

@ -65,5 +65,59 @@ export function main() {
}); });
}); });
describe('stripLeft', () => {
it('should strip the first character of the string if it matches the provided input', () => {
var input = "~angular2 is amazing";
var expectedOutput = "angular2 is amazing";
expect(StringWrapper.stripLeft(input, "~")).toEqual(expectedOutput);
});
it('should keep stripping characters from the start until the first unmatched character',
() => {
var input = "#####hello";
var expectedOutput = "hello";
expect(StringWrapper.stripLeft(input, "#")).toEqual(expectedOutput);
});
it('should not alter the provided input if the first charater does not match the provided input',
() => {
var input = "+angular2 is amazing";
expect(StringWrapper.stripLeft(input, "*")).toEqual(input);
});
it('should not do any alterations when an empty string or null value is passed in', () => {
expect(StringWrapper.stripLeft("", "S")).toEqual("");
expect(StringWrapper.stripLeft(null, "S")).toEqual(null);
});
});
describe('stripRight', () => {
it('should strip the first character of the string if it matches the provided input', () => {
var input = "angular2 is amazing!";
var expectedOutput = "angular2 is amazing";
expect(StringWrapper.stripRight(input, "!")).toEqual(expectedOutput);
});
it('should not alter the provided input if the first charater does not match the provided input',
() => {
var input = "angular2 is amazing+";
expect(StringWrapper.stripRight(input, "*")).toEqual(input);
});
it('should keep stripping characters from the end until the first unmatched character',
() => {
var input = "hi&!&&&&&";
var expectedOutput = "hi&!";
expect(StringWrapper.stripRight(input, "&")).toEqual(expectedOutput);
});
it('should not do any alterations when an empty string or null value is passed in', () => {
expect(StringWrapper.stripRight("", "S")).toEqual("");
expect(StringWrapper.stripRight(null, "S")).toEqual(null);
});
});
}); });
} }

View File

@ -1196,6 +1196,9 @@ var NG_ALL = [
'UpperCasePipe.transform()', 'UpperCasePipe.transform()',
'UrlResolver', 'UrlResolver',
'UrlResolver.resolve()', 'UrlResolver.resolve()',
'getUrlScheme()',
'PACKAGE_ROOT_URL',
'DEFAULT_PACKAGE_URL_PROVIDER',
'Validators#compose()', 'Validators#compose()',
'Validators#composeAsync()', 'Validators#composeAsync()',
'Validators#nullValidator()', 'Validators#nullValidator()',

View File

@ -0,0 +1,3 @@
library playground.e2e_test.relative_assets.assets_spec;
main() {}

View File

@ -0,0 +1,34 @@
import {verifyNoBrowserErrors} from 'angular2/src/testing/e2e_util';
import {Promise} from 'angular2/src/facade/async';
function waitForElement(selector) {
var EC = (<any>protractor).ExpectedConditions;
// Waits for the element with id 'abc' to be present on the dom.
browser.wait(EC.presenceOf($(selector)), 20000);
}
describe('relative assets relative-app', () => {
afterEach(verifyNoBrowserErrors);
var URL = 'playground/src/relative_assets/';
it('should load in the templateUrl relative to the my-cmp component', () => {
browser.get(URL);
waitForElement('my-cmp .inner-container');
expect(element.all(by.css('my-cmp .inner-container')).count()).toEqual(1);
});
it('should load in the styleUrls relative to the my-cmp component', () => {
browser.get(URL);
waitForElement('my-cmp .inner-container');
var elem = element(by.css('my-cmp .inner-container'));
var width = browser.executeScript(function(e) {
return parseInt(window.getComputedStyle(e).width);
}, elem.getWebElement());
expect(width).toBe(432);
});
});

View File

@ -31,6 +31,7 @@ transformers:
- web/src/routing/index.dart - web/src/routing/index.dart
- web/src/template_driven_forms/index.dart - web/src/template_driven_forms/index.dart
- web/src/zippy_component/index.dart - web/src/zippy_component/index.dart
- web/src/relative_assets/index.dart
- web/src/svg/index.dart - web/src/svg/index.dart
- web/src/material/button/index.dart - web/src/material/button/index.dart
- web/src/material/checkbox/index.dart - web/src/material/checkbox/index.dart

View File

@ -0,0 +1,11 @@
<!doctype html>
<html>
<title>Relative URLs</title>
<body>
<relative-app>
Loading...
</relative-app>
$SCRIPTS$
</body>
</html>

View File

@ -0,0 +1,19 @@
import {bootstrap} from 'angular2/bootstrap';
import {reflector} from 'angular2/src/core/reflection/reflection';
import {ReflectionCapabilities} from 'angular2/src/core/reflection/reflection_capabilities';
import {Renderer, ElementRef, Component, Directive, Injectable} from 'angular2/core';
import {MyCmp} from './my_cmp/my_cmp';
export function main() {
reflector.reflectionCapabilities = new ReflectionCapabilities();
bootstrap(RelativeApp);
}
@Component({
selector: 'relative-app',
directives: [MyCmp],
template: `component = <my-cmp></my-cmp>`,
})
export class RelativeApp {
}

View File

@ -0,0 +1,9 @@
import 'package:angular2/core.dart' show Component;
@Component(
selector: 'my-cmp',
templateUrl: 'tpl.html',
styleUrls: const ['style.css']
)
class MyCmp {
}

View File

@ -0,0 +1,5 @@
import {Component} from 'angular2/core';
@Component(
{selector: 'my-cmp', templateUrl: 'tpl.html', styleUrls: ['style.css'], moduleId: module.id})
export class MyCmp {
}

View File

@ -0,0 +1,12 @@
:host {
background:red;
padding:1em;
display:block;
font-size:20px;
color:white;
}
.inner-container {
width:432px;
font-weight:bold;
}

View File

@ -0,0 +1,3 @@
<div class="inner-container">
my component content goes here
</div>

View File

@ -49,6 +49,7 @@ const kServedPaths = [
'playground/src/http', 'playground/src/http',
'playground/src/jsonp', 'playground/src/jsonp',
'playground/src/key_events', 'playground/src/key_events',
'playground/src/relative_assets',
'playground/src/routing', 'playground/src/routing',
'playground/src/sourcemap', 'playground/src/sourcemap',
'playground/src/svg', 'playground/src/svg',

View File

@ -68,8 +68,8 @@ function getSourceTree() {
translateBuiltins: true, translateBuiltins: true,
}); });
// Native sources, dart only examples, etc. // Native sources, dart only examples, etc.
var dartSrcs = var dartSrcs = modulesFunnel(
modulesFunnel(['**/*.dart', '**/*.ng_meta.json', '**/*.aliases.json', '**/css/**']); ['**/*.dart', '**/*.ng_meta.json', '**/*.aliases.json', '**/css/**', '**/*.css']);
return mergeTrees([transpiled, dartSrcs]); return mergeTrees([transpiled, dartSrcs]);
} }