@sdxc/xml
XML parser and serializer for RSS-style feeds
- Installs with
- @sdxc/result
- Source
- packages/xml
XML parser and serializer for RSS-style feeds.
It reads XML text into an XML document instance and writes that instance back out. The
target is the subset of XML feeds actually use: one root element, attributes, nested
elements, text nodes, CDATA sections, namespace-prefixed names, and an XML declaration.
Real feeds are written by tools that emit and — without declaring a DTD,
so the parser resolves the five entities XML predefines, numeric character references such
as ’, and the 248 named entities the three XHTML 1.0 entity sets declare. A name
outside those sets is reported as a parse error. Serialization escapes only the five
predefines, so a document that arrives with round-trips as the character itself.
Installation
npm add @sdxc/xml
Parsing and serialization report failures as a Result from
@sdxc/result, which installs alongside this
package and supplies isFailure, isSuccess, and unwrap.
Usage
Parse A Document
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let result = XML.parse(
`<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title></channel></rss>`,
);
if (isFailure(result)) throw result.error;
let xml = result.data;
xml.query("channel/title"); // { name: "title", children: ["Feed"] }
Whitespace-only text nodes are indentation rather than content, so by default the parser drops them and a traversal never has to step over them.
Traverse A Document
query and queryAll take a /-delimited path rooted at the document's root element;
find and findAll take a predicate and walk depth-first.
let channel = xml.query("channel");
let items = xml.queryAll("channel/item");
let firstLink = xml.find((element) => element.name === "link");
let allLinks = xml.findAll((element) => element.name === "link");
Write A Document
XML.stringify takes a root element and returns the text for it:
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let result = XML.stringify({
name: "rss",
attributes: { version: "2.0" },
children: [
{
name: "channel",
children: [{ name: "title", children: ["Feed"] }],
},
],
});
if (isFailure(result)) throw result.error;
let source = result.data; // <rss version="2.0"><channel><title>Feed</title></channel></rss>
A declaration belongs to a document rather than to an element, so pass the whole document to write one:
let result = XML.stringify({
declaration: { version: "1.0", encoding: "UTF-8" },
root: { name: "rss", attributes: { version: "2.0" } },
});
// <?xml version="1.0" encoding="UTF-8"?>
// <rss version="2.0"/>
Round-Trip A Document
toJSON hands back plain data that both the XML constructor and XML.stringify accept,
and toString serializes the instance in place.
let json = xml.toJSON();
let copy = new XML(json);
let source = copy.toString();
XML.stringify(json); // the same text, as a Result
Keep The Source's Whitespace
By default a run of character data holding nothing but whitespace is indentation, and stays out of the tree, so traversal sees elements and the text that carries meaning. A caller that hashes a document needs the opposite, because a byte of indentation changes the digest:
let xml = XML.parse("<r>\n\t<t>x</t>\n</r>", { whitespace: "preserve" });
// children: ["\n\t", { name: "t", children: ["x"] }, "\n"]
Both modes resolve references first, so   counts as the space it decodes to, and both
allow whitespace around the root element.
API
XML.parse(source: string, options?: XML.ParseOptions): Result<XML, XMLParseError>
Parses XML text into an XML instance. options.whitespace is "collapse" by default and
"preserve" to keep whitespace-only text and CDATA in the tree.
XML.stringify(input: XML | XML.Input): Result<string, XMLStringifyError>
Serializes an instance, whole document data, or a bare root element into XML text. An element carries no declaration; pass a document or an instance to write one.
XML.stringify({ name: "rss", attributes: { version: "2.0" } });
// success('<rss version="2.0"/>')
new XML(document: XML.Document)
Wraps a document that is already plain data, such as one from toJSON.
xml.declaration: XML.Declaration | undefined
The parsed XML declaration, when the document carried one.
xml.root: XML.Element
The root element.
xml.find(predicate: XML.Predicate): XML.Element | undefined
The first element the predicate accepts, in depth-first order.
xml.findAll(predicate: XML.Predicate): XML.Element[]
Every element the predicate accepts, in depth-first order.
xml.query(path: string): XML.Element | undefined
The first element at a /-delimited path such as channel/item/title.
xml.queryAll(path: string): XML.Element[]
Every element at a /-delimited path such as channel/item.
xml.toJSON(): XML.Document
The document as plain serializable data.
xml.toString(): string
The document as XML text. This is the one entry point that throws an XMLStringifyError
rather than returning it, because toString has no room for a Result; reach for
XML.stringify where a failure is a value you want to handle.
XMLParseError
The source is malformed, carries no root element, or names an entity outside the resolved sets.
XMLStringifyError
The tree cannot be expressed as valid XML: a name that fails the XML Name production, or a
prefix with no namespace declared in scope.
Types
Every public type lives in the XML namespace: XML.Declaration, XML.Element,
XML.Node, XML.Document, XML.Input, XML.Predicate, XML.Whitespace, and
XML.ParseOptions. XML.Input is what stringify accepts: a whole XML.Document or the
root XML.Element alone.
import type { XML } from "@sdxc/xml";
function titleOf(element: XML.Element): string | undefined {
let [text] = element.children ?? [];
return typeof text === "string" ? text : undefined;
}
An XML.Node is either an XML.Element or a string, and a string is always a text node.
Markup belongs in child elements: a string holding <p>Hi</p> serializes as escaped text,
which is what makes the output parse back into the tree it was given.
Pattern: Reading A Feed's Channel
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let result = XML.parse(source);
if (isFailure(result)) throw result.error;
let xml = result.data;
for (let item of xml.queryAll("channel/item")) {
let title = item.children?.find((child) => typeof child !== "string" && child.name === "title");
}
Pattern: Preserving Namespaces
A prefix is resolved against the xmlns:* attributes in scope, so declare one on the same
element tree that uses it. Serialization fails on a prefix with no declaration rather than
writing XML that will not parse.
XML.stringify({
name: "rss",
attributes: {
version: "2.0",
"xmlns:content": "http://purl.org/rss/1.0/modules/content/",
},
children: [{ name: "content:encoded", children: ["<p>HTML</p>"] }],
});