Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

A stab at solving the single-page architecture problem for Javascript+your_server_here applications

branch: master
readme.mdown

Spah: Build single-page apps without breaking the web.

Introduction

WARNING: EPIC CONSTRUCTION YARD AHEAD. DO NOT BUILD ANYTHING WITH THIS UNTIL THIS MESSAGE DISAPPEARS.

Spah is a toolkit for building dynamic, single-page web apps without breaking the features supported by regular HTML pages - such as deep-linking, bookmarking, scraping and indexing.

  • Be a good web citizen. With Spah, every URL in your site may be indexed, bookmarked, shared, syndicated and CURLed.
  • Progressively enhance, gracefully degrade. Users with modern browsers get the full rich javascript experience. Users with old browsers get to surf like its 1999. Everyone gets to use your app.
  • Do away with code duplication. Spah solves the difficult problems of shared client/server templating and state management, leaving you to work on your app.
  • Use the tools you like. Spah is not a Rails-alike MVC/ORM framework - its a view layer for your application and nothing more. It doesn't care what you use to do your routing, your validations or your persistence. Nor does it stop you creating whatever futuristic client-side stuff you have in mind.

Core Concepts

The most important concept in Spah is the state. The state is a Javascript Object that describes your UI at a given point in time - which tabs are active, lists of model objects, user preferences, saved form data, that sort of thing.

To make the state object useful, we need some way of making the UI correctly reflect the desired state. To this end, we have what Spah refers to as the blueprint.

The blueprint is two things. One, it contains the raw HTML for your application. Two, it contains instructions for how that HTML should be modified to reflect changes in the state. These instructions are each made up of an action and a condition - such as showing an element only if the user is logged in, or populating an element from a template if the state contains the data required to do so.

Sticking those concepts together; responding to requests with Spah involves creating or modifying a state object, and applying it to a blueprint. All of the conditions in your blueprint are checked, and the appropriate actions are run to produce a fully-populated HTML document that properly reflects your state.

The trick is that this rendering process can be performed by either the client or the server. When a user first loads a URL within your app, this action can (and should) be performed on the server side to deliver some proper HTML. This initial request is called a cold request. Once the user has your app loaded, the rendering process can take place over the wire as state updates are returned in JSON format to the client, which then does the work of updating your markup - a warm request.

No code duplication. Shared templates. Rich, stateful UI. And your pages still have all the features of a good-old linkable, bookmarkable, scraper-friendly HTML document.

What makes Spah

Spah is really a small set of utilities that taste great together. Here's what you get:

  • A query system named SpahQL for working with states. Think of it like jQuery, but for dealing with object data instead of DOM elements.
  • A runner for your blueprints. It can apply any given state to a blueprint to produce markup reflecting that state.
  • Some basic actions to get you started making blueprints.
  • Server-side app helpers to help you keep your controller/responder code nice and brief.
  • A client-side navigation manager that turns navigational actions such as link clicks and form submissions into async requests to the server, which can then modify the state.
  • A client-side history manager that allows the user to move forward and back between pages in your app without refreshing the entire page. You can also define which parts of the state should be persisted across the entire history.

SpahQL

Getting started

Think of SpahQL like jQuery, but instead of handling DOM elements, it handles JSON data. Instead of CSS selectors, you use SpahQL queries. It's a querying system for JSON data, aware of hashes, arrays, strings and booleans.

Using SpahQL starts out with you putting your data into a SpahQL Database. The State is itself a SpahQL database, and you can manipulate it accordingly.

Let's start out with an example - the state for a basic Twitter app UI.

var data = {
  "user": {
  "logged_in": true,
    "name": "John Doe",
    "handle": "johndoe",
            "avatar": {
                    "small": "https://myapp.com/avatar-small/johndoe.png",
                    "large": "https://myapp.com/avatar-large/johndoe.png"
                }
        }
    },
    "draft_status": "The meaning of life is",
    "active_tab": "timeline",
    "timeline": [
        {
            "type": "status",
            "status": "FFFFFFFUUUUUUUUUUUU",
            "user": {
                "name": "Rage Guy",
                "handle": "rageguy",
                "avatar": {
                    "small": "https://myapp.com/avatar-small/f7u12.png",
                    "large": "https://myapp.com/avatar-large/f7u12.png"
                }
            }
        },
        ...
    ],
    "mentions": null
    "direct_messages": null
}

