NOTE: You are viewing documentation for the MoovJS/Adapt version of the Moovweb SDK
View documentation for next-gen Moovweb XDN & PWA framework
Moovweb | functions.js
Menu Developer Moovweb University

functions.js

/**
 * Use the `fns` namespace for standard Moovweb functionality.
 * @namespace fns
 */

/* globals $, $body, env, moovGetLogs, $head, $html, layers, moovManifest, $root, tag, txt, rules, project */

'use strict';
const url = require('url');
const path = require('path');
const banner = require('./banner');

/**
 * @function tag
 * @summary functions
 * @description Creates a new element. Note that this is <i>not</i> a Cheerio
 * object: you should wrap it in `$()` if Cheerio methods need to be invoked on
 * it.
 * @param {String} name A string for the name of the element.
 * @param {Object} [attribs] An object containing the attribute-value pairs of
 * the new element.
 * @param {String} [content] A string for the text node for the new element.
 * @return {Object} An object associated with the newly-created DOM element.
 * defined by the arguments passed in (denoted by a `type` value of "tag").
 * @example
 * let grape = tag("li", {class: "grape"}, "Grape");
 * grape.insertAfter(".apple");
 * // => Throws: TypeError (grape is not a Cheerio object, and the object
 * //    associated with the grape DOM element does not have an insertAfter
 * //    method defined)
 * $body.find("ul").append(grape);
 * // => Note: This is OK - you can append a non-Cheerio object here
 * // => HTML output:
 * //    <ul id="fruits">
 * //      <li class="apple" data-which="fuji">Apple</li>
 * //      <li class="grape">Grape</li>
 * //      <li class="pear">Pear</li>
 * //      <li class="orange">Orange</li>
 * //    </ul>
 * @example
 * let $grape = $(tag("div", {class: "grape"}, "Grape"));
 * $grape.insertAfter(".apple");
 * // => Returns: original Cheerio object associated with the newly-created
 * //    `.grape` element
 * // => HTML output:
 * //    <ul id="fruits">
 * //      <li class="apple" data-which="fuji">Apple</li>
 * //      <li class="grape">Grape</li>
 * //      <li class="pear">Pear</li>
 * //      <li class="orange">Orange</li>
 * //    </ul>
 */
global.tag = function(name, attribs, content) {
    let type = name === 'script' || name === 'style' ? name : 'tag';
    return {
        // for some reason <script> and <style> aren't exactly elements in htmlparser2
        // __proto__: type == 'tag' ? ElementPrototype : NodePrototype, // uncomment if withDomLvl1 is true
        type: type,
        name: name,
        attribs: attribs || {},
        children: content ? [txt(content)] : [],
        next: null,
        prev: null,
        parent: null
    };
};

/**
 * @function txt
 * @summary functions
 * @description Creates a new text node.
 * @param {string} content A string for the content that the text node will
 * contain.
 * @return {Object} An object associated with the newly-created text node
 * defined by the `content` argument passed in (denoted by a `type` value of
 * "text").
 * @example
 * $body.find(".apple").append(txt(", more text"));
 * // => HTML output:
 * //    <ul id="fruits">
 * //      <li class="apple" data-which="fuji">Apple, more text</li>
 * //      <li class="pear">Pear</li>
 * //      <li class="orange">Orange</li>
 * //    </ul>
 *
 */
global.txt = function(content) {
    return {
        // __proto__: NodePrototype, // uncomment if withDomLvl1 is true
        data: content,
        type: 'text',
        next: null,
        prev: null,
        parent: null
    };
};

/**
 * @function breakpoint
 * @summary functions
 * @description Calls the debugger and logs a message to the console, provided
 * that the debug mode has been enabled (the moov_debug parameter must exist in
 * the URL and the Dashboard Inspector must be open).
 * @param {String} breakpointId A string for a breakpoint terminal message.
 * @return {undefined} `undefined`
 * @example
 * if (env.path.indexOf("moov_debug=true") >= 0) {
 *   breakpoint("Parameter 'moov_debug=true' detected in the URL.");
 * }
 * // => Result: Breaks in the Dashboard Inspector and logs the appropriate
 * //    message.
 */
global.breakpoint = function(breakpointId) {
    if (global.debugEnabled) {
        console.log('Moov SDK breakpoint:', (breakpointId || ''));
        // eslint-disable-next-line no-debugger
        debugger;
    }
};

/**
 * @method asset
 * @summary functions
 * @memberof fns
 * @description Retrieves the URL for the specified JavaScript or image asset
 * path, which changes depending upon the project environment.
 * @param {String} pathToFile A string for the relative path to the asset file.
 * Must be relative to the <em>assets</em> directory in your project structure.
 * @return {String} A string containing the URL for the JavaScript or image
 * asset.
 * @example
 * $body.append(tag("script", {type: "text/javascript", src: fns.asset("javascript/custom_script.js")}));
 * // => HTML input:
 * //    <body></body>
 * // => HTML output (local):
 * //    <body>
 * //      <script type="text/javascript" src="//mlocal.mysite.com/_moovweb_local_assets_/javascript/custom_script.js"></script>
 * //    </body>
 * // => HTML output (production):
 * //    <body>
 * //      <script type="text/javascript" src="//assets.moovweb.net/project-uid/mode-uid/build-version/javascript/custom_script.js"></script>
 * //    </body>
 */
exports.asset = function(pathToFile) {
    return url.resolve(url.format(env.asset_host), pathToFile);
};

/**
 * @method addCanonicalTag
 * @summary functions
 * @memberof fns
 * @description Injects a canonical tag into the head, as long as one doesn't
 * already exist. Additionally, remove any alternate tags.
 * @return {undefined} `undefined`
 * @example
 * fns.addCanonicalTag();
 * // => HTML input:
 * //    <head></head>
 * // => HTML output:
 * //    <head>
 * //      <link rel="canonical" href="http://www.mysite.com/">
 * //    </head>
 */
