Skip to content

Commit

Permalink
Add support for client-side URL generation and some bugfixes
Browse files Browse the repository at this point in the history
  • Loading branch information
ckknight committed Apr 17, 2011
1 parent f93266c commit 92e8e08
Show file tree
Hide file tree
Showing 6 changed files with 825 additions and 133 deletions.
80 changes: 80 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@
incoming URL matches the prefix will cut down lookups by a significant amount in the general case (varying from app
to app). Not having to traverse all the `/pages/` routes for something that we know is under `/forums/` removes a
significant amount of work.

* Client-side URL generation

Escort provides a way to serialize its URL structure such that a client-side library can interpret that JSON data
and be able to generate URLs. This is extremely handy if you have a web app where you generate HTML rather than
leave it up to the server.

The `escort-client.js` is provided for you that can be used in the browser.

## Hello, world!

Expand Down Expand Up @@ -312,6 +320,9 @@
toUrl: function (value) {
// return a String
return encodeURIComponent(value);
},
serialize: function () {
return { type: "customName" };
}
}

Expand All @@ -333,6 +344,9 @@
},
toUrl: function (value) {
return value ? trueName : falseName;
},
serialize: function () {
return { type: "bool" };
}
};
};
Expand Down Expand Up @@ -671,6 +685,72 @@
* `routing.get` is used instead of `app.get`.
* `url` is provided to `dynamicHelpers`. This is optional, but nice inside views.

## Client-side URL generation

First, you'll need to serialize the URL structure of your webapp. This can be done at any point in your app's
lifecycle, even in its own exposed route, as long as it occurs after configuration.

As you may notice, the URL generation API is the exact same once the `url` object has been created.

### In-development example
For development, it may be handy to have your URL JSON dump accessible by its own route, but once you go into
production/staging, I strongly recommend placing the serialized dump directly into your client javascript files.

Node.js code

connect(
escort(function() {
this.get("root", "/", function(req, res) {
res.end("GET /");
});
this.get("post", "/{post}", function(req, res, params) {
res.end("GET /" + params.post);
});
if (process.env.NODE_ENV !== "production") {
// we only want to expose this during development
var serialize = this.serialize;
this.get("routeExport", "/routes.js", function(req, res) {
res.writeHead(200, {"Content-Type", "text/javascript"});
res.end("window.url = escortClient.generateUrlObject(" + JSON.stringify(serialize()) + ")");
});
}
})
).listen(3000);

Browser HTML code

<script src="/static/scripts/escort-client.js"></script>
<script src="/routes.js"></script>
<script>
url.root() === "/";
url.post("hey") === "/hey";
url.post({ post: "hey" }) === "/hey";
</script>

### Production example
You'll actually want to concatenate all your scripts as well as minify them when launching your production app, but
I'm leaving that part out for clarity.

The Node.js code is the same as above, since `/routes.js` is not available during production.

Browser Javascript code (url-routes.js)

// sticking this on the global window object is probably a bad idea.
window.url = escortClient.generateUrlObject(/* paste your blob into here */);

Browser HTML code

<script src="/static/scripts/escort-client.js"></script>
<script src="/static/scripts/url-routes.js"></script>
<script>
url.root() === "/";
url.post("hey") === "/hey";
url.post({ post: "hey" }) === "/hey";
</script>

## Running Tests

first:
Expand Down
222 changes: 222 additions & 0 deletions lib/escort-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*!
* Escort client
* Copyright(c) 2011 Cameron Kenneth Knight
* MIT Licensed
*/

/*jshint evil: true*/

