Given that

  1. AngularJS enforces a Single Page Application structure;
  2. a web application can easily have two or more layouts;

I understood that I needed to setup a Multi Page Application in AngularJS to be able to have multiple layouts with standard routing, i.e. without installing, understanding, and learning special solutions like ui-router. How did I do it? In a few words,

  1. independent SPAs share the same routes
  2. routes reference the SPA they belong to
  3. when a user crosses one’s SPA boundaries, the other SPA is navigated to

Application Structure

(click on bold text to expand / collapse)
client/
  apps/
    auth/
      components/
        login/
          login.html
          login.js
        register/
        reset-password/
      index/
        index.css
      index.html
      shared/
        config.js
        modules/
        services/
      start.js
    core/
      components/
        bears/
          bears.html
          bears.js
        home/
          home.html
        users/
          users.html
          users.js
      index/
        index.css
      index.html
      shared/
        config.js
        modules/
        services/
          bears.service.js
          users.service.js
      start.js
  bower_components/
  docs/
  shared/
    config.js
    modules/
      my-empty-constroller.js
      my-route.js
    services/
      authentication.service.js
      crud.service.js
      flash.service.js
      storage.service.js
    my-project.js
    routes.js

This MPA allows to host its SPAs into their own folder in ´client/apps/´ and functionalities shared among them (like the authentication service) into ´client/shared/´.

Each of those SPAs allows to host its components into their own folders in ´client/apps/<app>/components/´ and functionalities shared among them (like the user service) into ´client/apps/<app>/shared/´.

Thus the organization of specific and shared stuff between the MPA and its SPAs is analogous to that between each SPA and its components.

client/shared/routes.js

Routes belong to the MPA, i.e. they are shared among all the SPAs. This allows me to look at one file and know exactly which URL translates to which component of which app.

define([
    '/shared/modules/my-route.js'  // loads routeProvider (no $ prefix)
], function() {
    'use strict';

    MyProject.Config({
        anonymousRoutes: ['/login', '/register', '/reset-password']
    });

    angular
        .module(MyProject.AppName())
        .config(routes);

    routes.$inject = ['$routeProvider', 'routeProvider'];

    function routes($routeProvider, routeProvider) {

        var route = routeProvider.route;

        /* beautify preserve:start */

        $routeProvider

            .when('/login',                 route.forComponent('auth: login as vm'))
            .when('/register',              route.forComponent('auth: register as vm'))
            .when('/reset-password',        route.forComponent('auth: reset-password as vm'))

            // Case 1: This works because AngularJS allows a view without a controller (but it does not allow a route without a view).
            .when('/',                      route.forComponent({
                                                app: 'core',
                                                filepath: 'home',
                                                controller: '',
                                                title: ''
                                            }))

            // Case 2: This works like Case 1, but it requires a controller at /apps/core/components/home/home.js.
            // .when('/',                      route.forComponent('core: home'))

            // Case 3: This works like Case 2, but using an empty controller.
            // .when('/',                      route.forComponent({
            //                                     app: 'core',
            //                                     filepath: 'home',
            //                                     controller: 'myEmptyController',
            //                                     controllerUrl: '/shared/modules/my-empty-controller.js'
            //                                 }))

            // .when('/path-to/some-stuff',      route.forComponent('core: some-dir/something'))
            // .when('/path-to/more-stuff',      route.forComponent('core: some-dir/anything'))

            .when('/bears',                 route.forComponent('core: bears'))

            .otherwise({
                redirectTo: function(params, path, search) {
                    console.log('otherwise...');
                    return '/';
                }
            });
        /* beautify preserve:end */

    }

});

Based on the article Dynamically Loading Controllers and Views… by Dan Wahlin, I wrote my own route provider, which sets up routes for components, using the Folders-by-Feature Structure shown in the Application Structure section above. A few things to notice:

  • The ´routeProvider.route.forComponent(options)´ method creates a standard ´route´ argument for the ´$routeProvider.when(path, route)´ method.
  • The ´options´ argument can be a string or an object literal.
  • If ´options´ is a string, then its format must be: ´'<app>: <filepath>[ as <alias>]’´.
  • If ´options´ is an object, then its properties are the same as the ´route´ argument (documentation),
    • PLUS:
      • ´app´: required
      • ´filepath´: required
      • ´controllerUrl´: optional
  • Set ´options.controller´ to specify a special controller name. By default, a controller name is built by taking the last part of ´filepath´, making it camel case, and adding ´Controller´. It must be the registration name.
  • Set ´options.controller´ to ´”´ to indicate that the view has no controller.
  • Set ´options.templateUrl´ and / or ´options.controllerUrl´ to specify a special view and / or controller file. By default, view and controller files are built by taking the last part of ´filepath´ and adding ´.html´ and ´.js´. They must be the path part of a valid URL, i.e. based off ´client/´, like ´/shared/modules/my-empty-controller.js´.
  • A ´filepath´ is always based off ´client/ apps/ <app>/´ (reduced to ´…/´ below here).
    • Use a ´filepath´ like ´/ path/ to/ file´ (with a leading slash)
      for addressing files like ´…/ path/ to/ file.*´.
    • Use a ´filepath´ like ´path/ to/ file´ (without a leading slash, with middle slashes)
      for addressing files like ´…/ components/ path/ to/ file.*´.
    • Use a ´filepath´ like ´file´ (without a leading slash, without middle slashes)
      for addressing files like ´…/ components/ file/ file.*´.

