diff --git a/README.md b/README.md
index be7b83f4..b94f422e 100644
--- a/README.md
+++ b/README.md
@@ -325,6 +325,59 @@ value})``. Possible options are:
}
```
Added in 0.4.6
+ * `sourcemap` (default: `false`): Allows exporting node positions in the XML
+ source document. By default it adds to all nodes a non-enumerable property
+ called `$source` (doesn't show up during enumeration of the properties on the objects).
+ The name of the property and whether or not you want it enumerable can be
+ configured with the options below.
+ * `sourcemapkey` (default: `$source`): Change the key to use when exporting sourcemaps.
+ * `sourcemapEnumerable` (default: `false`): Make the sourcemap enumerable (visible).
+ Note that converting back the JSON to XML will include `$source` attributes
+ if you make them enumerable. Here's a small example:
+ ```javascript
+ xml = "\n hello\n";
+ xml2js.parseString(xml, {sourcemap: true, sourcemapEnumerable: true}, (err, parsed) => {
+ console.log(JSON.stringify(parsed));
+ });
+ ```
+ This will output:
+ ```json
+ {
+ "a": {
+ "$source": {
+ "start": {
+ "line": 0,
+ "column": 3,
+ "position": 3
+ },
+ "end": {
+ "line": 2,
+ "column": 4,
+ "position": 23
+ }
+ },
+ "b": [
+ {
+ "_": "hello",
+ "$source": {
+ "start": {
+ "line": 1,
+ "column": 5,
+ "position": 9
+ },
+ "end": {
+ "line": 1,
+ "column": 14,
+ "position": 18
+ }
+ }
+ }
+ ]
+ }
+ }
+ ```
+ If `sourcemapEnumerable` was not set, `$source` wouldn't appear in the generated
+ JSON but would be accessible the same way.
Options for the `Builder` class
-------------------------------
diff --git a/lib/builder.js b/lib/builder.js
index 58f36384..47961795 100644
--- a/lib/builder.js
+++ b/lib/builder.js
@@ -1,13 +1,16 @@
// Generated by CoffeeScript 1.12.7
(function() {
"use strict";
- var builder, defaults, escapeCDATA, requiresCDATA, wrapCDATA,
+ var CHILDREN_KEY, builder, defaults, escapeCDATA, requiresCDATA, wrapCDATA,
+ bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
hasProp = {}.hasOwnProperty;
builder = require('xmlbuilder');
defaults = require('./defaults').defaults;
+ CHILDREN_KEY = '$$children_da914993d9904559be754444a4685d08$$';
+
requiresCDATA = function(entry) {
return typeof entry === "string" && (entry.indexOf('&') >= 0 || entry.indexOf('>') >= 0 || entry.indexOf('<') >= 0);
};
@@ -22,6 +25,7 @@
exports.Builder = (function() {
function Builder(opts) {
+ this.preprocess = bind(this.preprocess, this);
var key, ref, value;
this.options = {};
ref = defaults["0.2"];
@@ -38,7 +42,7 @@
}
Builder.prototype.buildObject = function(rootObj) {
- var attrkey, charkey, render, rootElement, rootName;
+ var attrkey, charkey, entry, i, key, len, obj, render, rootArray, rootElement, rootName;
attrkey = this.options.attrkey;
charkey = this.options.charkey;
if ((Object.keys(rootObj).length === 1) && (this.options.rootName === defaults['0.2'].rootName)) {
@@ -49,22 +53,13 @@
}
render = (function(_this) {
return function(element, obj) {
- var attr, child, entry, index, key, value;
+ var attr, child, childObj, i, key, len, value;
if (typeof obj !== 'object') {
if (_this.options.cdata && requiresCDATA(obj)) {
element.raw(wrapCDATA(obj));
} else {
element.txt(obj);
}
- } else if (Array.isArray(obj)) {
- for (index in obj) {
- if (!hasProp.call(obj, index)) continue;
- child = obj[index];
- for (key in child) {
- entry = child[key];
- element = render(element.ele(key), entry).up();
- }
- }
} else {
for (key in obj) {
if (!hasProp.call(obj, key)) continue;
@@ -82,31 +77,13 @@
} else {
element = element.txt(child);
}
- } else if (Array.isArray(child)) {
- for (index in child) {
- if (!hasProp.call(child, index)) continue;
- entry = child[index];
- if (typeof entry === 'string') {
- if (_this.options.cdata && requiresCDATA(entry)) {
- element = element.ele(key).raw(wrapCDATA(entry)).up();
- } else {
- element = element.ele(key, entry).up();
- }
- } else {
- element = render(element.ele(key), entry).up();
- }
+ } else if (key === CHILDREN_KEY) {
+ for (i = 0, len = child.length; i < len; i++) {
+ childObj = child[i];
+ element = render(element.ele(childObj.name), childObj.node).up();
}
- } else if (typeof child === "object") {
- element = render(element.ele(key), child).up();
} else {
- if (typeof child === 'string' && _this.options.cdata && requiresCDATA(child)) {
- element = element.ele(key).raw(wrapCDATA(child)).up();
- } else {
- if (child == null) {
- child = '';
- }
- element = element.ele(key, child.toString()).up();
- }
+ throw new Error('Invalid object given to xml2js builder');
}
}
}
@@ -117,9 +94,80 @@
headless: this.options.headless,
allowSurrogateChars: this.options.allowSurrogateChars
});
+ if (Array.isArray(rootObj)) {
+ rootArray = rootObj;
+ rootObj = {};
+ for (i = 0, len = rootArray.length; i < len; i++) {
+ obj = rootArray[i];
+ for (key in obj) {
+ entry = obj[key];
+ if (rootObj[key]) {
+ rootObj[key].push(entry);
+ } else {
+ rootObj[key] = [entry];
+ }
+ }
+ }
+ }
+ rootObj = this.preprocess(rootObj);
return render(rootElement, rootObj).end(this.options.renderOpts);
};
+ Builder.prototype.preprocess = function(obj) {
+ var child, children, i, key, len, ref, ref1, ref2, ref3, ret, sourcePosition, value;
+ if (typeof obj !== 'object') {
+ return obj || '';
+ }
+ if (Array.isArray(obj)) {
+ return obj.map(this.preprocess);
+ }
+ ret = {};
+ children = [];
+ sourcePosition = 0;
+ for (key in obj) {
+ if (!hasProp.call(obj, key)) continue;
+ value = obj[key];
+ if (key === this.options.attrkey || key === this.options.charkey) {
+ ret[key] = value;
+ } else if (key === this.options.sourcemapkey) {
+ continue;
+ } else {
+ if (Array.isArray(value)) {
+ for (i = 0, len = value.length; i < len; i++) {
+ child = value[i];
+ sourcePosition = (child != null ? (ref = child.$source) != null ? (ref1 = ref.start) != null ? ref1.position : void 0 : void 0 : void 0) || (sourcePosition + 1);
+ children.push({
+ name: key,
+ sourcePosition: sourcePosition,
+ node: this.preprocess(child)
+ });
+ }
+ } else if (typeof value === 'object') {
+ sourcePosition = (value != null ? (ref2 = value.$source) != null ? (ref3 = ref2.start) != null ? ref3.position : void 0 : void 0 : void 0) || (sourcePosition + 1);
+ children.push({
+ name: key,
+ sourcePosition: sourcePosition,
+ node: this.preprocess(value)
+ });
+ } else {
+ sourcePosition = sourcePosition + 1;
+ children.push({
+ name: key,
+ sourcePosition: sourcePosition,
+ node: value || ''
+ });
+ }
+ }
+ }
+ if (children.length > 0) {
+ children.sort(function(a, b) {
+ return a.sourcePosition - b.sourcePosition;
+ });
+ ret[CHILDREN_KEY] = children;
+ }
+ return ret;
+ };
+
return Builder;
})();
diff --git a/lib/defaults.js b/lib/defaults.js
index 0a21da0a..6234ce0e 100644
--- a/lib/defaults.js
+++ b/lib/defaults.js
@@ -65,7 +65,10 @@
headless: false,
chunkSize: 10000,
emptyTag: '',
- cdata: false
+ cdata: false,
+ sourcemap: false,
+ sourcemapkey: '$source',
+ sourcemapEnumerable: false
}
};
diff --git a/lib/parser.js b/lib/parser.js
index 9e8261eb..24466d30 100644
--- a/lib/parser.js
+++ b/lib/parser.js
@@ -37,6 +37,9 @@
function Parser(opts) {
this.parseString = bind(this.parseString, this);
this.reset = bind(this.reset, this);
+ this.setNonEnumerableSourcemap = bind(this.setNonEnumerableSourcemap, this);
+ this.storeSourcemapEnd = bind(this.storeSourcemapEnd, this);
+ this.storeSourcemapStart = bind(this.storeSourcemapStart, this);
this.assignOrPush = bind(this.assignOrPush, this);
this.processAsync = bind(this.processAsync, this);
var key, ref, value;
@@ -105,6 +108,56 @@
}
};
+ Parser.prototype.storeSourcemapStart = function(obj, value) {
+ if (this.options.sourcemap) {
+ return Object.defineProperty(obj, this.options.sourcemapkey, {
+ value: {
+ start: {
+ line: value.line,
+ column: value.column,
+ position: value.position
+ }
+ },
+ enumerable: true,
+ configurable: true
+ });
+ }
+ };
+
+ Parser.prototype.storeSourcemapEnd = function(obj, value) {
+ var currentValue;
+ if (this.options.sourcemap) {
+ currentValue = obj[this.options.sourcemapkey];
+ return Object.defineProperty(obj, this.options.sourcemapkey, {
+ value: {
+ start: currentValue.start,
+ end: {
+ line: value.line,
+ column: value.column,
+ position: value.position
+ }
+ }
+ });
+ }
+ };
+
+ Parser.prototype.setNonEnumerableSourcemap = function(obj) {
+ var key, results;
+ if (obj instanceof Object) {
+ if (this.options.sourcemapkey in obj) {
+ Object.defineProperty(obj, this.options.sourcemapkey, {
+ enumerable: false
+ });
+ }
+ results = [];
+ for (key in obj) {
+ if (!hasProp.call(obj, key)) continue;
+ results.push(this.setNonEnumerableSourcemap(obj[key]));
+ }
+ return results;
+ }
+ };
+
Parser.prototype.reset = function() {
var attrkey, charkey, ontext, stack;
this.removeAllListeners();
@@ -142,6 +195,7 @@
var key, newValue, obj, processedKey, ref;
obj = {};
obj[charkey] = "";
+ _this.storeSourcemapStart(obj, _this.saxParser);
if (!_this.options.ignoreAttrs) {
ref = node.attributes;
for (key in ref) {
@@ -176,6 +230,7 @@
if (!_this.options.explicitChildren || !_this.options.preserveChildrenOrder) {
delete obj["#name"];
}
+ _this.storeSourcemapEnd(obj, _this.saxParser);
if (obj.cdata === true) {
cdata = obj.cdata;
delete obj.cdata;
@@ -257,6 +312,9 @@
obj[nodeName] = old;
}
_this.resultObject = obj;
+ if (_this.options.sourcemap && !_this.options.sourcemapEnumerable) {
+ _this.setNonEnumerableSourcemap(_this.resultObject);
+ }
_this.saxParser.ended = true;
return _this.emit("end", _this.resultObject);
}
diff --git a/package.json b/package.json
index 590d4cee..51746f53 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"David Wood (http://codesleuth.co.uk/)",
"Nicolas Maquet (https://github.com/nmaquet)",
"Lovell Fuller (http://lovell.info/)",
+ "Jean-Christophe Hoelt (http://github.com/j3k0)",
"d3adc0d3 (https://github.com/d3adc0d3)"
],
"main": "./lib/xml2js",
diff --git a/src/builder.coffee b/src/builder.coffee
index 5653fde0..dd67453c 100644
--- a/src/builder.coffee
+++ b/src/builder.coffee
@@ -3,6 +3,8 @@
builder = require 'xmlbuilder'
defaults = require('./defaults').defaults
+CHILDREN_KEY = '$$children_da914993d9904559be754444a4685d08$$'
+
requiresCDATA = (entry) ->
return typeof entry is "string" && (entry.indexOf('&') >= 0 || entry.indexOf('>') >= 0 || entry.indexOf('<') >= 0)
@@ -48,11 +50,6 @@ class exports.Builder
element.raw wrapCDATA obj
else
element.txt obj
- else if Array.isArray obj
- # fix issue #119
- for own index, child of obj
- for key, entry of child
- element = render(element.ele(key), entry).up()
else
for own key, child of obj
# Case #1 Attribute
@@ -61,37 +58,18 @@ class exports.Builder
# Inserts tag attributes
for attr, value of child
element = element.att(attr, value)
-
# Case #2 Char data (CDATA, etc.)
else if key is charkey
if @options.cdata && requiresCDATA child
element = element.raw wrapCDATA child
else
element = element.txt child
-
- # Case #3 Array data
- else if Array.isArray child
- for own index, entry of child
- if typeof entry is 'string'
- if @options.cdata && requiresCDATA entry
- element = element.ele(key).raw(wrapCDATA entry).up()
- else
- element = element.ele(key, entry).up()
- else
- element = render(element.ele(key), entry).up()
-
- # Case #4 Objects
- else if typeof child is "object"
- element = render(element.ele(key), child).up()
-
- # Case #5 String and remaining types
+ # Case #3 Preprocessed node
+ else if key is CHILDREN_KEY
+ for childObj in child
+ element = render(element.ele(childObj.name), childObj.node).up()
else
- if typeof child is 'string' && @options.cdata && requiresCDATA child
- element = element.ele(key).raw(wrapCDATA child).up()
- else
- if not child?
- child = ''
- element = element.ele(key, child.toString()).up()
+ throw new Error 'Invalid object given to xml2js builder'
element
@@ -99,4 +77,55 @@ class exports.Builder
headless: @options.headless
allowSurrogateChars: @options.allowSurrogateChars)
+ # fix issue #119
+ if Array.isArray rootObj
+ rootArray = rootObj
+ rootObj = {}
+ for obj in rootArray
+ for key, entry of obj
+ if rootObj[key]
+ rootObj[key].push entry
+ else
+ rootObj[key] = [entry]
+
+ rootObj = @preprocess rootObj
render(rootElement, rootObj).end(@options.renderOpts)
+
+ preprocess: (obj) =>
+ if typeof obj != 'object'
+ return obj || ''
+ if Array.isArray obj
+ return obj.map @preprocess
+ ret = {}
+ children = []
+ sourcePosition = 0
+ for own key, value of obj
+ if key is @options.attrkey or key is @options.charkey
+ ret[key] = value
+ else if key is @options.sourcemapkey
+ continue # do not export metadata to the final XML
+ else
+ if Array.isArray value
+ for child in value
+ sourcePosition = (child?.$source?.start?.position) || (sourcePosition + 1)
+ children.push
+ name: key
+ sourcePosition: sourcePosition
+ node: @preprocess child
+ else if typeof value == 'object'
+ sourcePosition = (value?.$source?.start?.position) || (sourcePosition + 1)
+ children.push
+ name: key
+ sourcePosition: sourcePosition
+ node: @preprocess value
+ else
+ sourcePosition = sourcePosition + 1
+ children.push
+ name: key
+ sourcePosition: sourcePosition
+ node: value || ''
+
+ if children.length > 0
+ children.sort (a, b) -> a.sourcePosition - b.sourcePosition
+ ret[CHILDREN_KEY] = children
+ ret
diff --git a/src/defaults.coffee b/src/defaults.coffee
index a9bd214b..012d22c9 100644
--- a/src/defaults.coffee
+++ b/src/defaults.coffee
@@ -70,4 +70,7 @@ exports.defaults = {
chunkSize: 10000
emptyTag: ''
cdata: false
+ sourcemap: false
+ sourcemapkey: '$source'
+ sourcemapEnumerable: false
}
diff --git a/src/parser.coffee b/src/parser.coffee
index 8a375197..54c4df96 100644
--- a/src/parser.coffee
+++ b/src/parser.coffee
@@ -61,6 +61,35 @@ class exports.Parser extends events.EventEmitter
obj[key] = [obj[key]] if not (obj[key] instanceof Array)
obj[key].push newValue
+ storeSourcemapStart: (obj, value) =>
+ if @options.sourcemap
+ Object.defineProperty obj, @options.sourcemapkey,
+ value:
+ start:
+ line: value.line
+ column: value.column
+ position: value.position
+ enumerable: true
+ configurable: true
+
+ storeSourcemapEnd: (obj, value) =>
+ if @options.sourcemap
+ currentValue = obj[@options.sourcemapkey]
+ Object.defineProperty obj, @options.sourcemapkey,
+ value:
+ start: currentValue.start
+ end:
+ line: value.line
+ column: value.column
+ position: value.position
+
+ setNonEnumerableSourcemap: (obj) =>
+ if obj instanceof Object
+ if @options.sourcemapkey of obj
+ Object.defineProperty obj, @options.sourcemapkey, enumerable: false
+ for own key of obj
+ @setNonEnumerableSourcemap obj[key]
+
reset: =>
# remove all previous listeners for events, to prevent event listener
# accumulation
@@ -104,6 +133,8 @@ class exports.Parser extends events.EventEmitter
@saxParser.onopentag = (node) =>
obj = {}
obj[charkey] = ""
+ @storeSourcemapStart obj, @saxParser
+
unless @options.ignoreAttrs
for own key of node.attributes
if attrkey not of obj and not @options.mergeAttrs
@@ -125,6 +156,7 @@ class exports.Parser extends events.EventEmitter
obj = stack.pop()
nodeName = obj["#name"]
delete obj["#name"] if not @options.explicitChildren or not @options.preserveChildrenOrder
+ @storeSourcemapEnd obj, @saxParser
if obj.cdata == true
cdata = obj.cdata
@@ -199,6 +231,8 @@ class exports.Parser extends events.EventEmitter
obj[nodeName] = old
@resultObject = obj
+ if @options.sourcemap and not @options.sourcemapEnumerable
+ @setNonEnumerableSourcemap @resultObject
# parsing has ended, mark that so we won't throw exceptions from
# here anymore
@saxParser.ended = true
diff --git a/test/builder.test.coffee b/test/builder.test.coffee
index e9c12be3..0e019d49 100644
--- a/test/builder.test.coffee
+++ b/test/builder.test.coffee
@@ -281,3 +281,39 @@ module.exports =
actual = builder.buildObject obj
diffeq expected, actual
test.finish()
+
+ 'test restores children order when $source is available': (test) ->
+ expected = '123'
+ obj = {"xml":{
+ "A":[{"_":"1"}, {"_": "3"}],
+ "B":[{"_": "2","$":{"attr":"x"}}]
+ }}
+ definePosition = (node, value) ->
+ Object.defineProperty(node, '$source', {
+ value: {start: position: value}
+ enuerable: false
+ })
+ definePosition obj.xml.A[0], 64
+ definePosition obj.xml.A[1], 79
+ definePosition obj.xml.B[0], 71
+ builder = new xml2js.Builder renderOpts: pretty: false
+ actual = builder.buildObject obj
+ diffeq expected, actual
+ test.finish()
+
+ 'test uses enumerable sourcemap but dont export to XML': (test) ->
+ expected = '123'
+ obj = {"xml":{
+ "A":[{"_":"1"}, {"_": "3"}],
+ "B":[{"_": "2","$":{"attr":"x"}}]
+ }}
+ definePosition = (node, value) ->
+ node['$source'] = {start: position: value}
+ definePosition obj.xml.A[0], 64
+ definePosition obj.xml.A[1], 79
+ definePosition obj.xml.B[0], 71
+ builder = new xml2js.Builder renderOpts: pretty: false
+ actual = builder.buildObject obj
+ diffeq expected, actual
+ test.finish()
+
diff --git a/test/parser.test.coffee b/test/parser.test.coffee
index 6d531d56..46b9b7de 100644
--- a/test/parser.test.coffee
+++ b/test/parser.test.coffee
@@ -574,3 +574,32 @@ module.exports =
console.log 'Result object: ' + util.inspect r, false, 10
equ r.hasOwnProperty('SAMP'), true
equ r.SAMP.hasOwnProperty('TAGN'), true)
+
+ 'test source map with defaults': (test) ->
+ xml = "\n hello\n \n"
+ xml2js.parseString xml, {sourcemap: true}, (err, parsed) ->
+ # sourcemap doesn't appear, but can be accessed anyway
+ equ JSON.stringify(parsed).indexOf("$source") < 0, true
+ equ parsed.a.$source.start.line, 0
+ equ parsed.a.$source.end.line, 3
+ equ parsed.a.b[0].$source.start.line, 1
+ equ parsed.a.b[0].$source.end.line, 1
+ equ parsed.a.c[0].$source.start.line, 2
+ equ parsed.a.c[0].$source.end.line, 2
+ equ parsed.a.c[0].d[0].$source.start.line, 2
+ test.finish()
+
+ 'test source map with sourcemapEnumerable': (test) ->
+ xml = "\n hello\n \n"
+ xml2js.parseString xml, {sourcemap: true, sourcemapEnumerable: true}, (err, parsed) ->
+ # sourcemap is public
+ equ JSON.stringify(parsed).indexOf("$source") > 0, true
+ equ parsed.a.$source.start.line, 0
+ equ parsed.a.$source.end.line, 3
+ equ parsed.a.b[0].$source.start.line, 1
+ equ parsed.a.b[0].$source.end.line, 1
+ equ parsed.a.c[0].$source.start.line, 2
+ equ parsed.a.c[0].$source.end.line, 2
+ equ parsed.a.c[0].d[0].$source.start.line, 2
+ test.finish()
+