diff --git a/lib/xml-parser.ts b/lib/xml-parser.ts
new file mode 100644
index 0000000..95fbbe2
--- /dev/null
+++ b/lib/xml-parser.ts
@@ -0,0 +1,338 @@
+const CHAR_SINGLE_QUOTE = 39;
+const CHAR_DOUBLE_QUOTE = 34;
+const CHAR_LESS_THAN = 60;
+const CHAR_GREATER_THAN = 62;
+const CHAR_COLON = 58;
+const CHAR_SPACE = 32;
+const CHAR_TAB = 9;
+const CHAR_CR = 13;
+const CHAR_LF = 10;
+const CHAR_SLASH = 47;
+const CHAR_EXMARK = 33;
+const CHAR_QMARK = 63;
+const CHAR_EQUAL = 61;
+
+const STATE_LESS_THAN = 1;
+const STATE_SINGLE_QUOTE = 2;
+const STATE_DOUBLE_QUOTE = 3;
+
+export interface Attribute {
+ name: string;
+ namespace: string;
+ localName: string;
+ value: string;
+}
+
+export interface Element {
+ name: string;
+ namespace: string;
+ localName: string;
+ attrs: string;
+ text: string;
+ bodyIndex: number;
+ children: Element[];
+}
+
+export function parseXmlDeclaration(buffer: Buffer): Attribute[] {
+ for (const enc of ["utf16le", "utf8", "latin1", "ascii"]) {
+ let str = buffer.toString(enc, 0, 150);
+ if (str.startsWith("= colonIdx) colonIdx = i;
+ continue;
+
+ case CHAR_EQUAL:
+ if (name) throw new Error(`Unexpected character at ${i}`);
+ name = string.slice(idx, i).trim();
+ // TODO validate name
+ if (colonIdx > idx) {
+ namespace = string.slice(idx, colonIdx).trim();
+ localName = string.slice(colonIdx + 1, i).trim();
+ } else {
+ namespace = "";
+ localName = name;
+ }
+ }
+ }
+
+ if (name) throw new Error(`Attribute must have value at ${idx}`);
+
+ const tail = string.slice(idx);
+ if (tail.trim()) throw new Error(`Unexpected string at ${len - tail.length}`);
+
+ return attrs;
+}
+
+export function decodeEntities(string): string {
+ return string.replace(/&[0-9a-z#]+;/gi, match => {
+ switch (match) {
+ case """:
+ return '"';
+
+ case "&":
+ return "&";
+
+ case "'":
+ return "'";
+
+ case "<":
+ return "<";
+
+ case ">":
+ return ">";
+
+ default:
+ if (match.startsWith("")) {
+ const str = match.slice(3, -1).toLowerCase();
+ const n = parseInt(str, 16);
+ if (str.endsWith(n.toString(16))) return String.fromCharCode(n);
+ } else if (match.startsWith("")) {
+ const str = match.slice(2, -1);
+ const n = parseInt(str);
+ if (str.endsWith(n.toString())) return String.fromCharCode(n);
+ }
+ }
+ return match;
+ });
+}
+
+export function encodeEntities(string): string {
+ const entities = {
+ "&": "&",
+ '"': """,
+ "'": "'",
+ "<": "<",
+ ">": ">"
+ };
+ return string.replace(/[&"'<>]/g, m => entities[m]);
+}
+
+export function parseXml(string: string): Element {
+ const len = string.length;
+ let state1 = 0;
+ let state1Index = 0;
+ let state2 = 0;
+ let state2Index = 0;
+
+ const root: Element = {
+ name: "root",
+ namespace: "",
+ localName: "root",
+ attrs: "",
+ text: "",
+ bodyIndex: 0,
+ children: []
+ };
+
+ const stack: Element[] = [root];
+
+ for (let i = 0; i < len; ++i) {
+ switch (string.charCodeAt(i)) {
+ case CHAR_SINGLE_QUOTE:
+ switch (state1 & 0xff) {
+ case STATE_SINGLE_QUOTE:
+ state1 = state2;
+ state1Index = state2Index;
+ state2 = 0;
+ continue;
+
+ case STATE_LESS_THAN:
+ state2 = state1;
+ state2Index = state1Index;
+ state1 = STATE_SINGLE_QUOTE;
+ state1Index = i;
+ continue;
+ }
+ continue;
+
+ case CHAR_DOUBLE_QUOTE:
+ switch (state1 & 0xff) {
+ case STATE_DOUBLE_QUOTE:
+ state1 = state2;
+ state1Index = state2Index;
+ state2 = 0;
+ continue;
+
+ case STATE_LESS_THAN:
+ state2 = state1;
+ state2Index = state1Index;
+ state1 = STATE_DOUBLE_QUOTE;
+ state1Index = i;
+ continue;
+ }
+ continue;
+
+ case CHAR_LESS_THAN:
+ if ((state1 & 0xff) === 0) {
+ state2 = state1;
+ state2Index = state1Index;
+ state1 = STATE_LESS_THAN;
+ state1Index = i;
+ }
+ continue;
+
+ case CHAR_COLON:
+ if ((state1 & 0xff) === STATE_LESS_THAN) {
+ const colonIndex = (state1 >> 8) & 0xff;
+ if (colonIndex === 0) state1 ^= ((i - state1Index) & 0xff) << 8;
+ }
+ continue;
+
+ case CHAR_SPACE:
+ case CHAR_TAB:
+ case CHAR_CR:
+ case CHAR_LF:
+ if ((state1 & 0xff) === STATE_LESS_THAN) {
+ const wsIndex = (state1 >> 16) & 0xff;
+ if (wsIndex === 0) state1 ^= ((i - state1Index) & 0xff) << 16;
+ }
+ continue;
+
+ case CHAR_GREATER_THAN:
+ if ((state1 & 0xff) === STATE_LESS_THAN) {
+ const secondChar = string.charCodeAt(state1Index + 1);
+ const wsIndex: number = (state1 >> 16) & 0xff;
+ let name: string,
+ colonIndex: number,
+ e: Element,
+ parent: Element,
+ selfClosing: number,
+ localName: string,
+ namespace: string;
+
+ switch (secondChar) {
+ case CHAR_SLASH:
+ e = stack.pop();
+ name =
+ wsIndex === 0
+ ? string.slice(state1Index + 2, i)
+ : string.slice(state1Index + 2, state1Index + wsIndex);
+ if (e.name !== name)
+ throw new Error(`Unmatched closing tag at ${i}`);
+ if (!e.children.length)
+ e.text = string.slice(e.bodyIndex, state1Index);
+ state1 = state2;
+ state1Index = state2Index;
+ state2 = 0;
+ continue;
+
+ case CHAR_EXMARK:
+ if (string.startsWith("![CDATA[", state1Index + 1)) {
+ if (string.endsWith("]]", i))
+ throw new Error(`CDATA nodes are not supported at ${i}`);
+ } else if (string.startsWith("!--", state1Index + 1)) {
+ // Comment node, ignore
+ if (string.endsWith("--", i)) {
+ state1 = state2;
+ state1Index = state2Index;
+ state2 = 0;
+ }
+ }
+ continue;
+
+ case CHAR_QMARK:
+ if (string.charCodeAt(i - 1) === CHAR_QMARK) {
+ // XML declaration node, ignore
+ state1 = state2;
+ state1Index = state2Index;
+ state2 = 0;
+ }
+ continue;
+
+ default:
+ selfClosing = +(string.charCodeAt(i - 1) === CHAR_SLASH);
+ parent = stack[stack.length - 1];
+ colonIndex = (state1 >> 8) & 0xff;
+
+ name =
+ wsIndex === 0
+ ? string.slice(state1Index + 1, i - selfClosing)
+ : string.slice(state1Index + 1, state1Index + wsIndex);
+ if (colonIndex && (!wsIndex || colonIndex < wsIndex)) {
+ localName = name.slice(colonIndex);
+ namespace = name.slice(0, colonIndex - 1);
+ } else {
+ localName = name;
+ namespace = "";
+ }
+
+ e = {
+ name: name,
+ namespace: namespace,
+ localName: localName,
+ attrs: wsIndex
+ ? string.slice(state1Index + wsIndex + 1, i - selfClosing)
+ : "",
+ text: "",
+ bodyIndex: i + 1,
+ children: []
+ };
+ parent.children.push(e);
+ if (!selfClosing) stack.push(e);
+
+ state1 = state2;
+ state1Index = state2Index;
+ state2 = 0;
+ continue;
+ }
+ }
+ continue;
+ }
+ }
+
+ if (state1) throw new Error(`Unclosed token at ${state1Index}`);
+
+ if (stack.length > 1) {
+ const e = stack[stack.length - 1];
+ throw new Error(`Unclosed XML element at ${e.bodyIndex}`);
+ }
+
+ if (!root.children.length) root.text = string;
+ return root;
+}
diff --git a/test/xml-parser.ts b/test/xml-parser.ts
new file mode 100644
index 0000000..acc48c0
--- /dev/null
+++ b/test/xml-parser.ts
@@ -0,0 +1,78 @@
+import ava from "ava";
+import {
+ parseXmlDeclaration,
+ decodeEntities,
+ parseXml
+} from "../lib/xml-parser";
+
+ava("parseXmlDeclaration", t => {
+ const buf = Buffer.from(
+ '\n'
+ );
+ const attrs = parseXmlDeclaration(buf);
+ t.deepEqual(attrs, [
+ {
+ name: "version",
+ namespace: "",
+ localName: "version",
+ value: "1.0"
+ },
+ {
+ name: "encoding",
+ namespace: "",
+ localName: "encoding",
+ value: "UTF-8"
+ }
+ ]);
+});
+
+ava("decodeEntities", t => {
+ t.is(
+ decodeEntities("&&<>"'>§;"),
+ "&&<>\"'>§𠮷;"
+ );
+});
+
+ava("parse", t => {
+ const xml =
+ '\ni';
+ const parsed = parseXml(xml);
+ t.deepEqual(parsed, {
+ name: "root",
+ namespace: "",
+ localName: "root",
+ attrs: "",
+ text: "",
+ bodyIndex: 0,
+ children: [
+ {
+ name: "a-b:c",
+ namespace: "a-b",
+ localName: "c",
+ attrs: "",
+ text: "",
+ bodyIndex: 29,
+ children: [
+ {
+ name: "d",
+ namespace: "",
+ localName: "d",
+ attrs: 'f="1"',
+ text: "",
+ bodyIndex: 42,
+ children: []
+ },
+ {
+ name: "h",
+ namespace: "",
+ localName: "h",
+ attrs: "",
+ text: "i",
+ bodyIndex: 62,
+ children: []
+ }
+ ]
+ }
+ ]
+ });
+});