Imported from pablo-botella/cargoxml (
AGENTS.md). Install upstream withnpx skills add pablo-botella/cargoxml. Copyright stays with the author.
cargoxml
Parse and rewrite XML preserving anything foreign to your spec — forward compatibility and free room for auxiliary data. Use with github.com/pablo-botella/cargoxml.
Overview
cargoxml reads and writes XML on top of encoding/xml — same tokens, same escaping, same well-formedness — adding the one thing the stdlib drops: content foreign to your specification survives — unknown attributes and elements, third-party extensions, newer-version fields — plus the document's comments and formatting.
That buys you two things: forward compatibility (documents from newer or richer specs flow through your tool without loss) and free housing for auxiliary data (annotations and tooling extras live in the document without your types modeling them).
Example: in your Go ecosystem, mkskill can ride piggyback inside miniskin's
XML files using mkskill- prefixed attributes — miniskin processes what it
knows, the rest travels in the cargo and survives every rewrite.
- Reading: your types implement
XmlTokenConsumerand claim what they know. Everything else — comments, whitespace, processing instructions, unknown attributes and children — is preserved: positioned as trails and stored in each consumer's cargo (or parsed asGenericXmlItemfor fully unclaimed subtrees). - Writing: types stream themselves as tokens (
XmlTokenProducer). The easy path is describing yourself (XmlDescribeWithCargo+DescribedTokens): the cargo is woven back in automatically.
The philosophy, everywhere: easy is easy; needing more means writing your own XmlTokens stream. The package knows nothing about your types — and doesn't want to: it speaks interfaces and stdlib tokens only.
Reading — DecoderWithCargo
d := cargoxml.NewDecoderWithCargo(xml.NewDecoder(r))
d.Root = myRootConsumer // nil → the whole document parses as GenericXmlItem
err := d.Parse()
The decoder walks the token stream and dispatches events to
XmlTokenConsumer:
OnXmlChildStart— the parent decides who consumes each child (settingchild.Consumer). Unclaimed children fall to the generic fallback, or are skipped entirely whenSkipUnknownChildrenis set (the flag inherits from the parent frame; the parent can override it per child).OnXmlStart/OnXmlEnd— the element's own lifecycle; atOnXmlEndits trails are already positioned and its cargo complete.OnXmlAttribute— claim it (true) or let it fall to the cargo (false).OnXmlChildEnd— the parent sees each closed child; the child frame's trails are positioned (harvest text content here).GetCargoXml— return a*CargoXmlto preserve the unclaimed (attributes, generic children, trails);nilto discard it.
Embed NullXmlConsumer and override only what you need. The decoder-level
hooks OnRootStart/OnRootEnd cover the prolog and the document level.
The decoder's Context field accompanies the run: every callback can
reach it (decoder.Context) for application parameters, and Parse
honors its cancellation between tokens. nil means context.Background().
Trails — where foreign content lives
A Trail is one non-element token: whitespace, text, comment, processing
instruction or directive. Each trail belongs to exactly one element, with a
position:
- Before — it announced the element ("a comment belongs to what follows"): anything between the previous sibling (or the parent's start tag) and this element.
- Inner — inside the element, after its last child (an element with only text keeps that text here).
- After — only the root ever has them: the epilog after the document element.
The single-owner rule makes re-emission deterministic: the prolog is the root's Before, a comment between siblings is the next sibling's Before, and trailing content is the parent's Inner.
Writing — producers and the encoder
Breaking change (v0.0.2, breaks v0.0.1):
XmlTokensand everyXmlDescribe*question now receive the run'scontext.Context, andDescribedTokenstakes it as its first argument.
The boundary is one method — tokens out, the run's context in:
type XmlTokenProducer interface {
XmlTokens(ctx context.Context) iter.Seq[xml.Token]
}
The easy path is describing yourself — plain data in, the helper assembles the stream:
type XmlDescribeWithCargo interface {
XmlDescribeNodeName(ctx context.Context) xml.Name
XmlDescribeNodeType(ctx context.Context, policy MixedNodePolicy) XmlNodeType // what will you serialize: Mixed (zero) | Text | Container
XmlDescribeAttributes(ctx context.Context) []xml.Attr
XmlDescribeInitialComments(ctx context.Context) []string
XmlDescribeText(ctx context.Context) []string // leaf: either text…
XmlDescribeItems(ctx context.Context) []XmlTokenProducer // …or nodes (Items adapts typed slices)
GetCargoXml() *CargoXml // nil when nothing extra to preserve
}
func (p *Product) XmlTokens(ctx context.Context) iter.Seq[xml.Token] {
return cargoxml.DescribedTokens(ctx, p, cargoxml.PreserveMixed)
}
Every answer that can be absent is a slice: nil means "I don't have that".
The declared node type is how a node is known before serializing it — a
token stream cannot be asked whether it has children. The policy argument
is the caller's preference; the answer is the type's decision. Mixed (the
zero value) changes nothing: the element emits what it wants and the cargo
emits what it has. A strict answer (Text/Container) filters the type's own
answers and knowingly kills the part of the cargo that does not
correspond — never by accident: the type decided with the cargo in hand.
A Text node drops the child elements, a Container drops the inner text;
attributes always survive, and comment policy lives in another layer (the
encoder's SkipComments).
DescribedTokens emits in fixed order: cargo Before trails → initial
comments → start tag (own + cargo attributes) → text runs → items → cargo
children → cargo Inner trails → end tag → cargo After trails. Text and
items together are allowed, but there is no interleaving — the base
serializer does not organize content. Need more? Build your own XmlTokens
stream.
Encoding is a pipe into the stdlib:
enc := xml.NewEncoder(w)
e := cargoxml.NewEncoderWithCargo(enc)
e.SkipWhiteSpace = true // optional: drop the preserved formatting
err := e.Encode(root) // remember enc.Flush() afterwards
SkipWhiteSpace alone minifies; combined with enc.Indent it reformats —
comments, PIs and real text survive, and adjacent text fragments count as
one unit. SkipComments drops every comment. MixedNodePolicy decides
mixed elements (real text and children under the same parent):
PreserveMixed (default) emits everything, PreserveChildren drops the
text of mixed elements, PreserveText drops their child elements. All
defaults preserve: the document round-trips as is.
The run's context
Both DecoderWithCargo and EncoderWithCargo carry a Context context.Context field — free room for application parameters that the
package never looks inside (nil means context.Background()). On the
reading side every consumer callback can reach it through the decoder; on
the writing side Encode injects it into the producer chain, so every
XmlTokens and every describe question receives it at any depth. That
makes an output variation a property of the run, not of your model:
e := cargoxml.NewEncoderWithCargo(enc)
e.Context = WithDebug(nil, true) // your own context values
e.Encode(project) // same tree, debug output
// and in a describe answer, at any level:
if IsDebug(ctx) { items = append(items, extraDebugItems...) }
Cancellation is honored too: Parse and Encode check the context
between tokens, so a timeout or a cancel() aborts a run cleanly
(context.Canceled / context.DeadlineExceeded).
Autonomy scales in three tiers: producing is free (a pure
XmlTokenProducer decides what it emits, no questions asked),
describing is a pact (DescribedTokens enforces the type's own
declaration), and emitting is governed (the encoder's policies apply
to every stream alike — described, generic or hand-rolled).
GenericXmlItem and CargoXml
GenericXmlItem is the reference implementation of both interfaces and the
decoder's fallback: name, attributes, children and trails in plain fields.
An untouched generic parse re-encodes to a semantically identical document.
CargoXml is the package's only storage contract — what a consumer
preserved without claiming: MoreAttributes, MoreChildren (generic
items, already producers) and Trails. Everything else about your types is
your own business: the package neither knows nor cares.
Example — edit without losing anything
product.xml, written by hand or by some other tool:
<product sku="A1" price="9.99" currency="EUR">
<!-- bestseller -->
<name>Coffee</name>
</product>
A type that models only sku and price, and preserves the rest:
type Product struct {
cargoxml.NullXmlConsumer
Cargo *cargoxml.CargoXml
Sku, Price string
}
// reading: claim what you know, keep a cargo for the rest
func (p *Product) GetCargoXml() *cargoxml.CargoXml {
if p.Cargo == nil {
p.Cargo = cargoxml.NewCargoXml()
}
return p.Cargo
}
func (p *Product) OnXmlAttribute(d *cargoxml.DecoderWithCargo, a *xml.Attr) bool {
switch a.Name.Local {
case "sku":
p.Sku = a.Value
return true
case "price":
p.Price = a.Value
return true
}
return false // unclaimed → the cargo
}
// writing: describe yourself; Mixed keeps the cargo whole
func (p *Product) XmlDescribeNodeName(ctx context.Context) xml.Name { return xml.Name{Local: "product"} }
func (p *Product) XmlDescribeNodeType(ctx context.Context, policy cargoxml.MixedNodePolicy) cargoxml.XmlNodeType {
return cargoxml.XmlMixedNode
}
func (p *Product) XmlDescribeAttributes(ctx context.Context) []xml.Attr {
return []xml.Attr{
{Name: xml.Name{Local: "sku"}, Value: p.Sku},
{Name: xml.Name{Local: "price"}, Value: p.Price},
}
}
func (p *Product) XmlDescribeInitialComments(ctx context.Context) []string { return nil }
func (p *Product) XmlDescribeText(ctx context.Context) []string { return nil }
func (p *Product) XmlDescribeItems(ctx context.Context) []cargoxml.XmlTokenProducer { return nil }
func (p *Product) XmlTokens(ctx context.Context) iter.Seq[xml.Token] {
return cargoxml.DescribedTokens(ctx, p, cargoxml.PreserveMixed)
}
Parse, edit, rewrite:
product := &Product{}
d := cargoxml.NewDecoderWithCargo(xml.NewDecoder(in))
d.Root = product
if err := d.Parse(); err != nil { /* … */ }
product.Price = "10.99"
enc := xml.NewEncoder(out)
if err := cargoxml.NewEncoderWithCargo(enc).Encode(product); err != nil { /* … */ }
enc.Flush()
The output keeps everything the type never modeled:
<product sku="A1" price="10.99" currency="EUR">
<!-- bestseller -->
<name>Coffee</name>
</product>
The runnable version of this and every other pattern — hand-rolled producers, fresh authored types, reformatting, mixed-node policies — lives under test/.
Limits — equivalent, not byte-identical
Both ends are deliberately the concrete stdlib types (*xml.Decoder /
*xml.Encoder): this package sits on top of encoding/xml and does not
reinvent that wheel. Output is well-formed and semantically equivalent,
never byte-faithful:
<a/>comes out as<a></a>; escaping is normalized; line endings become LF.- Namespaces are rewritten the stdlib way (URLs, not the original prefixes).
- CDATA sections come back as escaped text: the stdlib neither marks them when decoding nor writes them at token level.
- The interleaving between claimed and unclaimed children is not recorded: cargo children re-emit after the owner's.
Rules & gotchas
- The decoder is the single authority on trails: consumers read
frame.Trails, they never reposition or reassign them. - In transit, pointer; stored, value:
OnXmlAttributereceives*xml.Attr, but storage (CargoXml.MoreAttributes,GenericXmlItem.Attributes) holds[]xml.Attrvalues. Plain arrays for data; producers only for items. - OnRootStart claims or loses: if the hook is set, it must take what it
wants from
d.Trails— unclaimed prolog trails are discarded. - Skip asymmetry: a skipped child fired
OnXmlChildStart(where it was declined) but neverOnXmlChildEnd— for consumers that node never existed. - Empty slice means "none": describe answers and
GetCargoXmltreat nil/empty as absence — no optional interfaces, no flags. - The declaration is the type's decision: the
MixedNodePolicyargument ofXmlDescribeNodeTypeis only the caller's preference. A strict answer (Text/Container) kills the non-corresponding part of the own answers and the cargo alike — consciously, never by accident; attributes always survive. - Encoder policies are stream-level and universal:
SkipWhiteSpace,SkipCommentsandMixedNodePolicyapply to every producer alike.MixedNodePolicycosts a flag per open element and holds only the undecided run — no document buffering. - Autonomy tiers: producing is free, describing is a pact, emitting is governed. No layer decides twice.
- README.md and AGENTS.md are generated from
_mkskill/— edit the sources, not the artifacts.