In this state we've got the user's profile available to us for display, we know that the "timeline" tab is open and populated with some tweets, and we know that the user hasn't loaded any mentions or direct messages just yet. We also know that the user has typed something into the status field but has not yet saved it. We'll be using this example data below to explore SpahQL's capabilities.

To start using this data with SpahQL, we need to put it in a SpahQL database:

var db = Spah.SpahQL.db(data);

Selecting data

Now that we've got a SpahQL Database assigned to the db variable, we can start to pull data from it using SpahQL selection queries. We call the db object the root.

The query syntax is a little like XPath. Every item in your database can be considered to have a unique path, and you can query for that path specifically, or perform more advanced actions such as recursion and filtering.

To select items from the database, use the select method. This will return a new SpahQL object containing your results.

var user = db.select("/user");
user.length; //-> 1
user.value(); //-> {"logged_in": true, "name": "John Doe", "handle": "johndoe" ... }

In the above example, we queried for the path /user which pulled the key "user" from the data. We can also chain keys together:

var avatar_large = db.select("/user/avatar/large");
avatar_large.value(); //-> "https://myapp.com/avatar-large/johndoe.png"

The select method returns a SpahQL object, so we can scope queries to results we already have:

var avatars = db.select("/user/avatar");
var avatar_large = avatars.select("/large")
avatar_large.value(); //-> "https://myapp.com/avatar-large/johndoe.png"

Much like XPath, SpahQL supports recursion with a double-slash anywhere in the path. To find all avatars, no matter where they appear in the state, we'd do this:

var all_avatars = db.select("//avatar");

This will return a set containing multiple results from several places in the original db object:

all_avatars.length; //-> 2
all_avatars.paths(); //-> ["/user/avatar", "/timeline/0/user/avatar"]
all_avatars.values(); //-> ["https://myapp.com/avatar-large/johndoe.png", "https://myapp.com/avatar-large/f7u12.png"]

Notice that the second path returned by all_avatars.paths() starts with /timeline/0. The key 0 refers to the first item an array, and this is how SpahQL handles arrays in general.

var second_tweet_in_timeline = db.select("/timeline/1");

The * (asterisk) character works as a wildcard in paths, allowing you to pull every value from an object without recursion. To grab all tweets from the timeline:

var timeline_tweets = db.select("/timeline/*")
timeline_tweets.paths(); //-> ["/timeline/0", "/timeline/1", "/timeline/2", ...]

We can also filter the results at any point in the query. Here's an example where we filter the timeline for all tweets from a given user, and take the actual text of each tweet as the value:

var tweets_from_bob = db.select("/timeline/*[/user/handle == 'bob']/status");

