Skip to content

XML ↔ JSON Converter — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package xmljson is the Go twin of CosmoDev's src/lib/xml-to-json.ts (dual
// source: the web lib wraps fast-xml-parser in TypeScript, the CLI lib uses
// encoding/xml + encoding/json in Go — kept in lock-step so the pure logic
// produces structurally equivalent output). Pure + deterministic, never panics.
// The table-driven tests in xmljson_test.go share vectors with
// src/lib/xml-to-json.test.ts so the two implementations are held to the same
// contract.
//
// Note: number-coercion of element text may differ slightly from
// fast-xml-parser's parseTagValue (the TS lib coerces numeric element text to
// numbers; this Go twin leaves element text as strings); attributes are always
// strings on both sides (parseAttributeValue is OFF).
package xmljson

import (
	"bytes"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"sort"
	"strconv"
	"strings"
)

// XMLToJSON parses a well-formed XML string into pretty-printed JSON (indented
// 2 spaces, matching the TS pretty output). It returns ("", false) if the input
// is not well-formed XML (any decoder error, no root element, or multiple root
// elements). Attributes become "@_<name>" string keys; element text becomes a
// string value when the element has no children, or a "#text" key when mixed
// with child elements. Repeated child tags become JSON arrays.
func XMLToJSON(xmlStr string) (string, bool) {
	dec := xml.NewDecoder(strings.NewReader(xmlStr))

	// Find the root StartElement, skipping any leading processing
	// instructions, comments, or whitespace-only CharData. If the stream ends
	// first (or the decoder errors), the input is not a well-formed XML
	// document with a single root element.
	var root xml.StartElement
	gotRoot := false
	for {
		tok, err := dec.Token()
		if err != nil {
			// io.EOF here means "no root element found" (e.g. bare text or
			// empty input) — still a malformed-document case.
			return "", false
		}
		if se, ok := tok.(xml.StartElement); ok {
			root = se
			gotRoot = true
			break
		}
		// Skip CharData, Comment, ProcInst, Directive before the root.
	}
	if !gotRoot {
		return "", false
	}

	val, err := parseElement(dec, root)
	if err != nil {
		return "", false
	}

	// After the root closes, consume the remainder of the document. Trailing
	// CharData / comments / PIs are fine; a second root element or any decoder
	// error is malformed.
	for {
		tok, err := dec.Token()
		if err == io.EOF {
			break
		}
		if err != nil {
			return "", false
		}
		if _, ok := tok.(xml.StartElement); ok {
			// Multiple root elements — not a well-formed document.
			return "", false
		}
	}

	result := map[string]interface{}{root.Name.Local: val}
	out, err := json.MarshalIndent(result, "", "  ")
	if err != nil {
		return "", false
	}
	return string(out), true
}

// parseElement is called with the decoder positioned just after the element's
// StartElement (which is passed in as `se`). It consumes the element's contents
// and the matching EndElement, returning the element's value:
//   - a bare string for a text-only element with no attributes,
//   - a map[string]interface{} otherwise (attributes as "@_<name>", child tags
//     keyed by name, and "#text" for mixed content).
//
// Any decoder error (including a mismatched close tag) is propagated.
func parseElement(dec *xml.Decoder, se xml.StartElement) (interface{}, error) {
	attrs := map[string]string{}
	for _, a := range se.Attr {
		attrs["@_"+a.Name.Local] = a.Value
	}

	children := map[string]interface{}{}
	hasChild := false
	var textBuf strings.Builder

	for {
		tok, err := dec.Token()
		if err != nil {
			return nil, err
		}
		switch t := tok.(type) {
		case xml.CharData:
			textBuf.Write(t)
		case xml.StartElement:
			hasChild = true
			child, cerr := parseElement(dec, t)
			if cerr != nil {
				return nil, cerr
			}
			tag := t.Name.Local
			if existing, ok := children[tag]; ok {
				if arr, isArr := existing.([]interface{}); isArr {
					children[tag] = append(arr, child)
				} else {
					children[tag] = []interface{}{existing, child}
				}
			} else {
				children[tag] = child
			}
		case xml.EndElement:
			// This is the close of `se` — the decoder enforces nesting, so the
			// first EndElement we see matches our StartElement.
			return composeValue(attrs, children, textBuf.String(), hasChild), nil
		}
	}
}

