[ Content & formats ]
@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 the XML feeds use — one root element, attributes, nested elements, text, CDATA,
namespace-prefixed names and an XML declaration — into plain data you can query, and writes that
data back out. Entities resolve to characters: the five XML predefines, numeric references such
as ’, and the XHTML named entities ( , —) feeds use without a DTD.
Installation
npm add @sdxc/xml
Parsing and serialization answer a Result from
@sdxc/result, which installs alongside it.
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.declaration; // { version: "1.0" }
xml.root.name; // "rss"
Whitespace-only text between elements is dropped, so the tree holds elements and the text that carries meaning. Comments, processing instructions and a doctype are skipped.
Find elements
query and queryAll follow a /-delimited path of child names from the root; the path may
start with the root's own name. find and findAll walk the tree depth-first with a predicate.
xml.query("channel/title"); // { name: "title", children: ["Feed"] }
xml.query("rss/channel/title"); // the same element
xml.queryAll("channel/item"); // every item, in document order
xml.find((element) => element.name === "link");
xml.findAll((element) => element.attributes?.rel === "alternate");
Write a document
XML.stringify takes a root element, or a whole document to include a declaration.
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let result = XML.stringify({
declaration: { version: "1.0", encoding: "UTF-8" },
root: {
name: "rss",
attributes: { version: "2.0" },
children: [{ name: "channel", children: [{ name: "title", children: ["Feed"] }] }],
},
});
if (isFailure(result)) throw result.error;
result.data;
// <?xml version="1.0" encoding="UTF-8"?>
// <rss version="2.0"><channel><title>Feed</title></channel></rss>
A string child is always text: <, & and quotes are escaped, so markup belongs in child
elements and the output parses back into the tree it came from.
API
XML.parse(source: string, options?: XML.ParseOptions): Result<XML, XMLParseError>
Parses XML text into an XML instance. options.whitespace is "collapse" (the default) to drop
whitespace-only text and CDATA, or "preserve" to keep every run.
XML.stringify(input: XML | XML.Input): Result<string, XMLStringifyError>
Serializes an instance, a document, or a bare root element. A declaration, when present, is written on its own line before the root.
new XML(document: XML.Document)
Wraps a document that is already plain data, such as one from toJSON.
xml.declaration: XML.Declaration | undefined
The XML declaration, when the document has one.
xml.root: XML.Element
The root element. Every element an instance hands out is a copy, so changing it leaves the instance untouched.
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, in document order.
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.toJSON(): XML.Document
The document as plain data, which both new XML and XML.stringify accept.
xml.toString(): string
The document as XML text. It throws an XMLStringifyError on a tree that cannot be serialized;
use XML.stringify to get the failure as a value.
XMLParseError
The source is malformed, has no root element, or names an entity outside the resolved sets.
XMLStringifyError
The tree cannot be written as valid XML: an element or attribute name that is not a valid XML
name, a namespace prefix with no xmlns:* declaration in scope, or an input with no root
element.
Types
All types live in the XML namespace.
XML.Document:{ declaration?: XML.Declaration; root: XML.Element }.XML.Declaration:{ version?: string; encoding?: string; standalone?: "yes" | "no" }.XML.Element:{ name: string; attributes?: Record<string, string>; children?: XML.Node[] }.XML.Node: anXML.Elementor a string, which is always a text node.XML.Input: whatstringifyaccepts, anXML.Documentor a rootXML.Element.XML.Predicate:(element: XML.Element) => boolean.XML.Whitespace:"collapse" | "preserve".XML.ParseOptions:{ whitespace?: XML.Whitespace }.
Pattern: Reading a feed's items
Collect each item's fields, reading text whether it arrived plain or as CDATA.
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
function textOf(element: XML.Element | undefined, name: string): string | undefined {
let child = element?.children?.find(
(node): node is XML.Element => typeof node !== "string" && node.name === name,
);
let text = child?.children?.filter((node) => typeof node === "string").join("");
return text || undefined;
}
let response = await fetch("https://example.com/feed.xml");
let result = XML.parse(await response.text());
if (isFailure(result)) throw result.error;
let items = result.data.queryAll("channel/item").map((item) => ({
title: textOf(item, "title"),
link: textOf(item, "link"),
html: textOf(item, "content:encoded"),
}));
Pattern: Writing a feed with a namespace
Declare each prefix with an xmlns:* attribute on the element that uses it or an ancestor;
serialization fails on a prefix with nothing declared in scope.
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let posts = [{ title: "Hello", url: "https://example.com/hello", html: "<p>Hi</p>" }];
let result = XML.stringify({
declaration: { version: "1.0", encoding: "UTF-8" },
root: {
name: "rss",
attributes: { version: "2.0", "xmlns:content": "http://purl.org/rss/1.0/modules/content/" },
children: [
{
name: "channel",
children: [
{ name: "title", children: ["Example"] },
...posts.map((post) => ({
name: "item",
children: [
{ name: "title", children: [post.title] },
{ name: "link", children: [post.url] },
{ name: "content:encoded", children: [post.html] },
],
})),
],
},
],
},
});
if (isFailure(result)) throw result.error;
let response = new Response(result.data, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
The HTML in content:encoded is written as escaped text, which feed readers decode back into
markup.
Pattern: Editing a parsed document
An instance is read-only; edit its plain data and wrap the result again.
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let parsed = XML.parse(source);
if (isFailure(parsed)) throw parsed.error;
let document = parsed.data.toJSON();
let channel = document.root.children?.find(
(node) => typeof node !== "string" && node.name === "channel",
);
if (channel && typeof channel !== "string") {
channel.children = channel.children?.filter(
(node) => typeof node === "string" || node.name !== "item",
);
}
let emptied = new XML(document);
emptied.toString(); // the same feed with no items
Pattern: Hashing a document byte for byte
A digest changes with every byte of indentation, so keep whitespace in the tree before hashing a serialized copy.
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let result = XML.parse("<r>\n\t<t>x</t>\n</r>", { whitespace: "preserve" });
if (isFailure(result)) throw result.error;
result.data.root.children; // ["\n\t", { name: "t", children: ["x"] }, "\n"]
let bytes = new TextEncoder().encode(result.data.toString());
let digest = await crypto.subtle.digest("SHA-256", bytes);
References resolve before whitespace is judged, so   counts as the space it decodes to.