Relative Template And Style URLs Using System.js Without moduleId In Angular 2.4.9
Once I learned that the in-browser TypeScript transpiler was dropping support for type-checking (one of the main reasons I'm using TypeScript), I decided it was time to start learning how to compile my Angular 2 demos offline using the tsc compiler. The tsc compiler can save the generated files to a different directory; but, in order to keep the relative templateUrl and styleUrls component meta-data working, I'm having the tsc compiler save the generated JS files right alongside the original TS files. As I was tinkering with this, however, I came across the Angular 2 Docs change-log in which they outlined a new way to load component-relative assets. Rather than providing the moduleId meta-data (and Node's module.id token), they are using a System.js loader that translates relative paths on-the-fly as the application is loading. Since I'm still using System.js to load my already-compiled files, I figured this is a change that I should make in my Angular 2 demos as well.
Run this demo in my JavaScript Demos project on GitHub.
Traditionally, when building Angular 2 demos using System.js, it has been critical to include the "moduleId" property in the component's meta-data so that System.js And Angular 2 can figure out how to load HTML templates and CSS stylesheets:
@Component({
moduleId: module.id,
selector: "my-app",
styleUrls: [ "./app.component.css" ],
templateUrl: "./app.component.htm"
})
export class AppComponent {
// ....
}
By including "moduleId: module.id" in the meta-data, the asset loader knows that the path "./app.component.htm" is relative to the location of the "app.component.ts" module. If you try loading this component without the moduleId meta-data, the asset loader will complain:
Unhandled Promise rejection: Failed to load app.component.htm
Using the moduleId meta-data works for System.js, but unfortunately, it causes problems for other technologies like webpack, which provides the module.id as a Number, not as a String. So, in an effort to keep the source-code technology-agnostic, the Angular 2 team has come up with a way to remove the dependency on the moduleId meta-data: they started using a System.js "loader" to translate module-relative paths to app-relative paths on-the-fly as the application is loading.
This loader is just a System.js plugin that can hook into the life-cycle of module consumption within the System.js application. For my Angular 2 demos, I know that I'm only using Template and Style urls in my component directives; so, in my System.js config, I'm only using this special loader for files that end in ".component.js":
(function( global ) {
System.config({
warnings: true,
map: {
"@angular/": "../../vendor/angular2/2.4.9-tsc/node_modules/@angular/",
"rxjs/": "../../vendor/angular2/2.4.9-tsc/node_modules/rxjs/"
},
packages: {
"@angular/common": {
main: "bundles/common.umd.js"
},
"@angular/compiler": {
main: "bundles/compiler.umd.js"
},
"@angular/core": {
main: "bundles/core.umd.js"
},
"@angular/forms": {
main: "bundles/forms.umd.js"
},
"@angular/http": {
main: "bundles/http.umd.js"
},
"@angular/platform-browser": {
main: "bundles/platform-browser.umd.js"
},
"@angular/platform-browser-dynamic": {
main: "bundles/platform-browser-dynamic.umd.js"
},
"@angular/router": {
main: "bundles/router.umd.js"
},
"app": {
main: "main",
defaultExtension: "js",
// The only modules that will contain template or styles URLs are the
// component directives. And, by convention, these files will all end
// with the suffix, ".component.js". The rest of the modules can be
// loaded without any in-browser translation.
meta: {
"*.component.js": {
loader: "system.component-loader.js"
}
}
},
"rxjs": {
defaultExtension: "js"
}
}
});
global.bootstrapping = System
.import( "app" )
.then(
function handleResolve() {
console.info( "System.js successfully bootstrapped app." );
},
function handleReject( error ) {
console.warn( "System.js could not bootstrap the app." );
console.error( error );
return( Promise.reject( error ) );
}
)
;
})( window );
Notice that in the "meta" config for my app package, I'm telling System.js to use the loader, "system.component-loader.js" for any component directives that reside within my "app" package. The job of this component-loader is to take the raw source code of the component and replace the module-relative paths with app-relative paths:
// CAUTION: The RegEx patterns used to parse the module source-code are fairly simple in
// terms of the way they match string values - they assume that none of the string values
// will contain any string-like delimiters embedded within their outer quotes. This also
// means that when we construct quoted values within the replacement functions, we assume
// we can wrap double-quotes around a value and NOT get any unmatched string delimiter
// errors. While this is not a "correct" approach, it's a "sufficient" approach since no
// one should be using quotes (of any kind) in their file-names ('nuff said).
// I match the @Component() meta data properties.
var templateUrlRegex = /templateUrl\s*:\s*(['"`](.*?)['"`])/g;
var styleUrlsRegex = /styleUrls\s*:\s*(\[[^\]]*\])/g;
// I match the individual URLs in the styleUrls array.
var stringRegex = /(['`"](.*?)['`"])/g;
// I match the relative-prefix ( "./" or "../" ) at the start of a string.
var relativePathRegex = /^\.{1,2}\//i;
// I update the contents of the load object (specifically load.source), replacing
// module-relative paths with app-relative paths.
exports.translate = function( load ) {
// Let's calculate the root-relative path to the application. This should generate
// something like "path/to/app/".
var pathToApp = load.address
// Strip out the protocol, domain, and pre-app prefix.
.replace( this.baseURL, "" )
// Strip out the trailing filename (leaving in the TRAILING SLASH).
.replace( new RegExp( "[^/]+$" ), "" )
;
// Replace the module-relative template URL with an app-relative URL. We denote a
// module-relative URL as one that starts with "./" or "../".
// --
// NOTE: To keep things simple, we are leaving the "." constructs in the URL. This
// may produce a URL that look like, "path/to/app/../app/template.htm", which may
// look funny, but works well and keeps the logic easy to read.
load.source = load.source.replace(
templateUrlRegex,
function replaceMatch( $0, quotedUrl, url ) {
var absoluteUrl = relativePathRegex.test( url )
? ( pathToApp + url )
: url
;
return( `templateUrl: "${ absoluteUrl }"` );
}
);
// Replace the module-relative style URLs with a app-relative URLs. Unlike the
// templateUrl property, the styleUrls property references an array of paths, each
// of which will have to be evaluated individually. As such, this time, we have to
// iterate over each string match within the styleUrls value. We denote a module-
// relative URL as one that starts with "./" or "../".
// --
// NOTE: To keep things simple, we are leaving the "." constructs in the URL. This
// may produce a URL that look like, "path/to/app/../app/template.htm", which may
// look funny, but works well and keeps the logic easy to read.
load.source = load.source.replace(
styleUrlsRegex,
function replaceMatch( $0, styleUrls ) {
// Loop over the matches inside the "[ url, url, url ]" collection, and
// replace each URL with an absolute one.
var absoluteUrls = styleUrls.replace(
stringRegex,
function replaceMatch( $0, quotedUrl, url ) {
var absoluteUrl = relativePathRegex.test( url )
? ( pathToApp + url )
: url
;
return( `"${ absoluteUrl }"` );
}
);
return( `styleUrls: ${ absoluteUrls }` );
}
);
};
There's a lot going on there, but the basic gist of the loader is that is takes the raw source code and looks for module-relative values like:
./app.component.htm
... and replaces them with app-relative paths like:
path/to/app/./app.component.htm
I have chosen to leave the relative navigation path tokens "." and ".." in the translated URL because it works while leaving the code more flexible and more straightforward.
With this loader in place, I can now omit the moduleId meta-data from my components:
// Import the core angular services.
import { Component } from "@angular/core";
import { Observable } from "rxjs/Observable";
// Import these modules to create side-effects.
import "rxjs/add/observable/of";
@Component({
selector: "my-app",
styleUrls: [ "./app.component.css" ],
templateUrl: "./app.component.htm"
})
export class AppComponent {
public movies: Observable<string[]>;
// I initialize the app component.
constructor() {
this.movies = Observable.of([
"Elysium",
"Inside Man",
"Contact",
"Maverick",
"Little Man Tate",
"The Silence of the Lambs"
]);
}
}
... and when I transpile this code with tsc and load it with System.js, I get the following output:
As you can see, it worked quite nicely - the Angular 2 demo was able to load the external HTML template and CSS stylesheets without the use of moduleId meta-data.
NOTE: Now that I am no longer using module.id, I can remove the "node" @Type from my tsconfig.
Now that I'm using the tsc compiler for offline TypeScript transpiling, it really doesn't make as much sense to use System.js anymore. As long as I'm using a build-step, I might was well use something like webpack and just bundle the whole thing together, including external templates and stylesheets. But, until I can figure that out, this should make my Angular 2 demos with System.js a little more cross-technology compliant.
Want to use code from this post? Check out the license.
Reader Comments
@All,
I'm actually having trouble getting lodash's @types library to work with this approach. Trying to figure out what is going wrong.
Hi, that works very well for html templates however css files are not downloaded for my case.
@Component({
templateUrl: './design-test.component.html',
styleUrls: ["./design-test.component.css"]
})
When I debug, I see that the loader creates the correct url ["/app/panel/test-manager/./design-test.component.css"] but the file is never requested
@Mustafa,
That's really odd - it looks like we're doing exactly the same thing. The path-translation sounds like its working; I am not sure why Angular would fail to actually make the request for the CSS file. I am stumped.
Hi Ben, have you tried this with typescript compiler outFile option ? when i tried systemjs doesn't run translate if scripts are loaded using outfile ...
@Lakmal,
I did look at the outFile setting at one point, but I abandoned it for some reason. I don't remember why exactly. Looking at the tsconfig documentation, it states:
> Only "AMD" and "System" can be used in conjunction with --outFile.
... perhaps that was the problem I was running into - I think I currently use "commonjs" as the target module type, which isn't compatible. But, honestly, I don't remember why I stopped looking into outFile.
That said, I'm trying to move onto Webpack, which makes this a bit moot - I wish I had a better answer.
This is a GREAT article! Thanks!
I am having an issue. Adding meta for
"*.component.js": {
loader: "system.component-loader.js"
}
does nothing. I'm hosting the file, but systemjs never requests it. Still fails at the template request.
@Tyguy,
Hmm, so the app is bootstrapping, but the component-loader isn't working? Could it be that the name of the app you're loading isn't matching the package that contains the meta-data? For example, I'm calling:
System.import( "app" )
... and my package is also called "app".
To be honest, I'm not so great with System.js. Getting this kind of stuff to work was an uphill battle and I didn't really form a solid mental model for it. I've since moved to using Webpack since I was running into issues with System.js that I couldn't get around (both from a loading-performance standpoint and from a TypeScript standpoint -- the ts-loader was dropping support for in-browser type-checking).
@Mustafa,
Hi! It seems a bug: styles with absolute path skipped:
https://github.com/angular/angular/issues/4974