228 lines
9.1 KiB
JavaScript
228 lines
9.1 KiB
JavaScript
"use strict";
|
|
|
|
// Source-preserving XML: parses into elements that remember where every attribute value and text
|
|
// sits in the original string, so edits replace just those characters (comments, spacing, line
|
|
// breaks, CP949 bytes — the text travels as latin1 — all stay as they were). After an edit the
|
|
// document is re-parsed (UI files are small).
|
|
|
|
const XmlDoc = (() => {
|
|
const isName = (c) => /[A-Za-z0-9_:.\-]/.test(c);
|
|
|
|
/** → { src, root, elements } ; element = { tag, attrs: [{ name, value, vs, ve, ns }], start, openEnd, selfClose, end, children, parent, textStart, textEnd } */
|
|
function parse(src) {
|
|
const els = [];
|
|
const stack = [];
|
|
const warnings = [];
|
|
let root = null;
|
|
let i = 0;
|
|
const n = src.length;
|
|
const err = (msg) => {
|
|
const line = src.slice(0, i).split("\n").length;
|
|
throw new Error(`XML line ${line}: ${msg}`);
|
|
};
|
|
while (i < n) {
|
|
const lt = src.indexOf("<", i);
|
|
if (lt < 0) break;
|
|
const top = stack[stack.length - 1];
|
|
if (top && top.textStart === undefined && lt > i) {
|
|
// text between tags (only kept for leaf elements such as <Style>, <Text>)
|
|
top._text = (top._text || []).concat([[i, lt]]);
|
|
}
|
|
i = lt;
|
|
if (src.startsWith("<!--", i)) {
|
|
const e = src.indexOf("-->", i + 4);
|
|
i = e < 0 ? n : e + 3;
|
|
continue;
|
|
}
|
|
if (src.startsWith("<?", i)) {
|
|
const e = src.indexOf("?>", i + 2);
|
|
i = e < 0 ? n : e + 2;
|
|
continue;
|
|
}
|
|
if (src.startsWith("<![CDATA[", i)) {
|
|
const e = src.indexOf("]]>", i);
|
|
i = e < 0 ? n : e + 3;
|
|
continue;
|
|
}
|
|
if (src.startsWith("<!", i)) {
|
|
const e = src.indexOf(">", i);
|
|
i = e < 0 ? n : e + 1;
|
|
continue;
|
|
}
|
|
if (src[i + 1] === "/") {
|
|
// closing tag
|
|
let j = i + 2;
|
|
while (j < n && isName(src[j])) j++;
|
|
const tag = src.slice(i + 2, j);
|
|
const gt = src.indexOf(">", j);
|
|
if (gt < 0) err("unclosed end tag");
|
|
// a stray / mismatched end tag: close up to the matching element, or ignore it
|
|
const at = stack.map((e) => e.tag.toLowerCase()).lastIndexOf(tag.toLowerCase());
|
|
if (at < 0) {
|
|
warnings.push(`line ${src.slice(0, i).split("\n").length}: stray </${tag}> ignored`);
|
|
i = gt + 1;
|
|
continue;
|
|
}
|
|
while (stack.length - 1 > at) {
|
|
const open = stack.pop();
|
|
delete open._text;
|
|
open.closeStart = i;
|
|
open.end = i;
|
|
open.unclosed = true;
|
|
warnings.push(`<${open.tag}> closed by </${tag}>`);
|
|
}
|
|
const el = stack.pop();
|
|
if (!el.children.length && el._text) {
|
|
el.textStart = el._text[0][0];
|
|
el.textEnd = el._text[el._text.length - 1][1];
|
|
}
|
|
delete el._text;
|
|
el.closeStart = i;
|
|
el.end = gt + 1;
|
|
i = gt + 1;
|
|
continue;
|
|
}
|
|
// opening tag
|
|
let j = i + 1;
|
|
while (j < n && isName(src[j])) j++;
|
|
const el = { tag: src.slice(i + 1, j), attrs: [], start: i, children: [], parent: stack[stack.length - 1] || null };
|
|
for (;;) {
|
|
while (j < n && /\s/.test(src[j])) j++;
|
|
if (src[j] === "/" && src[j + 1] === ">") {
|
|
el.selfClose = true;
|
|
el.openEnd = j;
|
|
el.end = j + 2;
|
|
j += 2;
|
|
break;
|
|
}
|
|
if (src[j] === ">") {
|
|
el.openEnd = j;
|
|
j += 1;
|
|
break;
|
|
}
|
|
const ns = j;
|
|
while (j < n && isName(src[j])) j++;
|
|
if (j === ns) {
|
|
warnings.push(`line ${src.slice(0, j).split("\n").length}: stray “${src[j]}” in <${el.tag}>`);
|
|
j++;
|
|
continue;
|
|
}
|
|
const name = src.slice(ns, j);
|
|
while (j < n && /\s/.test(src[j])) j++;
|
|
if (src[j] !== "=") {
|
|
el.attrs.push({ name, value: "", vs: j, ve: j, ns, bare: true });
|
|
continue;
|
|
}
|
|
j++;
|
|
while (j < n && /\s/.test(src[j])) j++;
|
|
const q = src[j];
|
|
if (q !== '"' && q !== "'") {
|
|
// unquoted value (a few game files have them; the client's parser takes them)
|
|
const vs = j;
|
|
while (j < n && !/[\s>]/.test(src[j]) && !(src[j] === "/" && src[j + 1] === ">")) j++;
|
|
el.attrs.push({ name, value: src.slice(vs, j), vs, ve: j, ns, unquoted: true });
|
|
continue;
|
|
}
|
|
const ve = src.indexOf(q, j + 1);
|
|
if (ve < 0) err(`unclosed attribute ${name}`);
|
|
el.attrs.push({ name, value: src.slice(j + 1, ve), vs: j + 1, ve, ns });
|
|
j = ve + 1;
|
|
}
|
|
els.push(el);
|
|
if (el.parent) el.parent.children.push(el);
|
|
else if (!root) root = el;
|
|
if (!el.selfClose) stack.push(el);
|
|
i = j;
|
|
}
|
|
// unclosed elements at the end (the client tolerates them): closed at the end of the file
|
|
const unclosed = stack.map((e) => e.tag);
|
|
while (stack.length) {
|
|
const el = stack.pop();
|
|
delete el._text;
|
|
el.closeStart = n;
|
|
el.end = n;
|
|
el.unclosed = true;
|
|
}
|
|
if (unclosed.length) warnings.push(`never closed: <${unclosed.join("> <")}>`);
|
|
return { src, root, elements: els, warnings };
|
|
}
|
|
|
|
const attr = (el, name) => el?.attrs.find((a) => a.name.toLowerCase() === name.toLowerCase())?.value;
|
|
const num = (el, name, d = 0) => {
|
|
const v = parseFloat(attr(el, name));
|
|
return isFinite(v) ? v : d;
|
|
};
|
|
const text = (doc, el) => (el && el.textStart !== undefined ? doc.src.slice(el.textStart, el.textEnd) : "");
|
|
const child = (el, tag) => el?.children.find((c) => c.tag.toLowerCase() === tag.toLowerCase()) || null;
|
|
const childrenOf = (el, tag) => (el ? el.children.filter((c) => c.tag.toLowerCase() === tag.toLowerCase()) : []);
|
|
const escAttr = (v) => String(v).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
|
|
/** The edits: each returns the new source string. */
|
|
function setAttr(doc, el, name, value) {
|
|
const a = el.attrs.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
const v = escAttr(value);
|
|
if (a && a.unquoted) return doc.src.slice(0, a.vs) + `"${v}"` + doc.src.slice(a.ve);
|
|
if (a && !a.bare) return doc.src.slice(0, a.vs) + v + doc.src.slice(a.ve);
|
|
const at = el.openEnd;
|
|
const before = doc.src.slice(0, at).replace(/\s*$/, "");
|
|
const cut = before.length;
|
|
return doc.src.slice(0, cut) + ` ${name}="${v}"` + (el.selfClose ? " " : "") + doc.src.slice(el.selfClose ? at : at);
|
|
}
|
|
function removeAttr(doc, el, name) {
|
|
const a = el.attrs.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
if (!a) return doc.src;
|
|
let s = a.ns;
|
|
while (s > 0 && /[ \t]/.test(doc.src[s - 1])) s--;
|
|
const e = a.bare || a.unquoted ? a.ve : a.ve + 1;
|
|
return doc.src.slice(0, s) + doc.src.slice(e);
|
|
}
|
|
function setText(doc, el, value) {
|
|
if (el.textStart !== undefined) return doc.src.slice(0, el.textStart) + value + doc.src.slice(el.textEnd);
|
|
if (el.selfClose) return doc.src.slice(0, el.openEnd).replace(/\s*$/, "") + `>${value}</${el.tag}>` + doc.src.slice(el.end);
|
|
return doc.src.slice(0, el.openEnd + 1) + value + doc.src.slice(el.openEnd + 1);
|
|
}
|
|
/** Line break + indentation used around an element. */
|
|
function indentOf(doc, el) {
|
|
const ls = doc.src.lastIndexOf("\n", el.start - 1);
|
|
const ws = doc.src.slice(ls + 1, el.start);
|
|
return /^[ \t]*$/.test(ws) ? ws : "";
|
|
}
|
|
const eolOf = (doc) => (doc.src.includes("\r\n") ? "\r\n" : "\n");
|
|
/** Inserts markup as the last child of `parent` (or after `after`). */
|
|
function insertChild(doc, parent, markup, after = null) {
|
|
const eol = eolOf(doc);
|
|
const base = indentOf(doc, parent);
|
|
const step = parent.children[0] ? indentOf(doc, parent.children[0]).slice(base.length) || "\t" : "\t";
|
|
const ind = base + step;
|
|
const body = markup.split(/\r?\n/).map((l, k) => (k ? ind + l : l)).join(eol);
|
|
if (after) return doc.src.slice(0, after.end) + eol + ind + body + doc.src.slice(after.end);
|
|
if (parent.selfClose) {
|
|
const open = doc.src.slice(parent.start, parent.openEnd).replace(/\s*$/, "");
|
|
return doc.src.slice(0, parent.start) + open + ">" + eol + ind + body + eol + base + `</${parent.tag}>` + doc.src.slice(parent.end);
|
|
}
|
|
// before the closing tag, on its own line
|
|
let cs = parent.closeStart;
|
|
let ls = cs;
|
|
while (ls > 0 && /[ \t]/.test(doc.src[ls - 1])) ls--;
|
|
return doc.src.slice(0, ls) + ind + body + eol + doc.src.slice(ls);
|
|
}
|
|
function removeElement(doc, el) {
|
|
let s = el.start;
|
|
let e = el.end;
|
|
// take the whole line when the element sits alone on it
|
|
let ls = s;
|
|
while (ls > 0 && /[ \t]/.test(doc.src[ls - 1])) ls--;
|
|
let le = e;
|
|
while (le < doc.src.length && /[ \t]/.test(doc.src[le])) le++;
|
|
if ((ls === 0 || doc.src[ls - 1] === "\n") && (doc.src[le] === "\r" || doc.src[le] === "\n")) {
|
|
s = ls;
|
|
e = le + (doc.src[le] === "\r" && doc.src[le + 1] === "\n" ? 2 : 1);
|
|
}
|
|
return doc.src.slice(0, s) + doc.src.slice(e);
|
|
}
|
|
const markupOf = (doc, el) => doc.src.slice(el.start, el.end);
|
|
|
|
return { parse, attr, num, text, child, childrenOf, setAttr, removeAttr, setText, insertChild, removeElement, markupOf, indentOf };
|
|
})();
|
|
window.XmlDoc = XmlDoc;
|