Skip to content
Browse files

Initial commit

  • Loading branch information...
0 parents commit 6101e44d412dd357035a4a44b96239e4633b5800 @StevenLooman committed
Showing with 948 additions and 0 deletions.
  1. +4 −0 .gitignore
  2. +2 −0 README.md
  3. +1 −0 index.js
  4. +1 −0 lib/index.js
  5. +66 −0 lib/recorder.js
  6. +115 −0 lib/saxpath.js
  7. +56 −0 lib/state.js
  8. +589 −0 lib/xpath_parser.js
  9. +26 −0 lib/xpath_parser.pegjs
  10. +39 −0 package.json
  11. +18 −0 run.js
  12. +31 −0 test/test.xml
4 .gitignore
@@ -0,0 +1,4 @@
+*.swp
+.sonar/
+coverage/
+node_modules/
2 README.md
@@ -0,0 +1,2 @@
+SaXPath
+=======
1 index.js
@@ -0,0 +1 @@
+module.exports = require('./lib');
1 lib/index.js
@@ -0,0 +1 @@
+module.exports.SaXPath = require('./saxpath');
66 lib/recorder.js
@@ -0,0 +1,66 @@
+function Recorder() {
+// this.document = null;
+// this.currentNode = null;
+}
+
+
+// XXX: move this to own class, bind to opentag, text, closetag evens and control recording with a boolean
+Recorder.prototype.start = function() {
+ console.log('start recording');
+
+ this.recording = true;
+
+// this.document = new Document();
+// this.currentNode = this.document.docElement;
+};
+
+Recorder.prototype.stop = function() {
+ console.log('stop recording');
+
+ this.recording = false;
+
+// this.emit('match', this.document);
+// this.document = null;
+// this.currentNode = null;
+};
+
+Recorder.prototype.onOpenTag = function(node) {
+ if (!this.recording) {
+ return;
+ }
+
+ console.log('record open tag');
+
+// var e = new Element(node.name);
+//
+// this.currentNode.appendChild(e);
+// for (var key in node.attributes) {
+// var value = node.attributes[key];
+// e.setAttribute(key, value);
+// }
+//
+// this.currentNode = e;
+};
+
+Recorder.prototype.onCloseTag = function(tag) {
+ if (!this.recording) {
+ return;
+ }
+
+ console.log('record close tag');
+
+// this.currentNode = this.currentNode.parentNode;
+};
+
+Recorder.prototype.onText = function(text) {
+ if (!this.recording) {
+ return;
+ }
+
+ console.log('record text');
+
+// var e = new TextNode(test);
+// this.currentNode.appendChild(e);
+};
+
+module.exports = Recorder;
115 lib/saxpath.js
@@ -0,0 +1,115 @@
+var events = require('events');
+var util = require('util');
+var XPathParser = require('./xpath_parser');
+var State = require('./state');
+var Recorder = require('./recorder');
+
+
+function SaXPath(saxParser, xpath) {
+ this.saxParser = saxParser;
+
+ this.saxParser.on('opentag', this.onOpenTag.bind(this));
+ this.saxParser.on('closetag', this.onCloseTag.bind(this));
+ this.saxParser.on('text', this.onText.bind(this));
+ this.saxParser.on('end', this.onEnd.bind(this));
+
+ this.recorder = new Recorder();
+ this.saxParser.on('opentag', this.recorder.onOpenTag.bind(this.recorder));
+ this.saxParser.on('closetag', this.recorder.onCloseTag.bind(this.recorder));
+ this.saxParser.on('text', this.recorder.onText.bind(this.recorder));
+
+ this.xpathExpr = XPathParser.parse(xpath);
+ this.states = this.parseXPathExpr(this.xpathExpr);
+
+ this.currentDepth = -1;
+ this.currentState = 0;
+ this.recordingDepth = -1;
+}
+
+
+util.inherits(SaXPath, events.EventEmitter);
+
+
+SaXPath.prototype.parseXPathExpr = function(expr) {
+ var stack = [];
+
+ var i;
+ var previousState = null; // XXX: dummy state?
+ for (i = 0; i < expr.length; ++i) {
+ var part = expr[i];
+ var state = new State(part);
+
+ if (previousState) {
+ state.previous = previousState;
+ previousState.next = state;
+ } else {
+ previousState = state;
+ }
+
+ stack.push(state);
+ }
+
+ return stack;
+};
+
+
+SaXPath.prototype.isRecording = function() {
+ return this.recordingDepth !== -1 && this.currentDepth >= this.recordingDepth;
+};
+
+
+SaXPath.prototype.onOpenTag = function(node) {
+ this.currentDepth += 1;
+ console.log('=== open tag', node, this.currentDepth);
+
+ if (this.currentState < this.states.length) {
+ // still matching states
+ var state = this.states[this.currentState];
+ console.log('current state:', state.name, state.matchedDepth);
+
+ if (state.matches(node, this.currentDepth)) {
+ // move forward a state
+ console.log('match current state by name and predicate');
+ state.matchedDepth = this.currentDepth;
+ this.currentState += 1;
+ }
+ }
+
+ // have we reached the top of the state-stack?
+ if (this.currentState === this.states.length && this.recordingDepth === -1) {
+ this.recordingDepth = this.currentDepth;
+ this.recorder.start();
+ }
+};
+
+SaXPath.prototype.onCloseTag = function(tag) {
+ this.currentDepth -= 1;
+ console.log('=== close tag', tag, this.currentDepth);
+
+ var state = this.states[this.currentState - 1];
+ if (state.unmatches(tag, this.currentDepth)) {
+ console.log('unmatch');
+ state.matchedDepth = -1;
+
+ if (this.recordingDepth !== -1) {
+ // stop recording
+ this.recordingDepth = -1;
+ this.recorder.stop();
+ }
+
+ // go back a state
+ this.currentState -= 1;
+ }
+};
+
+SaXPath.prototype.onText = function(text) {
+// console.log('=== text', text);
+ var state = this.states[this.currentState];
+};
+
+SaXPath.prototype.onEnd = function() {
+ console.log('=== end');
+ this.emit('end');
+};
+
+module.exports = SaXPath;
56 lib/state.js
@@ -0,0 +1,56 @@
+function State(part) {
+ this.matchedDepth = -1;
+
+ var axis = part[0];
+ var name = part[1];
+ var predicates = part[2];
+
+ if (axis === '/') {
+ this.axis = 'child';
+ this.name = name;
+ this.immediate = true;
+
+ if (predicates) {
+ // strip '[' and ']'
+ this.predicates = predicates.slice(1).slice(0, -1);
+ }
+ }
+
+ this.matchedDepth = -1;
+}
+
+State.prototype.matchesName = function(node) {
+ return node.name === this.name;
+};
+
+State.prototype.matchesPredicate = function(node) {
+ // XXX: hardcoded to test @attr = literal
+ var predicate = this.predicates[0];
+
+ var left = predicate[0];
+ var op = predicate[1];
+ var right = predicate[2];
+
+ var lValue = node.attributes[left[1]];
+ var rValue = right;
+ if (op === '=') {
+ return lValue === rValue;
+ }
+
+ return false;
+};
+
+State.prototype.matches = function(node, depth) {
+ var match = this.matchesName(node);
+ if (match && this.predicates) {
+ match = match && this.matchesPredicate(node);
+ }
+ return match;
+};
+
+State.prototype.unmatches = function(tag, depth) {
+ return depth < this.matchedDepth;
+};
+
+
+module.exports = State;
589 lib/xpath_parser.js
@@ -0,0 +1,589 @@
+module.exports = (function(){
+ /*
+ * Generated by PEG.js 0.7.0.
+ *
+ * http://pegjs.majda.cz/
+ */
+
+ function quote(s) {
+ /*
+ * ECMA-262, 5th ed., 7.8.4: All characters may appear literally in a
+ * string literal except for the closing quote character, backslash,
+ * carriage return, line separator, paragraph separator, and line feed.
+ * Any character may appear in the form of an escape sequence.
+ *
+ * For portability, we also escape escape all control and non-ASCII
+ * characters. Note that "\0" and "\v" escape sequences are not used
+ * because JSHint does not like the first and IE the second.
+ */
+ return '"' + s
+ .replace(/\\/g, '\\\\') // backslash
+ .replace(/"/g, '\\"') // closing quote character
+ .replace(/\x08/g, '\\b') // backspace
+ .replace(/\t/g, '\\t') // horizontal tab
+ .replace(/\n/g, '\\n') // line feed
+ .replace(/\f/g, '\\f') // form feed
+ .replace(/\r/g, '\\r') // carriage return
+ .replace(/[\x00-\x07\x0B\x0E-\x1F\x80-\uFFFF]/g, escape)
+ + '"';
+ }
+
+ var result = {
+ /*
+ * Parses the input with a generated parser. If the parsing is successfull,
+ * returns a value explicitly or implicitly specified by the grammar from
+ * which the parser was generated (see |PEG.buildParser|). If the parsing is
+ * unsuccessful, throws |PEG.parser.SyntaxError| describing the error.
+ */
+ parse: function(input, startRule) {
+ var parseFunctions = {
+ "start": parse_start,
+ "axis": parse_axis,
+ "name": parse_name,
+ "predicate": parse_predicate,
+ "expr": parse_expr,
+ "attribute_ref": parse_attribute_ref,
+ "op": parse_op,
+ "string_literal": parse_string_literal,
+ "number_literal": parse_number_literal
+ };
+
+ if (startRule !== undefined) {
+ if (parseFunctions[startRule] === undefined) {
+ throw new Error("Invalid rule name: " + quote(startRule) + ".");
+ }
+ } else {
+ startRule = "start";
+ }
+
+ var pos = 0;
+ var reportFailures = 0;
+ var rightmostFailuresPos = 0;
+ var rightmostFailuresExpected = [];
+
+ function padLeft(input, padding, length) {
+ var result = input;
+
+ var padLength = length - input.length;
+ for (var i = 0; i < padLength; i++) {
+ result = padding + result;
+ }
+
+ return result;
+ }
+
+ function escape(ch) {
+ var charCode = ch.charCodeAt(0);
+ var escapeChar;
+ var length;
+
+ if (charCode <= 0xFF) {
+ escapeChar = 'x';
+ length = 2;
+ } else {
+ escapeChar = 'u';
+ length = 4;
+ }
+
+ return '\\' + escapeChar + padLeft(charCode.toString(16).toUpperCase(), '0', length);
+ }
+
+ function matchFailed(failure) {
+ if (pos < rightmostFailuresPos) {
+ return;
+ }
+
+ if (pos > rightmostFailuresPos) {
+ rightmostFailuresPos = pos;
+ rightmostFailuresExpected = [];
+ }
+
+ rightmostFailuresExpected.push(failure);
+ }
+
+ function parse_start() {
+ var result0, result1, result2, result3;
+ var pos0;
+
+ pos0 = pos;
+ result1 = parse_axis();
+ if (result1 !== null) {
+ result2 = parse_name();
+ if (result2 !== null) {
+ result3 = parse_predicate();
+ result3 = result3 !== null ? result3 : "";
+ if (result3 !== null) {
+ result1 = [result1, result2, result3];
+ } else {
+ result1 = null;
+ pos = pos0;
+ }
+ } else {
+ result1 = null;
+ pos = pos0;
+ }
+ } else {
+ result1 = null;
+ pos = pos0;
+ }
+ if (result1 !== null) {
+ result0 = [];
+ while (result1 !== null) {
+ result0.push(result1);
+ pos0 = pos;
+ result1 = parse_axis();
+ if (result1 !== null) {
+ result2 = parse_name();
+ if (result2 !== null) {
+ result3 = parse_predicate();
+ result3 = result3 !== null ? result3 : "";
+ if (result3 !== null) {
+ result1 = [result1, result2, result3];
+ } else {
+ result1 = null;
+ pos = pos0;
+ }
+ } else {
+ result1 = null;
+ pos = pos0;
+ }
+ } else {
+ result1 = null;
+ pos = pos0;
+ }
+ }
+ } else {
+ result0 = null;
+ }
+ return result0;
+ }
+
+ function parse_axis() {
+ var result0;
+
+ if (input.substr(pos, 2) === "//") {
+ result0 = "//";
+ pos += 2;
+ } else {
+ result0 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"//\"");
+ }
+ }
+ if (result0 === null) {
+ if (input.charCodeAt(pos) === 47) {
+ result0 = "/";
+ pos++;
+ } else {
+ result0 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"/\"");
+ }
+ }
+ }
+ return result0;
+ }
+
+ function parse_name() {
+ var result0, result1;
+ var pos0;
+
+ pos0 = pos;
+ if (/^[a-z]/i.test(input.charAt(pos))) {
+ result1 = input.charAt(pos);
+ pos++;
+ } else {
+ result1 = null;
+ if (reportFailures === 0) {
+ matchFailed("[a-z]i");
+ }
+ }
+ if (result1 !== null) {
+ result0 = [];
+ while (result1 !== null) {
+ result0.push(result1);
+ if (/^[a-z]/i.test(input.charAt(pos))) {
+ result1 = input.charAt(pos);
+ pos++;
+ } else {
+ result1 = null;
+ if (reportFailures === 0) {
+ matchFailed("[a-z]i");
+ }
+ }
+ }
+ } else {
+ result0 = null;
+ }
+ if (result0 !== null) {
+ result0 = (function(offset, str) { return str.join(""); })(pos0, result0);
+ }
+ if (result0 === null) {
+ pos = pos0;
+ }
+ return result0;
+ }
+
+ function parse_predicate() {
+ var result0, result1, result2;
+ var pos0;
+
+ pos0 = pos;
+ if (input.charCodeAt(pos) === 91) {
+ result0 = "[";
+ pos++;
+ } else {
+ result0 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"[\"");
+ }
+ }
+ if (result0 !== null) {
+ result1 = parse_expr();
+ if (result1 !== null) {
+ if (input.charCodeAt(pos) === 93) {
+ result2 = "]";
+ pos++;
+ } else {
+ result2 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"]\"");
+ }
+ }
+ if (result2 !== null) {
+ result0 = [result0, result1, result2];
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ return result0;
+ }
+
+ function parse_expr() {
+ var result0, result1, result2;
+ var pos0;
+
+ pos0 = pos;
+ result0 = parse_attribute_ref();
+ if (result0 !== null) {
+ result1 = parse_op();
+ if (result1 !== null) {
+ result2 = parse_string_literal();
+ if (result2 === null) {
+ result2 = parse_number_literal();
+ }
+ if (result2 !== null) {
+ result0 = [result0, result1, result2];
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ return result0;
+ }
+
+ function parse_attribute_ref() {
+ var result0, result1;
+ var pos0;
+
+ pos0 = pos;
+ if (input.charCodeAt(pos) === 64) {
+ result0 = "@";
+ pos++;
+ } else {
+ result0 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"@\"");
+ }
+ }
+ if (result0 !== null) {
+ result1 = parse_name();
+ if (result1 !== null) {
+ result0 = [result0, result1];
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ } else {
+ result0 = null;
+ pos = pos0;
+ }
+ return result0;
+ }
+
+ function parse_op() {
+ var result0;
+
+ if (input.charCodeAt(pos) === 61) {
+ result0 = "=";
+ pos++;
+ } else {
+ result0 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"=\"");
+ }
+ }
+ return result0;
+ }
+
+ function parse_string_literal() {
+ var result0, result1, result2;
+ var pos0, pos1;
+
+ pos0 = pos;
+ pos1 = pos;
+ if (input.charCodeAt(pos) === 34) {
+ result0 = "\"";
+ pos++;
+ } else {
+ result0 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"\\\"\"");
+ }
+ }
+ if (result0 !== null) {
+ if (/^[a-z]/i.test(input.charAt(pos))) {
+ result2 = input.charAt(pos);
+ pos++;
+ } else {
+ result2 = null;
+ if (reportFailures === 0) {
+ matchFailed("[a-z]i");
+ }
+ }
+ if (result2 !== null) {
+ result1 = [];
+ while (result2 !== null) {
+ result1.push(result2);
+ if (/^[a-z]/i.test(input.charAt(pos))) {
+ result2 = input.charAt(pos);
+ pos++;
+ } else {
+ result2 = null;
+ if (reportFailures === 0) {
+ matchFailed("[a-z]i");
+ }
+ }
+ }
+ } else {
+ result1 = null;
+ }
+ if (result1 !== null) {
+ if (input.charCodeAt(pos) === 34) {
+ result2 = "\"";
+ pos++;
+ } else {
+ result2 = null;
+ if (reportFailures === 0) {
+ matchFailed("\"\\\"\"");
+ }
+ }
+ if (result2 !== null) {
+ result0 = [result0, result1, result2];
+ } else {
+ result0 = null;
+ pos = pos1;
+ }
+ } else {
+ result0 = null;
+ pos = pos1;
+ }
+ } else {
+ result0 = null;
+ pos = pos1;
+ }
+ if (result0 !== null) {
+ result0 = (function(offset, str) { return str.join(""); })(pos0, result0[1]);
+ }
+ if (result0 === null) {
+ pos = pos0;
+ }
+ return result0;
+ }
+
+ function parse_number_literal() {
+ var result0, result1;
+ var pos0;
+
+ pos0 = pos;
+ if (/^[0-9]/.test(input.charAt(pos))) {
+ result1 = input.charAt(pos);
+ pos++;
+ } else {
+ result1 = null;
+ if (reportFailures === 0) {
+ matchFailed("[0-9]");
+ }
+ }
+ if (result1 !== null) {
+ result0 = [];
+ while (result1 !== null) {
+ result0.push(result1);
+ if (/^[0-9]/.test(input.charAt(pos))) {
+ result1 = input.charAt(pos);
+ pos++;
+ } else {
+ result1 = null;
+ if (reportFailures === 0) {
+ matchFailed("[0-9]");
+ }
+ }
+ }
+ } else {
+ result0 = null;
+ }
+ if (result0 !== null) {
+ result0 = (function(offset, str) { return str.join(""); })(pos0, result0);
+ }
+ if (result0 === null) {
+ pos = pos0;
+ }
+ return result0;
+ }
+
+
+ function cleanupExpected(expected) {
+ expected.sort();
+
+ var lastExpected = null;
+ var cleanExpected = [];
+ for (var i = 0; i < expected.length; i++) {
+ if (expected[i] !== lastExpected) {
+ cleanExpected.push(expected[i]);
+ lastExpected = expected[i];
+ }
+ }
+ return cleanExpected;
+ }
+
+ function computeErrorPosition() {
+ /*
+ * The first idea was to use |String.split| to break the input up to the
+ * error position along newlines and derive the line and column from
+ * there. However IE's |split| implementation is so broken that it was
+ * enough to prevent it.
+ */
+
+ var line = 1;
+ var column = 1;
+ var seenCR = false;
+
+ for (var i = 0; i < Math.max(pos, rightmostFailuresPos); i++) {
+ var ch = input.charAt(i);
+ if (ch === "\n") {
+ if (!seenCR) { line++; }
+ column = 1;
+ seenCR = false;
+ } else if (ch === "\r" || ch === "\u2028" || ch === "\u2029") {
+ line++;
+ column = 1;
+ seenCR = true;
+ } else {
+ column++;
+ seenCR = false;
+ }
+ }
+
+ return { line: line, column: column };
+ }
+
+
+ var result = parseFunctions[startRule]();
+
+ /*
+ * The parser is now in one of the following three states:
+ *
+ * 1. The parser successfully parsed the whole input.
+ *
+ * - |result !== null|
+ * - |pos === input.length|
+ * - |rightmostFailuresExpected| may or may not contain something
+ *
+ * 2. The parser successfully parsed only a part of the input.
+ *
+ * - |result !== null|
+ * - |pos < input.length|
+ * - |rightmostFailuresExpected| may or may not contain something
+ *
+ * 3. The parser did not successfully parse any part of the input.
+ *
+ * - |result === null|
+ * - |pos === 0|
+ * - |rightmostFailuresExpected| contains at least one failure
+ *
+ * All code following this comment (including called functions) must
+ * handle these states.
+ */
+ if (result === null || pos !== input.length) {
+ var offset = Math.max(pos, rightmostFailuresPos);
+ var found = offset < input.length ? input.charAt(offset) : null;
+ var errorPosition = computeErrorPosition();
+
+ throw new this.SyntaxError(
+ cleanupExpected(rightmostFailuresExpected),
+ found,
+ offset,
+ errorPosition.line,
+ errorPosition.column
+ );
+ }
+
+ return result;
+ },
+
+ /* Returns the parser source code. */
+ toSource: function() { return this._source; }
+ };
+
+ /* Thrown when a parser encounters a syntax error. */
+
+ result.SyntaxError = function(expected, found, offset, line, column) {
+ function buildMessage(expected, found) {
+ var expectedHumanized, foundHumanized;
+
+ switch (expected.length) {
+ case 0:
+ expectedHumanized = "end of input";
+ break;
+ case 1:
+ expectedHumanized = expected[0];
+ break;
+ default:
+ expectedHumanized = expected.slice(0, expected.length - 1).join(", ")
+ + " or "
+ + expected[expected.length - 1];
+ }
+
+ foundHumanized = found ? quote(found) : "end of input";
+
+ return "Expected " + expectedHumanized + " but " + foundHumanized + " found.";
+ }
+
+ this.name = "SyntaxError";
+ this.expected = expected;
+ this.found = found;
+ this.message = buildMessage(expected, found);
+ this.offset = offset;
+ this.line = line;
+ this.column = column;
+ };
+
+ result.SyntaxError.prototype = Error.prototype;
+
+ return result;
+})();
26 lib/xpath_parser.pegjs
@@ -0,0 +1,26 @@
+start
+ = (axis name predicate?)+
+
+axis
+ = '//' / '/'
+
+name
+ = str:[a-z]i+ { return str.join(""); }
+
+predicate
+ = '[' expr ']'
+
+expr
+ = attribute_ref op (string_literal / number_literal)
+
+attribute_ref
+ = '@' name
+
+op
+ = '='
+
+string_literal
+ = '"' str:[a-z]i+ '"' { return str.join(""); }
+
+number_literal
+ = str:[0-9]+ { return str.join(""); }
39 package.json
@@ -0,0 +1,39 @@
+{
+ "private": true,
+ "name": "saxpath",
+ "description": "XXX",
+ "version": "0.0.1",
+ "author": "Steven Looman <steven.looman@gmail.com>",
+ "keywords": [ ],
+ "licenses" : [
+ {
+ "type": "2-clause BSD",
+ "url": "https://raw.github.com/StevenLooman/saxpath/master/LICENSE"
+ }
+ ],
+
+ "dependencies": {
+ "sax": "0.4.2",
+ "xmldom": "0.1.11"
+ },
+ "devDependencies": {
+ "pegjs": "0.7.0",
+ "mocha": "1.3.2",
+ "mocha-lcov-reporter": "0.0.1"
+ },
+ "directories": {
+ "lib": "./lib",
+ "test": "./test"
+ },
+ "main": "index.js",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "scripts": {
+ "test": "mocha test/*"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/StevenLooman/saxpath.git"
+ }
+}
18 run.js
@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+
+var fs = require('fs');
+var sax = require('sax');
+var xps = require('./lib');
+
+var filename = 'test/test.xml';
+
+function main() {
+ var saxParser = sax.createStream(true);
+ var streamer = new xps.SaXPath(saxParser, '/bookstore/book');
+// var streamer = new xps.SaXPath(saxParser, '/bookstore/book[@category="COOKING"]');
+
+ var fileStream = fs.createReadStream(filename);
+ fileStream.pipe(saxParser);
+}
+
+main();
31 test/test.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<bookstore>
+ <book category="COOKING">
+ <title lang="en">Everyday Italian</title>
+ <author>Giada De Laurentiis</author>
+ <year>2005</year>
+ <price>30.00</price>
+ </book>
+ <book category="CHILDREN">
+ <title lang="en">Harry Potter</title>
+ <author>J K. Rowling</author>
+ <year>2005</year>
+ <price>29.99</price>
+ </book>
+ <book category="WEB">
+ <title lang="en">XQuery Kick Start</title>
+ <author>James McGovern</author>
+ <author>Per Bothner</author>
+ <author>Kurt Cagle</author>
+ <author>James Linn</author>
+ <author>Vaidyanathan Nagarajan</author>
+ <year>2003</year>
+ <price>49.99</price>
+ </book>
+ <book category="WEB">
+ <title lang="en">Learning XML</title>
+ <author>Erik T. Ray</author>
+ <year>2003</year>
+ <price>39.95</price>
+ </book>
+</bookstore>

0 comments on commit 6101e44

Please sign in to comment.
Something went wrong with that request. Please try again.