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 →