exports.addCanonicalTag = function() {
    // Inject a canonical link as long as there isn't already one.
    if (!$head.find('link[rel="canonical"]')[0]) {
        $head.append(
            tag('link', {
                rel: 'canonical',
                href: 'http://' + env.source_host + env.path
            })
        );
    }
    // Remove any alternate tags
    $head.find('link[rel="alternate"]').remove();
};

/**
 * @method rewriteToProxy
 * @summary functions
 * @memberof fns
 * @description Rewrites a given link to the Moovweb proxy, given additional
 * security and domain settings.
 * @param {String} hostHH A string for the host link to be rewritten.
 * @param {Boolean} secure A boolean for whether you're on the secure protocol,
 * allowing for the rewriting process to speed up if in the proper environment
 * @param {String} catchAll A string for a catch-all domain, such as
 * .moovapp.com (for when a CNAME isn't set up and your project is pushed to
 * the Moovweb staging cluster).
 * @return {String} A string containing the link rewritten to the Moovweb proxy.
 * @example
 * fns.rewriteToProxy("www.mysite.com", false, env.__catch_all__);
 * // => Returns (local): "mlocal.mysite.com"
 * // => Returns (production with CNAME): "m.mysite.com"
 * // => Returns (moovapp staging): "m.mysite.com.moovapp.com"
 * @example
 * fns.rewriteToProxy("//www.mysite.com", false, env.__catch_all__);
 * // => Returns (local): "//mlocal.mysite.com"
 * // => Returns (production with CNAME): "//m.mysite.com"
 * // => Returns (moovapp staging): "//m.mysite.com.moovapp.com"
 */

