Home Identifier Source Repository

src/Angie.js

/**
 * @module Angie.js
 * @author Joe Groseclose <@benderTheCrime>
 * @date 8/16/2015
 */

// System Modules
import fs from                                          'fs';
import {magenta, blue} from                             'chalk';
import $LogProvider from                                'angie-log';
import {$injectionBinder} from                          'angie-injector';

// Angie Modules
import {config} from                                    './Config';
import {$scope} from                                    './controllers/$ScopeProvider';
import $RouteProvider from                              './factories/$RouteProvider';
import $CacheFactory from                               './factories/$CacheFactory';
import $compile from                                    './factories/$Compile';
import {$templateCache, $resourceLoader} from           './factories/$TemplateCache';
import {$StringUtil} from                               './util/Util';
import * as $ExceptionsProvider from                    './util/$ExceptionsProvider';

const CWD = process.cwd(),
    $$require = v => {

        // If we dont first clear this out of the module cache, then we don't
        // actually do anything with the require call that isn't assigned
        delete require.cache[ v ];

        // Furthermore because it is unassigned, we do not have to force anything
        // to return from this arrow function
        require(v);
    },
    parse = v => JSON.parse(fs.readFileSync(v, 'utf8'));

/**
 * @desc This is the default Angie class. It is instantiated and given
 * the namespace `global.app`. Static methods are available via this
 * instance.
 *
 * It is ill advised to tray and redefine the native app as it is tightly coupled
 * with the resource pipeline & webserver, instead, use the Angie class to
 * access commonly used static methods.
 *
 * @since 0.0.1
 * @access public
 * @extends {$Util}
 * @example Angie.noop() // = undefined
 */
class Angie {
    constructor() {
        this.constants = {};
        this.configs = [];
        this.services = {};
        this.factories = {};
        this.Controllers = {};
        this.directives = {};
        this.$dependencies = [];
        this.$$registry = {};
    }

    /**
     * @desc Creates an Angie constant provider
     *
     * @since 0.0.1
     * @access public
     *
     * @param {string} name The name of the constant being created
     * @param {object|string|number|Array<>|boolean} obj The object value
     * @returns {object} this instanceof Angie
     *
     * @example Angie.constant('foo', 'bar');
     */
    constant(name, obj) {
        return this.$$register('constants', name, obj);
    }

    /**
     * @desc Creates an Angie service provider. This must be an object.
     *
     * @since 0.0.1
     * @access public
     *
     * @param {string} name The name of the service being created
     * @param {object} obj The object value
     * @returns {object} this instanceof Angie
     *
     * @example Angie.service('foo', {});
     */
    service(name, obj) {

        // Verify that the service is an object
        if (typeof obj !== 'object') {
            throw new $ExceptionsProvider.$$InvalidServiceConfigError(name);
        }
        return this.$$register('services', name, obj);
    }

    /**
     * @desc Creates an Angie service provider. This must be a function.
     *
     * @since 0.3.1
     * @access public
     *
     * @param {string} name The name of the factory being created
     * @param {function} fn The function value
     * @returns {object} this instanceof Angie
     *
     * @example Angie.factory('foo', () => undefined);
     */
    factory(name, fn) {

        // Verify that the factory is a function
        if (typeof fn !== 'function' || !fn.prototype.constructor) {
            throw new $ExceptionsProvider.$$InvalidFactoryConfigError(name);
        }
        return this.$$register('factories', name, fn);
    }

    /**
     * @desc Creates an Angie Controller provider. This must be a function that
     * returns an object or an object.
     *
     * @since 0.0.1
     * @access public
     *
     * @param {string} name The name of the factory being created
     * @param {function|object} obj The Controller value
     * @param {string} obj.template An actual HTML template to be added as a
     * byproduct of the Controller
     * @param {string} obj.templatePath The path to an HTML template to be
     * added as a byproduct of the Controller
     * @returns {object} this instanceof Angie
     *
     * @example Angie.Controller('foo', () => {});
     */
    Controller(name, obj) {
        if (typeof obj !== 'function') {
            throw new $ExceptionsProvider.$$InvalidControllerConfigError(name);
        }
        return this.$$register('Controllers', name, obj);
    }

    /**
     * @desc Alias of the Controller function.
     * @since 0.4.1
     * @access public
     * @example Angie.Controller('foo', () => {});
     */
    controller(name, obj) {
        return this.Controller.call(this, name, obj);
    }

