XML ↔ JSON Converter — JavaScript source
Convert XML to JSON and back, preserving attributes. Validates input and reports errors clearly, runs entirely in your browser, with a shareable link to your exact input.
This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
/**
* xml-to-json - bidirectional XML <-> JSON converter.
*
* Language: JavaScript (ES2020+, browser/DOM environment - uses the native
* DOMParser, which is the browser-stdlib XML parser)
* Source: CosmoDev polyglot showcase port of the XML-to-JSON tool, ported
* from cli/xml-to-json/xml-to-json.go (the canonical Go CLI twin -
* this port mirrors its contract rather than the TS lib, which
* wraps fast-xml-parser).
* License: display source - part of CosmoDev's polyglot tool pages.
*
* Design goals:
* - Pure + deterministic; never throws (malformed input returns null).
* - Functionally equivalent to the Go twin: same inputs -> same JSON structure.
* - Self-contained: stdlib only (DOMParser; no npm dependencies).
*
* Contract (matches the Go twin, encoding/xml + encoding/json):
* - Malformed XML (any parse error, no root, multiple roots) -> null.
* - Attributes become "@_<name>" string keys.
* - A text-only element with no attributes becomes its bare string value.
* - An element with attributes and/or children becomes an object; its direct
* text becomes the "#text" key.
* - Repeated child tags become a JSON array.
*
* Note: DOMParser signals malformed XML by embedding a <parsererror> element in
* the result document, which we detect. localName is used (not tagName) so
* namespaced tags map to their local part, matching the Go twin's Name.Local.
* JSON.stringify preserves document order (like fast-xml-parser); the Go twin
* sorts keys alphabetically - a cosmetic ordering difference only, the JSON
* structure is identical.
*
* The showcase tests at the bottom require a DOM (browser or jsdom); they are
* runnable but not executed by Node without a DOM global.
*/
'use strict';
/**
* Convert a DOM Element to the JSON value the Go twin produces.
*
* @param {Element} el
* @returns {string|Object<string, unknown>}
*/
function elementToValue(el) {
const attrs = {};
for (const attr of el.attributes) {
attrs['@_' + attr.name] = attr.value;
}
// element children only (excludes text/comment nodes).
const children = Array.from(el.children);
const hasChild = children.length > 0;
// Direct text: gather immediate TEXT/CDATA child nodes (not flattened
// descendant text), mirroring the Go twin's CharData accumulation.
let text = '';
for (const node of el.childNodes) {
if (node.nodeType === 3 /* Node.TEXT_NODE */ || node.nodeType === 4 /* Node.CDATA_SECTION_NODE */) {
text += node.nodeValue;
}
}
text = text.trim();
if (!hasChild && Object.keys(attrs).length === 0) {
// Pure text element (or empty element) - value is the bare string.
return text;
}
// attrs first, then children, then #text - matches the Go twin.
const obj = { ...attrs };
for (const child of children) {
const tag = child.localName;
const val = elementToValue(child);
if (tag in obj) {
if (Array.isArray(obj[tag])) {
obj[tag].push(val);
} else {
obj[tag] = [obj[tag], val];
}
} else {
obj[tag] = val;
}
}
if (text) obj['#text'] = text;
return obj;
}
/**
* Parse a well-formed XML string to pretty-printed JSON (2-space indent), or
* null if malformed.
*
* @param {string} xml
* @returns {string|null}
*/
function xmlToJSON(xml) {
const doc = new DOMParser().parseFromString(xml, 'application/xml');
// Browsers embed a <parsererror> element when the XML is not well-formed.
if (doc.querySelector('parsererror')) return null;
const root = doc.documentElement;
if (!root) return null;
const result = { [root.localName]: elementToValue(root) };
return JSON.stringify(result, null, 2);
}
/**
* Render a JSON-decoded scalar as a string for XML text/attribute content
* (mirrors the Go twin's stringify).
*
* @param {unknown} v
* @returns {string}
*/
function stringify(v) {
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (v === null) return '';
if (typeof v === 'number') {
return Number.isInteger(v) ? String(v) : String(v);
}
return String(v);
}
/** Escape XML special characters (& < > " ') for text/attribute content. */
function escapeXml(s) {
return s.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Render <tag ...>...</tag> for a value at an indent depth, mirroring the Go
* twin's writeElement: @_ keys -> attributes, #text -> text, other keys ->
* child elements (arrays -> repeated siblings).
*
* @param {string} tag
* @param {unknown} val
* @param {number} depth
* @returns {string}
*/
function writeElement(tag, val, depth) {
const indent = ' '.repeat(depth);
const inner = ' '.repeat(depth + 1);
const attrParts = [];
const children = []; // [tag, value]
let textPart = '';
let hasText = false;
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
// Sort keys for deterministic output, matching the Go twin's sortedKeys.
for (const key of Object.keys(val).sort()) {
const v = val[key];
if (key.startsWith('@_')) {
attrParts.push(`${key.slice(2)}="${escapeXml(stringify(v))}"`);
} else if (key === '#text') {
textPart = stringify(v);
hasText = true;
} else {
children.push([key, v]);
}
}
} else {
textPart = stringify(val);
hasText = true;
}
const open = indent + '<' + tag + attrParts.map((a) => ' ' + a).join('');
const noChildren = children.length === 0;
if (noChildren && !hasText) return `${open}></${tag}>\n`;
if (noChildren) return `${open}>${escapeXml(textPart)}</${tag}>\n`;
let s = `${open}>\n`;
if (hasText && textPart.trim() !== '') {
s += inner + escapeXml(textPart) + '\n';
}
for (const [childTag, childVal] of children) {
if (Array.isArray(childVal)) {
for (const item of childVal) s += writeElement(childTag, item, depth + 1);
} else {
s += writeElement(childTag, childVal, depth + 1);
}
}
s += `${indent}</${tag}>\n`;
return s;
}
/**
* Build an indented XML string from a JSON string whose root is an object, or
* null if the JSON does not parse or the root is not an object (numbers,
* strings, bools, null, arrays are rejected).
*
* @param {string} json
* @returns {string|null}
*/
function jsonToXML(json) {
let obj;
try {
obj = JSON.parse(json);
} catch {
return null;
}
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null;
let out = '';
for (const key of Object.keys(obj).sort()) {
out += writeElement(key, obj[key], 0);
}
return out;
}
// CommonJS export so the file is consumable from Node without a build step,
// while staying dependency-free and framework-agnostic.
module.exports = { xmlToJSON, jsonToXML, elementToValue, writeElement, stringify, escapeXml };
// ---------- showcase tests (require a DOM; run in a browser or jsdom) ----------
// Mirror the src/lib/xml-to-json.test.ts vectors (assert on parsed structure so
// formatting differences don't mask contract drift). They self-run only when
// executed directly under Node with a DOM global present.
if (typeof require !== 'undefined' && require.main === module && typeof DOMParser !== 'undefined') {
// 1. simple parse
const one = JSON.parse(xmlToJSON('<root><name>Alice</name></root>'));
console.assert(one.root.name === 'Alice');
// 2. attributes preserved as @_<name> strings
const two = JSON.parse(xmlToJSON('<user id="7"><name>Alice</name></user>'));
console.assert(two.user['@_id'] === '7');
console.assert(two.user.name === 'Alice');
// 3. repeated child tags become a JSON array
const three = JSON.parse(xmlToJSON('<list><item>a</item><item>b</item></list>'));
console.assert(Array.isArray(three.list.item) && three.list.item.length === 2);
// 4. malformed XML -> null
console.assert(xmlToJSON('<a><b></a>') === null);
console.assert(xmlToJSON('not xml at all') === null);
// 5. json_to_xml containment + attributes + invalid roots
console.assert(jsonToXML('{"root":{"name":"Alice"}}').includes('<name>Alice</name>'));
console.assert(jsonToXML('{"user":{"@_id":"7","name":"Alice"}}').includes('id="7"'));
console.assert(jsonToXML('{not valid json') === null);
console.assert(jsonToXML('42') === null);
console.assert(jsonToXML('null') === null);
console.log('ok');
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →