glexml
A pure Gleam XML parser that runs on both the Erlang and JavaScript targets —
the same code works on the server and in the browser, with no FFI and no
native dependencies. It was built to be the core of an EPUB tool, so it
handles the documents found inside EPUB files well: package documents (OPF),
container.xml, NCX, and XHTML content documents.
gleam add glexml
Quick start
import glexml
pub fn main() {
let assert Ok(document) =
glexml.parse("<greeting lang=\"en\">Hello, Joe!</greeting>")
document.root.name
// -> "greeting"
glexml.attribute(document.root, "lang")
// -> Ok("en")
glexml.text_content(document.root)
// -> "Hello, Joe!"
}
An EPUB example
Reading the title and manifest out of an EPUB package document:
import gleam/list
import glexml
pub fn read_package(opf_source: String) {
let assert Ok(document) = glexml.parse(opf_source)
let package = document.root
// <metadata><dc:title>…</dc:title></metadata>
let assert Ok(title) = glexml.at(package, ["metadata", "dc:title"])
let title = glexml.text_content(title)
// Every <item> in the <manifest>
let assert Ok(manifest) = glexml.first_child_named(package, "manifest")
let hrefs =
glexml.children_named(manifest, "item")
|> list.filter_map(glexml.attribute(_, "href"))
#(title, hrefs)
}
Features
- Both targets. Pure Gleam with only
gleam_stdlibas a dependency; the test suite runs on Erlang and on Node.js. Parsing is iterative where it matters, so documents with very long runs of sibling nodes do not overflow the JavaScript stack. - A complete tree. Elements, attributes, text, CDATA sections, comments, and processing instructions, with document order and whitespace preserved.
- The XML you meet in practice. XML declarations, byte order marks, CRLF/CR line ending normalisation, the five predefined entities, and decimal and hexadecimal character references.
- Full DTD support. DOCTYPE declarations are parsed, not skipped:
<!ELEMENT>,<!ATTLIST>,<!ENTITY>(general and parameter), and<!NOTATION>declarations, entity expansion in content and attribute values — including entities that contain markup, with recursion detection — plus conditional sections and parameter entities in external subsets viaparse_dtd. - Validation. The
glexml/dtdmodule validates documents against a DTD: content models, required/fixed/enumerated attributes, ID uniqueness and IDREF integrity, notations, and unparsed entities, with readable violation messages.with_default_attributesfills in attribute defaults the way a validating processor would. - Namespace-friendly. Qualified names like
dc:titleare kept as written, withlocal_nameandnamespace_prefixhelpers for splitting them. - Navigation helpers.
attribute,children_named,first_child_named,descendants_named,at(path lookup), andtext_content. - CSS selectors. The
glexml/selectormodule implements the practical selector subset over XML: type/attribute/class/ID selectors, all four combinators, selector lists, and structural pseudo-classes including:nth-child()and:not(). Matching is case-sensitive, anddc|title/|titlehandle namespace prefixes. - Typed errors with positions. Parse failures return an
ErrorKindplus line and column, anderror_to_stringrenders a readable message. - Serialisation.
document_to_stringandelement_to_stringwrite a tree back out with correct escaping, for tools that also produce XML.
CSS selectors
import glexml/selector
// One-shot
selector.select(package, "manifest > item[properties~=nav]")
// -> Ok([the nav item])
// Parse once, query many times
let assert Ok(chapters) = selector.parse("item[media-type='application/xhtml+xml']")
selector.query(package, chapters)
selector.query_first(package, chapters)
selector.matches(item, chapters)
Because glexml keeps qualified names as literal strings, dc|title matches
the element written <dc:title>, and |title (or *|title) matches a
title local name under any prefix. .x and #x follow the usual XML
convention of meaning [class~=x] and [id=x].
Working with DTDs
Entities declared in a document’s internal subset just work:
let assert Ok(document) =
glexml.parse("<!DOCTYPE p [<!ENTITY nbsp \" \">]><p>a b</p>")
The parser never performs I/O, so external DTDs are not fetched (fetching them is a classic security hole). To use one — say the XHTML DTD entities an EPUB 2 content document relies on — load its text yourself and pass it in:
let assert Ok(xhtml) = glexml.parse_dtd(xhtml_dtd_text)
let assert Ok(document) = glexml.parse_with_dtd(chapter_source, xhtml)
And to validate:
import glexml/dtd
let assert Some(doctype) = document.doctype
case dtd.validate(document, doctype.declarations) {
[] -> Ok(document)
violations -> Error(list.map(violations, dtd.violation_to_string))
}
Limitations
- External entities and external DTD subsets are never fetched; supply them
with
parse_dtd+parse_with_dtdas shown above. Undeclared entities are a parse error, except in documents whose DTD provably could not be read completely (an external subset or parameter entity references, and not standalone) — there the specification makes them a validity error, so they are kept asEntityReferenceNodes and reported byglexml/dtd.validate. parsetakes aString(already-decoded text). For raw bytes,parse_bytesdetects the encoding the way the XML specification describes and decodes UTF-8 and UTF-16 itself — everything an EPUB may legally contain. UTF-32, EBCDIC, and legacy 8-bit encodings are recognised but rejected; decode those yourself first.- Namespaces are not resolved to URIs; names keep their prefixes as written.
- Serialisation does not re-render a DOCTYPE’s internal subset, only its name and external identifier.
Further documentation can be found at https://hexdocs.pm/glexml.
Conformance
glexml is tested against the [W3C XML Conformance Test Suite]
(https://www.w3.org/XML/Test/) — the aggregated James Clark, Sun, IBM,
OASIS/NIST, Fuji Xerox, and University of Edinburgh suites. The harness
plays the role the library deliberately refuses: it loads external DTD
subsets and external entity files from disk and supplies them through
parse_dtd_with / parse_bytes_with_dtd, so tests requiring external
entities run too. glexml passes 100% of the applicable XML 1.0
fifth-edition tests (1895 of 1895):
- not well-formed documents rejected: 986/986
- valid documents parsed and matching the canonical-form output (including DTD attribute defaulting and normalisation): 699/699
- invalid documents caught by
glexml/dtd.validateandglexml/dtd.standalone_violations: 210/210
DTD subsets are parsed over a stack of entity segments rather than by
splicing replacement text, so entity boundaries stay visible: the Proper
Declaration/Group/Conditional Section PE Nesting validity constraints are
checked (Dtd.pe_nesting_violations), and external entity declarations
record which parameter entity’s text declared them
(ExternalEntity.declared_in), the base for resolving their system
identifiers.
Run it yourself:
./scripts/fetch-xmlconf.sh # download the suite into ./xmlconf/ (not committed)
gleam dev # run and print the conformance report
Development
gleam test # Run the tests on Erlang
gleam test --target javascript # Run the tests on Node.js
gleam dev # Run the W3C conformance suite (fetch first)
gleam format src test dev # Format the code