In the above, we took all objects from the timeline (/timeline/*) and filtered the list with an assertion ([/user/handle == 'bob']) - then we picked the tweet text from the remaining items (/status).

Note that the contents of the filter were scoped to the object being filtered. This is fine for basic cases, but what if you need to compare the handle of each user to something else stored in the database?

Let's add a field to the root object, for handling searches:

db.set("show_only_from_handle", "coolguy99");
db.select("/show_only_from_handle").value(); //-> "coolguy99"

Now to filter the tweets based on this new bit of data, we can use the $ (dollar sign) to scope any part of a filter to the root data:

var tweets_filtered = db.select("/timeline/*[/user/handle == $/show_only_from_handle]/status");

And voila, we've filtered one part of the state based on the contents of another, and selected some data from within.

Filters may be chained together to produce logical AND gates. Here we'll pull all users who have both a large and a small avatar available:

var users_with_both_avatars = db.select("//user[/avatar/small][/avatar/large]");

Modifying data

SpahQL objects provide a set of methods for modifying their data values. SpahQL always maintains strict pointer equality to the original database data, so be aware that calling these methods will result in alterations being made directly to the object you originally passed to Spah.SpahQL.db(your_data).

Most destructive methods apply only to the first item in a SpahQL result set, and have a partner method which applies to the entire set.

For instance, here are the replace and replaceAll methods - just two of the many methods SpahQL offers for easy data editing:

db.select("//user").replace("This string will replace the first user in the set");
db.select("//user").replaceAll("NO USERS FOR YOU");

Listening for changes

SpahQL objects are able to dispatch events when certain paths are changed, using an event-bubbling model similar to the HTML DOM.

db.listen(function(db, path, subpaths) {
    console.log("Something in the DB was modified. Modified paths: "+subpaths.join(","));
})

The above code listens for changes to the database as a whole. You may scope listeners to certain paths using either of the following methods:

db.listen("/user", function(user, path, subpaths) {
    console.log("User was modified: ", user.value());
})
db.select("/user").listen(function(user, path, subpaths) {
    console.log("User was modified: ", user.value());
});

The callback function always receives three arguments; result, a SpahQL object containing the data found at the path on which you registered the listener, path, the path on which you registered the listener (allowing you to assign a single listener function cabable of responding to multiple changes), and subpaths, an array of paths within the path that were detected as having been modified.

db.listen("/user", function(user, path, subpaths) {
    console.log("Subpaths modified on user ("+path+"): ", subpaths.join(","));
});
db.select("/user").set({handle: "modified-handle", newobject: {foo: "bar"}});
// -> prints the following to console:
// Subpaths modified on user (/user): /handle,/newobject,/newobject/foo

Properties

Properties are like imaginary paths on objects in your database. They allow you to make more interesting assertions about your data. Each property uses the .propertyName syntax and may be used in any path query:

Use .type When you need to know what type of data is at any given path. Returns the object type as 'object', 'array', 'string', 'number', 'boolean' or 'null':

results = db.select("/timeline/.type");
results.value() //-> 'Array'

The type property lets you query for all paths matching more precise criteria:

// Find all arrays everywhere. 
var all_arrays = db.select("//[/.type == 'array']")

Use .size when you need to know about the amount of data in an object. Returns the object's size if it is a String (number of characters), Array (number of items) or Object (number of keys):

var timeline_is_empty = db.assert("/timeline/.size < 1"); //-> false, timeline contains items

Use .explode when you need to break an object down into components. Returns the object broken into a set that may be compared to other sets. Strings are exploded into a set of characters. Arrays and objects do not support this property - use the wildcard * character instead.

// Does the user's handle contain a, b and c?
results = db.assert("/user/handle/.explode }>{ {'a','b','c'}")

Making assertions

We've already seen how assertion queries can be used as filters in selection queries. Assertion queries are also used heavily in Spah's HTML handling.

Since the entity on either side of the comparison operator could contain one or more results (or no results at all), all comparisons in SpahQL are set comparisons.

Assertions are run through the assert method on the state:

result = db.assert(myQuery) //-> true or false.

Assertions don't have to use comparisons:

db.assert("/user"); //-> true, since /user exists and has a truthy value
db.assert("/flibbertygibbet"); //-> false, since /flibbertygibbet doesn't exist, or is false or null

Much like selections, assertions can be scoped to a set of results you already have available:

db.select("/user").assert("/handle"); //-> true, since /user/handle exists

Comparisons

SpahQL's set arithmetic uses the following operators for comparing values. To learn how values are compared, see Object equality.

  • Set equality ==

    Asserts that both the left-hand and right-hand sets have a 1:1 relationship between their values. The values do not have to be in the same order.

  • Set inequality !=

    Asserts that the sets are not identical under the rules of the == operator.

  • Subset of }<{

    Asserts that the left-hand set is a subset of the right-hand set. All values present in the left-hand set must have a matching counterpart in the right-hand set.

  • Superset of }>{

    Asserts that the left-hand set is a superset of the right-hand set. All values present in the right-hand set must have a matching counterpart in the left-hand set.

  • Joint set }~{

    Asserts that the left-hand set contains one or more values that are also present in the right-hand set.

  • Disjoint set }!{

    Asserts that the left-hand set contains no values that are also present in the right-hand set.

  • Rough equality =~

    Asserts that one or more values from the left-hand set are roughly equal to one or more values from the right-hand set. See Object equality.

  • Greater than (or equal to) >= and >

    Asserts that one or more values from the left-hand set is greater than (or equal to) one or more values from the right-hand set.

  • Less than (or equal to) <= and <

    Asserts that one or more values from the left-hand set is less than (or equal to) one or more values from the right-hand set.

Literals

SpahQL does support literals - strings, integers, floats, true, false and null may all be used directly in SpahQL queries. Strings may use single or double quotes as you prefer.

Because all SpahQL comparisons compare sets to one another, all literals count as sets containing just one value.

As such, the following basic comparisons work just as you'd expect:

db.assert("/user/handle == 'johndoe'") //-> true
db.assert("//user/handle == 'johndoe'") //-> false. The left-hand set contains more than one item.

You may use set literals in SpahQL assertions.

A set literal is wrapped in {} mustaches:

db.assert("//user/handle }~{ {'johndoe', 'anotherguy'}") //-> true. The left set is a joint set with the right.

Set literals may combine numbers, strings, booleans and even selection queries:

// a set containing all the handles, plus one arbitrary one.
{"arbitrary_handle", //user/handle} 

Sets may not be nested - in the above example, SpahQL flattens the set literal to contain all the results of querying for //user/handle and one other value, "arbitrary_handle".

Ranges are also supported in set literals:

{"a".."c"} // a set containing "a", "b" and "c"
{"A".."Z"} // a set containing all uppercase letters
{"Aa".."Ac"} // a set containing "Aa", "Ab", "Ac"
{0..3} // a set containing 0, 1, 2 and 3.
{"a"..9} // COMPILER ERROR - ranges must be composed of objects of the same type.
{"a"../foo/bar} // COMPILE ERROR - ranges do not support path lookup.

Object equality

The equality of objects is calculated based on their type. Firstly, for two objects to be equal under strict equality (==) they must have the same base type.

  • Object equality: The objects being compared must contain the same set of keys, and the value of each key must be the same in each object. If the value is an object or an array, it will be evaluated recursively.
  • Array equality: The arrays must each contain the same values in the same order. If any value is an array or object, it will be evaluated recursively.
  • Number, String, Bool, null: The objects must be of equal type and value.

Under rough equality (=~) the rules are altered:

  • Strings are evaluated to determine if the left-hand value matches the right-hand value, evaluating the right-hand value as a regular expression e.g. "bar" =~ "^b" returns true but "bar" =~ "^a" returns false
  • Numbers are evaluated with integer accuracy only (using Math.floor, numeric.floor or an equivalent operation)
  • Arrays behave as if compared with the joint set operator.
  • Objects are roughly equal if both hashes contain one or more keys with the same corresponding values. Values are compared using strict equality.
  • Booleans and null are evaluated based on truthiness rather than exact equality. false =~ null is true but true =~ false is false.

When using inequality operators <, =<, >, >=:

  • Strings are evaluated based on alphanumeric sorting. "a" <= "b" returns true but "z" >= "a" returns false.
  • Numbers are evaluated, as you'd expect, based on their native values.
  • Arrays, Objects, Booleans, null are not compatible with these operators and will automatically result in false being returned.

SpahQL Strategies

Strategies are a mechanism provided by SpahQL allowing you to define a queue of asynchronous actions to be run in order against a SpahQL object, provided that the value of the query result matches the criteria you specify. Pattern-wise, they're somewhere between a macro and a stored procedure. Strategies are managed using the Strategiser class.

    var state = Spah.SpahQL.db({a: {aa: "a.aa.val", bb: "a.bb.val"}, b: {bb: "b.bb.val", cc: "b.cc.val"}});
    var strategiser = new Spah.SpahQL.Strategiser();

Strategies are objects which define a set of target paths, a condition which must be met for the strategy to run, and an action to take against the matched paths.

    // Add a strategy to the strategiser...
    strategiser.addStrategy(
        // which will take action on /aa and /b/cc, but only if the assertion "/b/bb" returns true
        {"paths": ["/aa", "/b/cc"], "if": "/b/bb"}, 
        // with a named category
        "reduce",
        // when triggered, the strategy will be called
        function(results, root, attachments, strategy) {
                // make changes to the matched results
                results.deleteAll();
                // signal that the strategiser can advance to the next strategy in the queue
                strategy.done();
        }
    );

Strategies must specify the key path or paths, a path or array of paths for the strategy to modify. Strategies may optionally use the key if or unless, containing a SpahQL assertion whose expectation must be met for this strategy to be included. When we execute the strategies against a target SpahQL object, path, paths, if and/or unless will be evaluated relative to the target.

Strategies also specify an action, a function containing the strategy's behaviour. It receives the arguments results, a SpahQL instance containing matches for the path, root, the original target SpahQL instance, attachments, an arbitrary object you may pass in when you execute the strategies, and strategy, an object containing flow control functions allowing you to signal that the strategy has completed.

Specifying multiple paths using the paths key is equivalent to registering multiple strategies each with the same expectation and action - the action function will be called once for each query specified in the paths array and calling strategy.done() will advance the queue to the next path in this strategy, or to the next strategy.

Execution is as follows:

strategiser.run(target, category, attachments, callback);

When applied to the above example:

    // Clone the State first to run the strategies without modifying the original
    // Run the strategies in the "reduce" category
    // Pass {foo: "bar"} as an attachment that will be available to all the strategies
    // Pass a callback function which will receive the modified SpahQL and the attachments
    strategiser.run(state.clone(), "reduce", {foo: "bar"}, function(clone, attachments) {
            console.log(clone.select("/aa").length()); //-> 0, as the above strategy deleted this value
    });

The Spah Server

The Spah Server is a Node.js library intended to deliver state updates to users over the wire. Spah doesn't include an HTTP server - you're free to pick your application framework.

The Spah Server is a view layer for your application. As discussed in Core Concepts, Spah seperates view handling into two steps - state modification and blueprint rendering. Each of these may be performed on either the client or the server. Warm requests require only that the state be modified and sent to the client as JSON, while cold requests require that the state be modified and rendered as HTML.

In a sense, Spah allows your server-side app to behave like a browser and boot your client-side application to the point of usefulness, before letting the client browser carry out the rest of the startup process. This is achieved by moving template logic to a place where both the client and the server can use it.

Install the server

npm install spah

Boot the server

A Spah Server is simple enough to initialise. Start out with some HTML. Put it on your file system, in mongo, wherever you like.

// If you use readFileSync during a request cycle,
// you will be haunted by a pigeon. During app startup
// is fine.
var html = require('fs').readFileSync("blueprint.html", "utf-8");

Now you just create a Spah Server:

var spah = require('spah');
// Lets specify a default state
var stateDefaults = {
    "active_tab": "timeline"
};
// Create a new StateServer instance
var stateServer = spah.createServer();

Configure the server

Spah doesn't know which HTTP stack you're using, so by extension it doesn't know how to work with your choice of HTTP Request and HTTP Response objects.

Spah only needs to know a few basic interactions, so we'll configure it here:

// Tell the server how to get the current UI state
// from an inbound request. This will vary depending
// on your choice of HTTP stack.
// 
// If this function returns a string, Spah will parse
// it as JSON before doing anything with it. If it
// returns an object, we'll use it as-is.
//
// The Spah Client defaults to attaching the current UI
// state to the query string, but you can customise that
// using client.attachStateToRequest. See the API docs
// for examples.
var stateServer.identifyStateFromRequest(function(request) {
    return request.param("spah_state", "{}");
});

// Tell the server how to tell if a request is warm
// or not. 
// 
// Much like getStateFromRequest (above), the method
// used to mark a request as "warm" - that is,
// as coming from an initialised Spah Client and not 
// requiring a full HTML render - can be customised
// on the Spah Client. The default is to use a custom
// Accept header to indicate the client's preferred format.
var stateServer.identifyWarmRequest(function(request) {
    return request.header("Accept") == "application/state+json";
});

The final step is to load an HTML Blueprint for Spah to work with when rendering HTML:

// Remember, using fs.readFileSync during a request will
// lead to you being haunted by a pigeon. During app boot
// is fine.
var html = require('fs').readFileSync("/path/to/layout.html", "utf-8");
stateServer.compileBlueprint(html, startServerWithBlueprint);

We haven't defined startServerWithBlueprint yet, but it's going to be used to kick off the app itself in the next example.

Responding to user requests

I'll assume Express is being used for this example, purely for sanity's sake. Let's take a look at building an action:

// This is the callback for our blueprint example above.
// it takes an error and the Spah.DOM.Blueprint instance
// you just created.
function startServerWithBlueprint(err, blueprint) {
    if(err) throw err;  

    // Got a blueprint, got a spah server.
    // Good to go.

    var express = require('express');
    var params = require('express-params');
    var app = express.createServer();
    params.extend(app);

    app.get("/", function(request, response, next) {

        // This is the homepage, so it re-applies the
        // defaults without changing other stuff.
        var state = stateServer.stateWith(
            // Pull the state from the params
            // the state server will parse this as JSON
            // if you hand it a string
            request.param("state", "{}"),
            // Apply the defaults to the user state,
            // restoring the app to the "home" state
            stateDefaults
        );

        var warm = request.param("state", null);
        stateServer.render(warm, state, function(err, contentType, content) {
            // "content" might be JSON or HTML
            if(err) throw err;
            response.header('Content-Type', contentType);
            response.send(content);
        });
    });

    // Open the floodgates
    app.listen(80);

}

The State Expander

We want populating the state to be clean - you shouldn't have to account for everything required by your UI in each response from your server - and most parts of the state simply won't be relevant unless you're responding to a cold request. For instance, rendering a page showing Twitter mentions for the user would require only that the state be populated with tweets for warm requests, but cold requests would require that the page be populated with other information - the user's follower count, for example.

The Spah Server solves the problem of overcomplicated controller code by providing state expanders, which are macros used to flesh out the state and populate it with data in the event that a full HTML render is required. The State Expander also allows you to enforce security rules by only populating the state with objects according by your app's security model.

Let's look at an example. We want to populate the state with some information about the user's Twitter profile when rendering HTML responses.

stateServer.addExpander(
    {"path": "/followers/count", "if": "/user_authenticated"},
    function(results, state, request, expander) {
        twitterUser.fetchFollowerCount(function(count) {
            console.log("setting "+results.paths().join(",")+" to "+ count);
            results.replaceAll(count);
            expander.done();
        }
    }
);

The example above checks that the SpahQL assertion /user_authenticated returns true, and if so, selects /followers/count from the state and runs the callback function against the returned result. If no "if" or "unless" condition is specified, the expander will run unconditionally. Note that both paths and if/unless are scoped to the root of your state. If you specify multiple paths, you are effectively registering multiple expanders each with the same condition and callback - the expander will execute against one path query at a time.

The callback receives as its arguments results, a SpahQL instance containing all matches for the specified path, state, the state object being queried, the request object, and the expander object which itself will contain a function done(value). In our callback example, we overwrite the value of /followers/count with some abitrary value and then call the done function to signal that the expander has finished fetching data, and that the next expander may be run. Expanders are run in serial fashion for data integrity reasons.

State Expanders are applied in the order in which they were registered via addExpander. Multiple expanders may be run on the same path. You may set expanders for multiple paths by using {paths: [path1, path2, pathN], "if": "/condition"}. If any given path returns more than one result (because it uses recursion, set literals or what have you), the results of the expander will be applied to all the selected results.

The Spah Client

TODO: In environments that support JS, the state in forms and links is replaced with the current state on submission/activation.

The Spah client handles things at the browser end. Its primary tasks are:

  1. Ensuring that links and forms are submitted asynchronously and that they attach the state when activated (you may also prevent some links and forms from acting asynchronously)
  2. Ensuring that async requests are submitted with a content-accept header of application/state+json, allowing the server-side application to determine that this is a warm request and should be responded to with an updated state
  3. Re-evaluating and processing any document logic whenever the state is modified
  4. Raising path-specific events whenever the state is modified

This is achieved by embedding template logic within the markup using HTML5 data attributes, thus making the same template logic available to both client and server. When the state is updated by a response from the server, elements with embedded display logic are re-evaluated automatically and the display updated accordingly.

You may also bind more advanced behaviours to changes in the state using jQuery responders and state queries

Install the client

Initialise the client

For cleanliness, Spah keeps all of its functions, classes and behaviour within the top level Spah object. Initialising Spah is simple:

 $(document).ready(function() { 
     window.stateClient = Spah.createClient();
 });

Configure the client

The State Reducer

Whenever the Spah Client intercepts a navigation action, such as submitting a form or following a link, the current state is attached to the request so that the server is able to make decisions. If your state contains model objects, this could get pretty large. Spah provides state reducers, macros which once registered are run against the state and remove unnecessary information before the state is sent to the server. The State Reducer uses the same basic SpahQL Strategies mechanism as the State Expander on the server side.

// Remove the contents of the "mentions" tree if it contains anything
stateClient.addReducer(
    {"path": "/mentions", "if": "/mentions/.length > 0"}, 
    function(mentions, root, attachments, strategy) {
            mentions.deleteAll();
            strategy.done();
    };
);

Spah provides some convience strategies for the most common reducer use cases - removing a list of keys from a set of paths, and removing everything except a list of keys from a set of paths.

Here's some examples:

// Remove everything from /mentions and /timeline
// except "id" keys, keeping the order and structure intact.
stateClient.addReducer(
    {"paths": ["/mentions", "/timeline"]}, 
    Spah.State.Strategies.keeper("//id")
);
// Remove everything from /mentions without deleting 
// /mentions altogether
stateClient.addReducer(
    {"path": "/mentions"}, 
    Spah.State.Strategies.remover("/*")
);
// Reduce a model, wherever it appears, to include only 
// type and id
stateClient.addReducer(
    {"path": "//[/type=='myModel']"}, 
    Spah.State.Strategies.keeper("/id", "/type")
);

Responding to state changes with Javascript

Forcing links and forms to load synchronously

To prevent Spah from adding asynchronous behaviour, add the data-async="false" attribute to the link or form element:

<a href="/login" data-async="false">Log in</a>

<form action="/login" method="POST" data-async="false">
  ...
</form>

Eager client state changes

HTML Blueprints

In a Spah application, document logic (show this, hide that, populate this with that) is moved away from in-place Erb views and into a place where both the client and the server will have access to it - the document itself. In doing this, we want to avoid doing anything silly like adding new tags or attributes that break HTML validity, or dirtying up your markup with billions of extra nested div elements.

Spah handles document logic with HTML5 data attributes. Elements may query the state and declare how they should be altered when the query result is returned. When cold-booting your application from the server, the document logic is evaluated and run in-place before sending the HTML down the wire - this is what allows Spah to respond to HTML requests with valid, useful documents. On the client side, updates to the state cause any embedded document logic to be re-evaluated automatically. All document logic is achieved using State Queries. Conditions such as if statements use truthiness queries, as seen in the state query documentation.

Spah provides the following operations on document logic:

Hide or show an element

Show/hide of an element is achieved by appending display: none; to the element's style attribute if it should be hidden.

<form class="add-tags" data-show-if="/user-authenticated">
  This form will only show if the query "/user-authenticated" returns true
</form>

<form class="add-tags" data-show-unless="/user-anonymous">
  An alternative way of expressing a similar query using 
  the "data-*-unless" syntax. All conditions support both 
  the -if and -unless syntaxes.
</form>

Add or remove element classes

Use the data-class-[classname]-if or -unless attribute to specify that a specific class should be appended to the element's class attribute.

<li class="item" data-class-current-if="/items[0]/important">
  This list item will have the class "important" if the first item in the 
  array at "/items" has the "important" property
</li>

Set element ID

Use the data-id-[id]-if or -unless to specify that an element should be given a new id under certain circumstances. If the condition causes Spah to write the ID on an element, Spah will automatically remove the ID from any other elements that previously possessed it.

<li class="item" data-id-current_item-if="/items.last/id == 3">
  This list item will have the id "current_item" if the last object 
  in the items array has the property "id" with value 3.
</li>

Stash and unstash element content

Stashing content allows you to render semantically-null empty elements into your document, with their content stowed in a data attribute for later use. Stashing only occurs if there is no content already stashed on the element - thus stashed content may be used for state-toggling. See example.

<div  class="new-message-notification" 
      data-stash-unless="/messages[/unread]/.size > 0">

      You have new messages!

      (This text will be moved into the "data-stashed-content" attribute 
      on the containing DIV element if the user has no new messages. 
      This way, your markup won't contain a hidden, but erroneous, 
      new message notification.)
</div>

<!-- When the content is stashed, the element will look like this: -->
<div  class="new-message-notification" 
      data-stash-unless="/messages[/unread]/.size > 0" 
      data-stashed-content="You%20have%20new%20messages%21....">
</div>

Populate element from a template

Spah can use shared Mustache templates to render state data into a containing element. The Populate modifier is added to your Blueprint by default and adds two methods for managing templates:

myBlueprint.addTemplate("users/listItem", templateString, "text/mustache");
myBlueprint.removeTemplate("users/listItem");

Once added, templates appear in the DOM as semantically-neutral script tags:

<script type="text/mustache" id="users/listItem">
  User's name: {{name}}
</script>

On the client side, the Spah client will automatically add a jQuery responder to the path referenced in the data-populate-with attribute, ensuring that the populated content is updated when the state is modified.

<ul class="users" data-populate-if="/users/.length > 0" 
                  data-populate-with="/users" 
                  data-populate-template="/views/users/single">

  This text will be replaced with the results of running the template contained
  in an element with ID "/views/users/single" through Mustache.js using data from
  the state at /users as the payload.

  If the data found at /users is an array, the array will be wrapped in an object 
  so that Mustache can make it available: {"items": [content, of, array]}
</ul>

Using multiple operations on a single element

All of the above operations may be combined on a single element. The order in which the operations will run is strictly defined in order of the type of operation. -if operations always run before -unless operations.

  1. data-show-if and data-show-unless
  2. data-class-[classname]-if, data-class-[classname]-unless
  3. data-id-[id]-if, data-id-[id]-unless
  4. data-stash-if, data-stash-unless
  5. data-populate-if, data-populate-unless

Advanced document logic example

Let's make an advanced example: We want a list of users to default to an "empty" state, becoming populated with users if there is a non-empty list of users in the state. If the user list in the state is emptied, then the "empty" state should be restored on the element

<ul class="users"
    data-class-empty-if="/users.length < 1">
    data-stash-if="/users.length > 0"
    data-populate-if="/users.length > 0"
    data-populate-with="/users"
    data-populate-template="/views/users/single">

    <li class="empty">
      No users found.
    </li>
</ul>

Because stash operations run before populate operations, the following chain of events will occur:

  1. The element will render in the "empty" state, with class "empty" applied.
  2. If the /users array is empty, it will remain in the empty state
  3. If the /users array becomes non-empty:
    1. The "No users found" element will be stashed
    2. The ul element will be populated using the template
    3. The "empty" class will be removed from the ul
  4. If the /users array reverts to an empty state:
    1. The "empty" class will be removed from the ul
    2. The "No users found" element will be unstashed into the ul, overwriting the rendered template content.

API

Example Node application

Something went wrong with that request. Please try again.