The ´forComponent(options)´ method returns a route object with a ´resolve´ property which is a map of dependencies to be injected into the controller. Given that the ´load´ dependency returns a promise while loading the controller, the router will wait for it to be resolved. That is all explained in the documentation. The nice paradox is that, when the router gets the promise of the dependency to inject into the controller, there is no controller yet, i.e. the dependency is the controller itself. It’s a bit weird, but works like a charm, and all under the hood.

Compare the routes with the application structure above to understand how they collaborate. For example, when the router matches the ´’/login’´ path, it applies the route returned by ´forComponent(‘auth: login as vm’)´. In particular,

  • if the current app is ´auth´ then the ´client/ apps/ auth/ components/ login/ login.js´ controller is required before being bound to the ´client/ apps/ auth/ components/ login/ login.html´ view that Angular automatically downloads. Instead,
  • if the current app is not ´auth´ then the browser is redirected to the ´/login´ path of the ´auth´ app, which will cause the ´client/ apps/ auth/ index.html´ layout to be loaded. That will set the current app to ´auth´, and allow the routing flow to proceed like above.

Remember that the routes file is loaded (and ´forComponent´ is run) at bootstrap time, but each controller is lazily loaded only when the router matches its path, if ever.

client/shared/modules/my-route.js

(function() {
    'use strict';

    // See https://github.com/DanWahlin/CustomerManager/blob/master/CustomerManager/app/customersApp/services/routeResolver.js 

    // Note that this service is a module hosting a provider (it is loaded by the routes definition file)
    // (we are not using MyProject.CodeSetup here because this is not a component of an application)

    angular
        .module('myRoute', [])
        .constant('_', window._)
        .provider('route', provider); // 'route' is seen outside as 'routeProvider'

    function provider() {

        this.$get = function() {
            return this;
        };

        this.route = (function() {  // stick to AngularJS name convention to blend seamlessly

            return {
                forComponent: ForComponent  // stick to AngularJS name convention to blend seamlessly 
            };


            function NamedMatch(source, regexp, names) {
                var result = {};
                var matches = source.match(regexp);
                for (var i = 0, iTop = names.length; i < iTop; i++) {
                    if (! names[i]) continue;
                    result[names[i]] = matches[i];
                }
                return result;
            }

            function InitRoute(options) {
                var result = {};
                if (_.isPlainObject(options)) {
                    result = options;
                } else {
                    var simplified = options.replace(/^\s+|\s+$/, '').replace(/\s+/, ' ').replace(/ ?: ?/, ':');
                    var formatAppPathAlias = /(?:([\w-]+):)?([\/\w-]+)(?: as ([\w-]+))?/i;
                    result = NamedMatch(simplified, formatAppPathAlias, ['', 'app', 'filepath', 'controllerAs']);
                }
                return result;
            }

            function RedirectTo(appRoot) {
                return {
                    redirectTo: function(params, path, search) {
                        // Replace instead of assign, because we get here only when crossing an SPA border.
                        // In such a case, the current URL is "wrong". Examples: "/auth/#/", "/core/#/login"...
                        var new_path = appRoot + '/#' + path;
                        window.location.replace(new_path);
                        return; // do not return a string !
                    }
                };
            }

            function RealPath(filepath) {
                var result = filepath;
                if (filepath[0] == '/') {
                    // expecting a filepath relative to '<appRoot>'
                } else {
                    // expecting a filepath relative to '<appRoot>/components'
                    if (filepath.search('/') > 0) {
                        // explicit filepath (always without extension)
                        // like: 'some-folder/some-file' --> '<appRoot>/components/some-folder/some-file'
                    } else {
                        // implicit filepath (always without extension)
                        // like: 'some-file'             --> '<appRoot>/components/some-file/some-file'
                        result += '/' + filepath;
                    }
                    result = '/components/' + result;
                }
                return result;
            }

            function DefaultRoute(filepath) {
                var formatPathToFile = /^((?:\/[\w-]+)*)\/([\w-]+)$/;
                var file = NamedMatch(filepath, formatPathToFile, ['', '', 'file']).file;
                var result = {
                    title: _.startCase(file),
                    templateUrl: filepath + '.html',
                    controller: _.camelCase(file) + 'Controller',
                    controllerUrl: filepath + '.js'
                };
                return result;
            }

            function RequireDependencies($q, $rootScope, dependencies) {
                var defer = $q.defer();
                require(dependencies, function() {
                    defer.resolve();
                    $rootScope.$apply();
                });
                return defer.promise;
            }

            function LoadController(route) {
                var result = {};
                var dependencies = route.controller ? [route.controllerUrl] : [];
                if (dependencies.length) {
                    var promiseName = 'load ' + route.controller;
                    var promiseMap = {};
                    promiseMap[promiseName] = ['$q', '$rootScope', function ($q, $rootScope) {
                        // we are going to a route inside the same SPA we are into
                        // because we took care earlier about crossing the SPA border
                        return RequireDependencies($q, $rootScope, dependencies);
                    }];
                    result.resolve = _.extend(route.resolve || {}, promiseMap);
                }
                return result;
            }

            function ForComponent(options) {
                var result = InitRoute(options);
                if (!result.filepath) {
                    throw 'Expected a filepath for the component.';
                }

                var appName = result.app;
                var appRoot = '/apps/' + appName;
                if (appName && MyProject.AppName() !== appName) {
                    return RedirectTo(appRoot);
                }

                result.filepath = appRoot + RealPath(result.filepath);
                result = _.extend(DefaultRoute(result.filepath), result);
                result = _.extend(result, LoadController(result));
                return result;
            }
        })();

    }

})();