Skip to content

Instantly share code, notes, and snippets.

@sukima
Last active December 10, 2015 22:38
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sukima/4504087 to your computer and use it in GitHub Desktop.
Save sukima/4504087 to your computer and use it in GitHub Desktop.
A JavaScript (CoffeeScript) Implementation of a client side (static) URL Shortener.
# A module to handle fetching, parsing, and displaying the short urls data and
# to handle loading the long url baed on the passed in hash tag.
#
# When this is executed on a page it will load the data from a JSON file. It
# the checks if there is a hash tag in the URL. If so it redirects the page to
# the long url. If not it lists out all the short urls.
#
# For example, `http://mysite.com/s/` will display the list and
# `http://mysite.com/s/#1` will redirect to the associated url for the id 1.
#
# This assumes that you have an index.html in the /s directory of your website.
# It is dependent on jQuery.
#
# ## Usage ##
# var s = new ShortUrl("path/to/json");
# s.setPath("http://mysite.com/s/")
# .onRedirect(function(e) {
# console.log(e.url);
# }).ready();
#
# The following functions can be chained: `onDisplay`, `onRedirect`,
# `onError`, and `setPath` (`ready` only works at the end of the chain).
#
# ### Events ###
# The following events are triggered and data is sent with the event objects passed in.
#
# #### display ####
# Used to build the list (view) of all items. The event object will have these
# properties:
# items: [
# { id: "", url: "", short_path: "" },
# ...
# ]
#
# #### redirect ####
# Called when the browser is about to redirect. Used to offer feed back to the
# user just before the redirect. The event object will have these properties:
# id: "",
# url: "",
# short_path: ""
#
# #### error ####
# Used to display any errors encountered. The event object will have these
# properties:
# id: ""
#
# Examples:
# var s = new ShortUrl("path/to/json");
# $(s).on("display", function(e) {
# console.log(e.items);
# });
# s.onDisplay(function(e) {
# console.log(e.items);
# });
$ = jQuery
class ShortUrl
# Load the json file passed in as a string
constructor: (json_url, options) ->
# Allows for alternative syntax
if arguments.length is 1 and typeof json_url isnt "string"
options = json_url
json_url = options.json
@data = null
# Promises
@_waiting_for_ready = $.Deferred()
@_loading_JSON = $.getJSON(json_url)
.done( (@data) => )
@_waiting_for_all_done = $.when(@_waiting_for_ready, @_loading_JSON)
.done( => @checkUrl() )
# Allows an object to preset parameters and immediatly declare ready state.
@setPath()
if options?
if options.domain? and options.path?
@setPath options.domain, options.path
else
@setPath options.path
@onError options.onError
@onRedirect options.onRedirect
@onDisplay options.onDisplay
@_waiting_for_ready.resolve()
onRedirect: (fn) ->
$(@).on("redirect", fn) if fn?
@
onError: (fn) ->
$(@).on("error", fn) if fn?
@
onDisplay: (fn) ->
$(@).on("display", fn) if fn?
@
# Explicity set the short url path (default: '/s/')
#
# Examples:
# setPath(); // => "/s/"
# setPath("/foo/"); // => "/foo/"
# setPath("http://foobar/", "/foo/"); // => "http://foobar/foo/"
# setPath("http://foobar", "bar.html"); // => "http://foobar/bar.html"
setPath: (domain,path) ->
if path?
domain = "#{domain}/" unless domain[domain.length-1] is '/'
path = path.substring(1) if path[0] is '/'
@path = "#{domain}#{path}"
else if domain?
@path = domain
else
@path = "/s/"
@
# Abstact function for testing
@redirectTo: (url) -> window.location.href = url
# Atempt to redirect the browser
loadLocation: (id) ->
url = @data[id]
if url?
e = $.Event("redirect")
$(@).triggerHandler $.extend(e, @buildOutputItem(id, url))
setTimeout ( => ShortUrl.redirectTo url ), 10
return true
else
$(@).triggerHandler
type: "error"
id: id
return false
# Util function to make output data
buildOutputItem: (id, url) ->
return {
id: id
url: url
short_path: "#{@path}##{id}"
}
# Build data then call output event callback
output: ->
output_data = []
output_data.push @buildOutputItem(id,url) for id,url of @data
e = $.Event("display")
e.items = output_data
$(@).triggerHandler e
# Abstact function for testing
@getHash: -> window.location.hash
# Check if requested a redirect or a list
# If you have a hash value in the URL then it will attempt to redirect to
# that short url. Otherwise it will output the list on the page.
checkUrl: ->
return false unless @_waiting_for_all_done.isResolved()
id = ShortUrl.getHash()?.substring(1) or null
if id?.length > 1
@loadLocation id
else
setTimeout ( => @output() ), 10
return true
# Register that checking, redirecting or outputing is ready after all
# asynchronous tasks are finished.
ready: ->
@_waiting_for_ready.resolve()
return
if module?
# CommonJS
module.exports = ShortUrl
else
# Attach to global object (not CommonJS)
@ShortUrl = ShortUrl
// Generated by CoffeeScript 1.3.3
(function() {
var $, ShortUrl;
$ = jQuery;
ShortUrl = (function() {
function ShortUrl(json_url, options) {
var _this = this;
if (arguments.length === 1 && typeof json_url !== "string") {
options = json_url;
json_url = options.json;
}
this.data = null;
this._waiting_for_ready = $.Deferred();
this._loading_JSON = $.getJSON(json_url).done(function(data) {
_this.data = data;
});
this._waiting_for_all_done = $.when(this._waiting_for_ready, this._loading_JSON).done(function() {
return _this.checkUrl();
});
this.setPath();
if (options != null) {
if ((options.domain != null) && (options.path != null)) {
this.setPath(options.domain, options.path);
} else {
this.setPath(options.path);
}
this.onError(options.onError);
this.onRedirect(options.onRedirect);
this.onDisplay(options.onDisplay);
this._waiting_for_ready.resolve();
}
}
ShortUrl.prototype.onRedirect = function(fn) {
if (fn != null) {
$(this).on("redirect", fn);
}
return this;
};
ShortUrl.prototype.onError = function(fn) {
if (fn != null) {
$(this).on("error", fn);
}
return this;
};
ShortUrl.prototype.onDisplay = function(fn) {
if (fn != null) {
$(this).on("display", fn);
}
return this;
};
ShortUrl.prototype.setPath = function(domain, path) {
if (path != null) {
if (domain[domain.length - 1] !== '/') {
domain = "" + domain + "/";
}
if (path[0] === '/') {
path = path.substring(1);
}
this.path = "" + domain + path;
} else if (domain != null) {
this.path = domain;
} else {
this.path = "/s/";
}
return this;
};
ShortUrl.redirectTo = function(url) {
return window.location.href = url;
};
ShortUrl.prototype.loadLocation = function(id) {
var e, url,
_this = this;
url = this.data[id];
if (url != null) {
e = $.Event("redirect");
$(this).triggerHandler($.extend(e, this.buildOutputItem(id, url)));
setTimeout((function() {
return ShortUrl.redirectTo(url);
}), 10);
return true;
} else {
$(this).triggerHandler({
type: "error",
id: id
});
return false;
}
};
ShortUrl.prototype.buildOutputItem = function(id, url) {
return {
id: id,
url: url,
short_path: "" + this.path + "#" + id
};
};
ShortUrl.prototype.output = function() {
var e, id, output_data, url, _ref;
output_data = [];
_ref = this.data;
for (id in _ref) {
url = _ref[id];
output_data.push(this.buildOutputItem(id, url));
}
e = $.Event("display");
e.items = output_data;
return $(this).triggerHandler(e);
};
ShortUrl.getHash = function() {
return window.location.hash;
};
ShortUrl.prototype.checkUrl = function() {
var id, _ref,
_this = this;
if (!this._waiting_for_all_done.isResolved()) {
return false;
}
id = ((_ref = ShortUrl.getHash()) != null ? _ref.substring(1) : void 0) || null;
if ((id != null ? id.length : void 0) > 1) {
this.loadLocation(id);
} else {
setTimeout((function() {
return _this.output();
}), 10);
}
return true;
};
ShortUrl.prototype.ready = function() {
this._waiting_for_ready.resolve();
};
return ShortUrl;
})();
if (typeof module !== "undefined" && module !== null) {
module.exports = ShortUrl;
} else {
this.ShortUrl = ShortUrl;
}
}).call(this);
(function($) {
describe("ShortUrl", function() {
var ShortUrl;
if (typeof require !== "undefined" && require !== null) {
ShortUrl = require("ShortUrl");
}
else {
ShortUrl = window.ShortUrl;
}
beforeEach(function() {
this.test_loading_JSON = $.Deferred()
spyOn($, "getJSON").andReturn(this.test_loading_JSON.promise());
spyOn(ShortUrl, "redirectTo"); // Prevent testing from causing a redirect.
});
describe("#constructor", function() {
var test_constructor = function() {
it("should define @path", function() {
expect( this.test.path ).toBeDefined();
expect( this.test.path ).toEqual(jasmine.any(String));
});
it("should deffer when loading JSON", function() {
expect( this.test._loading_JSON.state() ).toBe("pending");
});
it("should deffer when waiting for all done", function() {
expect( this.test._waiting_for_all_done.state() ).toBe("pending");
});
};
describe("invocation with path argument", function() {
beforeEach(function() {
this.test = new ShortUrl("foobar");
});
test_constructor();
it("should deffer when waiting for ready", function() {
expect( this.test._waiting_for_ready.state() ).toBe("pending");
});
});
describe("invocation with path and options", function() {
beforeEach(function() {
this.test = new ShortUrl("foobar", {});
});
test_constructor();
it("should not deffer when waiting for ready", function() {
expect( this.test._waiting_for_ready.state() ).toBe("resolved");
});
});
describe("invocation with only options", function() {
beforeEach(function() {
this.test = new ShortUrl({});
});
test_constructor();
it("should not deffer when waiting for ready", function() {
expect( this.test._waiting_for_ready.state() ).toBe("resolved");
});
});
});
describe("AJAX done", function() {
beforeEach(function() {
this.test = new ShortUrl("foobar");
spyOn(this.test, "checkUrl");
});
it("should get JSON from path argument", function() {
expect( $.getJSON ).toHaveBeenCalledWith("foobar");
});
it("should set the data", function() {
this.test_loading_JSON.resolve("test_data");
expect( this.test.data ).toBe("test_data");
});
it("should not call checkUrl when not ready", function() {
this.test_loading_JSON.resolve("test_data");
expect( this.test.checkUrl ).not.toHaveBeenCalled();
});
it("should call checkUrl when ready", function() {
this.test._waiting_for_ready.resolve();
this.test_loading_JSON.resolve("test_data");
expect( this.test.checkUrl ).toHaveBeenCalled();
});
});
describe("#onRedirect", function() {
beforeEach(function() {
this.test = new ShortUrl();
});
it("should be chainable", function() {
expect( this.test.onRedirect() ).toEqual(this.test);
});
});
describe("#onError", function() {
beforeEach(function() {
this.test = new ShortUrl();
});
it("should be chainable", function() {
expect( this.test.onError() ).toEqual(this.test);
});
});
describe("#onDisplay", function() {
beforeEach(function() {
this.test = new ShortUrl();
});
it("should be chainable", function() {
expect( this.test.onDisplay() ).toEqual(this.test);
});
});
describe("#setPath", function() {
beforeEach(function() {
this.test = new ShortUrl();
});
it("should provide a default", function() {
this.test.setPath();
expect( this.test.path ).toEqual(jasmine.any(String));
});
it("should accept one parameter", function() {
this.test.setPath("foo");
expect( this.test.path ).toBe("foo");
});
it("should accept two parameters", function() {
this.test.setPath("foo", "bar");
expect( this.test.path ).toContain("foo");
expect( this.test.path ).toContain("bar");
});
it("should concate two parameters with a '/'", function() {
this.test.setPath("foo", "bar");
expect( this.test.path ).toBe("foo/bar");
this.test.setPath("foo/", "bar");
expect( this.test.path ).toBe("foo/bar");
this.test.setPath("foo", "/bar");
expect( this.test.path ).toBe("foo/bar");
this.test.setPath("foo/", "/bar");
expect( this.test.path ).toBe("foo/bar");
});
it("should be chainable", function() {
expect( this.test.setPath() ).toEqual(this.test);
});
});
describe("#redirectTo (static)", function() {
it("needs no test");
});
describe("#loadLocation", function() {
beforeEach(function() {
jasmine.Clock.useMock();
ShortUrl.redirectTo.reset();
this.test = new ShortUrl();
this.test.data = {"test_id":"test_url"};
});
it("should return true when id is found in data", function() {
expect( this.test.loadLocation("test_id") ).toBeTruthy();
});
it("should redirect to proper url", function() {
this.test.loadLocation("test_id");
expect( ShortUrl.redirectTo ).not.toHaveBeenCalled();
jasmine.Clock.tick(10);
expect( ShortUrl.redirectTo ).toHaveBeenCalledWith("test_url");
});
it("should return false when id is not found in data", function() {
expect( this.test.loadLocation("test_bad_id") ).toBeFalsy();
});
describe("'redirect' event", function() {
beforeEach(function() {
this.event_callback = jasmine.createSpy("onRedirect");
$(this.test).on("redirect", this.event_callback);
this.test.loadLocation("test_id");
});
it("should trigger when id is found in data", function() {
expect( this.event_callback ).toHaveBeenCalled();
});
describe("passed in event object", function() {
beforeEach(function() {
this.result = this.event_callback.mostRecentCall.args[0];
});
it("should have id property", function() {
expect( this.result.id ).toBeDefined();
expect( this.result.id ).toEqual(jasmine.any(String));
});
it("should have url property", function() {
expect( this.result.url ).toBeDefined();
expect( this.result.url ).toEqual(jasmine.any(String));
});
it("should have short_path property", function() {
expect( this.result.short_path ).toBeDefined();
expect( this.result.short_path ).toEqual(jasmine.any(String));
});
});
});
describe("'error' event", function() {
beforeEach(function() {
this.event_callback = jasmine.createSpy("onError");
$(this.test).on("error", this.event_callback);
this.test.loadLocation("test_bad_id");
});
it("should trigger when id is not found in data", function() {
expect( this.event_callback ).toHaveBeenCalled();
});
describe("passed in event object", function() {
beforeEach(function() {
this.result = this.event_callback.mostRecentCall.args[0];
});
it("should have id property", function() {
expect( this.result.id ).toBeDefined();
expect( this.result.id ).toEqual(jasmine.any(String));
});
});
});
});
describe("#buildOutputItem", function() {
beforeEach(function() {
this.test = new ShortUrl({
json: "test_json",
path: "test_path"
});
this.test_id = "test_id";
this.test_url = "test_url";
});
it("should return an object", function() {
expect( this.test.buildOutputItem(this.test_id, this.test_url) ).toEqual(jasmine.any(Object));
});
describe("return object", function() {
beforeEach(function() {
this.result = this.test.buildOutputItem(this.test_id, this.test_url);
});
it("should have 'id' property", function() {
expect( this.result.id ).toBeDefined();
expect( this.result.id ).toBe("test_id");
});
it("should have 'url' property", function() {
expect( this.result.url ).toBeDefined();
expect( this.result.url ).toBe("test_url");
});
it("should have 'short_url' property", function() {
expect( this.result.short_path ).toBeDefined();
expect( this.result.short_path ).toContain("test_path");
expect( this.result.short_path ).toContain("#test_id");
});
});
});
describe("#output", function() {
beforeEach(function() {
this.display_callback = jasmine.createSpy("display_callback");
this.test = new ShortUrl();
this.test.onDisplay(this.display_callback);
this.test.data = {
"test_id1": "test_url1",
"test_id2": "test_url2",
"test_id3": "test_url3",
"test_id4": "test_url4"
};
this.test.output();
});
it("should trigger 'display' event", function() {
expect( this.display_callback ).toHaveBeenCalled();
});
describe("passed in event object", function() {
beforeEach(function() {
this.result = this.display_callback.mostRecentCall.args[0];
});
it("should have 'items' property as an array", function() {
expect( this.result.items ).toBeDefined();
expect( this.result.items ).toEqual(jasmine.any(Array));
});
describe("'items' array", function() {
it("should have 'id' property", function() {
expect( this.result.items[0].id ).toBeDefined();
expect( this.result.items[0].id ).toEqual(jasmine.any(String));
});
it("should have 'url' property", function() {
expect( this.result.items[0].url ).toBeDefined();
expect( this.result.items[0].url ).toEqual(jasmine.any(String));
});
it("should have 'short_path' property", function() {
expect( this.result.items[0].short_path ).toBeDefined();
expect( this.result.items[0].short_path ).toEqual(jasmine.any(String));
});
});
});
});
describe("#checkUrl", function() {
beforeEach(function() {
jasmine.Clock.useMock();
this.test = new ShortUrl();
spyOn(ShortUrl, "getHash");
spyOn(this.test, "loadLocation");
spyOn(this.test, "output");
this.resolve = function() {
this.test_loading_JSON.resolve("test_data");
this.test._waiting_for_ready.resolve();
};
});
it("should return false when not ready", function() {
expect( this.test.checkUrl() ).toBeFalsy();
});
it("should load location when hash has id", function() {
this.resolve();
ShortUrl.getHash.andReturn("#test_id");
this.test.checkUrl();
expect( this.test.output ).not.toHaveBeenCalled();
expect( this.test.loadLocation ).toHaveBeenCalledWith("test_id");
});
it("should output when hash is null", function() {
this.resolve();
ShortUrl.getHash.andReturn("");
this.test.checkUrl();
expect( this.test.loadLocation ).not.toHaveBeenCalled();
expect( this.test.output ).not.toHaveBeenCalled();
jasmine.Clock.tick(10);
expect( this.test.loadLocation ).not.toHaveBeenCalled();
expect( this.test.output ).toHaveBeenCalled();
});
});
describe("#ready", function() {
beforeEach(function() {
this.test = new ShortUrl("foobar");
spyOn(this.test, "checkUrl");
});
it("should not call checkUrl when AJAX not done", function() {
this.test._waiting_for_ready.resolve();
expect( this.test.checkUrl ).not.toHaveBeenCalled();
});
it("should call checkUrl when AJAX done", function() {
this.test_loading_JSON.resolve("test_data");
this.test._waiting_for_ready.resolve();
expect( this.test.checkUrl ).toHaveBeenCalled();
});
});
});
})(jQuery);
@sukima
Copy link
Author

sukima commented Feb 24, 2013

I realized the first versions were bugged. Err rather badly designed. Here is my latest redesigned using TDD to develop it. To see the tests run check them out live.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment