An In-Browser XML Transformation Builder

Published on

XSLT works, but writing it means holding the whole transform in your head. I wanted something closer to a rule builder: paste XML, add rules through a form, watch the output update. The result is XML transformation builder (source) - a proof of concept, everything in memory, no backend.

The interesting part isn’t the UI. It’s the engine underneath it, and the one decision that shaped everything else: the ruleset JSON is the contract, the UI is disposable.

pnpm install
pnpm dev        # http://localhost:5173
pnpm test       # engine + app tests

The ruleset format

A ruleset is an ordered list of rules. Each rule runs against the whole document, and its output feeds the next rule - top to bottom, one pass:

{
	"version": 1,
	"namespaces": { "o": "urn:orders" },
	"rules": [
		{
			"id": "r1",
			"name": "Rename Addr to Address",
			"enabled": true,
			"match": { "sel": "//o:Order/o:Addr" },
			"actions": [{ "act": "renameElement", "to": "Address" }]
		},
		{
			"id": "r2",
			"name": "Flag discounted items",
			"enabled": true,
			"match": {
				"sel": "//Item",
				"where": { "op": "exists", "sel": "@discount" }
			},
			"actions": [{ "act": "setAttr", "name": "flagged", "value": { "lit": "Y" } }]
		}
	]
}

match.sel is a plain XPath evaluated from the document root. Every other sel - inside a where, a value, or a fragment hole - is relative to the matched node. That single rule is the whole mental model a user needs.

Types, not strings

Rules, values, and predicates are closed unions in src/engine/types.ts. This is the file that isn’t allowed to break:

export type Value =
	| { lit: string }
	| { sel: string } // XPath, relative to the matched node
	| { concat: Value[] }
	| { case: { of: Value; to: 'upper' | 'lower' } }
	| { regexReplace: { of: Value; pattern: string; replace: string; flags?: string } };

export type Predicate =
	| { op: 'cmp'; left: Value; cmp: '=' | '!=' | '>' | '<'; right: Value }
	| { op: 'contains'; of: Value; value: Value }
	| { op: 'exists'; sel: string }
	| { op: 'and'; args: Predicate[] }
	| { op: 'not'; arg: Predicate };
// ...

export type Action =
	| { act: 'renameElement'; to: string }
	| { act: 'setAttr'; name: string; value: Value }
	| { act: 'addChild'; xml: string; position: 'first' | 'last' }
	| { act: 'wrap'; element: string }
	| { act: 'unwrap' };
// ...

evalValue and evalPredicate are then just a recursive switch over these unions, with const never: never = p at the bottom so TypeScript refuses to compile a new Action variant that no case handles.

Collect, then apply

The rule that makes the engine predictable: read the whole document first, mutate it only after every value has been computed.

// src/engine/run.ts
export function applyRule(doc: Document, rule: Rule, ns: NsMap | undefined): RuleReport {
	const nodes = selectMatches(doc, rule, resolver, created);

	// Collect: walk the match set once, computing every value and fragment
	// against the document as it stands now.
	const appliers: Array<() => boolean> = [];
	for (const node of nodes) {
		const target: MatchTarget = { node, removed: false };
		for (const action of rule.actions) {
			appliers.push(prepareAction(action, target, actx));
		}
	}

	// Apply: only now does the document change.
	let applied = 0;
	for (const apply of appliers) {
		if (apply()) applied++;
	}
	return { ruleId: rule.id, status: 'ok', matched: nodes.length, applied };
}

prepareAction evaluates a value’s XPath immediately and returns a thunk that only touches the DOM. That separation is what buys three guarantees for free:

  • A rule never matches nodes its own actions just created (created tracks them, selectMatches filters them out).
  • Acting on a node an earlier action already removed is a no-op, not a crash - alive() checks isAttached before every mutation.
  • A rule that throws mid-way is rolled back on its own, because it ran against a clone:
// src/engine/run.ts
for (const rule of ruleset.rules) {
	const next = cloneDocument(doc);
	const report = applyRule(next, rule, ruleset.namespaces);
	reports.push(report);
	if (report.status !== 'error') doc = next; // discard `next` on failure
}

One cloneNode(true) per rule is not free, but it turns “half-applied rule corrupts the pipeline” into a class of bug that can’t happen.

Fragments: {xpath} holes in literal XML

addChild and replaceNode take a raw XML string with holes, so you write the shape you want instead of composing it action by action:

<Address type="billing">
  <Street>{Line1}</Street>
  <City>{upper(City)}</City>
  {@*}
</Address>

Three forms, and that’s the whole spec:

// src/engine/fragment.ts
function substitute(expr: string, ctx: EvalCtx, inTag: boolean): string {
	if (expr === '@*') return copyAttributesMarker(); // {@*}  copy attributes
	if (expr === 'node()' && !inTag) return copyChildren(); // {node()} copy children
	const text = stringOf(expr, ctx.node, ctx.resolver); // {expr} XPath string value
	return inTag ? escapeAttr(text) : escapeText(text);
}

renderFragment is a small hand-rolled scanner (tracks whether it’s inside a tag, so { inside an attribute value is escaped differently than { in text) that substitutes the holes into a string, then parseFragmentNodes wraps that string in a throwaway root element, so it can borrow the ruleset’s namespace declarations, and parses it with DOMParser. {{ / }} are the literal-brace escape.

Because it’s render-then-parse, a fragment is instantiated fresh per matched node - no shared DOM nodes leaking between matches.

Rename an element without losing its children

Renaming in the DOM isn’t a property assignment - createElementNS gives you a new element, and you have to move everything over yourself, including working out whether the new tag keeps the old namespace prefix:

// src/engine/actions.ts
function renamedElement(el: Element, to: string, actx: ActionCtx): Element {
	const uri = namespaceForNewName(to, actx.ns, el.namespaceURI);
	const next = actx.doc.createElementNS(uri, qualify(to, el, uri));
	for (const a of Array.from(el.attributes)) {
		if (a.namespaceURI === XMLNS_NS) continue;
		next.setAttributeNS(a.namespaceURI, a.name, a.value);
	}
	while (el.firstChild) next.appendChild(el.firstChild);
	return next;
}

// An unprefixed new name keeps the old element's prefix when it stays in the
// old element's namespace: renaming o:Addr to Address gives o:Address.
function qualify(name: string, from: Element, uri: string | null): string {
	const { prefix } = splitQName(name);
	if (prefix) return name;
	if (from.prefix && uri === from.namespaceURI) return `${from.prefix}:${name}`;
	return name;
}

renameElement swaps the node in place (el.parentNode?.replaceChild(next, el)) and repoints target.node = next, so later actions in the same rule keep operating on the right element.

Where the browser leaks through

The engine is a thin wrapper over DOMParser, XMLSerializer, and document.evaluate - there’s no XPath library:

// src/engine/dom.ts
export function selectNodes(expr: string, ctx: Node, resolver: NsResolver): Node[] {
	const doc = ownerDocumentOf(ctx);
	assertPrefixesKnown(expr, resolver); // fail loudly on an unbound prefix, not silently match nothing
	const result = doc.evaluate(expr, ctx, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
	const out: Node[] = [];
	for (let i = 0; i < result.snapshotLength; i++) out.push(result.snapshotItem(i)!);
	return out;
}

The one real wrinkle: tests run under vitest + jsdom, and jsdom’s XPath implementation lowercases attribute names and matches namespace prefixes literally, which real browsers don’t. Rather than patch around it, the README just says so - tests stay inside what both engines agree on, and src/engine/run.test.ts has one block per action so the disagreement never hides a real bug.

Wiring the engine to React

App.tsx is a three-pane layout (react-resizable-panels) holding almost no state beyond typedXml and ruleset - everything else is useMemo:

const xml = useDebounced(typedXml, 300);
const parsed = useMemo(() => parseXml(xml), [xml]);

const result = useMemo(() => (parsed.ok ? runOnDocument(parsed.doc, effective) : null), [parsed, effective]);

Every keystroke re-runs the entire pipeline against the debounced input. No incremental diffing, no memoizing individual rules - for documents this size, re-running everything is simpler than the alternative and fast enough that it’s not worth the complexity. The rule editor also needs the document as a specific rule saw it, not just the final output, so runOnDocument returns inputs: Map<ruleId, Document> alongside the final xml - one entry per rule, captured before it ran.

What’s deliberately out of scope

The README is upfront about the edges:

  • wrap takes a plain tag name - no attributes on the wrapper. Use replaceNode for that.
  • Namespaces are read from the document; the UI can’t edit them.
  • Output is pretty-printed on copy/download, so whitespace between elements is normalized.

None of these are hard to lift. They’re just not needed to prove the idea works, and the ruleset format doesn’t have to change to add them later - only src/engine/actions.ts would.

Stack

React 19, TypeScript, Vite, Tailwind v4, shadcn/base-ui for the primitives, CodeMirror for the XML editor, @dnd-kit for reordering rule cards, Vitest for both engine and component tests. No backend, no XML library beyond what the browser ships - runOnDocument is a pure function of (Document, Ruleset) → RunResult, which is what makes it feasible to unit test the engine with plain jsdom documents and never touch the UI at all.