// composeValue assembles an element's value from its parsed parts.
func composeValue(attrs map[string]string, children map[string]interface{}, text string, hasChild bool) interface{} {
	trimmed := strings.TrimSpace(text)
	if !hasChild && len(attrs) == 0 {
		// Pure text element (or empty element) — value is the bare string.
		return trimmed
	}
	m := map[string]interface{}{}
	for k, v := range attrs {
		m[k] = v
	}
	if hasChild {
		for k, v := range children {
			m[k] = v
		}
		if trimmed != "" {
			m["#text"] = trimmed
		}
	} else {
		// Attributes but no children — keep text under #text alongside the
		// attribute keys so nothing is lost.
		if trimmed != "" {
			m["#text"] = trimmed
		}
	}
	return m
}

// JSONToXML builds an indented XML string from a JSON string whose root is a
// JSON object (map). It returns ("", false) if the JSON does not parse or if
// the root is not an object (a number, string, bool, null, or array at the top
// level is rejected). Attribute keys of the form "@_<name>" are rendered as XML
// attributes; a "#text" key renders as element text; every other key becomes a
// child element (array values produce repeated sibling elements).
func JSONToXML(jsonStr string) (string, bool) {
	var obj interface{}
	if err := json.Unmarshal([]byte(jsonStr), &obj); err != nil {
		return "", false
	}
	rootMap, ok := obj.(map[string]interface{})
	if !ok {
		// Reject non-object roots: numbers, strings, bools, null, and arrays.
		return "", false
	}

	var buf bytes.Buffer
	for _, k := range sortedKeys(rootMap) {
		writeElement(&buf, k, rootMap[k], 0)
	}
	return buf.String(), true
}

// writeElement renders <tag ...>...</tag> for the given value at the given
// indent depth, appending a trailing newline.
func writeElement(buf *bytes.Buffer, tag string, val interface{}, depth int) {
	indent := strings.Repeat("  ", depth)
	inner := strings.Repeat("  ", depth+1)

	var attrParts []string
	type child struct {
		tag string
		val interface{}
	}
	var children []child
	var textPart string
	hasText := false

	switch v := val.(type) {
	case map[string]interface{}:
		for _, k := range sortedKeys(v) {
			if strings.HasPrefix(k, "@_") {
				attrName := k[2:]
				attrParts = append(attrParts, fmt.Sprintf(`%s="%s"`, attrName, escapeXML(stringify(v[k]))))
			} else if k == "#text" {
				textPart = stringify(v[k])
				hasText = true
			} else {
				children = append(children, child{tag: k, val: v[k]})
			}
		}
	default:
		textPart = stringify(val)
		hasText = true
	}

	// Opening tag + attributes.
	buf.WriteString(indent)
	buf.WriteString("<")
	buf.WriteString(tag)
	for _, a := range attrParts {
		buf.WriteString(" ")
		buf.WriteString(a)
	}

	noChildren := len(children) == 0
	if noChildren && !hasText {
		buf.WriteString("></")
		buf.WriteString(tag)
		buf.WriteString(">\n")
		return
	}
	if noChildren {
		buf.WriteString(">")
		buf.WriteString(escapeXML(textPart))
		buf.WriteString("</")
		buf.WriteString(tag)
		buf.WriteString(">\n")
		return
	}

	// Element with children.
	buf.WriteString(">\n")
	if hasText && strings.TrimSpace(textPart) != "" {
		buf.WriteString(inner)
		buf.WriteString(escapeXML(textPart))
		buf.WriteString("\n")
	}
	for _, c := range children {
		if arr, isArr := c.val.([]interface{}); isArr {
			for _, item := range arr {
				writeElement(buf, c.tag, item, depth+1)
			}
		} else {
			writeElement(buf, c.tag, c.val, depth+1)
		}
	}
	buf.WriteString(indent)
	buf.WriteString("</")
	buf.WriteString(tag)
	buf.WriteString(">\n")
}

// stringify renders a generic JSON-decoded value as a string for XML text /
// attribute content.
func stringify(v interface{}) string {
	switch s := v.(type) {
	case string:
		return s
	case float64:
		if s == float64(int64(s)) {
			return strconv.FormatInt(int64(s), 10)
		}
		return strconv.FormatFloat(s, 'f', -1, 64)
	case bool:
		if s {
			return "true"
		}
		return "false"
	case nil:
		return ""
	}
	b, err := json.Marshal(v)
	if err != nil {
		return ""
	}
	return string(b)
}

// escapeXML escapes XML special characters (& < > ' ") via encoding/xml, safe
// for both text content and double-quoted attribute values.
func escapeXML(s string) string {
	var b bytes.Buffer
	if err := xml.EscapeText(&b, []byte(s)); err != nil {
		return s
	}
	return b.String()
}

// sortedKeys returns the keys of m in sorted order for deterministic output.
func sortedKeys(m map[string]interface{}) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

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 →