Skip to content

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

/**
 * 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 →