(function (exports, undefined) {
"use strict";

var has = Object.prototype.hasOwnProperty;
var slice = Array.prototype.slice;

/**
* Generate a function that returns a static URL.
*
* @param {String} the static path
* @return {Function} A function which returns the path passed in.
* @api private
*
* @example generateStaticUrlFunction("/forums")() === "/forums";
*/
var generateStaticUrlFunction = function (path) {
return function () {
return path;
};
};

/**
* Dynamically create a url-generation function.
*
* @param {Object} route A descriptor for the route in question
* @param {Object} converters A map of converter name to converter factory.
* @return {Function} A function which will generate a URL.
* @api private
*
* @example generateUrlFunction({ literals: ["/prefix"], params: [ { name: "name", type: "string" } ] })({name: "hey"}) === "/prefix/hey"
*/
var generateDynamicUrlFunction = function (route, converters) {
var literals = route.literals;
var params = route.params;

var conv = [];

var fun = "";
fun += "var generate = function (params) {\n";
fun += " if (arguments.length === 1 && typeof params === 'object' && params.constructor !== String) {\n";
fun += " return ";
fun += JSON.stringify(literals[0]);
for (var i = 0, len = params.length; i < len; i += 1) {
fun += "+converters[";
fun += i;
fun += "](params[";
fun += JSON.stringify(params[i].name);
fun += "])";
if (literals[i + 1]) {
fun += "+";
fun += JSON.stringify(literals[i + 1]);
}

var paramType = params[i].type;
if (!has.call(converters, paramType)) {
throw new Error("Unknown converter: " + paramType);
}

var converter = converters[paramType];
if (!converter) {
throw new Error("Misconfigured converter: " + paramType);
}

conv.push(converter(params[i]));
}
fun += ";\n";
fun += " }\n";
fun += " return generate({";

for (i = 0, len = params.length; i < len; i += 1) {
if (i > 0) {
fun += ", ";
}
fun += JSON.stringify(params[i].name);
fun += ":arguments[";
fun += i;
fun += "]";
}

fun += "});\n";
fun += "};\n";
fun += "return generate;\n";
return new Function("converters", fun)(conv);
};

/**
* Generate a URL function based on the provided routes.
*
* @param {Array} routes An array of route descriptors
* @param {Object} converters A map of type to converter factory.
* @return {Function} A function that will generate a URL, or null if routes is blank.
* @api private
*/
var generateUrlFunction = function (routes, converters) {
var staticRoute, dynamicRoute;
// we traverse backwards because the beginning ones take precedence and thus can override.
for (var i = routes.length - 1; i >= 0; i -= 1) {
var route = routes[i];

if (route.path) {
staticRoute = route.path;
} else {
dynamicRoute = route;
}
}

if (dynamicRoute) {
dynamicRoute = generateDynamicUrlFunction(dynamicRoute, converters);
}

if (staticRoute) {
staticRoute = generateStaticUrlFunction(staticRoute);
if (dynamicRoute) {
// this can occur if the url is like "/posts" and "/posts/page/{page}"
return function () {
if (arguments.length === 0) {
return staticRoute();
} else {
return dynamicRoute.apply(this, slice.call(arguments, 0));
}
};
} else {
return staticRoute;
}
} else {
if (dynamicRoute) {
return dynamicRoute;
} else {
return null;
}
}
};

/**
* A map of default converters.
* This consists of "string", "path", "int", and "any".
*
* @api private
*/
var defaultConverters = (function () {
var defaultConverters = {};

defaultConverters.string = function (param) {
return encodeURIComponent;
};
defaultConverters.any = defaultConverters.string;

var pathConverter = function (value) {
var segments = String(value).split("/");
for (var i = segments.length - 1; i >= 0; i -= 1) {
segments[i] = encodeURIComponent(segments[i]);
}
return segments.join("/");
};
defaultConverters.path = function (param) {
return pathConverter;
};

defaultConverters.int = function (param) {
var fixedDigits = param.fixedDigits;
if (fixedDigits) {
return function (value) {
var result = (Math.floor(value) || 0).toString();

var numMissing = fixedDigits - result.length;
var prefix = "";
while (numMissing > 0) {
prefix += "0";
numMissing -= 1;
}
return prefix + result;
};
} else {
return function (value) {
return (Math.floor(value) || 0).toString();
};
}
};

return defaultConverters;
}());

/**
* Generate a map of route name to URL generation function.
*
* @param {Object} data The serialized route data from Escort.
* @param {Object} options An options object. Can contain "converters", which can be used to override or add custom converters.
* @return {Object} A map of route name to URL generation function.
*/
var generateUrlObject = exports.generateUrlObject = function (data, options) {
if (!options) {
options = {};
}

var url = {};
var converters = options.converters || {};
for (var key in defaultConverters) {
if (has.call(defaultConverters, key) && !has.call(converters, key)) {
converters[key] = defaultConverters[key];
}
}

for (key in data) {
if (has.call(data, key)) {
var func = generateUrlFunction(data[key], converters);
if (func) {
url[key] = func;
}
}
}

return url;
};
}(exports || (this.escortClient = {})));
Loading

0 comments on commit 92e8e08

Please sign in to comment.