Updated to latest jsonld.js.
authorDave Longley <dlongley@digitalbazaar.com>
Mon, 16 Apr 2012 14:02:31 -0400
changeset 522 9335f39a3978
parent 521 2f7578142f6e
child 523 f3f4a5608495
Updated to latest jsonld.js.
playground/jsonld.js
--- a/playground/jsonld.js	Mon Apr 16 13:59:20 2012 -0400
+++ b/playground/jsonld.js	Mon Apr 16 14:02:31 2012 -0400
@@ -17,41 +17,47 @@
  *
  * @param input the JSON-LD object to compact.
  * @param ctx the context to compact with.
- * @param [optimize] true to optimize the compaction (default: false).
- * @param [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
+ * @param [options] options to use:
+ *          [strict] use strict mode (default: true).
+ *          [optimize] true to optimize the compaction (default: false).
+ *          [graph] true to always output a top-level graph (default: false).
+ *          [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
  * @param callback(err, compacted) called once the operation completes.
  */
 jsonld.compact = function(input, ctx) {
+  // get arguments
+  var options = {};
+  var callbackArg = 2;
+  if(arguments.length > 3) {
+    options = arguments[2] || {};
+    callbackArg += 1;
+  }
+  var callback = arguments[callbackArg];
+
   // nothing to compact
   if(input === null) {
     return callback(null, null);
   }
 
-  // get arguments
-  var optimize = false;
-  var resolver = jsonld.urlResolver;
-  var callbackArg = 2;
-  if(arguments.length > 4) {
-    optimize = arguments[2];
-    resolver = arguments[3];
-    callbackArg += 2;
+  // set default options
+  if(!('strict' in options)) {
+    options.strict = true;
   }
-  else if(arguments.length > 3) {
-    if(_isBoolean(arguments[2])) {
-      optimize = arguments[2];
-    }
-    else {
-      resolver = arguments[2];
-    }
-    callbackArg += 1;
+  if(!('optimize') in options) {
+    options.optimize = false;
   }
-  var callback = arguments[callbackArg];
+  if(!('graph') in options) {
+    options.graph = false;
+  }
+  if(!('resolver') in options) {
+    options.resolver = jsonld.urlResolver;
+  }
 
   // default to empty context if not given
   ctx = ctx || {};
 
   // expand input then do compaction
-  jsonld.expand(input, function(err, expanded) {
+  jsonld.expand(input, options, function(err, expanded) {
     if(err) {
       return callback(new JsonLdError(
         'Could not expand input before compaction.',
@@ -59,7 +65,7 @@
     }
 
     // merge and resolve contexts
-    jsonld.mergeContexts({}, ctx, function(err, ctx) {
+    jsonld.mergeContexts({}, ctx, options, function(err, ctx) {
       if(err) {
         return callback(new JsonLdError(
           'Could not merge context before compaction.',
@@ -68,14 +74,14 @@
 
       try {
         // create optimize context
-        if(optimize) {
-          var optimizeCtx = {};
+        if(options.optimize) {
+          options.optimizeCtx = {};
         }
 
         // do compaction
         input = expanded;
-        var compacted = new Processor().compact(ctx, null, input, optimizeCtx);
-        cleanup(null, compacted, optimizeCtx);
+        var compacted = new Processor().compact(ctx, null, input, options);
+        cleanup(null, compacted, options);
       }
       catch(ex) {
         callback(ex);
@@ -84,15 +90,20 @@
   });
 
   // performs clean up after compaction
-  function cleanup(err, compacted, optimizeCtx) {
+  function cleanup(err, compacted, options) {
     if(err) {
       return callback(err);
     }
 
-    // if compacted is an array with 1 entry, remove array
-    if(_isArray(compacted) && compacted.length === 1) {
+    // if compacted is an array with 1 entry, remove array unless
+    // graph option is set
+    if(!options.graph && _isArray(compacted) && compacted.length === 1) {
       compacted = compacted[0];
     }
+    // always use array if graph option is on
+    else if(options.graph && _isObject(compacted)) {
+      compacted = [compacted];
+    }
 
     // build output context
     ctx = _clone(ctx);
@@ -100,8 +111,8 @@
       ctx = [ctx];
     }
     // add optimize context
-    if(optimizeCtx) {
-      ctx.push(optimizeCtx);
+    if(options.optimizeCtx) {
+      ctx.push(options.optimizeCtx);
     }
     // remove empty contexts
     var tmp = ctx;
@@ -112,18 +123,22 @@
       }
     }
 
+    // remove array if only one context
+    var hasContext = (ctx.length > 0);
+    if(ctx.length === 1) {
+      ctx = ctx[0];
+    }
+
     // add context
-    if(ctx.length > 0) {
-      // remove array if only one context
-      if(ctx.length === 1) {
-        ctx = ctx[0];
-      }
-
+    if(hasContext || options.graph) {
       if(_isArray(compacted)) {
         // use '@graph' keyword
-        var kwgraph = _getKeywords(ctx)['@graph'];
+        var kwgraph = _compactIri(ctx, '@graph');
         var graph = compacted;
-        compacted = {'@context': ctx};
+        compacted = {};
+        if(hasContext) {
+          compacted['@context'] = ctx;
+        }
         compacted[kwgraph] = graph;
       }
       else if(_isObject(compacted)) {
@@ -144,29 +159,42 @@
  * Performs JSON-LD expansion.
  *
  * @param input the JSON-LD object to expand.
- * @param [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
+ * @param [options] the options to use:
+ *          [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
  * @param callback(err, expanded) called once the operation completes.
  */
 jsonld.expand = function(input) {
   // get arguments
-  var resolver = jsonld.urlResolver;
+  var options = {};
   var callback;
   var callbackArg = 1;
   if(arguments.length > 2) {
-    resolver = arguments[1];
+    options = arguments[1] || {};
     callbackArg += 1;
   }
   callback = arguments[callbackArg];
 
+  // set default options
+  if(!('resolver' in options)) {
+    options.resolver = jsonld.urlResolver;
+  }
+
   // resolve all @context URLs in the input
   input = _clone(input);
-  _resolveUrls(input, resolver, function(err, input) {
+  _resolveUrls(input, options.resolver, function(err, input) {
     if(err) {
       return callback(err);
     }
     try {
       // do expansion
-      var expanded = new Processor().expand({}, null, input);
+      var expanded = new Processor().expand({}, null, input, false);
+
+      // optimize away @graph with no other properties
+      if(_isObject(expanded) && ('@graph' in expanded) &&
+        Object.keys(expanded).length === 1) {
+        expanded = expanded['@graph'];
+      }
+      // normalize to an array
       if(!_isArray(expanded)) {
         expanded = [expanded];
       }
@@ -184,32 +212,27 @@
  * @param input the JSON-LD object to frame.
  * @param frame the JSON-LD frame to use.
  * @param [options] the framing options.
- * @param [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
+ *          [embed] default @embed flag (default: true).
+ *          [explicit] default @explicit flag (default: false).
+ *          [omitDefault] default @omitDefault flag (default: false).
+ *          [optimize] optimize when compacting (default: false).
+ *          [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
  * @param callback(err, framed) called once the operation completes.
  */
 jsonld.frame = function(input, frame) {
   // get arguments
-  var resolver = jsonld.urlResolver;
-  var options;
+  var options = {};
   var callbackArg = 2;
-  if(arguments.length > 4) {
-    options = arguments[2];
-    resolver = arguments[3];
-    callbackArg += 2;
-  }
-  else if(arguments.length > 3) {
-    if(_isObject(arguments[2])) {
-      options = arguments[2];
-    }
-    else {
-      resolver = arguments[2];
-    }
+  if(arguments.length > 3) {
+    options = arguments[2] || {};
     callbackArg += 1;
   }
   var callback = arguments[callbackArg];
 
   // set default options
-  options = options || {};
+  if(!('resolver' in options)) {
+    options.resolver = jsonld.urlResolver;
+  }
   if(!('embed' in options)) {
     options.embed = true;
   }
@@ -217,121 +240,127 @@
   options.omitDefault = options.omitDefault || false;
   options.optimize = options.optimize || false;
 
-  // clone frame
-  frame = _clone(frame);
-  frame['@context'] = frame['@context'] || {};
-
-  // compact the input according to the frame context
-  jsonld.compact(input, frame['@context'], options.optimize, resolver,
-    function(err, compacted) {
+  // preserve frame context
+  var ctx = frame['@context'] || {};
+
+  // expand input
+  jsonld.expand(input, options, function(err, _input) {
+    if(err) {
+      return callback(new JsonLdError(
+        'Could not expand input before framing.',
+        'jsonld.FrameError', {cause: err}));
+    }
+
+    // expand frame
+    jsonld.expand(frame, options, function(err, _frame) {
       if(err) {
         return callback(new JsonLdError(
-          'Could not compact input before framing.',
+          'Could not expand frame before framing.',
           'jsonld.FrameError', {cause: err}));
       }
 
-      // preserve compacted context
-      var ctx = compacted['@context'] || {};
-      delete compacted['@context'];
-
-      // merge context
-      jsonld.mergeContexts({}, ctx, function(err, merged) {
+      try {
+        // do framing
+        var framed = new Processor().frame(_input, _frame, options);
+      }
+      catch(ex) {
+        callback(ex);
+      }
+
+      // compact result (force @graph option to true)
+      options.graph = true;
+      jsonld.compact(framed, ctx, options, function(err, compacted) {
         if(err) {
           return callback(new JsonLdError(
-            'Could not merge context before framing.',
+            'Could not compact framed output.',
             'jsonld.FrameError', {cause: err}));
         }
-
-        try {
-          // do framing
-          var framed = new Processor().frame(compacted, frame, merged, options);
-
-          // attach context to each framed entry
-          if(Object.keys(ctx).length > 0) {
-            for(var i in framed) {
-              var next = framed[i];
-              if(_isObject(next)) {
-                // reorder keys so @context is first
-                framed[i] = {'@context': ctx};
-                for(var key in next) {
-                  framed[i][key] = next[key];
-                }
-              }
-            }
+        // get graph alias
+        var graph;
+        for(var key in compacted) {
+          if(key !== '@context') {
+            graph = key;
+            break;
           }
-          callback(null, framed);
         }
-        catch(ex) {
-          callback(ex);
-        }
+        // remove @preserve from results
+        compacted[graph] = _removePreserve(compacted[graph]);
+        callback(null, compacted);
       });
     });
+  });
 };
 
 /**
  * Performs JSON-LD normalization.
  *
  * @param input the JSON-LD object to normalize.
- * @param [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
+ * @param [options] the options to use:
+ *          [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
  * @param callback(err, normalized) called once the operation completes.
  */
 jsonld.normalize = function(input, callback) {
   // get arguments
-  var resolver = jsonld.urlResolver;
+  var options = {};
   var callback;
   var callbackArg = 1;
   if(arguments.length > 2) {
-    resolver = arguments[1];
+    options = arguments[1] || {};
     callbackArg += 1;
   }
   callback = arguments[callbackArg];
 
+  // set default options
+  if(!('resolver' in options)) {
+    options.resolver = jsonld.urlResolver;
+  }
+
   // expand input then do normalization
-  jsonld.expand(input, function(err, expanded) {
+  jsonld.expand(input, options, function(err, expanded) {
     if(err) {
       return callback(new JsonLdError(
         'Could not expand input before normalization.',
         'jsonld.NormalizeError', {cause: err}));
     }
 
-    try {
-      // do normalization
-      var normalized = new Processor().normalize(expanded);
-      callback(null, normalized);
-    }
-    catch(ex) {
-      callback(ex);
-    }
+    // do normalization
+    new Processor().normalize(expanded, callback);
   });
 };
 
 /**
- * Outputs the triples found in the given JSON-LD object.
+ * Outputs the RDF statements found in the given JSON-LD object.
  *
  * @param input the JSON-LD object.
- * @param [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
- * @param callback(err, triple) called when a triple is output, with the last
- *          triple as null.
+ * @param [options] the options to use:
+ *          [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
+ * @param callback(err, statement) called when a statement is output, with the
+ *          last statement as null.
  */
-jsonld.triples = function(input, callback) {
+jsonld.toRDF = function(input, callback) {
   // get arguments
-  var resolver = jsonld.urlResolver;
+  var options = {};
   var callback;
   var callbackArg = 1;
   if(arguments.length > 2) {
-    resolver = arguments[1];
+    options = arguments[1] || {};
     callbackArg += 1;
   }
   callback = arguments[callbackArg];
 
+  // set default options
+  if(!('resolver' in options)) {
+    options.resolver = jsonld.urlResolver;
+  }
+
   // resolve all @context URLs in the input
   input = _clone(input);
-  _resolveUrls(input, resolver, function(err, input) {
+  _resolveUrls(input, options.resolver, function(err, input) {
     if(err) {
       return callback(err);
     }
-    // output triples
-    return new Processor().triples(input, callback);
+    // output RDF statements
+    return new Processor().toRDF(input, callback);
   });
 };
 
@@ -412,7 +441,8 @@
  *
  * @param ctx1 the context to overwrite/append to.
  * @param ctx2 the new context to merge onto ctx1.
- * @param [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
+ * @param [options] the options to use:
+ *          [resolver(url, callback(err, jsonCtx))] the URL resolver to use.
  * @param callback(err, ctx) called once the operation completes.
  */
 jsonld.mergeContexts = function(ctx1, ctx2) {
@@ -422,25 +452,30 @@
   }
 
   // get arguments
-  var resolver = jsonld.urlResolver;
+  var options = {};
   var callbackArg = 2;
   if(arguments.length > 3) {
-    resolver = arguments[2];
+    options = arguments[2] || {};
     callbackArg += 1;
   }
   var callback = arguments[callbackArg];
 
+  // set default options
+  if(!('resolver' in options)) {
+    options.resolver = jsonld.urlResolver;
+  }
+
   // default to empty context
   ctx1 = _clone(ctx1 || {});
   ctx2 = _clone(ctx2 || {});
 
   // resolve URLs in ctx1
-  _resolveUrls({'@context': ctx1}, resolver, function(err, ctx1) {
+  _resolveUrls({'@context': ctx1}, options.resolver, function(err, ctx1) {
     if(err) {
       return callback(err);
     }
     // resolve URLs in ctx2
-    _resolveUrls({'@context': ctx2}, resolver, function(err, ctx2) {
+    _resolveUrls({'@context': ctx2}, options.resolver, function(err, ctx2) {
       if(err) {
         return callback(err);
       }
@@ -526,39 +561,24 @@
   propertyIsArray = _isUndefined(propertyIsArray) ? false : propertyIsArray;
 
   if(_isArray(value)) {
+    if(value.length === 0 && propertyIsArray && !(property in subject)) {
+      subject[property] = [];
+    }
     for(var i in value) {
       jsonld.addValue(subject, property, value[i], propertyIsArray);
     }
   }
-  else if(_isListValue(value)) {
-    // create list
-    if(!(property in subject)) {
-      subject[property] = {'@list': []};
-    }
-    // add list values
-    var list = value['@list'];
-    for(var i in list) {
-      jsonld.addValue(subject, property, list[i]);
-    }
-  }
   else if(property in subject) {
     var hasValue = jsonld.hasValue(subject, property, value);
 
     // make property an array if value not present or always an array
-    var isList = _isListValue(subject[property]);
-    if(!_isArray(subject[property]) && !isList &&
-      (!hasValue || propertyIsArray)) {
+    if(!_isArray(subject[property]) && (!hasValue || propertyIsArray)) {
       subject[property] = [subject[property]];
     }
 
     // add new value
     if(!hasValue) {
-      if(isList) {
-        subject[property]['@list'].push(value);
-      }
-      else {
-        subject[property].push(value);
-      }
+      subject[property].push(value);
     }
   }
   else {
@@ -727,13 +747,18 @@
  * @param key the context key.
  * @param [type] the type of value to get (eg: '@id', '@type'), if not
  *          specified gets the entire entry for a key, null if not found.
- * @param [expand] true to expand the value, false not to (default: true).
+ * @param [expand] true to expand the key, false not to (default: false).
  *
  * @return the value.
  */
 jsonld.getContextValue = function(ctx, key, type, expand) {
   var rval = null;
 
+  // get default language
+  if(type === '@language' && (type in ctx)) {
+    rval = ctx[type];
+  }
+
   // return null for invalid key
   if(!key) {
     rval = null;
@@ -761,12 +786,17 @@
         {context: ctx, key: key});
     }
 
-    if(rval !== null) {
-      // expand term if requested
-      expand = _isUndefined(expand) ? true : expand;
-      if(expand) {
-        rval = _expandTerm(ctx, rval);
-      }
+    // expand term
+    if(rval !== null && type !== '@language') {
+      rval = _expandTerm(ctx, rval);
+    }
+  }
+  else {
+    // expand key if requested
+    expand = _isUndefined(expand) ? true : expand;
+    if(expand) {
+      key = _expandTerm(ctx, key);
+      rval = jsonld.getContextValue(ctx, key, type, false);
     }
   }
 
@@ -785,9 +815,6 @@
   // compact key
   key = _compactIri(ctx, key);
 
-  // get keyword for type
-  var kwtype = _getKeywords(ctx)[type];
-
   // add new key to @context or update existing key w/string value
   if(!(key in ctx) || _isString(ctx[key])) {
     if(type === '@id') {
@@ -795,12 +822,12 @@
     }
     else {
       ctx[key] = {};
-      ctx[key][kwtype] = value;
+      ctx[key][type] = value;
     }
   }
   // update existing key w/object value
   else if(_isObject(ctx[key])) {
-    ctx[key][kwtype] = value;
+    ctx[key][type] = value;
   }
   else {
     throw new JsonLdError(
@@ -835,7 +862,8 @@
 var RDF = {
   'first': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first',
   'rest': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest',
-  'nil': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'
+  'nil': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil',
+  'type': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'
 };
 
 /**
@@ -864,317 +892,390 @@
 var Processor = function() {};
 
 /**
- * Recursively compacts a value using the given context. All context URLs
+ * Recursively compacts an element using the given context. All context URLs
  * must have been resolved before calling this method and all values must
  * be in expanded form.
  *
  * @param ctx the context to use.
- * @param property the property that points to the value, null for none.
- * @param value the value to compact.
- * @param [optimizeCtx] the context to populate with optimizations.
+ * @param property the property that points to the element, null for none.
+ * @param element the element to compact.
+ * @param options the compaction options.
  *
  * @return the compacted value.
  */
-Processor.prototype.compact = function(ctx, property, value, optimizeCtx) {
-  // null is already compact
-  if(value === null) {
-    return null;
-  }
-
-  // recursively compact array or list
-  var isList = _isListValue(value);
-  if(_isArray(value) || isList) {
-    // get array from @list
-    if(isList) {
-      value = value['@list'];
-
-      // nothing to compact in null case
-      if(value === null) {
-        return null;
-      }
-      // invalid input if @list points at a non-array
-      else if(!_isArray(value)) {
-        throw new JsonLdError(
-          'Invalid JSON-LD syntax; "@list" value must be an array or null.',
-          'jsonld.SyntaxError');
+Processor.prototype.compact = function(ctx, property, element, options) {
+  // recursively compact array
+  if(_isArray(element)) {
+    var rval = [];
+    for(var i in element) {
+      var e = this.compact(ctx, property, element[i], options);
+      // drop null values
+      if(e !== null) {
+        rval.push(e);
       }
     }
-
-    // recurse through array
-    var rval = [];
-    for(var i in value) {
-      // compact value and add if non-null
-      var val = this.compact(ctx, property, value[i], optimizeCtx);
-      if(val !== null) {
-        rval.push(val);
-      }
-    }
-
-    // use @list if previously used unless @context specifies container @list
-    // which indicates value should be a simple array
-    if(isList) {
-      var prop = _compactIri(ctx, property);
-      var container = jsonld.getContextValue(ctx, prop, '@container');
-      var useList = (container !== '@list');
-      if(useList) {
-        // if optimizing, add @container entry
-        if(optimizeCtx && container === null) {
-          jsonld.setContextValue(optimizeCtx, prop, '@container', '@list');
-        }
-        else {
-          rval = {'@list': rval};
-        }
+    if(rval.length === 1) {
+      // use single element if no container is specified
+      var container = jsonld.getContextValue(ctx, property, '@container');
+      if(container !== '@list' && container !== '@set') {
+        rval = rval[0];
       }
     }
     return rval;
   }
 
-  // replace '@graph' keyword and recurse
-  if(_isObject(value) && '@graph' in value) {
-    var kwgraph = _getKeywords(ctx)['@graph'];
-    var rval = {};
-    rval[kwgraph] = this.compact(ctx, property, value['@graph'], optimizeCtx);
-    return rval;
-  }
-
-  // optimize away use of @set
-  if(_isSetValue(value)) {
-    return this.compact(ctx, property, value['@set'], optimizeCtx);
-  }
-
-  // try to type-compact value
-  if(_canTypeCompact(value)) {
-    // compact property to look for its @type definition in the context
-    var prop = _compactIri(ctx, property);
-    var type = jsonld.getContextValue(ctx, prop, '@type');
-    if(type !== null) {
-      var key = _isValue(value) ? '@value' : '@id';
-
-      // compact IRI
-      if(type === '@id') {
-        return _compactIri(ctx, value[key]);
-      }
-      // other type, return string value
-      else {
-        return value[key];
-      }
-    }
-  }
-
   // recursively compact object
-  if(_isObject(value)) {
-    var keywords = _getKeywords(ctx);
-    var rval = {};
-    for(var key in value) {
-      // compact non-context
-      if(key !== '@context') {
-        // FIXME: this should just be checking for absolute IRI or keyword
-        // drop unmapped and non-absolute IRI keys that aren't keywords
-        if(!jsonld.getContextValue(ctx, key) && !_isAbsoluteIri(key) &&
-          !(key in keywords)) {
-          continue;
+  if(_isObject(element)) {
+    // element is a @value
+    if(_isValue(element)) {
+      var type = jsonld.getContextValue(ctx, property, '@type');
+      var language = jsonld.getContextValue(ctx, property, '@language');
+
+      // matching @type specified in context, compact element
+      if(type !== null &&
+        ('@type' in element) && element['@type'] === type) {
+        element = element['@value'];
+
+        // use native datatypes for certain xsd types
+        if(type === XSD['boolean']) {
+          element = !(element === 'false' || element === '0');
         }
-
-        // compact property and value
-        var prop = _compactIri(ctx, key);
-        var val = this.compact(ctx, key, value[key], optimizeCtx);
-
-        // preserve empty arrays
-        if(_isArray(val) && val.length === 0 && !(prop in rval)) {
-          rval[prop] = [];
+        else if(type === XSD['integer']) {
+          element = parseInt(element);
         }
-
-        // add non-null value
-        var values = [];
-        if(val !== null) {
-          // optimize value compaction if optimize context is given
-          if(optimizeCtx) {
-            val = _optimalTypeCompact(ctx, prop, val, optimizeCtx);
-          }
-
-          // determine if an array should be used by @container specification
-          var container = jsonld.getContextValue(ctx, prop, '@container');
-          var isArray = (container === '@set' || container === '@list');
-          jsonld.addValue(rval, prop, val, isArray);
+        else if(type === XSD['double']) {
+          element = parseFloat(element);
         }
       }
+      // matching @language specified in context, compact element
+      else if(language !== null &&
+        ('@language' in element) && element['@language'] === language) {
+        element = element['@value'];
+      }
+      // compact @type IRI
+      else if('@type' in element) {
+        element['@type'] = _compactIri(ctx, element['@type']);
+      }
+      return element;
+    }
+
+    // compact subject references
+    if(_isSubjectReference(element)) {
+      var type = jsonld.getContextValue(ctx, property, '@type');
+      if(type === '@id') {
+        element = _compactIri(ctx, element['@id']);
+        return element;
+      }
     }
-    // drop empty objects when optimizing
-    if(optimizeCtx && Object.keys(rval).length === 0) {
-      rval = null;
-    }
-    return rval;
-  }
-
-  // compact @id or @type string
-  var prop = _expandTerm(ctx, property);
-  if(prop === '@id' || prop === '@type') {
-    return _compactIri(ctx, value);
-  }
-
-  // only primitives remain which are already compact
-  return value;
-};
-
-/**
- * Recursively expands a value using the given context. Any context in
- * the value will be removed. All context URLs must have been resolved before
- * calling this method.
- *
- * @param ctx the context to use.
- * @param property the expanded property for the value, null for none.
- * @param value the value to expand.
- *
- * @return the expanded value.
- */
-Processor.prototype.expand = function(ctx, property, value) {
-  // nothing to expand when value is null
-  if(value === null) {
-    return null;
-  }
-
-  // if no property is specified and the value is a string (this means the
-  // value is a property itself), expand to an IRI
-  if(property === null && _isString(value)) {
-    return _expandTerm(ctx, value);
-  }
-
-  // recursively expand array and @list
-  var isList = _isListValue(value);
-  if(_isArray(value) || isList) {
-    // get array from @list
-    if(isList) {
-      value = value['@list'];
-
-      // nothing to expand in null case
-      if(value === null) {
-        return null;
+
+    // recursively process element keys
+    var rval = {};
+    for(var key in element) {
+      var value = element[key];
+
+      // compact @id and @type(s)
+      if(key === '@id' || key === '@type') {
+        // compact single @id
+        if(_isString(value)) {
+          value = _compactIri(ctx, value);
+        }
+        // value must be a @type array
+        else {
+          var types = [];
+          for(var i in value) {
+            types.push(_compactIri(ctx, value[i]));
+          }
+          value = types;
+        }
+
+        // compact property and add value
+        var prop = _compactIri(ctx, key);
+        var isArray = (_isArray(value) && value.length === 0);
+        jsonld.addValue(rval, prop, value, isArray);
+        continue;
       }
 
-      // invalid input if @list points at a non-array
-      if(!_isArray(value)) {
-        throw new JsonLdError(
-          'Invalid JSON-LD syntax; "@list" value must be an array or null.',
-          'jsonld.SyntaxError');
+      // Note: value must be an array due to expansion algorithm.
+
+      // preserve empty arrays
+      if(value.length === 0) {
+        var prop = _compactIri(ctx, key);
+        jsonld.addValue(rval, prop, [], true);
       }
-    }
-
-    // recurse through array
-    var rval = [];
-    for(var i in value) {
-      var val = value[i];
-      if(_isArray(val)) {
-        throw new JsonLdError(
-          'Invalid JSON-LD syntax; arrays of arrays are not permitted.',
-          'jsonld.SyntaxError');
-      }
-      // expand value and add if non-null
-      val = this.expand(ctx, property, val);
-      if(val !== null) {
-        rval.push(val);
-      }
-    }
-
-    // use @list if previously used or if @context indicates it is one
-    if(property !== null) {
-      var prop = _compactIri(ctx, property);
-      var container = jsonld.getContextValue(ctx, prop, '@container');
-      isList = isList || (container === '@list');
-      if(isList) {
-        rval = {'@list': rval};
+
+      // recusively process array values
+      for(var i in value) {
+        var v = value[i];
+        var isList = _isListValue(v);
+
+        // compact property
+        var prop;
+        if(_isValue(v)) {
+          prop = _compactIri(ctx, key, v);
+        }
+        else if(isList) {
+          prop = _compactIri(ctx, key, v, '@list');
+          v = v['@list'];
+        }
+        else if(_isString(v)) {
+          // pass expanded form of plain literal to handle null language
+          prop = _compactIri(ctx, key, {'@value': v});
+        }
+        else {
+          prop = _compactIri(ctx, key);
+        }
+
+        // recursively compact value
+        v = this.compact(ctx, prop, v, options);
+
+        // get container type for property
+        var container = jsonld.getContextValue(ctx, prop, '@container');
+
+        // handle @list
+        if(isList && container !== '@list') {
+          // handle messy @list compaction
+          if(prop in rval && options.strict) {
+            throw new JsonLdError(
+              'JSON-LD compact error; property has a "@list" @container ' +
+              'rule but there is more than a single @list that matches ' +
+              'the compacted term in the document. Compaction might mix ' +
+              'unwanted items into the list.',
+              'jsonld.SyntaxError');
+          }
+          // reintroduce @list keyword
+          var kwlist = _compactIri(ctx, '@list');
+          var val = {};
+          val[kwlist] = v;
+          v = val;
+        }
+
+        // if @container is @set or @list or value is an empty array, use
+        // an array when adding value
+        var isArray = (container === '@set' || container === '@list' ||
+          (_isArray(v) && v.length === 0));
+
+        // add compact value
+        jsonld.addValue(rval, prop, v, isArray);
       }
     }
     return rval;
   }
 
-  // optimize away use of @set
-  if(_isSetValue(value)) {
-    return this.expand(ctx, property, value['@set']);
-  }
-
-  // recursively expand object
-  if(_isObject(value)) {
-    // determine if value is a subject
-    var isSubject = _isSubject(value) || (property === null);
-
-    // if value has a context, merge it in
-    if('@context' in value) {
-      ctx = this.mergeContexts(ctx, value['@context']);
-    }
-
-    // optimize away use of @graph
-    var keywords = _getKeywords(ctx);
-    var kwgraph = keywords['@graph'];
-    if('@graph' in value) {
-      return this.expand(ctx, property, value['@graph']);
-    }
-
-    // recurse into object
-    var rval = {};
-    for(var key in value) {
-      // expand non-context
-      if(key !== '@context') {
-        // expand property
-        var prop = _expandTerm(ctx, key);
-
-        // drop non-absolute IRI keys that aren't keywords
-        if(!_isAbsoluteIri(prop) && !(prop in keywords)) {
-          continue;
-        }
-
-        // syntax error if @id is not a string
-        if(prop === '@id' && !_isString(value[key])) {
-          throw new JsonLdError(
-            'Invalid JSON-LD syntax; "@id" value must a string.',
-            'jsonld.SyntaxError');
-        }
-
-        // expand value
-        var val = this.expand(ctx, prop, value[key]);
-
-        // preserve empty arrays
-        if(_isArray(val) && val.length === 0 && !(prop in rval)) {
-          rval[prop] = [];
-        }
-
-        // add non-null expanded value
-        if(val !== null) {
-          // always use array for subjects except for @id key and @list
-          var useArray = isSubject && (prop !== '@id') && !_isListValue(val);
-          jsonld.addValue(rval, prop, val, useArray);
-        }
+  // only primitives remain which are already compact
+  return element;
+};
+
+/**
+ * Recursively expands an element using the given context. Any context in
+ * the element will be removed. All context URLs must have been resolved
+ * before calling this method.
+ *
+ * @param ctx the context to use.
+ * @param property the property for the element, null for none.
+ * @param element the element to expand.
+ * @param propertyIsList true if the property is a list, false if not.
+ *
+ * @return the expanded value.
+ */
+Processor.prototype.expand = function(ctx, property, element, propertyIsList) {
+  // recursively expand array
+  if(_isArray(element)) {
+    var rval = [];
+    for(var i in element) {
+      // expand element
+      var e = this.expand(ctx, property, element[i], propertyIsList);
+      if(_isArray(e) && propertyIsList) {
+        // lists of lists are illegal
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; lists of lists are not permitted.',
+          'jsonld.SyntaxError');
+      }
+      // drop null values
+      else if(e !== null) {
+        rval.push(e);
       }
     }
     return rval;
   }
 
-  // expand value
-  return _expandValue(ctx, property, value);
+  // recursively expand object
+  if(_isObject(element)) {
+    // if element has a context, merge it in
+    if('@context' in element) {
+      ctx = this.mergeContexts(ctx, element['@context']);
+      delete element['@context'];
+    }
+
+    // get keyword aliases
+    var keywords = _getKeywords(ctx);
+
+    var rval = {};
+    for(var key in element) {
+      // expand property
+      var prop = _expandTerm(ctx, key);
+
+      // drop non-absolute IRI keys that aren't keywords
+      if(!_isAbsoluteIri(prop) && !_isKeyword(keywords, prop)) {
+        continue;
+      }
+
+      // if value is null and property is not @value, continue
+      var value = element[key];
+      if(value === null && prop !== '@value') {
+        continue;
+      }
+
+      // syntax error if @id is not a string
+      if(prop === '@id' && !_isString(value)) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; "@id" value must a string.',
+          'jsonld.SyntaxError', {value: value});
+      }
+
+      // @type must be a string, array of strings, or an empty JSON object
+      if(prop === '@type' &&
+        !(_isString(value) || _isArrayOfStrings(value) ||
+        _isEmptyObject(value))) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; "@type" value must a string, an array ' +
+          'of strings, or an empty object.',
+          'jsonld.SyntaxError', {value: value});
+      }
+
+      // @graph must be an array or an object
+      if(prop === '@graph' && !(_isObject(value) || _isArray(value))) {
+        throw new JsonLdError(
+            'Invalid JSON-LD syntax; "@value" value must not be an ' +
+            'object or an array.',
+            'jsonld.SyntaxError', {value: value});
+      }
+
+      // @value must not be an object or an array
+      if(prop === '@value' && (_isObject(value) || _isArray(value))) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; "@value" value must not be an ' +
+          'object or an array.',
+          'jsonld.SyntaxError', {value: value});
+      }
+
+      // @language must be a string
+      if(prop === '@language' && !_isString(value)) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; "@language" value must not be a string.',
+          'jsonld.SyntaxError', {value: value});
+      }
+
+      // recurse into @list, @set, or @graph, keeping the active property
+      var isList = (prop === '@list');
+      if(isList || prop === '@set' || prop === '@graph') {
+        value = this.expand(ctx, property, value, isList);
+        if(isList && _isListValue(value)) {
+          throw new JsonLdError(
+            'Invalid JSON-LD syntax; lists of lists are not permitted.',
+            'jsonld.SyntaxError');
+        }
+      }
+      else {
+        // update active property and recursively expand value
+        property = key;
+        value = this.expand(ctx, property, value, false);
+      }
+
+      // drop null values if property is not @value (dropped below)
+      if(value !== null || prop === '@value') {
+        // convert value to @list if container specifies it
+        if(prop !== '@list' && !_isListValue(value)) {
+          var container = jsonld.getContextValue(ctx, property, '@container');
+          if(container === '@list') {
+            // ensure value is an array
+            value = _isArray(value) ? value : [value];
+            value = {'@list': value};
+          }
+        }
+
+        // add value, use an array if not @id, @type, @value, or @language
+        var useArray = !(prop === '@id' || prop === '@type' ||
+          prop === '@value' || prop === '@language');
+        jsonld.addValue(rval, prop, value, useArray);
+      }
+    }
+
+    // get property count on expanded output
+    var count = Object.keys(rval).length;
+
+    // @value must only have @language or @type
+    if('@value' in rval) {
+      if((count === 2 && !('@type' in rval) && !('@language' in rval)) ||
+        count > 2) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; an element containing "@value" must have ' +
+          'at most one other property which can be "@type" or "@language".',
+          'jsonld.SyntaxError', {element: rval});
+      }
+      // value @type must be a string
+      if('@type' in rval && !_isString(rval['@type'])) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; the "@type" value of an element ' +
+          'containing "@value" must be a string.',
+          'jsonld.SyntaxError', {element: rval});
+      }
+      // return only the value of @value if there is no @type or @language
+      else if(count === 1) {
+        rval = rval['@value'];
+      }
+      // drop null @values
+      else if(rval['@value'] === null) {
+        rval = null;
+      }
+    }
+    // convert @type to an array
+    else if('@type' in rval && !_isArray(rval['@type'])) {
+      rval['@type'] = [rval['@type']];
+    }
+    // handle @set and @list
+    else if('@set' in rval || '@list' in rval) {
+      if(count !== 1) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; if an element has the property "@set" ' +
+          'or "@list", then it must be its only property.',
+          'jsonld.SyntaxError', {element: rval});
+      }
+      // optimize away @set
+      if('@set' in rval) {
+        rval = rval['@set'];
+      }
+    }
+    // drop objects with only @language
+    else if('@language' in rval && count === 1) {
+      rval = null;
+    }
+
+    return rval;
+  }
+
+  // expand element according to value expansion rules
+  return _expandValue(ctx, property, element);
 };
 
 /**
  * Performs JSON-LD framing.
  *
- * @param input the compacted JSON-LD object to frame.
- * @param frame the JSON-LD frame to use.
- * @param ctx the input's context.
+ * @param input the expanded JSON-LD to frame.
+ * @param frame the expanded JSON-LD frame to use.
  * @param options the framing options.
  *
  * @return the framed output.
  */
-Processor.prototype.frame = function(input, frame, ctx, options) {
+Processor.prototype.frame = function(input, frame, options) {
   // create framing state
   var state = {
-    context: ctx,
-    keywords: _getKeywords(ctx),
     options: options,
-    subjects: {},
-    embeds: {}
+    subjects: {}
   };
 
   // produce a map of all subjects and name each bnode
-  var namer = new BlankNodeNamer('t');
-  _getFramingSubjects(state, input, namer);
+  var namer = new UniqueNamer('_:t');
+  _flatten(state.subjects, input, namer);
 
   // frame the subjects
   var framed = [];
@@ -1186,104 +1287,189 @@
  * Performs JSON-LD normalization.
  *
  * @param input the expanded JSON-LD object to normalize.
- *
- * @return the normalized output.
+ * @param callback(err, normalized) called once the operation completes.
  */
-Processor.prototype.normalize = function(input) {
-  var self = this;
-
+Processor.prototype.normalize = function(input, callback) {
   // get statements
-  var namer = new BlankNodeNamer('t');
+  var namer = new UniqueNamer('_:t');
   var bnodes = {};
   var subjects = {};
   _getStatements(input, namer, bnodes, subjects);
 
-  // create bnode name maps
-  var maps = [{}, {}];
-
-  // initialize old map entries to 'z'
-  var oldMap = maps[0];
-  for(var bnode in bnodes) {
-    oldMap[bnode] = 'z';
-  }
-
-  // FIXME: do iterations asynchronously to allow other work to proceed
-
-  // do iterative hashing
-  var n = Object.keys(bnodes).length;
-  for(var i = 0; i <= n; ++i) {
-    // hash statements for all bnodes
-    for(var bnode in bnodes) {
+  // create canonical namer
+  namer = new UniqueNamer('_:c14n');
+
+  // generates unique and duplicate hashes for bnodes
+  hashBlankNodes(Object.keys(bnodes));
+  function hashBlankNodes(unnamed) {
+    var nextUnnamed = [];
+    var duplicates = {};
+    var unique = {};
+
+    // hash statements for each unnamed bnode
+    setTimeout(function() {hashUnnamed(0);}, 0);
+    function hashUnnamed(i) {
+      if(i === unnamed.length) {
+        // done, name blank nodes
+        return nameBlankNodes(unique, duplicates, nextUnnamed);
+      }
+
+      // hash unnamed bnode
+      var bnode = unnamed[i];
       var statements = bnodes[bnode];
-      _hashStatements(bnode, statements, maps[0], maps[1]);
+      var hash = _hashStatements(statements, namer);
+
+      // store hash as unique or a duplicate
+      if(hash in duplicates) {
+        duplicates[hash].push(bnode);
+        nextUnnamed.push(bnode);
+      }
+      else if(hash in unique) {
+        duplicates[hash] = [unique[hash], bnode];
+        nextUnnamed.push(unique[hash]);
+        nextUnnamed.push(bnode);
+        delete unique[hash];
+      }
+      else {
+        unique[hash] = bnode;
+      }
+
+      // hash next unnamed bnode
+      setTimeout(function() {hashUnnamed(i + 1);}, 0);
     }
-
-    // swap maps
-    var tmp = maps[1];
-    maps[0] = maps[1];
-    maps[1] = tmp;
   }
 
-  // name bnodes
-  namer = new BlankNodeNamer('c14n');
-  _nameBlankNode(bnodes, maps[0], namer, null);
-
-  // create JSON-LD array
-  var normalized = [];
-
-  // add all bnodes
-  for(var id in bnodes) {
-    var name = namer.getName(id);
-    var bnode = {'@id': name};
-
-    // add all property statements to bnode
-    var statements = bnodes[id];
-    for(var i in statements) {
-      var statement = statements[i];
-      if(statement.s === '_:a') {
+  // names unique hash bnodes
+  function nameBlankNodes(unique, duplicates, unnamed) {
+    // name unique bnodes in sorted hash order
+    var named = false;
+    var hashes = Object.keys(unique).sort();
+    for(var i in hashes) {
+      var bnode = unique[hashes[i]];
+      namer.getName(bnode);
+      named = true;
+    }
+
+    // continue to hash bnodes if a bnode was assigned a name
+    if(named) {
+      hashBlankNodes(unnamed);
+    }
+    // name the duplicate hash bnodes
+    else {
+      nameDuplicates(duplicates);
+    }
+  }
+
+  // names duplicate hash bnodes
+  function nameDuplicates(duplicates) {
+    // enumerate duplicate hash groups in sorted order
+    var hashes = Object.keys(duplicates).sort();
+
+    // process each group
+    processGroup(0);
+    function processGroup(i) {
+      if(i === hashes.length) {
+        // done, create JSON-LD array
+        return createArray();
+      }
+
+      // name each group member
+      var group = duplicates[hashes[i]];
+      var results = [];
+      nameGroupMember(group, 0);
+      function nameGroupMember(group, n) {
+        if(n === group.length) {
+          // name bnodes in hash order
+          results.sort(function(a, b) {
+            a = a.hash;
+            b = b.hash;
+            return (a < b) ? -1 : ((a > b) ? 1 : 0);
+          });
+          for(var r in results) {
+            // name all bnodes in path namer in key-entry order
+            // Note: key-order is preserved in javascript
+            for(var key in results[r].pathNamer.existing) {
+              namer.getName(key);
+            }
+          }
+          return processGroup(i + 1);
+        }
+
+        // skip already-named bnodes
+        var bnode = group[n];
+        if(namer.isNamed(bnode)) {
+          return nameGroupMember(group, n + 1);
+        }
+
+        // hash bnode paths
+        var pathNamer = new UniqueNamer('_:t');
+        pathNamer.getName(bnode);
+        _hashPaths(bnodes, bnodes[bnode], namer, pathNamer,
+          function(err, result) {
+            if(err) {
+              return callback(err);
+            }
+            results.push(result);
+            nameGroupMember(group, n + 1);
+          });
+      }
+    };
+  }
+
+  // creates the normalized JSON-LD array
+  function createArray() {
+    var normalized = [];
+
+    // add all bnodes
+    for(var id in bnodes) {
+      // add all property statements to bnode
+      var name = namer.getName(id);
+      var bnode = {'@id': name};
+      var statements = bnodes[id];
+      for(var i in statements) {
+        var statement = statements[i];
+        if(statement.s === '_:a') {
+          var z = _getBlankNodeName(statement.o);
+          var o = z ? {'@id': namer.getName(z)} : statement.o;
+          jsonld.addValue(bnode, statement.p, o, true);
+        }
+      }
+      normalized.push(bnode);
+    }
+
+    // add all non-bnodes
+    for(var id in subjects) {
+      // add all statements to subject
+      var subject = {'@id': id};
+      var statements = subjects[id];
+      for(var i in statements) {
+        var statement = statements[i];
         var z = _getBlankNodeName(statement.o);
         var o = z ? {'@id': namer.getName(z)} : statement.o;
-        jsonld.addValue(bnode, statement.p, o, true);
+        jsonld.addValue(subject, statement.p, o, true);
       }
+      normalized.push(subject);
     }
 
-    normalized.push(bnode);
+    // sort normalized output by @id
+    normalized.sort(function(a, b) {
+      a = a['@id'];
+      b = b['@id'];
+      return (a < b) ? -1 : ((a > b) ? 1 : 0);
+    });
+
+    callback(null, normalized);
   }
-
-  // add all non-bnodes
-  for(var id in subjects) {
-    var subject = {'@id': id};
-
-    // add all statements to subject
-    var statements = subjects[id];
-    for(var i in statements) {
-      var statement = statements[i];
-      var z = _getBlankNodeName(statement.o);
-      var o = z ? {'@id': namer.getName(z)} : statement.o;
-      jsonld.addValue(subject, statement.p, o, true);
-    }
-
-    normalized.push(subject);
-  }
-
-  // sort normalized output by @id
-  normalized.sort(function(a, b) {
-    a = a['@id'];
-    b = b['@id'];
-    return (a < b) ? -1 : ((a > b) ? 1 : 0);
-  });
-
-  return normalized;
 };
 
 /**
- * Outputs the triples found in the given JSON-LD object.
+ * Outputs the RDF statements found in the given JSON-LD object.
  *
  * @param input the JSON-LD object.
- * @param callback(err, triple) called when a triple is output, with the last
- *          triple as null.
+ * @param callback(err, statement) called when a statement is output, with the
+ *          last statement as null.
  */
-Processor.prototype.triples = function(input, callback) {
+Processor.prototype.toRDF = function(input, callback) {
   // FIXME: implement
   callback(new JsonLdError('Not implemented', 'jsonld.NotImplemented'), null);
 };
@@ -1297,6 +1483,8 @@
  * @return the resulting merged context.
  */
 Processor.prototype.mergeContexts = function(ctx1, ctx2) {
+  // FIXME: consider using spec context processing rules instead
+
   // flatten array context
   if(_isArray(ctx1)) {
     ctx1 = this.mergeContexts({}, ctx1);
@@ -1316,19 +1504,26 @@
     }
   }
   else if(_isObject(ctx2)) {
-    // if the ctx2 has a new definition for an IRI (possibly using a new
-    // key), then the old definition must be removed
+    // iterate over new keys
     for(var key in ctx2) {
-      var newIri = jsonld.getContextValue(ctx2, key, '@id');
+      // ensure @language is a string
+      if(key === '@language' && !_isString(ctx2[key])) {
+        throw new JsonLdError(
+          'Invalid JSON-LD syntax; @language must be a string.',
+          'jsonld.SyntaxError');
+      }
 
       // no IRI defined, skip
+      var newIri = jsonld.getContextValue(ctx2, key, '@id', false);
       if(newIri === null) {
         continue;
       }
 
+      // if the ctx2 has a new definition for an IRI (possibly using a new
+      // key), then the old definition must be removed
       for(var mkey in rval) {
         // matching IRI, remove old entry
-        if(newIri === jsonld.getContextValue(rval, mkey, '@id')) {
+        if(newIri === jsonld.getContextValue(rval, mkey, '@id', false)) {
           delete rval[mkey];
           break;
         }
@@ -1355,7 +1550,7 @@
  * given context.
  *
  * @param ctx the context to use.
- * @param property the expanded property the value is associated with.
+ * @param property the property the value is associated with.
  * @param value the value to expand.
  *
  * @return the expanded value.
@@ -1365,13 +1560,13 @@
   var rval = value;
 
   // special-case expand @id and @type (skips '@id' expansion)
-  if(property === '@id' || property === '@type') {
+  var prop = _expandTerm(ctx, property);
+  if(prop === '@id' || prop === '@type') {
     rval = _expandTerm(ctx, value);
   }
   else {
     // compact property to look for its type definition in the context
-    var prop = _compactIri(ctx, property);
-    var type = jsonld.getContextValue(ctx, prop, '@type');
+    var type = jsonld.getContextValue(ctx, property, '@type');
 
     // do @id expansion
     if(type === '@id') {
@@ -1381,6 +1576,13 @@
     else if(type !== null) {
       rval = {'@value': String(value), '@type': type};
     }
+    // check for language tagging
+    else {
+      var language = jsonld.getContextValue(ctx, property, '@language');
+      if(language !== null) {
+        rval = {'@value': String(value), '@language': language};
+      }
+    }
   }
 
   return rval;
@@ -1390,7 +1592,7 @@
  * Recursively gets all statements from the given expanded JSON-LD input.
  *
  * @param input the valid expanded JSON-LD input.
- * @param namer the BlankNodeNamer to use when encountering blank nodes.
+ * @param namer the UniqueNamer to use when encountering blank nodes.
  * @param bnodes the blank node statements map to populate.
  * @param subjects the subject statements map to populate.
  * @param [name] the name (@id) assigned to the current input.
@@ -1431,11 +1633,15 @@
       }
 
       var objects = input[p];
-      var isList = _isListValue(objects);
-      if(isList) {
-        // convert @list array into embedded blank node linked list
-        objects = _makeLinkedList(objects);
+
+      // convert @lists into embedded blank node linked lists
+      for(var i in objects) {
+        var o = objects[i];
+        if(_isListValue(o)) {
+          objects[i] = _makeLinkedList(o);
+        }
       }
+
       for(var i in objects) {
         var o = objects[i];
 
@@ -1499,7 +1705,7 @@
  *
  * @param value the @list value.
  *
- * @return the linked list of blank nodes.
+ * @return the head of the linked list of blank nodes.
  */
 function _makeLinkedList(value) {
   // convert @list array into embedded blank node linked list
@@ -1518,7 +1724,7 @@
     tail = e;
   }
 
-  return [tail];
+  return tail;
 }
 
 /**
@@ -1540,15 +1746,14 @@
 }
 
 /**
- * Hashes all of the statements about the given blank node, generating a
- * new hash for it.
+ * Hashes all of the statements about a blank node.
  *
- * @param bnode the bnode @id to generate the new hash for.
  * @param statements the statements about the bnode.
- * @param oldMap the old map of hashes for adjacent blank nodes.
- * @param newMap the new map to store the new hash in.
+ * @param namer the canonical bnode namer.
+ *
+ * @return the new hash.
  */
-function _hashStatements(bnode, statements, oldMap, newMap) {
+function _hashStatements(statements, namer) {
   // serialize all statements
   var triples = [];
   for(var i in statements) {
@@ -1559,27 +1764,30 @@
 
     // serialize subject
     if(statement.s === '_:a') {
-      triple += '_:a ';
+      triple += '_:a';
     }
     else if(statement.s.indexOf('_:') === 0) {
-      var hash = oldMap[statement.s];
-      triple += '_:' + hash + ' ';
+      var id = statement.s;
+      id = namer.isNamed(id) ? namer.getName(id) : '_:z';
+      triple += id;
     }
     else {
       triple += '<' + statement.s + '>';
     }
 
     // serialize property
-    triple += '<' + statement.p + '> ';
+    var p = (statement.p === '@type') ? RDF.type : statement.p;
+    triple += ' <' + p + '> ';
 
     // serialize object
     if(_isBlankNode(statement.o)) {
       if(statement.o['@id'] === '_:a') {
-        triple += '_:a ';
+        triple += '_:a';
       }
       else {
-        var hash = oldMap[statement.o['@id']];
-        triple += '_:' + hash + ' ';
+        var id = statement.o['@id'];
+        id = namer.isNamed(id) ? namer.getName(id) : '_:z';
+        triple += id;
       }
     }
     else if(_isString(statement.o)) {
@@ -1607,82 +1815,182 @@
   // sort serialized triples
   triples.sort();
 
-  // hash triples and store result in new map
-  newMap[bnode] = sha1.hash(triples);
+  // return hashed triples
+  return sha1.hash(triples);
 }
 
 /**
- * Recursively canonically names blank nodes.
+ * Produces a hash for the paths of adjacent bnodes for a bnode,
+ * incorporating all information about its subgraph of bnodes. This
+ * method will recursively pick adjacent bnode permutations that produce the
+ * lexicographically-least 'path' serializations.
  *
- * @param bnodes the statements about blank nodes.
- * @param map the map of bnode name => hash.
- * @param namer the blank node namer.
- * @param bnode the next bnode to name, null if this is the root call.
+ * @param bnodes the map of bnode statements.
+ * @param statements the statements for the bnode to produce the hash for.
+ * @param namer the canonical bnode namer.
+ * @param pathNamer the namer used to assign names to adjacent bnodes.
+ * @param callback(err, result) called once the operation completes.
  */
-function _nameBlankNode(bnodes, map, namer, bnode) {
-  // skip blank nodes that are already named
-  if(bnode !== null && namer.isNamed(bnode)) {
-    return;
-  }
-
-  if(bnode === null) {
-    // get all hashes
-    var hashes = [];
-    for(var bnode in map) {
-      var hash = map[bnode];
-      hashes.push({hash: hash, bnode: bnode});
+function _hashPaths(bnodes, statements, namer, pathNamer, callback) {
+  // create SHA-1 digest
+  var md = sha1.create();
+
+  // group adjacent bnodes by hash, keep properties and references separate
+  var groups = {};
+  var cache = {};
+  var groupHashes;
+  setTimeout(function() {groupNodes(0);}, 0);
+  function groupNodes(i) {
+    if(i === statements.length) {
+      // done, hash groups
+      groupHashes = Object.keys(groups).sort();
+      return hashGroup(0);
     }
 
-    // sort hashes
-    hashes.sort(function(a, b) {
-      return (a.hash < b.hash) ? -1 : ((a.hash > b.hash) ? 1 : 0);
-    });
-  }
-  else {
-    // name blank node
-    namer.getName(bnode);
-
-    // get hashes from statements for the blank node, separated into
-    // different lists for properties vs. references
-    var props = [];
-    var refs = [];
-    var statements = bnodes[bnode];
-    for(var i in statements) {
-      var statement = statements[i];
-      var list = null;
-      // try to get blank node in object position
+    var statement = statements[i];
+    var bnode = null;
+    var direction = null;
+    if(statement.s !== '_:a' && statement.s.indexOf('_:') === 0) {
+      bnode = statement.s;
+      direction = 'p';
+    }
+    else {
       bnode = _getBlankNodeName(statement.o);
-      if(bnode !== null) {
-        list = props;
+      direction = 'r';
+    }
+
+    if(bnode) {
+      // get bnode name (try canonical, path, then hash)
+      var name;
+      if(namer.isNamed(bnode)) {
+        name = namer.getName(bnode);
+      }
+      else if(pathNamer.isNamed(bnode)) {
+        name = pathNamer.getName(bnode);
+      }
+      else if(bnode in cache) {
+        name = cache[bnode];
       }
       else {
-        // try to get blank node in subject position
-        bnode = _getBlankNodeName(statement.s);
-        if(bnode !== null) {
-          list = refs;
-        }
+        name = _hashStatements(bnodes[bnode], namer);
+        cache[bnode] = name;
       }
-      if(list) {
-        var hash = map[bnode];
-        list.push({hash: hash, bnode: bnode});
+
+      // hash direction, property, and bnode name/hash
+      var md = sha1.create();
+      md.update(direction);
+      md.update((statement.p === '@type') ? RDF.type : statement.p);
+      md.update(name);
+      var groupHash = md.digest();
+
+      // add bnode to hash group
+      if(groupHash in groups) {
+        groups[groupHash].push(bnode);
+      }
+      else {
+        groups[groupHash] = [bnode];
       }
     }
 
-    // sort hash lists independently
-    props.sort(function(a, b) {
-      return (a.hash < b.hash) ? -1 : ((a.hash > b.hash) ? 1 : 0);
-    });
-    refs.sort(function(a, b) {
-      return (a.hash < b.hash) ? -1 : ((a.hash > b.hash) ? 1 : 0);
-    });
-
-    // concatenate lists
-    var hashes = props.concat(refs);
+    setTimeout(function() {groupNodes(i + 1);}, 0);
   }
 
-  // recursively name blank nodes
-  for(var i in hashes) {
-    _nameBlankNode(bnodes, map, namer, hashes[i].bnode);
+  // hashes a group of adjacent bnodes
+  function hashGroup(i) {
+    if(i === groupHashes.length) {
+      // done, return SHA-1 digest and path namer
+      return callback(null, {hash: md.digest(), pathNamer: pathNamer});
+    }
+
+    // digest group hash
+    var groupHash = groupHashes[i];
+    md.update(groupHash);
+
+    // choose a path and namer from the permutations
+    var chosenPath = null;
+    var chosenNamer = null;
+    var permutator = new Permutator(groups[groupHash]);
+    setTimeout(function() {permutate();}, 0);
+    function permutate() {
+      var permutation = permutator.next();
+      var pathNamerCopy = pathNamer.clone();
+
+      // build adjacent path
+      var path = '';
+      var recurse;
+      for(var n in permutation) {
+        var bnode = permutation[n];
+        recurse = [];
+
+        // use canonical name if available
+        if(namer.isNamed(bnode)) {
+          path += namer.getName(bnode);
+        }
+        else {
+          // recurse if bnode isn't named in the path yet
+          if(!pathNamerCopy.isNamed(bnode)) {
+            recurse.push(bnode);
+          }
+          path += pathNamerCopy.getName(bnode);
+        }
+
+        // skip permutation if path is already >= chosen path
+        if(chosenPath !== null && path.length >= chosenPath.length &&
+          path > chosenPath) {
+          return nextPermutation(true);
+        }
+      }
+
+      // does the next recursion
+      nextRecursion(0);
+      function nextRecursion(n) {
+        if(n === recurse.length) {
+          // done, do next permutation
+          return nextPermutation(false);
+        }
+
+        // do recursion
+        var bnode = recurse[n];
+        _hashPaths(bnodes, bnodes[bnode], namer, pathNamerCopy,
+          function(err, result) {
+            if(err) {
+              return callback(err);
+            }
+            path += pathNamerCopy.getName(bnode) + '<' + result.hash + '>';
+            pathNamerCopy = result.pathNamer;
+
+            // skip permutation if path is already >= chosen path
+            if(chosenPath !== null && path.length >= chosenPath.length &&
+              path > chosenPath) {
+              return nextPermutation(true);
+            }
+
+            // do next recursion
+            nextRecursion(n + 1);
+          });
+      }
+
+      // stores the results of this permutation and runs the next
+      function nextPermutation(skipped) {
+        if(!skipped && (chosenPath === null || path < chosenPath)) {
+          chosenPath = path;
+          chosenNamer = pathNamerCopy;
+        }
+
+        // do next permutation
+        if(permutator.hasNext()) {
+          setTimeout(function() {permutate();}, 0);
+        }
+        else {
+          // digest chosen path and update namer
+          md.update(chosenPath);
+          pathNamer = chosenNamer;
+
+          // hash the next group
+          hashGroup(i + 1);
+        }
+      }
+    }
   }
 }
 
@@ -1701,122 +2009,89 @@
 }
 
 /**
- * Recursively gets the subjects in the given JSON-LD compact input for use
- * in the framing algorithm.
+ * Recursively flattens the subjects in the given JSON-LD expanded input.
  *
- * @param state the current framing state.
- * @param input the JSON-LD compact input.
+ * @param subjects a map of subject @id to subject.
+ * @param input the JSON-LD expanded input.
  * @param namer the blank node namer.
  * @param name the name assigned to the current input if it is a bnode.
+ * @param list the list to append to, null for none.
  */
-function _getFramingSubjects(state, input, namer, name) {
-  var kwgraph = state.keywords['@graph'];
-  var kwid = state.keywords['@id'];
-  var kwlist = state.keywords['@list'];
-
+function _flatten(subjects, input, namer, name, list) {
   // recurse through array
   if(_isArray(input)) {
     for(var i in input) {
-      _getFramingSubjects(state, input[i], namer);
+      _flatten(subjects, input[i], namer, undefined, list);
     }
   }
-  // recurse through @graph
-  else if(_isObject(input) && (kwgraph in input)) {
-    _getFramingSubjects(state, input[kwgraph], namer);
-  }
-  // input is a subject
+  // handle subject
   else if(_isObject(input)) {
+    // add value to list
+    if(_isValue(input) && list) {
+      list.push(input);
+      return;
+    }
+
     // get name for subject
     if(_isUndefined(name)) {
-      name = _isBlankNode(input, state.keywords) ?
-        namer.getName(input[kwid]) : input[kwid];
+      name = _isBlankNode(input) ? namer.getName(input['@id']) : input['@id'];
+    }
+
+    // add subject reference to list
+    if(list) {
+      list.push({'@id': name});
     }
 
     // create new subject or merge into existing one
-    var subject = state.subjects[name] = state.subjects[name] || {};
+    var subject = subjects[name] = subjects[name] || {};
+    subject['@id'] = name;
     for(var prop in input) {
-      // use assigned name for @id
-      if(_isKeyword(state.keywords, prop, '@id')) {
-        subject[prop] = name;
+      // skip @id
+      if(prop === '@id') {
         continue;
       }
 
       // copy keywords
-      if(_isKeyword(state.keywords, prop)) {
-        subject[prop] = _clone(input[prop]);
+      if(_isKeyword(null, prop)) {
+        subject[prop] = input[prop];
         continue;
       }
 
-      // determine if property @type is @id
-      var isId = _isKeyword(state.keywords,
-        jsonld.getContextValue(state.context, prop, '@type'), '@id');
-
-      // normalize objects to array
+      // iterate over objects
       var objects = input[prop];
-      // preserve list
-      if(_isListValue(objects, state.keywords)) {
-        jsonld.addValue(subject, prop, {kwlist: []});
-        objects = objects[kwlist];
-      }
-      var useArray = _isArray(objects);
-      objects = useArray ? objects : [objects];
       for(var i in objects) {
         var o = objects[i];
 
-        // determine if property
-
-        // get subject @id from expanded or compact form
-        var sid = null;
-        if(_isSubject(o, state.keywords) ||
-          _isSubjectReference(o, state.keywords)) {
-          sid = o[kwid];
-        }
-        else if(_isString(o) && isId) {
-          sid = o;
-          o = {};
-          o[kwid] = sid;
-        }
-
-        // regular subject
-        if(sid !== null && (kwid in o) && sid.indexOf('_:') !== 0) {
-          // add a reference, use an array
-          var ref;
-          if(isId) {
-            ref = sid;
+        // handle embedded subject or subject reference
+        if(_isSubject(o) || _isSubjectReference(o)) {
+          // rename blank node @id
+          var id = ('@id' in o) ? o['@id'] : '_:';
+          if(id.indexOf('_:') === 0) {
+            id = namer.getName(id);
           }
-          else {
-            ref = {};
-            ref[kwid] = sid;
-          }
-          jsonld.addValue(subject, prop, ref, useArray);
-
-          // recurse
-          _getFramingSubjects(state, o, namer);
-        }
-        // blank node subject
-        else if(sid !== null) {
-          // add a reference
-          var oName = namer.getName(sid);
-          var ref;
-          if(isId) {
-            ref = oName;
-          }
-          else {
-            ref = {};
-            ref[kwid] = oName;
-          }
-          jsonld.addValue(subject, prop, ref, useArray);
-
-          // recurse
-          _getFramingSubjects(state, o, namer, oName);
+
+          // add reference and recurse
+          jsonld.addValue(subject, prop, {'@id': id}, true);
+          _flatten(subjects, o, namer, id, null);
         }
         else {
-          // add value
-          jsonld.addValue(subject, prop, o, useArray);
+          // recurse into list
+          if(_isListValue(o)) {
+            var l = [];
+            _flatten(subjects, o['@list'], namer, name, l);
+            o = {'@list': l};
+          }
+
+          // add non-subject
+          jsonld.addValue(subject, prop, o, true);
         }
       }
     }
   }
+  // add non-object to list
+  else if(list) {
+    list.push(input);
+  }
 }
 
 /**
@@ -1826,28 +2101,33 @@
  * @param subjects the subjects to filter.
  * @param frame the frame.
  * @param parent the parent subject or top-level array.
- * @param property the parent property, null for an array parent.
+ * @param property the parent property, initialized to null.
  */
 function _frame(state, subjects, frame, parent, property) {
   // validate the frame
   _validateFrame(state, frame);
+  frame = frame[0];
 
   // filter out subjects that match the frame
   var matches = _filterSubjects(state, subjects, frame);
 
   // get flags for current frame
   var options = state.options;
-  var embedOn = _getFrameFlag(state, frame, options, 'embed');
-  var explicitOn = _getFrameFlag(state, frame, options, 'explicit');
-
-  // get keyword for @id
-  var kwid = state.keywords['@id'];
+  var embedOn = _getFrameFlag(frame, options, 'embed');
+  var explicitOn = _getFrameFlag(frame, options, 'explicit');
 
   // add matches to output
   for(var id in matches) {
+    /* Note: In order to treat each top-level match as a compartmentalized
+    result, create an independent copy of the embedded subjects map when the
+    property is null, which only occurs at the top-level. */
+    if(property === null) {
+      state.embeds = {};
+    }
+
     // start output
     var output = {};
-    output[kwid] = id;
+    output['@id'] = id;
 
     // prepare embed meta info
     var embed = {parent: parent, property: property};
@@ -1892,7 +2172,7 @@
       var subject = matches[id];
       for(var prop in subject) {
         // copy keywords to output
-        if(_isKeyword(state.keywords, prop)) {
+        if(_isKeyword(null, prop)) {
           output[prop] = _clone(subject[prop]);
           continue;
         }
@@ -1906,34 +2186,39 @@
           continue;
         }
 
-        // determine if property @type is @id
-        var isId = _isKeyword(state.keywords,
-          jsonld.getContextValue(state.context, prop, '@type'), '@id');
-
         // add objects
         var objects = subject[prop];
-        // preserve list
-        if(_isListValue(objects, state.keywords)) {
-          jsonld.addValue(output, prop, {'@list': []});
-          objects = objects['@list'];
-        }
-        objects = _isArray(objects) ? objects : [objects];
         for(var i in objects) {
           var o = objects[i];
 
-          // get subject @id from expanded or compact form
-          var sid = null;
-          if(_isSubjectReference(o, state.keywords)) {
-            sid = o[kwid];
+          // recurse into list
+          if(_isListValue(o)) {
+            // add empty list
+            var list = {'@list': []};
+            _addFrameOutput(state, output, prop, list);
+
+            // add list objects
+            var src = o['@list'];
+            for(var n in src) {
+              o = src[n];
+              // recurse into subject reference
+              if(_isSubjectReference(o)) {
+                var _subjects = {};
+                _subjects[o['@id']] = o;
+                _frame(state, _subjects, frame[prop], list, '@list');
+              }
+              // include other values automatically
+              else {
+                _addFrameOutput(state, list, '@list', _clone(o));
+              }
+            }
+            continue;
           }
-          else if(_isString(o) && isId) {
-            sid = o;
-          }
-
-          // recurse into sub-subjects
-          if(sid !== null) {
+
+          // recurse into subject reference
+          if(_isSubjectReference(o)) {
             var _subjects = {};
-            _subjects[sid] = o;
+            _subjects[o['@id']] = o;
             _frame(state, _subjects, frame[prop], output, prop);
           }
           // include other values automatically
@@ -1943,33 +2228,23 @@
         }
       }
 
-      var kwdefault = state.keywords['@default'];
+      // handle defaults
       for(var prop in frame) {
         // skip keywords
-        if(_isKeyword(state.keywords, prop)) {
+        if(_isKeyword(null, prop)) {
           continue;
         }
 
         // if omit default is off, then include default values for properties
         // that appear in the next frame but are not in the matching subject
-        var next = frame[prop];
-        var omitDefaultOn = _getFrameFlag(state, next, options, 'omitDefault');
+        var next = frame[prop][0];
+        var omitDefaultOn = _getFrameFlag(next, options, 'omitDefault');
         if(!omitDefaultOn && !(prop in output)) {
-          if(kwdefault in next) {
-            output[prop] = _clone(next[kwdefault]);
+          var preserve = '@null';
+          if('@default' in next) {
+            preserve = _clone(next['@default']);
           }
-          // no frame @default, use [] for @set/@list and null otherwise
-          else {
-            var container = jsonld.getContextValue(
-              state.context, prop, '@container');
-            if(_isKeyword(state.keywords, container, '@set') ||
-              _isKeyword(state.keywords, container, '@list')) {
-              output[prop] = [];
-            }
-            else {
-              output[prop] = null;
-            }
-          }
+          output[prop] = {'@preserve': preserve};
         }
       }
 
@@ -1982,16 +2257,15 @@
 /**
  * Gets the frame flag value for the given flag name.
  *
- * @param state the current framing state.
  * @param frame the frame.
  * @param options the framing options.
  * @param name the flag name.
  *
  * @return the flag value.
  */
-function _getFrameFlag(state, frame, options, name) {
-  var kw = state.keywords['@' + name];
-  return (kw in frame) ? frame[kw] : options[name];
+function _getFrameFlag(frame, options, name) {
+  var flag = '@' + name;
+  return (flag in frame) ? frame[flag][0] : options[name];
 };
 
 /**
@@ -2001,9 +2275,9 @@
  * @param frame the frame to validate.
  */
 function _validateFrame(state, frame) {
-  if(!_isObject(frame)) {
+  if(!_isArray(frame) || frame.length !== 1 || !_isObject(frame[0])) {
     throw new JsonLdError(
-      'Invalid JSON-LD syntax; a JSON-LD frame must be an object.',
+      'Invalid JSON-LD syntax; a JSON-LD frame must be a single object.',
       'jsonld.SyntaxError',
       {frame: frame});
   }
@@ -2022,7 +2296,7 @@
   var rval = {};
   for(var id in subjects) {
     var subject = state.subjects[id];
-    if(_filterSubject(state, subject, frame)) {
+    if(_filterSubject(subject, frame)) {
       rval[id] = subject;
     }
   }
@@ -2032,46 +2306,33 @@
 /**
  * Returns true if the given subject matches the given frame.
  *
- * @param state the current frame state.
  * @param subject the subject to check.
  * @param frame the frame to check.
  *
  * @return true if the subject matches, false if not.
  */
-function _filterSubject(state, subject, frame) {
-  var rval = false;
-
-  // check @type
-  var kwtype = state.keywords['@type'];
-  if(kwtype in frame && !_isObject(frame[kwtype])) {
-    // normalize to array
-    var types = frame[kwtype];
-    types = _isArray(types) ? types : [types];
+function _filterSubject(subject, frame) {
+  // check @type (object value means 'any' type, fall through to ducktyping)
+  if('@type' in frame &&
+    !(frame['@type'].length === 1 && _isObject(frame['@type'][0]))) {
+    var types = frame['@type'];
     for(var i in types) {
-      if(jsonld.hasValue(subject, kwtype, types[i])) {
-        rval = true;
-        break;
+      // any matching @type is a match
+      if(jsonld.hasValue(subject, '@type', types[i])) {
+        return true;
       }
     }
+    return false;
+  }
+
+  // check ducktype
+  for(var key in frame) {
+    // only not a duck if @id or non-keyword isn't in subject
+    if((key === '@id' || !_isKeyword(null, key)) && !(key in subject)) {
+      return false;
+    }
   }
-  // check ducktype
-  else {
-    rval = true;
-    var kwid = state.keywords['@id'];
-    for(var key in frame) {
-      // skip non-@id keywords
-      if(key !== kwid && _isKeyword(state.keywords, key)) {
-        continue;
-      }
-
-      if(!(key in subject)) {
-        rval = false;
-        break;
-      }
-    }
-  }
-
-  return rval;
+  return true;
 }
 
 /**
@@ -2084,42 +2345,34 @@
  * @param output the output.
  */
 function _embedValues(state, subject, property, output) {
-  var kwid = state.keywords['@id'];
-
-  // normalize to an array
+  // embed subject properties in output
   var objects = subject[property];
-  // preserve list
-  if(_isListValue(objects, state.keywords)) {
-    jsonld.addValue(output, property, {'@list': []});
-    objects = objects['@list'];
-  }
-  objects = _isArray(objects) ? objects : [objects];
   for(var i in objects) {
     var o = objects[i];
 
-    // get subject @id from expanded or compact form
-    var sid = null;
-    if(_isSubjectReference(o, state.keywords)) {
-      sid = o[kwid];
+    // recurse into @list
+    if(_isListValue(o)) {
+      var list = {'@list': []};
+      _addFrameOutput(state, output, property, list);
+      return _embedValues(state, o, '@list', list['@list']);
     }
-    else if(_isString(o) && _isKeyword(state.keywords,
-      jsonld.getContextValue(state.context, property, '@type'), '@id')) {
-      sid = o;
-    }
-
-    if(sid !== null) {
+
+    // handle subject reference
+    if(_isSubjectReference(o)) {
+      var id = o['@id'];
+
       // embed full subject if isn't already embedded
-      if(!(sid in state.embeds)) {
+      if(!(id in state.embeds)) {
         // add embed
         var embed = {parent: output, property: property};
-        state.embeds[sid] = embed;
+        state.embeds[id] = embed;
 
         // recurse into subject
         o = {};
-        var s = state.subjects[sid];
+        var s = state.subjects[id];
         for(var prop in s) {
           // copy keywords
-          if(_isKeyword(state.keywords, prop)) {
+          if(_isKeyword(null, prop)) {
             o[prop] = _clone(s[prop]);
             continue;
           }
@@ -2128,6 +2381,7 @@
       }
       _addFrameOutput(state, output, property, o);
     }
+    // copy non-subject value
     else {
       _addFrameOutput(state, output, property, _clone(o));
     }
@@ -2148,26 +2402,14 @@
   var property = embed.property;
 
   // create reference to replace embed
-  var subject = {};
-  var ref;
-  var kwid = state.keywords['@id'];
-  if(property !== null && _isKeyword(state.keywords,
-    jsonld.getContextValue(state.context, property, '@type'), '@id')) {
-    ref = id;
-    subject[kwid] = id;
-  }
-  else {
-    ref = {};
-    ref[kwid] = id;
-    subject[kwid] = id;
-  }
+  var subject = {'@id': id};
 
   // remove existing embed
   if(_isArray(parent)) {
     // replace subject with reference
     for(var i in parent) {
       if(jsonld.compareValues(parent[i], subject)) {
-        parent[i] = ref;
+        parent[i] = subject;
         break;
       }
     }
@@ -2176,7 +2418,7 @@
     // replace subject with reference
     var useArray = _isArray(parent[property]);
     jsonld.removeValue(parent, property, subject, useArray);
-    jsonld.addValue(parent, property, ref, useArray);
+    jsonld.addValue(parent, property, subject, useArray);
   }
 
   // recursively remove dependent dangling embeds
@@ -2186,7 +2428,7 @@
     for(var i in ids) {
       var next = ids[i];
       if(next in embeds && _isObject(embeds[next].parent) &&
-        embeds[next].parent[kwid] === id) {
+        embeds[next].parent['@id'] === id) {
         delete embeds[next];
         removeDependents(next);
       }
@@ -2200,21 +2442,12 @@
  *
  * @param state the current framing state.
  * @param parent the parent to add to.
- * @param property the parent property, null for an array parent.
+ * @param property the parent property.
  * @param output the output to add.
  */
 function _addFrameOutput(state, parent, property, output) {
   if(_isObject(parent)) {
-    // get keywords
-    var kwset = state.keywords['@set'];
-    var kwlist = state.keywords['@list'];
-    var kwcontainer = state.keywords['@container'];
-
-    // use an array if @container specifies it
-    var ctx = state.context;
-    var container = jsonld.getContextValue(ctx, property, kwcontainer);
-    var useArray = (container === kwset) || (container === kwlist);
-    jsonld.addValue(parent, property, output, useArray);
+    jsonld.addValue(parent, property, output, true);
   }
   else {
     parent.push(output);
@@ -2222,116 +2455,210 @@
 }
 
 /**
- * Optimally type-compacts a value.
+ * Removes the @preserve keywords as the last step of the framing algorithm.
  *
- * @param ctx the current context.
- * @param property the compacted property associated with the value.
- * @param value the value to type-compact.
- * @param optimizeCtx the context used to store optimization definitions.
+ * @param input the framed, compacted output.
  *
- * @return the optimally type-compacted value.
+ * @return the resulting output.
  */
-function _optimalTypeCompact(ctx, property, value, optimizeCtx) {
-  // only arrays and objects can be further optimized
-  if(!_isArray(value) && !_isObject(value)) {
-    return value;
-  }
-
-  // if @type is already in the context, value is already optimized
-  if(jsonld.getContextValue(ctx, property, '@type')) {
-    return value;
-  }
-
-  // if every value is the same type, optimization is possible
-  var values = _isArray(value) ? value : [value];
-  var type = null;
-  for(var i = 0; i < values.length; ++i) {
-    // val can only be a subject reference or a @value with no @language
-    var val = values[i];
-    var vtype = null;
-    if(_canTypeCompact(val)) {
-      if(_isSubjectReference(val)) {
-        vtype = '@id';
-      }
-      // must be a @value with no @language
-      else if('@type' in val) {
-        vtype = val['@type'];
-      }
+function _removePreserve(input) {
+  // recurse through arrays
+  if(_isArray(input)) {
+    for(var i in input) {
+      input[i] = _removePreserve(input[i]);
     }
-
-    if(i === 0) {
-      type = vtype;
-    }
-
-    // no type or type difference, can't compact
-    if(type === null || !_compareTypes(type, vtype)) {
-      return value;
+    // drop null-only arrays
+    if(input.length === 1 && input[0] === null) {
+      input = [];
     }
   }
-
-  // all values have same type so can be compacted, add @type to context
-  jsonld.setContextValue(optimizeCtx, property, '@type', _clone(type));
-
-  // do compaction
-  if(_isArray(value)) {
-    for(var i in value) {
-      var val = value[i];
-      if(_isSubjectReference(value[i])) {
-        value[i] = val['@id'];
+  else if(_isObject(input)) {
+    // remove @preserve
+    if('@preserve' in input) {
+      if(input['@preserve'] === '@null') {
+        return null;
       }
-      else {
-        value[i] = val['@value'];
-      }
+      return input['@preserve'];
+    }
+
+    // skip @values
+    if(_isValue(input)) {
+      return input;
+    }
+
+    // recurse through @lists
+    if(_isListValue(input)) {
+      input['@list'] = _removePreserve(input['@list']);
+      return input;
+    }
+
+    // recurse through properties
+    for(var prop in input) {
+      input[prop] = _removePreserve(input[prop]);
     }
   }
-  else if(_isSubjectReference(value)) {
-    value = value['@id'];
-  }
-  else {
-    value = value['@value'];
-  }
-
-  return value;
+  return input;
 }
 
 /**
- * Compacts an IRI into a term or prefix if it can be.
+ * Compares two strings first based on length and then lexicographically.
+ *
+ * @param a the first string.
+ * @param b the second string.
+ *
+ * @return -1 if a < b, 1 if a > b, 0 if a == b.
+ */
+function _compareShortestLeast(a, b) {
+  if(a.length < b.length) {
+    return -1;
+  }
+  else if(b.length < a.length) {
+    return 1;
+  }
+  return (a < b) ? -1 : ((a > b) ? 1 : 0);
+}
+
+/**
+ * Checks to see if a context key's type definition best matches the
+ * given value and @container.
+ *
+ * @param ctx the context.
+ * @param key the context key to check.
+ * @param value the value to check.
+ * @param container the specific @container to match or null.
+ * @param result the resulting term or CURIE.
+ * @param results the results array.
+ * @param bestMatch the current bestMatch value.
+ *
+ * @return the new bestMatch value.
+ */
+function _isBestMatch(ctx, key, value, container, result, results, bestMatch) {
+  // value is null, match any key
+  if(value === null) {
+    results.push(result);
+    return bestMatch;
+  }
+
+  var valueIsList = _isListValue(value);
+  var valueHasType = ('@type' in value);
+  var language = ('@language' in value) ? value['@language'] : null;
+  var entry = jsonld.getContextValue(ctx, key);
+  if(_isString(entry)) {
+    entry = {'@id': entry};
+  }
+  var entryHasContainer = ('@container' in entry);
+  var entryType = jsonld.getContextValue(ctx, key, '@type');
+
+  // container with type or language
+  if(!valueIsList && entryHasContainer &&
+    (entry['@container'] === container ||
+      (entry['@container'] === '@set' && container === null)) &&
+    ((valueHasType && entryType === value['@type']) ||
+    (!valueHasType && entry['@language'] === language))) {
+    if(bestMatch < 3) {
+      bestMatch = 3;
+      results.length = 0;
+    }
+    results.push(result);
+  }
+  // no container with type or language
+  else if(bestMatch < 3 &&
+    !entryHasContainer && !valueIsList &&
+    ((valueHasType && entryType === value['@type']) ||
+      (!valueHasType && entry['@language'] === language))) {
+    if(bestMatch < 2) {
+      bestMatch = 2;
+      results.length = 0;
+    }
+    results.push(result);
+  }
+  // container with no type or language
+  else if(bestMatch < 2 &&
+    entryHasContainer &&
+    (entry['@container'] === container ||
+      (entry['@container'] === '@set' && container === null)) &&
+    !('@type' in entry) && !('@language' in entry)) {
+    if(bestMatch < 1) {
+      bestMatch = 1;
+      results.length = 0;
+    }
+    results.push(result);
+  }
+  // no container, no type, no language
+  else if(bestMatch < 1 &&
+    !entryHasContainer && !('@type' in entry) && !('@language' in entry)) {
+    results.push(result);
+  }
+
+  return bestMatch;
+}
+
+/**
+ * Compacts an IRI or keyword into a term or prefix if it can be. If the
+ * IRI has an associated value, its @type, @language, and/or @container may
+ * be passed.
  *
  * @param ctx the context to use.
  * @param iri the IRI to compact.
+ * @param value the value to check or null.
+ * @param container the specific @container to match or null.
  *
  * @return the compacted IRI as a term or prefix or the original IRI.
  */
-function _compactIri(ctx, iri) {
+function _compactIri(ctx, iri, value, container) {
   // can't compact null
   if(iri === null) {
     return iri;
   }
 
-  // check the context for a term that could shorten the IRI
+  // if term is a keyword, use alias
+  if(_isKeyword(null, iri)) {
+    // pick shortest, least alias
+    var keywords = _getKeywords(ctx);
+    aliases = keywords[iri];
+    if(aliases.length > 0) {
+      aliases.sort(_compareShortestLeast);
+      return aliases[0];
+    }
+    else {
+      // no alias, keep original keyword
+      return iri;
+    }
+  }
+
+  // default value and container to null
+  if(_isUndefined(value)) {
+    value = null;
+  }
+  if(_isUndefined(container)) {
+    container = null;
+  }
+
+  // check the context for terms that could shorten the IRI
   // (give preference to terms over prefixes)
+  var terms = [];
+  var bestMatch = 0;
   for(var key in ctx) {
     // skip special context keys (start with '@')
     if(key.indexOf('@') === 0) {
       continue;
     }
-
-    // FIXME: there might be more than one choice, choose the most
-    // specific definition and if none is more specific, choose
-    // the lexicographically least term
     // compact to a term
     if(iri === jsonld.getContextValue(ctx, key, '@id')) {
-      return key;
+      bestMatch = _isBestMatch(
+        ctx, key, value, container, key, terms, bestMatch);
     }
   }
 
-  // term not found, if term is keyword, use alias
-  var keywords = _getKeywords(ctx);
-  if(iri in keywords) {
-    return keywords[iri];
+  if(terms.length > 0) {
+    // pick shortest, least term
+    terms.sort(_compareShortestLeast);
+    return terms[0];
   }
 
   // term not found, check the context for a prefix
+  var curies = [];
+  bestMatch = 0;
   for(var key in ctx) {
     // skip special context keys (start with '@')
     if(key.indexOf('@') === 0) {
@@ -2344,11 +2671,19 @@
       // compact to a prefix
       var idx = iri.indexOf(ctxIri);
       if(idx === 0 && iri.length > ctxIri.length) {
-        return key + ':' + iri.substr(ctxIri.length);
+        var curie = key + ':' + iri.substr(ctxIri.length);
+        bestMatch = _isBestMatch(
+          ctx, key, value, container, curie, curies, bestMatch);
       }
     }
   }
 
+  if(curies.length > 0) {
+    // pick shortest, least curie
+    curies.sort(_compareShortestLeast);
+    return curies[0];
+  }
+
   // could not compact IRI, return it as is
   return iri;
 }
@@ -2365,10 +2700,15 @@
  * @return the expanded term as an absolute IRI.
  */
 function _expandTerm(ctx, term, deep) {
+  // nothing to expand
+  if(term === null) {
+    return null;
+  }
+
   // default to the term being fully-expanded or not in the context
   var rval = term;
 
-  // 1. If the property has a colon, it is a prefix or an absolute IRI:
+  // 1. If the term has a colon, it is a prefix or an absolute IRI:
   var idx = term.indexOf(':');
   if(idx !== -1) {
     // get the potential prefix
@@ -2377,19 +2717,19 @@
     // expand term if prefix is in context, otherwise leave it be
     if(prefix in ctx) {
       // prefix found, expand property to absolute IRI
-      var iri = jsonld.getContextValue(ctx, prefix, '@id');
+      var iri = jsonld.getContextValue(ctx, prefix, '@id', false);
       rval = iri + term.substr(idx + 1);
     }
   }
-  // 2. If the property is in the context, then it's a term.
+  // 2. If the term is in the context, then it's a term.
   else if(term in ctx) {
     rval = jsonld.getContextValue(ctx, term, '@id', false);
   }
-  // 3. The property is a keyword or not in the context.
+  // 3. The term is a keyword or not in the context.
   else {
     var keywords = _getKeywords(ctx);
     for(var key in keywords) {
-      if(term === keywords[key]) {
+      if(keywords[key].indexOf(term) !== -1) {
         rval = key;
         break;
       }
@@ -2428,39 +2768,35 @@
  */
 function _getKeywords(ctx) {
   var rval = {
-    '@context': '@context',
-    '@container': '@container',
-    '@default': '@default',
-    '@embed': '@embed',
-    '@explicit': '@explicit',
-    '@graph': '@graph',
-    '@id': '@id',
-    '@language': '@language',
-    '@list': '@list',
-    '@omitDefault': '@omitDefault',
-    '@set': '@set',
-    '@type': '@type',
-    '@value': '@value'
+    '@context': [],
+    '@container': [],
+    '@default': [],
+    '@embed': [],
+    '@explicit': [],
+    '@graph': [],
+    '@id': [],
+    '@language': [],
+    '@list': [],
+    '@omitDefault': [],
+    '@preserve': [],
+    '@set': [],
+    '@type': [],
+    '@value': []
   };
 
   if(ctx) {
     // gather keyword aliases from context
-    var keywords = {};
     for(var key in ctx) {
-      if(_isString(ctx[key]) && ctx[key] in rval) {
-        if(ctx[key] === '@context') {
+      var kw = ctx[key];
+      if(_isString(kw) && kw in rval) {
+        if(kw === '@context' || kw === '@preserve') {
           throw new JsonLdError(
-            'Invalid JSON-LD syntax; @context cannot be aliased.',
+            'Invalid JSON-LD syntax; @context and @preserve cannot be aliased.',
             'jsonld.SyntaxError');
         }
-        keywords[ctx[key]] = key;
+        rval[kw].push(key);
       }
     }
-
-    // overwrite keywords
-    for(var key in keywords) {
-      rval[key] = keywords[key];
-    }
   }
 
   return rval;
@@ -2469,34 +2805,42 @@
 /**
  * Returns whether or not the given value is a keyword (or a keyword alias).
  *
- * @param keywords the map of keyword aliases to check against.
+ * @param keywords the keyword alias map to check against, null for default.
  * @param value the value to check.
  * @param [specific] the specific keyword to check against.
  *
  * @return true if the value is a keyword, false if not.
  */
 function _isKeyword(keywords, value, specific) {
-  switch(value) {
-  case '@container':
-  case '@default':
-  case '@embed':
-  case '@explicit':
-  case '@graph':
-  case '@id':
-  case '@language':
-  case '@list':
-  case '@omitDefault':
-  case '@set':
-  case '@type':
-  case '@value':
-    return _isUndefined(specific) ? true : (value === specific);
-  default:
+  if(keywords) {
+    if(value in keywords) {
+      return _isUndefined(specific) ? true : (value === specific);
+    }
     for(var key in keywords) {
-      if(value === keywords[key]) {
+      if(keywords[key].indexOf(value) !== -1) {
         return _isUndefined(specific) ? true : (key === specific);
       }
     }
   }
+  else {
+    switch(value) {
+    case '@context':
+    case '@container':
+    case '@default':
+    case '@embed':
+    case '@explicit':
+    case '@graph':
+    case '@id':
+    case '@language':
+    case '@list':
+    case '@omitDefault':
+    case '@preserve':
+    case '@set':
+    case '@type':
+    case '@value':
+      return true;
+    }
+  }
   return false;
 }
 
@@ -2512,6 +2856,17 @@
 }
 
 /**
+ * Returns true if the given input is an empty Object.
+ *
+ * @param input the input to check.
+ *
+ * @return true if the input is an empty Object, false if not.
+ */
+function _isEmptyObject(input) {
+  return _isObject(input) && Object.keys(input).length === 0;
+}
+
+/**
  * Returns true if the given input is an Array.
  *
  * @param input the input to check.
@@ -2523,6 +2878,25 @@
 }
 
 /**
+ * Returns true if the given input is an Array of Strings.
+ *
+ * @param input the input to check.
+ *
+ * @return true if the input is an Array of Strings, false if not.
+ */
+function _isArrayOfStrings(input) {
+  if(!_isArray(input)) {
+    return false;
+  }
+  for(var i in input) {
+    if(!_isString(input[i])) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/**
  * Returns true if the given input is a String.
  *
  * @param input the input to check.
@@ -2581,25 +2955,20 @@
  * Returns true if the given value is a subject with properties.
  *
  * @param value the value to check.
- * @param [keywords] the keywords map to use.
  *
  * @return true if the value is a subject with properties, false if not.
  */
-function _isSubject(value, keywords) {
+function _isSubject(value) {
   var rval = false;
 
   // Note: A value is a subject if all of these hold true:
   // 1. It is an Object.
   // 2. It is not a @value, @set, or @list.
   // 3. It has more than 1 key OR any existing key is not @id.
-  var kwvalue = keywords ? keywords['@value'] : '@value';
-  var kwset = keywords ? keywords['@set'] : '@set';
-  var kwlist = keywords ? keywords['@list'] : '@list';
-  var kwid = keywords ? keywords['@id'] : '@id';
   if(_isObject(value) &&
-    !((kwvalue in value) || (kwset in value) || (kwlist in value))) {
+    !(('@value' in value) || ('@set' in value) || ('@list' in value))) {
     var keyCount = Object.keys(value).length;
-    rval = (keyCount > 1 || !(kwid in value));
+    rval = (keyCount > 1 || !('@id' in value));
   }
 
   return rval;
@@ -2609,144 +2978,85 @@
  * Returns true if the given value is a subject reference.
  *
  * @param value the value to check.
- * @param [keywords] the keywords map to use.
  *
  * @return true if the value is a subject reference, false if not.
  */
-function _isSubjectReference(value, keywords) {
+function _isSubjectReference(value) {
   // Note: A value is a subject reference if all of these hold true:
   // 1. It is an Object.
   // 2. It has a single key: @id.
-  var kwid = keywords ? keywords['@id'] : '@id';
-  return _isObject(value) && Object.keys(value).length === 1 && (kwid in value);
+  return (_isObject(value) && Object.keys(value).length === 1 &&
+    ('@id' in value));
 }
 
 /**
  * Returns true if the given value is a @value.
  *
  * @param value the value to check.
- * @param [keywords] the keywords map to use.
  *
  * @return true if the value is a @value, false if not.
  */
-function _isValue(value, keywords) {
+function _isValue(value) {
   // Note: A value is a @value if all of these hold true:
   // 1. It is an Object.
   // 2. It has the @value property.
-  var kwvalue = keywords ? keywords['@value'] : '@value';
-  return _isObject(value) && (kwvalue in value);
+  return _isObject(value) && ('@value' in value);
 }
 
 /**
  * Returns true if the given value is a @set.
  *
  * @param value the value to check.
- * @param [keywords] the keywords map to use.
  *
  * @return true if the value is a @set, false if not.
  */
-function _isSetValue(value, keywords) {
+function _isSetValue(value) {
   // Note: A value is a @set if all of these hold true:
   // 1. It is an Object.
   // 2. It has the @set property.
-  var kwset = keywords ? keywords['@set'] : '@set';
-  return _isObject(value) && (kwset in value);
+  return _isObject(value) && ('@set' in value);
 }
 
 /**
  * Returns true if the given value is a @list.
  *
  * @param value the value to check.
- * @param [keywords] the keywords map to use.
  *
  * @return true if the value is a @list, false if not.
  */
-function _isListValue(value, keywords) {
+function _isListValue(value) {
   // Note: A value is a @list if all of these hold true:
   // 1. It is an Object.
   // 2. It has the @list property.
-  var kwlist = keywords ? keywords['@list'] : '@list';
-  return _isObject(value) && (kwlist in value);
+  return _isObject(value) && ('@list' in value);
 }
 
 /**
  * Returns true if the given value is a blank node.
  *
  * @param value the value to check.
- * @param [keywords] the keywords map to use.
  *
  * @return true if the value is a blank node, false if not.
  */
-function _isBlankNode(value, keywords) {
+function _isBlankNode(value) {
   var rval = false;
   // Note: A value is a blank node if all of these hold true:
   // 1. It is an Object.
   // 2. If it has an @id key its value begins with '_:'.
   // 3. It has no keys OR is not a @value, @set, or @list.
-  var kwvalue = keywords ? keywords['@value'] : '@value';
-  var kwset = keywords ? keywords['@set'] : '@set';
-  var kwlist = keywords ? keywords['@list'] : '@list';
-  var kwid = keywords ? keywords['@id'] : '@id';
   if(_isObject(value)) {
-    if(kwid in value) {
-      rval = (value[kwid].indexOf('_:') === 0);
+    if('@id' in value) {
+      rval = (value['@id'].indexOf('_:') === 0);
     }
     else {
       rval = (Object.keys(value).length === 0 ||
-        !((kwvalue in value) || (kwset in value) || (kwlist in value)));
+        !(('@value' in value) || ('@set' in value) || ('@list' in value)));
     }
   }
   return rval;
 }
 
 /**
- * Returns true if the given value can be possibly compacted based on type.
- *
- * Subject references and @values can be possibly compacted, however, a @value
- * must not have a @language or type-compaction would cause data loss.
- *
- * @param value the value to check.
- *
- * @return true if the value can be possibly type-compacted, false if not.
- */
-function _canTypeCompact(value) {
-  // Note: It may be possible to type-compact a value if all these hold true:
-  // 1. It is an Object.
-  // 2. It is a subject reference OR a @value with no @language.
-  return (_isObject(value) && (_isSubjectReference(value) ||
-    (_isValue(value) && !('@language' in value))));
-}
-
-/**
- * Compares types for equality. The given types can be arrays or strings, and
- * it is assumed that they are all in the same expanded/compacted state. If
- * both types are the same or, in the case of arrays of types, if both type
- * arrays contain the same types, they are equal.
- *
- * @param type1 the first type(s) to compare.
- * @param type2 the second types(s) to compare.
- *
- * @return true if the types are equal, false if not.
- */
-function _compareTypes(type1, type2) {
-  // normalize to arrays
-  type1 = _isArray(type1) ? type1.sort() : [type1];
-  type2 = _isArray(type2) ? type2.sort() : [type2];
-
-  if(type1.length !== type2.length) {
-    return false;
-  }
-
-  for(var i in type1) {
-    if(type1[i] !== type2[i]) {
-      return false;
-    }
-  }
-
-  return true;
-}
-
-/**
  * Returns true if the given value is an absolute IRI, false if not.
  *
  * @param value the value to check.
@@ -2754,7 +3064,7 @@
  * @return true if the value is an absolute IRI, false if not.
  */
 function _isAbsoluteIri(value) {
-  return /(\w+):\/\/(.+)/.test(value);
+  return (/\w+:\/\/.+/).test(value);
 }
 
 /**
@@ -2936,26 +3246,38 @@
 }
 
 /**
- * Creates a new BlankNodeNamer. A BlankNodeNamer issues blank node names
- * to blank nodes, keeping track of any previously issued names.
+ * Creates a new UniqueNamer. A UniqueNamer issues unique names, keeping
+ * track of any previously issued names.
  *
- * @param prefix the prefix to use ('_:<prefix>').
+ * @param prefix the prefix to use ('<prefix><counter>').
  */
-var BlankNodeNamer = function(prefix) {
-  this.prefix = '_:' + prefix;
+var UniqueNamer = function(prefix) {
+  this.prefix = prefix;
   this.counter = 0;
   this.existing = {};
 };
 
 /**
- * Gets the new blank node name for the given old name, where if no old name
- * is given a new name will be generated.
+ * Copies this UniqueNamer.
+ *
+ * @return a copy of this UniqueNamer.
+ */
+UniqueNamer.prototype.clone = function() {
+  var copy = new UniqueNamer(this.prefix);
+  copy.counter = this.counter;
+  copy.existing = _clone(this.existing);
+  return copy;
+};
+
+/**
+ * Gets the new name for the given old name, where if no old name is given
+ * a new name will be generated.
  *
  * @param [oldName] the old name to get the new name for.
  *
  * @return the new name.
  */
-BlankNodeNamer.prototype.getName = function(oldName) {
+UniqueNamer.prototype.getName = function(oldName) {
   // return existing old name
   if(oldName && oldName in this.existing) {
     return this.existing[oldName];
@@ -2983,13 +3305,110 @@
  *
  * @return true if the oldName has been assigned a new name, false if not.
  */
-BlankNodeNamer.prototype.isNamed = function(oldName) {
+UniqueNamer.prototype.isNamed = function(oldName) {
   return (oldName in this.existing);
 };
 
+/**
+ * A Permutator iterates over all possible permutations of the given array
+ * of elements.
+ *
+ * @param list the array of elements to iterate over.
+ */
+Permutator = function(list) {
+  // original array
+  this.list = list.sort();
+  // indicates whether there are more permutations
+  this.done = false;
+  // directional info for permutation algorithm
+  this.left = {};
+  for(var i in list) {
+    this.left[list[i]] = true;
+  }
+};
+
+/**
+ * Returns true if there is another permutation.
+ *
+ * @return true if there is another permutation, false if not.
+ */
+Permutator.prototype.hasNext = function() {
+  return !this.done;
+};
+
+/**
+ * Gets the next permutation. Call hasNext() to ensure there is another one
+ * first.
+ *
+ * @return the next permutation.
+ */
+Permutator.prototype.next = function() {
+  // copy current permutation
+  var rval = this.list.slice();
+
+  /* Calculate the next permutation using the Steinhaus-Johnson-Trotter
+   permutation algorithm. */
+
+  // get largest mobile element k
+  // (mobile: element is greater than the one it is looking at)
+  var k = null;
+  var pos = 0;
+  var length = this.list.length;
+  for(var i = 0; i < length; ++i) {
+    var element = this.list[i];
+    var left = this.left[element];
+    if((k === null || element > k) &&
+      ((left && i > 0 && element > this.list[i - 1]) ||
+      (!left && i < (length - 1) && element > this.list[i + 1]))) {
+      k = element;
+      pos = i;
+    }
+  }
+
+  // no more permutations
+  if(k === null) {
+    this.done = true;
+  }
+  else {
+    // swap k and the element it is looking at
+    var swap = this.left[k] ? pos - 1 : pos + 1;
+    this.list[pos] = this.list[swap];
+    this.list[swap] = k;
+
+    // reverse the direction of all elements larger than k
+    for(var i = 0; i < length; ++i) {
+      if(this.list[i] > k) {
+        this.left[this.list[i]] = !this.left[this.list[i]];
+      }
+    }
+  }
+
+  return rval;
+};
+
 // SHA-1 API
 var sha1 = jsonld.sha1 = {};
 
+if(_nodejs) {
+  var crypto = require('crypto');
+  sha1.create = function() {
+    var md = crypto.createHash('sha1');
+    return {
+      update: function(data) {
+        md.update(data, 'utf8');
+      },
+      digest: function() {
+        return md.digest('hex');
+      }
+    };
+  };
+}
+else {
+  sha1.create = function() {
+    return new sha1.MessageDigest();
+  };
+}
+
 /**
  * Hashes the given array of triples and returns its hexadecimal SHA-1 message
  * digest.
@@ -2998,25 +3417,13 @@
  *
  * @return the hexadecimal SHA-1 message digest.
  */
-if(_nodejs) {
-  var crypto = require('crypto');
-  sha1.hash = function(triples) {
-    var md = crypto.createHash('sha1');
-    for(var i in triples) {
-      md.update(triples[i], 'utf8');
-    }
-    return md.digest('hex');
-  };
-}
-else {
-  sha1.hash = function(triples) {
-    var md = new sha1.MessageDigest();
-    for(var i in triples) {
-      md.update(triples[i]);
-    }
-    return md.digest();
-  };
-}
+sha1.hash = function(triples) {
+  var md = sha1.create();
+  for(var i in triples) {
+    md.update(triples[i]);
+  }
+  return md.digest();
+};
 
 // only define sha1 MessageDigest for non-nodejs
 if(!_nodejs) {