diff --git a/src/project/util/xml.ts b/src/project/util/xml.ts index 33cfa8c..e80045a 100644 --- a/src/project/util/xml.ts +++ b/src/project/util/xml.ts @@ -16,6 +16,26 @@ export function parseXmlString(contents: string) { return new xmldom.DOMParser().parseFromString(contents); } +const FRAGMENT_ROOT = 'trampoline-fragment'; + +/** + * Parses a fragment that may hold more than one top-level node and returns + * those nodes. A document has a single root, so parsing a multi-element + * fragment on its own keeps the first element and discards the rest; wrapping + * it first is what makes every element survive. + */ +export function parseXmlFragment(fragment: string): any[] { + // A fragment is not a document, so a declaration it was pasted in with would + // land inside the wrapper, where it is neither legal nor printable. + const body = fragment.trimStart().startsWith('') + 2) + : fragment; + + const doc = parseXmlString(`<${FRAGMENT_ROOT}>${body}`); + + return Array.from(doc.documentElement?.childNodes ?? []); +} + export function serializeXml(doc: any) { return new XMLSerializer().serializeToString(doc); } diff --git a/src/project/xml.ts b/src/project/xml.ts index daa7eac..23b3f5d 100644 --- a/src/project/xml.ts +++ b/src/project/xml.ts @@ -1,4 +1,4 @@ -import { formatXml, parseXml, parseXmlString, serializeXml, writeXml } from './util/xml'; +import { formatXml, parseXml, parseXmlFragment, parseXmlString, serializeXml, writeXml } from './util/xml'; import xpath, { XPathSelect } from 'xpath'; import { xml2js, js2xml } from 'xml-js'; import { VFS, VFSFile, VFSStorable } from './vfs'; @@ -103,12 +103,9 @@ export class XmlFile extends VFSStorable { Logger.v('xml', 'injectFragment', `at ${target}`); nodes.forEach(n => { - const parsed = parseXmlString(fragment); - const children = Array.prototype.slice.call(parsed.childNodes ?? []) as any[]; - const firstElement = children.findIndex(d => d.nodeType === 1); - children - .filter((d, i) => firstElement < 0 || i <= firstElement || d.nodeType !== 3) - .forEach(d => n.appendChild(d)); + // Parsed per match: appendChild moves a node rather than copying it, so a + // single parse would empty itself into the first match. + parseXmlFragment(fragment).forEach(d => n.appendChild(d)); }); this.vfs.set(this.path, this); @@ -159,7 +156,10 @@ export class XmlFile extends VFSStorable { // Recursively merge nodes with some heuristics based on // likely merge expectations _mergeJson(target: any, fragment: any) { - for (const e of fragment.elements) { + // An element written with no children has no `elements` array at all. + target.elements ??= []; + + for (const e of fragment.elements ?? []) { let child: Element | null = null; for (const t of target.elements) {