    /**
     * @desc Creates an Angie directive provider. The second parameter
     * of the directive function must be an object, with properties defining the
     * directive itself.
     * @since 0.2.3
     * @access public
     * @param {string} name The name of the constant being created
     * @param {function|object} obj The directive value, returns directive params
     * @param {string} obj().Controller The associated directive controller
     * @param {number} obj().priority A number representing the directive's
     * priority, relative to the other declared directives
     * @param {string} obj().restrict What HTML components can parse this directive:
     *    'A': attribute
     *    'E': element
     *    'C': class
     * @param {function} obj().link A function to fire after the directive is
     * parsed
     * @param {string} obj().template An actual HTML template to be added as a
     * byproduct of the directive
     * @param {string} obj().templatePath The path to an HTML template to be
     * added as a byproduct of the directive
     * @returns {object} this instanceof Angie
     * @example Angie.directive('foo', {
     *     return {
     *         Controller: 'test',
     *         link: function() {}
     *     };
     * });
     */
    directive(name, obj) {
        const dir = typeof obj !== 'function' ?
            obj : new $injectionBinder(obj, 'directive')();

        if (dir.hasOwnProperty('Controller')) {
            if (typeof dir.Controller !== 'string') {
                delete dir.Controller;
            }
        } else if (/api.?view/i.test(dir.type)) {
            throw new $ExceptionsProvider.$$InvalidDirectiveConfigError(name);
        }
        return this.$$register('directives', name, dir);
    }

    /**
     * @desc Alias of the directive function.
     * @since 0.4.1
     * @access public
     * @example Angie.directive('foo', {
     *     return {
     *         Controller: 'test',
     *         link: function() {}
     *     };
     * });
     */
    view(name, obj) {
        return this.directive.call(this, name, obj);
    }
    config(fn) {
        if (typeof fn === 'function') {
            this.configs.push({
                fn: fn
            });
        } else {
            $LogProvider.warn('Invalid config type specified');
        }
        return this;
    }
    $$register(component, name, obj) {

        // `component` and `app.component` should always be defined
        if (name && obj) {
            this.$$registry[ name ] = component;
            this[ component ][ name ] = obj;
        } else {
            $LogProvider.warn(
                `Invalid name or object ${name} called on app.${component}`
            );
        }
        return this;
    }

    /**
     * @desc $$tearDown will remove any component registered by method in the
     * Angie `global.app` object. It will also remove any references to the
     * component in the registry, removing the object from memory. This will
     * not unload the file from which the component was loaded from the global
     * module cache.
     * @since 0.1.0
     * @param {Array|string}  [param=[]] names A string name or Array of names
     * to be torn down in the application. A list of argument strings can also
     * be passed
     * @access private
     */
    $$tearDown(names = []) {

        // Avoid using Array.from for polyfill reasons
        names = arguments[0] instanceof Array && arguments[0].length ?
            arguments[0] : Array.prototype.slice.call(arguments);

        for (let name of names) {

            // If the component is registered, remove it from the registry
            // and from it's respective object
            if (this.$$registry[ name ]) {
                const type = this.$$registry[ name ];
                delete this.$$registry[ name ];
                delete this[ type ][ name ];
            }
        }
        return this;
    }

    /**
     * @desc Load all project dependencies from an Array of dependencies
     * specified in the AngieFile.json. This will load packages in the order
     * they are specified, exposing any application Modules to the application
     * and prepping any application configuration in the nested modules. It will
     * not load duplicate modules. Dependencies are typically declared as a
     * node_module path, but can also be declared as a singular (main) file.
     * @since 0.1.0
     * @param {object}  [param=[]] dependencies The Array of dependencies
     * specified in the parent or localized AngieFile.json
     * @access private
     */
    $$loadDependencies(dependencies = []) {
        const DEPENDENCY_DIRS = [
            `${CWD}/node_modules/`,
            `${__dirname}/../node_modules/`,
            ''
        ];
        let me = this,
            proms = [];

        // Make sure we do not load duplicate dependencies
        dependencies = dependencies.filter(
            (v) => me.$dependencies.indexOf(v) === -1
        );

        // Add dependencies
        this.$dependencies = this.$dependencies.concat(dependencies);
        dependencies.forEach(function(v) {
            let dependency = $StringUtil.removeTrailingLeadingSlashes(v),

                // This will load all of the modules, overwriting a module name
                // will replace it
                prom = new Promise(function(resolve) {
                    let subDependencies = [],
                        $config,
                        $package,
                        name;

                    for (let i = DEPENDENCY_DIRS.length - 1; i >= 0; --i) {
                        let dir = DEPENDENCY_DIRS[ i ];
                        try {

                            // Load the angie and the package config
                            $config = parse(`${dir}${dependency}/AngieFile.json`);
                            $package = parse(`${dir}${dependency}/package.json`);

                            // If a config was found
                            if (typeof $config === 'object') {

                                // Grab the dependency name fo' reals
                                name = $config.projectName;

                                // Find any sub dependencies for recursive module
                                // loading
                                subDependencies = config.dependencies;

                                // Set the config in dependency configs (just in case)
                                if (!app.$dependencyConfig) {
                                    app.$dependencyConfig = {};
                                }
                            } else {

                                // Not an Angie package, pass
                                throw new Error();
                            }

                            // No package.json, can't be an Angie package
                            if (typeof $package !== 'object') {
                                throw new Error();
                            }

                            // Try to load package "main"
                            let service = $$require(
                                `${dir}${dependency}/${$package.main}`
                            );

                            if (service) {

                                // Instantiate the dependency as a provider
                                // determined by its type
                                me[
                                    typeof service === 'function' ? 'factory' :
                                        typeof service === 'object' ? 'service' :
                                            'constant'
                                ](
                                    name || $StringUtil.toCamel(dependency),
                                    service
                                );
                            }

                            app.$dependencyConfig[ dependency ] = $config;

                            $LogProvider.info(
                                `Successfully loaded dependency ${magenta(v)}`
                            );

                            break;
                        } catch(e) {
                            if (!e.code === 'ENOENT') {
                                $LogProvider.error(e);
                            }
                        }
                    }

                    return app.$$loadDependencies(
                        subDependencies || []
                    ).then(resolve);
                });
            proms.push(prom);
        });
        return Promise.all(proms);
    }

