diff --git a/.travis.yml b/.travis.yml index 6491036..63c844d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,4 @@ language: node_js node_js: - - "4.2" - - "0.12" - - "0.10" + - "lts/*" sudo: false diff --git a/README.md b/README.md index 1e178cb..fdf0ed5 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,138 @@ -## JSURL +# JSURL + +JSURL aims to be a drop-in replacement for JSON encoding with better size and time characteristics. + +JSURL has been designed to be + +- Fast: our test case actually outperforms native JSON +- Compact: shorter output than JSON +- Readable: it leaves accented characters unchanged and doesn't add much characters for encoding +- URI-ready: It does not encode everything that should be URI-encoded, but it does encode all delimiters of query strings. + - You can embed it as part of a query string, and normally you won't need to do any URI encoding yourself (browser/http client will take care of that). + - It will be correctly detected as part of the URI by most auto-URL-from-text implementations. +- Embeddable: + - You can safely put it in ` + + + +
Use the Chrome Devtools Timeline to profile page load. See JS console for timings.
diff --git a/lib/jsurl.js b/lib/jsurl.js index 6a89b90..90792d1 100644 --- a/lib/jsurl.js +++ b/lib/jsurl.js @@ -25,6 +25,8 @@ // (function(exports) { "use strict"; + var hasOwnProperty = new Object().hasOwnProperty; + exports.stringify = function stringify(v) { function encode(s) { return !/[^\w-.]/.test(s) ? s : s.replace(/[^\w-.]/g, function(ch) { @@ -57,7 +59,7 @@ return '~(' + (tmpAry.join('') || '~') + ')'; } else { for (var key in v) { - if (v.hasOwnProperty(key)) { + if (hasOwnProperty.call(v, key)) { var val = stringify(v[key]); // skip undefined and functions @@ -166,4 +168,4 @@ } } -})(typeof exports !== 'undefined' ? exports : (window.JSURL = window.JSURL || {})); +})(typeof exports !== 'undefined' ? exports : (window.JSURL1 = window.JSURL1 || {})); diff --git a/lib/jsurl2.js b/lib/jsurl2.js new file mode 100644 index 0000000..c5a3d8e --- /dev/null +++ b/lib/jsurl2.js @@ -0,0 +1,332 @@ +// TODO custom objects, support Set, Map etc +// TODO custom dictionary +;(function(exports) { + 'use strict' + var hasOwnProperty = new Object().hasOwnProperty + var stringRE = /^[a-zA-Z]/ + var numRE = /^[\d-]/ + var TRUE = '_T' + var FALSE = '_F' + var NULL = '_N' + var UNDEF = '_U' + var NAN = '_n' + var INF = '_I' + var NINF = '_J' + + var dict = { + T: true, + F: false, + N: null, + U: undefined, + n: NaN, + I: Infinity, + J: -Infinity, + } + + var fromEscape = { + '*': '*', + _: '_', + '-': '~', + S: '$', + P: '+', + '"': "'", + C: '(', // not necessary but we keep it for symmetry + D: ')', + L: '<', + G: '>', // not necessary but we keep it for symmetry + '.': '%', + Q: '?', + H: '#', + A: '&', + E: '=', + B: '\\', + N: '\n', + R: '\r', + U: '\u2028', + Z: '\0', + } + var toEscape = { + '*': '*', + _: '_', + '~': '-', + $: 'S', + '+': 'P', + "'": '"', + '(': 'C', + ')': 'D', + '<': 'L', + '>': 'G', + '%': '.', + '?': 'Q', + '#': 'H', + '&': 'A', + '=': 'E', + '\\': 'B', + '\n': 'N', + '\r': 'R', + '\0': 'Z', + '\u2028': 'U', + } + function origChar(s) { + if (s === '_') { + return ' ' + } + var c = fromEscape[s.charAt(1)] + if (!c) { + throw new Error('Illegal escape code', s) + } + return c + } + function escCode(c) { + if (c === ' ') { + return '_' + } + return '*' + toEscape[c] + } + var escapeRE = /(_|\*.)/g + function unescape(s) { + // oddly enough, testing first is faster + return escapeRE.test(s) ? s.replace(escapeRE, origChar) : s + } + // First half: encoding chars; second half: URI and script chars + var replaceRE = /([*_~$+'() <>%?#&=\\\n\r\0\u2028])/g + function escape(s) { + // oddly enough, testing first is faster + return replaceRE.test(s) ? s.replace(replaceRE, escCode) : s + } + function eat(a) { + var j, c + for ( + j = a.i; + j < a.l && ((c = a.s.charAt(j)), c !== '~' && c !== ')'); + j++ + ) {} + var w = a.s.slice(a.i, j) + if (c === '~') { + j++ + } + a.i = j + return w + } + function peek(a) { + return a.s.charAt(a.i) + } + function eatOne(a) { + a.i++ + } + var EOS = {} // unique symbol + function decode(a) { + var out, k, t + var c = peek(a) + if (!c) { + return EOS + } + if (c === '(') { + eatOne(a) + out = {} + while (((c = peek(a)), c && c !== ')')) { + k = unescape(eat(a)) + c = peek(a) + if (c && c !== ')') { + t = decode(a) + } else { + t = true + } + out[k] = t + } + if (c === ')') { + eatOne(a) + } + } else if (c === '!') { + eatOne(a) + out = [] + while (((c = peek(a)), c && c !== '~' && c !== ')')) { + out.push(decode(a)) + } + if (c === '~') { + eatOne(a) + } + } else if (c === '_') { + eatOne(a) + k = unescape(eat(a)) + if (k.charAt(0) === 'D') { + out = new Date(k.slice(1)) + } else if (k in dict) { + out = dict[k] + } else { + throw new Error('Unknown dict reference', k) + } + } else if (c === '*') { + eatOne(a) + out = unescape(eat(a)) + } else if (c === '~') { + eatOne(a) + out = true + } else if (numRE.test(c)) { + out = Number(eat(a)) + if (isNaN(out)) { + throw new Error('Not a number', c) + } + } else if (stringRE.test(c)) { + out = unescape(eat(a)) + } else { + throw new Error('Cannot decode part ' + [t].concat(a).join('~')) + } + return out + } + + function encode(v, out, rich, depth) { + var t, + T = typeof v + + if (T === 'number') { + out.push( + isFinite(v) + ? v.toString() + : rich + ? isNaN(v) + ? NAN + : v > 0 + ? INF + : NINF + : NULL + ) + } else if (T === 'boolean') { + out.push(v ? '' : FALSE) + } else if (T === 'string') { + t = escape(v) + if (stringRE.test(t)) { + out.push(t) + } else { + out.push('*' + t) + } + } else if (T === 'object') { + if (!v) { + out.push(NULL) + } else if (rich && v instanceof Date) { + out.push('_D' + v.toJSON().replace('T00:00:00.000Z', '')) + } else if (typeof v.toJSON === 'function') { + encode(v.toJSON(), out, rich, depth) + } else if (Array.isArray(v)) { + out.push('!') + for (var i = 0; i < v.length; i++) { + t = v[i] + // Special case: only use full -T~ in arrays + if (t === true) { + out.push(TRUE) + } else { + encode(t, out, rich, depth + 1) + } + } + out.push('') + } else { + out.push('(') + for (var key in v) { + if (hasOwnProperty.call(v, key)) { + t = v[key] + if (t !== undefined && typeof t !== 'function') { + out.push(escape(key)) + encode(t, out, rich, depth + 1) + } + } + } + while (out[out.length - 1] === '') { + out.pop() + } + out.push(')') + } + } else { + // function or undefined + out.push(rich || depth === 0 ? UNDEF : NULL) + } + } + + var antiJSON = {true: '*true', false: '*false', null: '*null'} + exports.stringify = function(v, options) { + var out = [], + t, + str = '', + len, + sep = false, + short = options && options.short, + rich = options && options.rich + encode(v, out, rich, 0) + // until where we have to stringify + len = out.length - 1 + while (((t = out[len]), t === '' || (short && t === ')'))) { + len-- + } + // extended join('~') + for (var i = 0; i <= len; i++) { + t = out[i] + if (sep && t !== ')') { + str += '~' + } + str += t + sep = !(t === '!' || t === '(' || t === ')') + } + if (short) { + if (str.length < 6) { + t = antiJSON[str] + if (t) str = t + } + } else { + str += '~' + } + return str + } + + function clean(s) { + var out = '' + var i = 0 + var j = 0 + var c + while (i < s.length) { + c = s.charCodeAt(i) + if (c === 37) { + // % + if (i > j) out += s.slice(j, i) + i++ + while (c === 37) { + c = parseInt(s.slice(i, i + 2), 16) + i += 2 + } + if (c > 32) { + // not a control character or space + out += String.fromCharCode(c) + } + j = i + } else if (c <= 32) { + if (i > j) out += s.slice(j, i) + i++ + j = i + } else { + i++ + } + } + if (i > j) out += s.slice(j, i) + return out + } + + var JSONRE = /^({|\[|"|true$|false$|null$)/ + exports.parse = function(s, options) { + if (options && options.deURI) s = clean(s) + if (JSONRE.test(s)) return JSON.parse(s) + var l = s.length + // if (s.charAt(l - 1) !== '~') { + // throw new Error('not a JSURL2 string') + // } + var r = decode({s: s, i: 0, l: l}) + return r === EOS ? true : r + } + + exports.tryParse = function(s, def, options) { + try { + return exports.parse(s, options) + } catch (ex) { + return def + } + } +})( + typeof exports !== 'undefined' ? exports : (window.JSURL = window.JSURL || {}) +) diff --git a/package.json b/package.json index f8f9cd7..21d4459 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,30 @@ { - "name": "jsurl", - "description": "URL friendly JSON-like formatting and parsing", - "version": "0.1.4", - "license": "MIT", - "homepage": "http://github.com/Sage/jsurl", - "author": "Bruno Jouhier", - "repository": { - "type": "git", - "url": "git://github.com/Sage/jsurl.git" - }, - "devDependencies": { - "qunit": "^0.7.7" - }, - "directories": {"lib": "./lib" }, - "scripts": { - "test": "node test" - }, - "main": "index.js" + "name": "jsurl", + "description": "URL friendly JSON-like formatting and parsing", + "version": "2.0.0-rc1", + "license": "MIT", + "homepage": "http://github.com/Sage/jsurl", + "author": "Bruno Jouhier", + "repository": { + "type": "git", + "url": "git://github.com/Sage/jsurl.git" + }, + "main": "lib/jsurl2.js", + "dependencies": {}, + "devDependencies": { + "blns": "^2.0.4", + "jest": "^23.4.0", + "jest-cli": "^23.4.0" + }, + "files": [ + "lib", + "v1.js" + ], + "directories": { + "lib": "./lib" + }, + "scripts": { + "test": "jest", + "test-watch": "jest --watch" + } } diff --git a/test/common/jsurlTest.js b/test/common/jsurlTest.js deleted file mode 100644 index 1e03c80..0000000 --- a/test/common/jsurlTest.js +++ /dev/null @@ -1,83 +0,0 @@ -"use strict"; -QUnit.module(module.id); - -var JSURL = require("../.."); -var undefined; - -function t(v, r) { - strictEqual(JSURL.stringify(v), r, "stringify " + (typeof v !== 'object' ? v : JSON.stringify(v))); - strictEqual(JSURL.stringify(JSURL.parse(JSURL.stringify(v))), r, "roundtrip " + (typeof v !== 'object' ? v : JSON.stringify(v))); -} - -test('basic values', 26, function() { - t(undefined, undefined); - t(function() { - foo(); - }, undefined); - t(null, "~null"); - t(false, "~false"); - t(true, "~true"); - t(0, "~0"); - t(1, "~1"); - t(-1.5, "~-1.5"); - t("hello world\u203c", "~'hello*20world**203c"); - t(" !\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~", "~'*20*21*22*23!*25*26*27*28*29*2a*2b*2c-.*2f09*3a*3b*3c*3d*3e*3f*40AZ*5b*5c*5d*5e_*60az*7b*7c*7d*7e"); - // JSON.stringify converts special numeric values to null - t(NaN, "~null"); - t(Infinity, "~null"); - t(-Infinity, "~null"); -}); -test('arrays', 4, function() { - t([], "~(~)"); - t([undefined, function() { - foo(); - }, - null, false, 0, "hello world\u203c"], "~(~null~null~null~false~0~'hello*20world**203c)"); -}); -test('objects', 4, function() { - t({}, "~()"); - t({ - a: undefined, - b: function() { - foo(); - }, - c: null, - d: false, - e: 0, - f: "hello world\u203c" - }, "~(c~null~d~false~e~0~f~'hello*20world**203c)"); -}); -test('mix', 2, function() { - t({ - a: [ - [1, 2], - [], {}], - b: [], - c: { - d: "hello", - e: {}, - f: [] - } - }, "~(a~(~(~1~2)~(~)~())~b~(~)~c~(d~'hello~e~()~f~(~)))"); -}); - -test('percent-escaped single quotes', 1, function() { - deepEqual(JSURL.parse("~(a~%27hello~b~%27world)"), { - a: 'hello', - b: 'world' - }); -}); - -test('percent-escaped percent-escaped single quotes', 1, function() { - deepEqual(JSURL.parse("~(a~%2527hello~b~%2525252527world)"), { - a: 'hello', - b: 'world' - }); -}); - -test('tryParse', 4, function() { - strictEqual(JSURL.tryParse("~null"), null); - strictEqual(JSURL.tryParse("~1", 2), 1); - strictEqual(JSURL.tryParse("1"), undefined); - strictEqual(JSURL.tryParse("1", 0), 0); -}); diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 97e5741..0000000 --- a/test/index.js +++ /dev/null @@ -1,25 +0,0 @@ -"use strict"; - -var fs = require('fs'); -var fsp = require('path'); - -var root = fsp.join(__dirname, 'common'); -var tests = fs.readdirSync(root).filter(function(file) { - return /\.js$/.test(file); -}).map(function(file) { - return fsp.join(root, file); -}); - -var testrunner = require("qunit"); - -process.on('uncaughtException', function(err) { - console.error(err.stack); - process.exit(1); -}); - -testrunner.run({ - code: '', - tests: tests, -}, function(err) { - if (err) throw err; -}); diff --git a/index.js b/v1.js similarity index 100% rename from index.js rename to v1.js