4
\$\begingroup\$

On page load, this script will locate all external links, and set their on click to open the page in a new window. "External links" includes pages that are on a different domain, PDF's on any domain, and any links with the class open-new. When a PDF is clicked, it also pings Google Analytics.

This is using only pure Javascript, no jQuery or any other libraries. A previous version did use jQuery, but the dependency was too heavy for the benefit, so no recommendations to use jQuery, please.

This is not intended to be a library to be used generally, but is for a specific purpose, so "excess specificity" is acceptable.

It is designed to work cross-browser, so IE9+, FF, Chrome, etc.

// Find all external links, and set them to open in a new window
"use strict";

function isNull(obj) {
    return typeof obj === "undefined" || obj === null;
}

function isNullOrEmptyString(obj) {
    return isNull(obj) || obj === "";
}

function Linkerator(doc, url) {
    this.document = doc;
    var domain = url.substring(url.indexOf(":"), url.indexOf("/", 8));
    this.URL_BASE = "http" + domain;
    this.SSL_BASE = "https" + domain;
}

Linkerator.Link = function(aElement, urlBase, sslBase) {
    this.aElement = aElement;
    this.URL_BASE = urlBase;
    this.SSL_BASE = sslBase;
};

Linkerator.Link.prototype = {
    "getHref": function() {
        return this.aElement.getAttribute("href");
    },
    "isHrefEmpty": function() {
        return isNullOrEmptyString(this.getHref());
    },
    "isExternal": function() {
        return !this.isLocal();
    },
    "isLocal": function() {
        return !this.hasClass("open-new") &&
            !this.isPDF() &&
            this.hasLocalPath();
    },
    "hasClass": function(wantedClass) {
        var classes = this.aElement.getAttribute("class");
        if(isNullOrEmptyString(classes)) {
            return false;
        }
        classes = classes.split(/\s+/);
        return classes.some(function(className) {
            return className === wantedClass;
        });
    },
    "isPDF": function() {
        return this.getHref().match(/\.pdf/i);
    },
    "hasLocalPath": function() {
        var href = this.getHref();
        return href.match(/^(\.\.?)?\//) ||
            href.match(this.URL_BASE) ||
            href.match(this.SSL_BASE);
    },
    "onClick": function(action) {
        var thisLink = this;
        var wrappedAction = function(evt) {
            return action.call(thisLink, evt, thisLink);
        };

        if(this.aElement.addEventListener) {
            this.aElement.addEventListener("click", wrappedAction, false);
        } else if(this.aElement.attachEvent) {
            this.aElement.attachEvent("onclick", wrappedAction, false);
        } else {
            this.aElement.onclick = action;
        }
    }
};

Linkerator.prototype.getExternalLinks = function() {
    var linksArr = [];
    var linksQS = this.document.querySelectorAll("#content a[href]:not([href^='#']):not([href^='javascript:']), a[href].open-new");

    for(var i = 0; i < linksQS.length; i++) {
        var link = new Linkerator.Link(linksQS[i], this.URL_BASE, this.SSL_BASE);

        if(!link.isHrefEmpty() && !link.isLocal()) {
            linksArr.push(link);
        }
    }

    return linksArr;
};

function documentReady() {
    var linkerator = new Linkerator(document, window.location.href);
    linkerator.getExternalLinks().forEach(function(link) {
        link.onClick(function(evt) {
            if(evt.preventDefault) {
                evt.preventDefault();
            }

            var href = this.getHref();

            window.open(href);

            if(typeof ga === "function" && this.isPDF()) {
                ga("send", "event", "pdf", "click", href, {"hitCallback": function() {}});
            }

            return false;
        });
    });
}

if(document.addEventListener) {
    document.addEventListener('DOMContentLoaded', documentReady);
} else if(document.attachEvent) {
    document.attachEvent("onreadystatechange", function() {
        if(document.readyState === "complete") {
            documentReady();
        }
    });
} else {
    window.onload = documentReady;
}
\$\endgroup\$
8
  • \$\begingroup\$ I kind-of like it but its very confusing tbh. I am trying to have a look at simplifying this. I also think your code is a bit too specific, like defining what classes can be used etc... And not letting it up to the user/developer. But interesting. \$\endgroup\$ Jul 21, 2015 at 14:57
  • \$\begingroup\$ @somethinghere It is not intended for use as a library. It is intended for use only on a single site so the specificity should not be an issue. Thanks for the feedback, I'll edit the question to note that. :) \$\endgroup\$
    – cbojar
    Jul 21, 2015 at 15:00
  • \$\begingroup\$ but as far as I understand it though, Code Review is about trying to help you write the best code possible, and I think specificity will bite you in the ass in the long run as you can;t easily reuse this code. \$\endgroup\$ Jul 21, 2015 at 15:02
  • \$\begingroup\$ @somethinghere I think I see what you are getting at there. For my purposes right now, it's not an immediate concern, but I see how it could become an issue in the future. If I deviate the structure of my HTML, I would need to vary the selector. Maybe a reviewer will address that issue in their review. \$\endgroup\$
    – cbojar
    Jul 21, 2015 at 15:06
  • \$\begingroup\$ This could really benefit from utilizing JavaScript event delegation. I threw together a quick Gist more in tune to what you are after, which you could modify to suite your needs. \$\endgroup\$ Jul 21, 2015 at 15:30

1 Answer 1

1
\$\begingroup\$

The first thing to point out is that you are using window.location.href, and then performing regex to extract out the parts. It would be much easier to just pass in the window.location object (actually, it might even be better to pass in document.location since you are only operating on the document), and let it handle breaking up the URL. The document hostname is then available through the #host property of the location. You can pass that location object down into the Linkerator.Links as well in place of URL_BASE and SSL_BASE.

Inside of your Linkerator.Link objects, you store a reference to an HTML DOM anchor element. This element provides several convenience properties like #host as well. With these properties, you can reduce some of the more cumbersome conditional logic.

For example, you could create an accessor for the anchor's href's host:

"getHost": function() {
    return this.aElement.host;
}

then use it like so in your #hasLocalPath method (this.location is the passed-in location object):

"hasLocalPath": function() {
    return this.getHost() === this.location.host;
}

That simplifies the condition without needing to use any regexes. Similarly, there is a #pathname property to anchors that could be used in your #isPDF method that would expose only the file path from the root. This would prevent your code from being tripped up by a PDF that also has a query string or hash (for whatever reason).


There are several methods that are unused, and a few that could be used better. For example, #isExternal is never used. It actually should be. It is the negation of #isLocal, which is only ever used once... negated. You should actually get rid of #isLocal, invert the conditional logic inside, and move it into #isExternal, then call #isExternal in the logic below.

#isHrefEmpty is another method that is used only once, negated. Better would be a #hasHref method that returns the inverse boolean. To make it even more compact, you could do something like:

"hasHref": function() {
    return [null, ""].indexOf(this.getHref()) === -1;
}

"hasClass": function(wantedClass) {
    var classes = this.aElement.getAttribute("class");
    if(isNullOrEmptyString(classes)) {
        return false;
    }
    classes = classes.split(/\s+/);
    return classes.some(function(className) {
        return className === wantedClass;
    });
}

This method is overly verbose, and a little too timid. #getAttribute can only return a string or null, so the check for undefined in the isNullOrEmptyString (--> isNull) function is unnecessary. Because the method is so short, bailing early with the guard clause actually adds noise. And the temporary variable can be eliminated to make it more fluent. Something like this:

"hasClass": function(wantedClass) {
    return (
        this.aElement.getAttribute("class") || ""
    ).trim().split(/\s+/).some(function(className) {
        return className === wantedClass;
    });
}

Speaking of making your code more functional and fluent, your #getExternalLinks method could also do for the same treatment. It is essentially a map and filter on an array-like object. Convert it to an array, then do a map and filter.

Linkerator.prototype.getExternalLinks = function() {
    return [].slice.call(
        this.document.querySelectorAll("#content a[href]:not([href^='#']):not([href^='javascript:']), a[href].open-new")
    ).map(function(link) {
        return new Linkerator.Link(link, this.location);
    }, this).filter(function(link) {
        return link.hasHref() && link.isExternal();
    }, this);
};

When you use an object literal to fill in an object's prototype, you clobber whatever may have originally been in the prototype. Generally this is fine, but the #constructor property is one of the things that gets clobbered. This typically is not an issue but may come up depending on potential use cases. It's good practice to restore the #constructor property:

"constructor": Linkerator.Link

This is a matter of opinion, but in #isPDF, you return the result of String#match, which is a truthy/falsy value, but is not strictly true/false. You can prefix it with a !! to return strictly true/false.


With the changes mentioned, the code now looks like:

"use strict";

function Linkerator(doc, loc) {
    this.document = doc;
    this.location = loc;
}

Linkerator.Link = function(aElement, loc) {
    this.aElement = aElement;
    this.location = loc;
};

Linkerator.Link.prototype = {
    "constructor": Linkerator.Link,
    "getHref": function() {
        return this.aElement.href;
    },
    "getHost": function() {
        return this.aElement.host;
    },
    "getPathname": function() {
        return this.aElement.pathname;
    },
    "hasHref": function() {
        return [null, ""].indexOf(this.getHref()) === -1;
    },
    "isExternal": function() {
        return this.hasClass("open-new") || this.isPDF() || !this.hasLocalPath();
    },
    "hasClass": function(wantedClass) {
        return (
            this.aElement.getAttribute("class") || ""
        ).trim().split(/\s+/).some(function(className) {
            return className === wantedClass;
        });
    },
    "isPDF": function() {
        return !!this.getPathname().match(/\.pdf$/i);
    },
    "hasLocalPath": function() {
        return this.getHost() === this.location.host;
    },
    "onClick": function(action) {
        var thisLink = this;
        var wrappedAction = function(evt) {
            return action.call(thisLink, evt, thisLink);
        };

        if(this.aElement.addEventListener) {
            this.aElement.addEventListener("click", wrappedAction, false);
        } else if(this.aElement.attachEvent) {
            this.aElement.attachEvent("onclick", wrappedAction, false);
        } else {
            this.aElement.onclick = action;
        }
    }
};

Linkerator.prototype.getExternalLinks = function() {
    return [].slice.call(
        this.document.querySelectorAll("#content a[href]:not([href^='#']):not([href^='javascript:']), a[href].open-new")
    ).map(function(link) {
        return new Linkerator.Link(link, this.location);
    }, this).filter(function(link) {
        return link.hasHref() && link.isExternal();
    }, this);
};

function documentReady() {
    var linkerator = new Linkerator(document, window.location);
    linkerator.getExternalLinks().forEach(function(link) {
        link.onClick(function(evt) {
            if(evt.preventDefault) {
                evt.preventDefault();
            }

            var href = this.getHref();

            window.open(href);

            if(typeof ga === "function" && this.isPDF()) {
                ga("send", "event", "pdf", "click", href, {"hitCallback": function() {}});
            }

            return false;
        });
    });
}

if(document.addEventListener) {
    document.addEventListener('DOMContentLoaded', documentReady);
} else if(document.attachEvent) {
    document.attachEvent("onreadystatechange", function() {
        if(document.readyState === "complete") {
            documentReady();
        }
    });
} else {
    window.onload = documentReady;
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.