// eslint-disable-next-line complexity
exports.rewriteToProxy = function(hostHH, secure, catchAll) {
    let parsedHost, sanitizedHost, prefix, missing = '',
        ctxRules, result;

    // fixup links that have been passed in with no protocol, or begin with forward slashes
    // parsing a bad url before doing this will lead to a Url object with unset keys
    prefix = hostHH.match(/^(?:https?:)?\/\//);
    if (!prefix) {
        missing = secure ? 'https://' : 'http://';
    } else if (prefix[0] === '//') {
        missing = secure ? 'https:' : 'http:';
    }
    sanitizedHost = missing + hostHH;

    // parse the sanitized host into a url object
    parsedHost = url.parse(sanitizedHost);
    if (parsedHost.port !== null && !(parsedHost.port === '443' || parsedHost.port === '80')) {
        // skip rewriting links using nonstandard ports
        return hostHH;
    }
    // Prevent adding the trailing slash when formatting
    parsedHost.pathname = '';

    // load the rules or interpolate them if they are missing from the context
    ctxRules = rules || [{
        Proxy: 'http://' + env.host,
        Upstream: 'http://' + env.source_host
    }, {
        Proxy: 'https://' + env.host,
        Upstream: 'https://' + env.source_host
    }, {
        Proxy: 'http://' + env.host,
        Upstream: 'http://' + env.source_host.replace(/^www\./, '')
    }, {
        Proxy: 'https://' + env.host,
        Upstream: 'https://' + env.source_host.replace(/^www\./, '')
    }];

    if (ctxRules[0]) {
        for (var i = 0; i < ctxRules.length; i++) {
            let rr = ctxRules[i];
            let match = sanitizedHost.match(rr.Upstream);
            if (rr.Direction !== 1 && match !== null && match.index === 0) {
                // We found a match
                let proxy = url.parse(rr.Proxy);
                // Prevent adding the trailing slash when formatting
                proxy.pathname = '';

                if (env.__catch_all_enabled__ === 'true') {
                    if (!proxy.host.match(catchAll)) {
                        // The host should have the catchall appended, but doesn't yet
                        proxy.hostname = proxy.hostname + catchAll;
                        // unsetting host will reformat the url for us
                        proxy.host = undefined;
                    }
                }

                result = url.format(proxy);
                if (result.substring(0, missing.length) === missing) {
                    result = result.substring(missing.length);
                }

                return result;
            }
        }
    }
    return hostHH;
};

/**
 * @method rewriteLink
 * @summary functions
 * @memberof fns
 * @description Rewrites a given absolute link to the Moovweb proxy (using
 * rewriteToProxy(), with default settings applied).
 * @param {String} hostHH A string for the host link to be rewritten.
 * @return {String} A string containing the link rewritten to the Moovweb proxy.
 * @example
 * fns.rewriteLink("www.mysite.com");
 * // => Returns (local): "mlocal.mysite.com"
 * // => Returns (production with CNAME): "m.mysite.com"
 * // => Returns (moovapp staging): "m.mysite.com.moovapp.com"
 * @example
 * fns.rewriteLink("//www.mysite.com");
 * // => Returns (local): "//mlocal.mysite.com"
 * // => Returns (production with CNAME): "//m.mysite.com"
 * // => Returns (moovapp staging): "//m.mysite.com.moovapp.com"
 * @example
 * fns.rewriteLink("https://mysite.com");
 * // => Returns (local, if naked subdomain in moov_config.json):
 * //    "https://mlocal.mysite.com"
 * // => Returns (production CNAME, if naked subdomain in moov_config.json):
 * //    "https://m.mysite.com"
 * // => Returns (moovapp staging, if naked subdomain in moov_config.json):
 * //    "https://m.mysite.com.moovapp.com"
 * // => Returns (if naked subdomain _not_ in moov_config.json):
 * //    "https://mysite.com"
 * @example
 * fns.rewriteLink("http://www.google.com/");
 * //=> Returns (if origin is not Google): "http://www.google.com/"
 * @example
 * fns.rewriteLink("/test.js");
 * // => Returns (stays the same): "/test.js"
 */
exports.rewriteLink = function(link) {
    link = link.trim();
    if (/^mailto:/.test(link)) {
        return link;
    }
    // eslint-disable-next-line no-useless-escape
    return link.replace(/((?:(?:(?:http(?:s?)):)?(?:\/\/)?(?:(?:[a-zA-Z0-9][a-zA-Z0-9\-]*)(?:\.[\.a-zA-Z0-9\-]*)|localhost))(?:\:[0-9]+)?)/gi, function(hostHH) {
        let rewritten = exports.rewriteToProxy(hostHH, env.secure === 'true', env.__catch_all__);
        return rewritten;
    });
};

/**
 * @method rewriteLinks
 * @summary functions
 * @memberof fns
 * @description Rewrites all links (`a` and `base` elements) to the correct
 * domain.
 * @return {undefined} `undefined`
 * @example
 * fns.rewriteLinks();
 * // => HTML input:
 * //    <head>
 * //      <base href="http://www.mysite.com/">
 * //    </head>
 * //    <body>
 * //      <a href="www.mysite.com/">Test</a>
 * //      <a href="//www.mysite.com/">Test</a>
 * //      <a href="//www.google.com/">Test</a>
 * //    </body>
 * // => HTML output:
 * //    <head>
 * //      <base href="http://m.mysite.com/" />
 * //    </head>
 * //    <body>
 * //      <a href="m.mysite.com/">Test</a>
 * //      <a href="//m.mysite.com/">Test</a>
 * //      <a href="//www.google.com/">Test</a>
 * //    </body>
 */
exports.rewriteLinks = function() {
    $html.find('a, head base[href]').attr('href', function(_, attr) {
        return attr ? exports.rewriteLink(attr) : null;
    });
    $html.find('form').attr('action', function(_, attr) {
        return attr ? exports.rewriteLink(attr) : null;
    });
};

/**
 * @method slashPath
 * @summary functions
 * @memberof fns
 * @description Gives the string for the given path's parent directory.
 * @return {String} A string for the slash path.
 * @example
 * fns.slashPath();
 * // => Returns (when on http://m.mysite.com/):
 * //    "/"
 * // => Returns (when on http://m.mysite.com/product):
 * //    "/"
 * // => Returns (when on http://m.mysite.com/product/):
 * //    "/product"
 * // => Returns (when on http://m.mysite.com/product/mittens):
 * //    "/product"
 * // => Returns (when on http://m.mysite.com/product/mittens/):
 * //    "/product/mittens"
 */
exports.slashPath = function() {
    // eslint-disable-next-line no-useless-escape
    return env.path.replace(/[^\/]+$/, '').replace(/^$/, '/');
};

/**
 * @method absolutize
 * @summary functions
 * @memberof fns
 * @description Converts a given origin URL to a fully-absolutized URL. Returns
 * the input URL if it is already absolutized, or if the address does not
 * reference the origin.
 * @param {String} href A string for the URL to be absolutized.
 * @return {String} A string for the absolutized version of the input URL.
 * @example
 * fns.absolutize("/test1/test2");
 * // => Returns: "//www.mysite.com/test1/test2"
 * @example
 * fns.absolutize("test1");
 * // => Returns (when on http://m.mysite.com):
 * //    "//www.mysite.com/www.mysite.com/test1"
 * // => Returns (when on http://m.mysite.com/test2):
 * //    "//www.mysite.com/www.mysite.com/test1"
 * // => Returns (when on http://m.mysite.com/test2/):
 * //    "//www.mysite.com/www.mysite.com/test2/test1"
 * @example
 * fns.absolutize("www.mysite.com/test");
 * // => Returns: "//www.mysite.com/www.mysite.com/test"
 * @example
 * fns.absolutize("http://www.mysite.com/test");
 * // => Returns (stays the same): "http://www.mysite.com/test"
 * @example
 * fns.absolutize("http://mysite.com/test");
 * // => Returns (stays the same, provided that the naked domain is in your
 * //    moov_config.json): "http://mysite.com/test"
 * @example
 * fns.absolutize("http://mlocal.mysite.com/test");
 * // => Returns (stays the same): "http://mlocal.mysite.com/test"
 * @example
 * fns.absolutize("http://www.google.com/");
 * // => Returns (stays the same, provided that Google is _not_ in your
 * //    moov_config.json): "http://www.google.com/"
 */
exports.absolutize = function(href) {
    href = href.trim();
    if (/^(?![a-zA-Z]+:)(?!\/\/)(?!$)/.test(href)) {
        return '//' + env.source_host + (href[0] === '/' ? '' : exports.slashPath()) + href;
    }
    return href;
};

/**
 * @method absolutizeSrcs
 * @summary functions
 * @memberof fns
 * @description Absolutizes all image and script `src` attribute values.
 * @return {undefined} `undefined`
 * @example
 * fns.absolutizeSrcs();
 * // => HTML input:
 * //    <head>
 * //      <script src="/script1.js"></script>
 * //    </head>
 * //    <body>
 * //      <img src="http://mysite.com/img1.jpeg" />
 * //      <img src="img2.gif" />
 * //      <script src="//www.mysite.com/script2.js"></script>
 * //    </body>
 * // => HTML output (when on http://m.mysite.com/test):
 * //    <head>
 * //      <script src="//www.mysite.com/script1.js"></script>
 * //    </head>
 * //    <body>
 * //      <img src="http://mysite.com/img1.jpeg" />
 * //      <img src="//www.mysite.com/test/img2.gif" />
 * //      <script src="//www.mysite.com/script2.js"></script>
 * //    </body>
 */
exports.absolutizeSrcs = function() {
    $html.find('img, script').attr('src', function(i, attr) {
        return attr ? exports.absolutize(attr) : null;
    });
};

/**
 * @method rewriteJsSrcs
 * @summary functions
 * @memberof fns
 * @description Rewrites all `src` attributes for `script` elements. Works on
 * HTML fragments, since this navigates from the root element `$root`.
 * @return {undefined} `undefined`
 * @example
 * fns.rewriteJsSrcs();
 * // => HTML input:
 * //    <head>
 * //      <script src="/script1.js"></script>
 * //    </head>
 * //    <body>
 * //      <img src="http://mysite.com/img1.jpeg" />
 * //      <img src="img2.gif" />
 * //      <script src="//www.mysite.com/script2.js"></script>
 * //    </body>
 * // => HTML output (production):
 * //    <head>
 * //      <script src="//m.mysite.com/script1.js"></script>
 * //    </head>
 * //    <body>
 * //      <img src="http://mysite.com/img1.jpeg" />
 * //      <img src="img2.gif" />
 * //      <script src="//m.mysite.com/script2.js"></script>
 * //    </body>
 */
exports.rewriteJsSrcs = function() {
    $root.find('script').attr('src', function(i, attr) {
        return attr ? exports.rewriteLink(attr) : null;
    });
};

/**
 * @method perfectProxy
 * @summary functions
 * @memberof fns
 * @description Rewrites the `href` links in any `a` elements to point to the
 * Moovweb proxy if it does not already, as well as the `src` attribute for any
 * `script` elements. Relative paths remain, since they are already passing
 * through the Moovweb engine.
 * This uses rewriteLinks() and rewriteJsSrcs().
 * @return {undefined} `undefined`
 * @example
 * fns.perfectProxy();
 * // => HTML input:
 * //    <head>
 * //      <script src="/script1.js"></script>
 * //    </head>
 * //    <body>
 * //      <a href="test">Test</a>
 * //      <a href="http://www.mysite.com/test">Test</a>
 * //      <a href="http://www.google.com/">Test</a>
 * //      <script src="//www.mysite.com/script2.js"></script>
 * //      <script src="//www.google.com/script3.js"></script>
 * //    </body>
 * // => HTML output (production):
 * //    <head>
 * //      <script src="/script1.js"></script>
 * //    </head>
 * //    <body>
 * //      <a href="test">Test</a>
 * //      <a href="http://m.mysite.com/test">Test</a>
 * //      <a href="http://www.google.com/">Test</a>
 * //      <script src="//m.mysite.com/script2.js"></script>
 * //      <script src="//www.google.com/script3.js"></script>
 * //    </body>
 */
exports.perfectProxy = function() {
    exports.rewriteLinks();
    exports.rewriteJsSrcs();
};

/**
 * @method rewriteAspNetScripts
 * @summary functions
 * @memberof fns
 * @description Rewrites only ASP.NET-related scripts, usually for the purpose
 * of access or content rewriting.
 * @return {undefined} `undefined`
 * @example
 * fns.rewriteAspNetScripts();
 * // => HTML input:
 * //    <head>
 * //      <script src="//www.mysite.com/script1.js"></script>
 * //      <script src="//www.mysite.com/ScriptResource.axd?d=long_hash&t=long_hash"></script>
 * //    </head>
 * // => HTML output (production):
 * //    <head>
 * //      <script src="//www.mysite.com/script1.js"></script>
 * //      <script src="//m.mysite.com/ScriptResource.axd?d=long_hash&t=long_hash"></script>
 * //    </head>
 */
exports.rewriteAspNetScripts = function() {
    $html.find('script[src*="ScriptResource.axd?"]').attr('src', function(_, attr) {
        return exports.rewriteLink(attr);
    });
};

/**
 * @method removeAllStyles
 * @summary functions
 * @memberof fns
 * @description Removes all `link` and `style` elements.
 * @return {undefined} `undefined`
 * @example
 * fns.removeAllStyles();
 * // => HTML input:
 * //    <head>
 * //     <link rel="stylesheet" href="http://www.mysite.com/my.css" />
 * //    </head>
 * //    <body>
 * //      <style>
 * //        body {
 * //          color: red;
 * //        }
 * //      </style>
 * //    </body>
 * // => HTML output:
 * //    <head></head>
 * //    <body></body>
 */
exports.removeAllStyles = function() {
    $html.find('link[rel="stylesheet"]:not([data-mw-keep]), style').remove();
};

/**
 * @method cleanMobileMetaTags
 * @summary functions
 * @memberof fns
 * @description Sanitizes any `meta` tags related to mobile development. This
 * entails resetting any existing `viewport` or `format-detection` meta tags to
 * be Moovweb-specific, in addition to an `http-equiv` tag.
 * @return {undefined} `undefined`
 * @example
 * fns.cleanMobileMetaTags();
 * // => HTML input:
 * //    <head>
 * //      <meta name="viewport" id="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,initial-scale=1.0">
 * //      <meta name="format-detection" content="telephone=yes">
 * //    </head>
 * // => HTML output:
 * //    <head>
 * //      <meta http-equiv="Content-Type" content="text/html">
 * //      <meta name="viewport" id="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
 * //      <meta name="format-detection" content="telephone=no">
 * //    </head>
 */
exports.cleanMobileMetaTags = function() {
    $head.find('meta[name="viewport"], meta[name="format-detection"]').remove();
    $head.append(
        tag('meta', {
            'http-equiv': 'Content-Type',
            content: 'text/html'
        }),
        tag('meta', {
            name: 'viewport',
            content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0'
        }),
        tag('meta', {
            name: 'format-detection',
            content: 'telephone=no'
        })
    );
};

/**
 * @method removeDesktopMobileIcon
 * @summary functions
 * @memberof fns
 * @description Removes any mobile icons on the origin site.
 * @return {undefined} `undefined`
 * @example
 * fns.removeDesktopMobileIcon();
 * // => HTML input:
 * //    <head>
 * //      <link rel="shortcut icon" href="http://www.mysite.com/64.png" sizes="64x64">
 * //      <link rel="shortcut icon" href="http://www.mysite.com/128.png" sizes="128x128">
 * //      <link rel="shortcut icon" href="http://www.mysite.com/192.png" sizes="192x192">
 * //      <link rel="apple-touch-icon" href="http://www.mysite.com/76.png" sizes="76x76">
 * //      <link rel="apple-touch-icon" href="http://www.mysite.com/120.png" sizes="120x120">
 * //      <link rel="apple-touch-icon" href="http://www.mysite.com/152.png" sizes="152x152">
 * //      <link rel="apple-touch-icon" href="http://www.mysite.com/180.png" sizes="180x180">
 * //    </head>
 * // => HTML input:
 * //    <head></head>
 */
exports.removeDesktopMobileIcon = function() {
    $head.find('link[rel="apple-touch-icon"], link[rel*="shortcut"][rel*="icon"]').remove();
};

/**
 * @method removeHtmlComments
 * @summary functions
 * @memberof fns
 * @description Removes HTML comments.
 * @return {undefined} `undefined`
 * @example
 * fns.removeHtmlComments();
 * // => HTML input:
 * //    <body>
 * //    <!-- Empty body element -->
 * //    </body>
 * // => HTML output:
 * //    <body></body>
 */
exports.removeHtmlComments = function() {
    function helper(node) {
        let children = node.children;
        if (!children || !children[0]) {
            return;
        }
        for (var i = 0; i < children.length; i++) {
            if (children[i].type === 'comment') {
                $(children[i]).remove();
            } else if (children[i].type === 'tag') {
                helper(children[i]);
            }
        }
    }
    helper($root[0]);
};

/**
 * @method sass
 * @summary functions
 * @memberof fns
 * @description Retrieves the URL for the specified Sass asset path, which
 * changes depending upon the project environment.
 * @param {String} baseName A string for the relative path to the asset file.
 * Must be relative to the <em>assets/stylesheets</em> directory in your project
 * structure.
 * @return {String} A string containing the URL for the compiled CSS asset.
 * @example
 * $head.append(tag("link", {rel: "stylesheet", type: "text/css", href: fns.sass("main")}));
 * // => HTML input:
 * //    <head></head>
 * // => HTML output (local):
 * //    <head>
 * //      <link rel="stylesheet" type="text/css" href="//mlocal.mysite.com/_moovweb_local_assets_/stylesheets/.css/main.css">
 * //    </head>
 * // => HTML output (production):
 * //    <head>
 * //      <link rel="stylesheet" type="text/css" href="//assets.moovweb.net/project-uid/mode-uid/build-version/stylesheets/.css/main.css">
 * //    </head>
 */
exports.sass = function(baseName) {
    return exports.asset(path.join('stylesheets/.css/', baseName + '.css'));
};

/**
 * @method addAssets
 * @summary functions
 * @memberof fns
 * @description Adds JavaScript and Sass assets. Sass assets get a
 * `data-mw-keep="true"` attribute-value pair added, and script assets get a
 * `data-keep="true"` pair added.
 * @return {undefined} `undefined`
 * @example
 * fns.addAssets();
 * // => HTML input:
 * //    <head></head>
 * //    <body></body>
 * // => HTML output (local):
 * //    <head>
 * //      <link rel="stylesheet" type="text/css" href="//mlocal.mysite.com/_moovweb_local_assets_/stylesheets/.css/main.css" data-mw-keep="true">
 * //    </head>
 * //    <body>
 * //      <script data-keep="true" type="text/javascript" src="//mlocal.mysite.com/_moovweb_local_assets_/javascript/main.js"></script>
 * //    </body>
 * // => HTML output (production):
 * //    <head>
 * //      <link rel="stylesheet" type="text/css" href="//assets.moovweb.net/project-uid/mode-uid/build-version/stylesheets/.css/main.css">
 * //    </head>
 * //    <body>
 * //      <script data-keep="true" type="text/javascript" src="//assets.moovweb.net/project-uid/mode-uid/build-version/javascript/main.js"></script>
 * //    </body>
 */
exports.addAssets = function() {
    $head.append(tag('link', {
        rel: 'stylesheet',
        type: 'text/css',
        href: exports.sass('main'),
        'data-mw-keep': 'true'
    }));
    $body.append(tag('script', {
        'data-keep': 'true',
        type: 'text/javascript',
        src: exports.asset('javascript/main.js')
    }));
};

/**
 * @method removeDesktopJs
 * @summary functions
 * @memberof fns
 * @description Removes non-ASP.NET desktop scripts that aren't explicitly
 * specified to be removed (using the `data-keep` attribute).
 * @return {undefined} `undefined`
 * @example
 * fns.removeDesktopJs();
 * // => HTML input:
 * //    <head>
 * //      <script type="text/javascript" src="/script1.js"></script>
 * //      <script data-keep="true" type="text/javascript" src="/script2.js"></script>
 * //    </head>
 * //    <body>
 * //      <script data-keep="false" type="text/javascript" src="/script3.js"></script>
 * //      <script data-keep="true" type="text/javascript" src="/script4.js"></script>
 * //    </body>
 * // => HTML output:
 * //    <head>
 * //      <script data-keep="true" type="text/javascript" src="/script2.js"></script>
 * //    </head>
 * //    <body>
 * //      <script data-keep="true" type="text/javascript" src="/script4.js"></script>
 * //    </body>
 * @example
 * fns.removeDesktopJs();
 * // => HTML input:
 * //    <head>
 * //      <script src="//m.mysite.com/ScriptResource.axd?d=long_hash&t=long_hash"></script>
 * //    </head>
 * // => HTML output (stays the same):
 * //    <head>
 * //      <script src="//m.mysite.com/ScriptResource.axd?d=long_hash&t=long_hash"></script>
 * //    </head>
 *
 */
exports.removeDesktopJs = function() {
    $html.find('script[src]').each(function( /* i, elem */ ) {
        if ((!$(this).attr('data-keep') || $(this).attr('data-keep') === 'false') && !/ScriptResource\.axd/.test($(this).attr('src'))) {
            $(this).remove();
        }
    });
};

/**
 * @method isRobots
 * @summary functions
 * @memberof fns
 * @description Checks if the current path is "/robots.txt."
 * @return {Boolean} A boolean for whether the current path is /robots.txt.
 * @example
 * fns.isRobots();
 * // => Returns (if on /robots.txt): true
 */
exports.isRobots = function() {
    return (/\/robots\.txt/).test(env.path);
};

/**
 * @method handleRobots
 * @summary functions
 * @memberof fns
 * @description Disallows robot crawling for non-production environments.
 * Retains origin production robot crawling settings for transformed production
 * environments.
 * @param {String} body A string containing the original robots.txt response.
 * @return {String} A string for the possibly-modified outgoing robots.txt
 * response.
 * @example
 * fns.handleRobots("User-agent: *\nDisallow: /account.php\nDisallow: /cart.php");
 * // => Returns (local, stage): "
 * //      User-agent: *
 * //      Disallow: /
 * //    "
 * // => Returns (production; stays the same): "
 * //      User-agent: *
 * //      Disallow: /account.php
 * //      Disallow: /cart.php
 * //    "
 */
exports.handleRobots = function(body) {
    let isProd = /^(m\.|t\.)/;

    if (isProd.test(env.host)) {
        return body;
    }
    return 'User-agent: *\nDisallow: /';
};

/**
 * @method relocateScripts
 * @summary functions
 * @memberof fns
 * @description Moves `script` elements to the bottom of the `body`, retaining
 * the original sequential order.
 * @return {undefined} `undefined`
 * @example
 * fns.relocateScripts();
 * // => HTML input:
 * //    <head>
 * //      <script type="text/javascript" src="/script1.js"></script>
 * //      <meta name="viewport" id="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,initial-scale=1.0">
 * //      <script type="text/javascript" src="/script2.js"></script>
 * //    </head>
 * //    <body>
 * //      <script type="text/javascript" src="/script3.js"></script>
 * //      <div></div>
 * //      <script type="text/javascript" src="/script4.js"></script>
 * //    </body>
 * // => HTML output:
 * //    <head>
 * //      <meta name="viewport" id="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,initial-scale=1.0">
 * //    </head>
 * //    <body>
 * //      <div></div>
 * //      <script type="text/javascript" src="/script1.js"></script>
 * //      <script type="text/javascript" src="/script2.js"></script>
 * //      <script type="text/javascript" src="/script3.js"></script>
 * //      <script type="text/javascript" src="/script4.js"></script>
 * //    </body>
 */
exports.relocateScripts = function() {
    $body.append($html.find('script'));
};

// Layers

/**
 * @method queryLayerText
 * @summary functions
 * @memberof fns
 * @description Indicates whether or not the specified layer is active, with
 * the indicator as a string.
 * @param {String} layer A string for the name of the layer to query.
 * @return {String} A string for a boolean number, i.e. "1" if layer is active,
 * "0" otherwise.
 * @example
 * fns.queryLayerText("tablet");
 * // => Returns (if tablet layer is active): "1"
 * // => Returns (if tablet layer is not active): "0"
 */
exports.queryLayerText = function(layer) {
    return layers.indexOf(layer) >= 0 ? '1' : '0';
};


/**
 * @method activeLayers
 * @summary functions
 * @memberof fns
 * @description Retrieves a list of active layers used to transform the project.
 * @return {Array} An array of strings representing each active layer.
 * @example
 * fns.activeLayers();
 * // => Context: Project has multiple layers, presumably for tests, called
 * //    "testA", "testB", "testC", and "testD." User starts a server running
 * //    only the "testA" and "testC" layers.
 * // => Returns: ["testA", "testC"]
 */
exports.activeLayers = function() {
    return layers;
};

/**
 * @method layer
 * @summary functions
 * @memberof fns
 * @description Indicates whether or not the specified layer is active, with
 * the indicator as a boolean.
 * @param {String} layer A string for the name of the layer to query.
 * @return {Boolean} A boolean for whether the layer is active.
 * @example
 * fns.layer("tablet");
 * // => Returns (if tablet layer is active): true
 * // => Returns (if tablet layer is not active): false
 */
exports.layer = function(layer) {
    return exports.queryLayerText(layer) === '1';
};

/**
 * @method layerNot
 * @summary functions
 * @memberof fns
 * @description Indicates whether or not the specified layer is inactive, with
 * the indicator as a boolean.
 * @param {string} layer A string for the name of the layer to query.
 * @return {boolean} A boolean for whether the layer is inactive.
 * @example
 * fns.layerNot("tablet");
 * // => Returns (if tablet layer is active): false
 * // => Returns (if tablet layer is not active): true
 */
exports.layerNot = function(layer) {
    return exports.queryLayerText(layer) === '0';
};

/**
 * @method onDevelopment
 * @summary functions
 * @memberof fns
 * @description Runs a callback function if the project is being run on a
 * development environment.
 * @param {Function} callback A callback function to run if the project is
 * being run on a development environment.
 * @return {Boolean} A boolean indicating whether the project is being run on
 * a development environment (and by extension, whether the callback functoin
 * was run).
 * @example
 * fns.onDevelopment(function() {
 *   $body.append(tag("div"));
 * });
 * // => HTML input:
 * //    <body></body>
 * // => HTML input (local):
 * //    <body>
 * //      <div></div>
 * //    </body>
 * // => HTML output (production; stays the same):
 * //    <body></body>
 */
exports.onDevelopment = function(callback) {
    if (process.env.NODE_ENV === 'development') {
        if (typeof(callback) === 'function') {
            callback();
            return true;
        }
        return true;
    }
    return false;
};

// Assert

/**
 * @method assert
 * @summary functions
 * @memberof fns
 * @description Make an assertion for conditional statement and run the callback
 * if the statement is correct. Generallyu used to evaluate expressions and
 * throw exceptions when developing locally.
 * @param {Boolean} conditional A boolean for whether the callback should run.
 * Usually should take the form of a full conditional statement.
 * @param {Function} callback A callback function to run if the conditional is
 * true.
 * @return {Boolean} A boolean for the original condition.
 * @example
 * fns.assert($body.children().length > 0, function() {
 *   console.log("The body is nonempty.");
 * });
 * // => HTML input:
 * //    <body></body>
 * // => Throws (local): generic JavaScript Error, with broken page functionality.
 * // => Returns (production): false
 * @example
 * fns.assert($body.children().length === 0, function() {
 *   console.log("The body is empty.");
 * });
 * // => HTML input:
 * //    <body></body>
 * // => Logs: "The body is empty."
 * // => Returns: true
 */
exports.assert = function(conditional, callback) {
    let failed = !conditional;
    exports.onDevelopment(function() {
        if (failed) {
            let error = new Error('Assert failed');
            throw error.stack;
        } else {
            if (typeof(callback) === 'function') {
                callback();
                return !failed;
            }
        }
    });

    return !failed;
};

/**
 * @method moveStylesAboveScripts
 * @summary functions
 * @memberof fns
 * @description Move `style` and `link` elements above the `script` elements.
 * @return {Object} A Cheerio object for the first `script` element found in the
 * HTML.
 * @example
 * fns.moveStylesAboveScripts();
 * // => HTML input:
 * //    <head>
 * //      <link rel="stylesheet" href="/styles.scss">
 * //    </head>
 * //    <body>
 * //      <script type="text/javascript" src="/script1.js"></script>
 * //      <style>
 * //        body {
 * //          color: red;
 * //        }
 * //      </style>
 * //      <script type="text/javascript" src="/script2.js"></script>
 * //    </body>
 * // => Returns: Cheerio object associated with the "script1.js" `script`
 * //    element.
 * // => HTML output:
 * //    <head></head>
 * //    <body>
 * //      <link rel="stylesheet" href="/styles.scss">
 * //      <style>
 * //        body {
 * //          color: red;
 * //        }
 * //      </style>
 * //      <script type="text/javascript" src="/script1.js"></script>
 * //      <script type="text/javascript" src="/script2.js"></script>
 * //    </body>
 */
exports.moveStylesAboveScripts = function() {
    let styles = $html.find('[rel~=stylesheet], style');
    return $html.find('script').closest('script').first().before(styles);
};

/**
 * @method moveStylesToHead
 * @summary functions
 * @memberof fns
 * @description Prepend `style` and `link` elements to the `head`. To be used
 * in conjuction (after) the moveStylesAboveScripts() method, which takes
 * effect if there is no `script` elements in the `head` (this is otherwise
 * redundant if the first `script` is already there).
 * @return {Object} A Cheerio object associated with the `head` element.
 * @example
 * // => HTML input:
 * //    <head>
 * //      <link rel="stylesheet" href="/styles.scss">
 * //    </head>
 * //    <body>
 * //      <script type="text/javascript" src="/script1.js"></script>
 * //      <style>
 * //        body {
 * //          color: red;
 * //        }
 * //      </style>
 * //      <script type="text/javascript" src="/script2.js"></script>
 * //    </body>
 * // => Returns: Cheerio object associated with the `head` element
 * // => HTML output:
 * //    <head>
 * //      <link rel="stylesheet" href="/styles.scss">
 * //      <style>
 * //        body {
 * //          color: red;
 * //        }
 * //      </style>
 * //    </head>
 * //    <body>
 * //      <script type="text/javascript" src="/script3.js"></script>
 * //      <script type="text/javascript" src="/script4.js"></script>
 * //    </body>
 */
exports.moveStylesToHead = function() {
    let styles = $html.find('[rel~=stylesheet], style');
    return $head.prepend(styles);
};

/**
 * @method insertSubresource
 * @summary functions
 * @memberof fns
 * @description Inserts an asset as a subresource to enable early loading of
 * resources within the current page. For more on subresources, see the
 * <a href="https://www.chromium.org/spdy/link-headers-and-server-hint/link-rel-subresource">Chromium
 * documentation</a>.
 * @param {String} resource A string for the href value to be used as a
 * subresource.
 * @return {Object} A Cheerio object associated with the newly-inserted
 * subresource element.
 * @example
 * fns.insertSubresource("http://www.google.com/script1.js");
 * // => HTML input:
 * //    <head></head>
 * // => Returns: DOM object associated with the newly-created subresource
 * //    element.
 * // => HTML output:
 * //    <head>
 * //        <link rel="subresource" href="http://www.google.com/script1.js">
 * //    </head>
 */
exports.insertSubresource = function(resource) {
    if (resource === undefined) {
        return undefined;
    } else {
        let subresource = tag('link', {
            rel: 'subresource',
            href: resource
        });
        $head.append(subresource);
        return subresource;
    }
};

function gatherHosts(selection, attribute, hosts) {
    // Gather hosts based on selection and attribute.
    // - selection: string for a selector of elements
    // - attribute: string for the attribute of each element to check
    // - hosts: array of strings for hosts
    // - returns: array of strings for new hosts
    let host, hostSelector = /^((https?:)?\/\/.*?)(\/.*|$)/;
    if (hosts === undefined) {
        hosts = {};
    }
    $html.find(selection).each(function() {
        if (hostSelector.test(this.attribs[attribute])) {
            host = hostSelector.exec(this.attribs[attribute]);
            if (host[1] !== undefined) {
                hosts[host[1]] = true;
            }
        }
    });
    return hosts;
}

/**
 * @method insertDnsPrefetch
 * @summary functions
 * @memberof fns
 * @description Insert DNS prefetch elements to the `head`, allowing to resolve
 * DNSs before the referenced items are needed. Note that the order of the
 * generated prefetch tags may not be in the same order as the asset order.
 * @return {undefined} `undefined`
 * @example
 * fns.insertDnsPrefetch();
 * // => HTML input:
 * //    <head>
 * //      <link rel="stylesheet" href="http://www.twitter.com/stylesheet1.css">
 * //    </head>
 * //    <body>
 * //      <a href="http://www.bing.com/"></a>
 * //      <img src="http://www.yahoo.com/"></a>
 * //      <script type="text/javascript" src="http://www.google.com/script1.js"></a>
 * //    </body>
 * // => HTML output:
 * //    <head>
 * //      <link rel="stylesheet" href="http://www.twitter.com/stylesheet1.css">
 * //      <link rel="dns-prefetch" href="http://www.yahoo.com/">
 * //      <link rel="dns-prefetch" href="http://www.google.com/">
 * //      <link rel="dns-prefetch" href="http://www.twitter.com/">
 * //      <link rel="dns-prefetch" href="http://www.bing.com/">
 * //    </head>
 * //    <body>
 * //      <a href="http://www.facebook.com/"></a>
 * //      <img src="http://www.microsoft.com/img1.js" />
 * //      <script type="text/javascript" src="http://www.google.com/script1.js"></a>
 * //    </body>
 */
exports.insertDnsPrefetch = function() {
    let domains = gatherHosts('img, script', 'src', {});
    gatherHosts('a, link', 'href', domains);

    for (var d in domains) {
        if (domains.hasOwnProperty(d)) {
            let prefetch = tag('link', {
                rel: 'dns-prefetch',
                href: d
            });
            $head.append(prefetch);
        }
    }
};

/**
 * @method setPageType
 * @summary functions
 * @memberof fns
 * @description Sets the internal `pageType` property for the global `env`
 * environment. In production, this is specifically used for writing in the
 * "pageType" field on the Moovweb Control Center's Stack Trace feature.
 * @param {string} type A string for the name of the `pageType` to set.
 * @return {undefined} `undefined`
 * @example
 * fns.setPageType(routes.pageType.name);
 * // => Result: Stack Trace reads the `pageType` as "home".
 * // => Note: Already included in the generator's fns.constructPageType()
 * //    method call.
 */
exports.setPageType = function(type) {
    if (typeof(type) === 'string' && type.length <= 256) {
        exports.export('pageType', type);
    } else {
        console.log('Invalid pageType: ' + type + '! It must be a string with a max length of 256.');
    }
};

/**
 * @method getPageType
 * @summary functions
 * @memberof fns
 * @description Access the internal `pageType` property of the environment
 * `env`, for the given address.
 * @return {String} A string for the name of the given address's `pageType`.
 * @example
 * fns.getPageType();
 * // => Returns: name for the pageType, e.g. "home"
 */
exports.getPageType = function() {
    return env.pageType;
};

/**
 * @method highlightDevelopment
 * @summary functions
 * @memberof fns
 * @description Inserts a banner on the page when developing locally. This
 * allows for a clear differentiation between the proxied single domain site and
 * the original site. Alias for the legacy fns.highlight_development() method.
 * @param {string} [position] A string for the position to place the banner ("top"
 * or "bottom"). Defaults to bottom positioning if "top" is not specified.
 * @return {undefined} `undefined`
 * @example
 * fns.highlightDevelopment("top");
 * // => HTML output: (omitted).
 */
exports.highlightDevelopment = exports.highlight_development = function(position) {
    if (position !== 'top') {
        position = 'bottom';
    }

    if (env.node_env === 'development') {
        let script = banner.developmentBanner(project, layers, position);
        $html.append(script);
    }
};

/**
 * @method highlightPreview
 * @summary functions
 * @memberof fns
 * @description Inserts a banner on the page if viewing a preview mode. This
 * allows for a clear differentiation between preview modes and live modes.
 * Alias for the legacy fns.highlight_preview() method.
 * @param {string} [position] A string for the position to place the banner ("top"
 * or "bottom"). Defaults to bottom positioning if "top" is not specified.
 * @example
 * fns.highlightPreview("top");
 * // => HTML output: (omitted).
 */
exports.highlightPreview = exports.highlight_preview = function(position) {
    if (position !== 'top') {
        position = 'bottom';
    }

    if (env.moov_mode_status === 'preview') {
        let script = banner.previewBanner(env.host, env.moov_mode_name, position);
        $html.append(script);
    }
};

/**
 * @method getModeInfoFromModeName
 * @summary functions
 * @memberof fns
 * @description Returns the mode details for the given mode name. http://developer.moovweb.com/docs/cloud/modes
 * @param {string} [nodeName] A string for the mode name to search for.
 * @return {object} The mode details or {} if the mode is not found
 * @example
 * fns.getModeInfoFromModeName("default");
 * // => HTML output: (omitted).
 */
exports.getModeInfoFromModeName = exports.getModeInfoFromModeName = function(modeName) {
    // If there is no mode information, return empty results.
    if (!moovManifest || (typeof(moovManifest) !== 'object') || !moovManifest.Modes) {
        return {};
    }

    modeName = modeName.toLowerCase();
    let modeIds = Object.keys(moovManifest.Modes);
    for (let i = 0; i < modeIds.length; i++) {
        let modeId = modeIds[i],
            mode = moovManifest.Modes[modeId];
        if (mode.Name.toLowerCase() === modeName) {
            mode.Id = modeId;
            return mode;
        }
    }

    // If there is no match, return empty results.
    return {};
};

/**
 * @method injectDebuggingInfo
 * @summary functions
 * @memberof fns
 * @description Append the console.log() output to the end of the DOM if moov_debug='true'
 * @return undefined
 * @example
 * fns.injectDebuggingInfo($body);
 */
exports.injectDebuggingInfo = exports.injectDebuggingInfo = function(body) {
    console.log('Response rewriter took', ((new Date()).getTime() - global.transformStartTime) + 'ms.');
    if (!body || !env.moov_debug || env.moov_debug !== 'true' || !global.moovGetLogs) {
        return;
    }
    let comments = '\n<!-- When moov_debug === "true", include the project\'s console.log() statements.' +
        '\n      ' + moovGetLogs().join('\n      ').replace(/<!--|-->/g, '') + '\n-->\n';
    body.append(comments);
};

module.exports = exports;
Last updated Mon Feb 26 2018 22:51:29 GMT+0000 (UTC)