    /**
     * @desc Load all of the files associated with the parent and child Angie
     * applications. This will transpile and deliver all modules associated with
     * both the parent and child applications.
     * @since 0.1.0
     * @access private
     * @param {string}  [param=process.cwd()] dir The dir to scan for modules
     */
    $$bootstrap(dir = CWD) {
        let me = this,
            src = typeof config.projectRoot === 'string' ?
                $StringUtil.removeTrailingLeadingSlashes(config.projectRoot) :
                'src';

        return new Promise(function(resolve) {
            resolve(
                fs.readdirSync(`${dir}/${src}`).map((v) => `${dir}/src/${v}`)
            );
        }).then(function(files) {
            let proms = [],
                fn = function loadFiles(files) {

                    // We don't want to load any of these files
                    files.forEach(function(v) {
                        if (
                            /node_modules|bower_components|templates|static/i
                            .test(v)
                        ) {

                            // If any part of our url contains these return
                            return;
                        } else if (
                            [ 'js', 'es6' ].indexOf(v.split('.').pop() || '') > -1
                        ) {
                            try {
                                $$require(v);
                                $LogProvider.info(
                                    `Successfully loaded file ${blue(v)}`
                                );
                            } catch(e) {
                                $LogProvider.error(e);
                            }
                        } else {
                            try {
                                fn(fs.readdirSync(v).map(($v) => `${v}/${$v}`));
                            } catch(e) {
                                $LogProvider.warn(
                                    `Treating ${blue(v)} as a directory, but it is a file`
                                );
                            }
                        }
                    });
                };
            fn(files);
            return Promise.all(proms);
        }).then(function() {

            // Once all of the modules are loaded, run the configs
            me.configs.map((v) => v.fn).forEach(function(v) {
                new $injectionBinder(v, 'config')();
            });

            // Once the configs object has been copied destroy it to prevent
            // those functions from ever being fired again in this or future
            // reloads within the same application context
            me.configs = [];
        });
    }
    $$load() {
        let me = this;

        // Load any app dependencies
        return this.$$loadDependencies(config.dependencies).then(function() {

            // Bootstrap the application
            me.$$bootstrap();
        });
    }
}

let app = global.app;
if (!app) {
     app = global.app = new Angie();

    // Require in any further external components
    // Constants
    app.constant('ANGIE_TEMPLATE_DIRS', [
        `${__dirname}/templates`
    ].concat((config.templateDirs || []).map(function(v) {
        if (v.indexOf(CWD) === -1) {
            v = `${CWD}/${$StringUtil.removeLeadingSlashes(v)}`;
        }
        v = $StringUtil.removeTrailingSlashes(v);
        return v;
    }))).constant(
        'ANGIE_STATIC_DIRS',
        config.staticDirs || []
    ).constant('RESPONSE_HEADER_MESSAGES', {
        200: 'Ok',
        404: 'File Not Found',
        500: 'Internal Server Error',
        504: 'Gateway Timeout'
    }).constant(
        'PRAGMA_HEADER',
        'no-cache'
    ).constant(
        'NO_CACHE_HEADER',
        'private, no-cache, no-store, must-revalidate'
    );

    // Configs
    app.config(function() {
        $templateCache.put(
            'index.html',
            fs.readFileSync(`${__dirname}/templates/html/index.html`, 'utf8')
        );
        $templateCache.put(
            '404.html',
            fs.readFileSync(`${__dirname}/templates/html/404.html`, 'utf8')
        );
    });

    // Factories
    app.factory('$Routes', $RouteProvider)
        .factory('$Cache', $CacheFactory)
        .factory('$compile', $compile)
        .factory('$resourceLoader', $resourceLoader);

    // Services
    app.service('$Exceptions', $ExceptionsProvider)
        .service('$scope', $scope)
        .service('$templateCache', $templateCache);
}

export default app;
export {Angie};