Skip to main content

README.md

# glexml

[![Package Version](https://img.shields.io/hexpm/v/glexml)](https://hex.pm/packages/glexml)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/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.

```sh
gleam add glexml
```

## Quick start

```gleam
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:

```gleam
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_stdlib` as 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 via `parse_dtd`.
- **Validation.** The `glexml/dtd` module 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_attributes` fills in attribute defaults
  the way a validating processor would.
- **Namespace-friendly.** Qualified names like `dc:title` are kept as
  written, with `local_name` and `namespace_prefix` helpers for splitting
  them.
- **Navigation helpers.** `attribute`, `children_named`, `first_child_named`,
  `descendants_named`, `at` (path lookup), and `text_content`.
- **CSS selectors.** The `glexml/selector` module 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, and `dc|title` /
  `|title` handle namespace prefixes.
- **Typed errors with positions.** Parse failures return an `ErrorKind` plus
  line and column, and `error_to_string` renders a readable message.
- **Serialisation.** `document_to_string` and `element_to_string` write a
  tree back out with correct escaping, for tools that also produce XML.

## CSS selectors

```gleam
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:

```gleam
let assert Ok(document) =
  glexml.parse("<!DOCTYPE p [<!ENTITY nbsp \"&#160;\">]><p>a&nbsp;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:

```gleam
let assert Ok(xhtml) = glexml.parse_dtd(xhtml_dtd_text)
let assert Ok(document) = glexml.parse_with_dtd(chapter_source, xhtml)
```

And to validate:

```gleam
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_dtd` as 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 as `EntityReferenceNode`s and reported by
  `glexml/dtd.validate`.
- `parse` takes a `String` (already-decoded text). For raw bytes,
  `parse_bytes` detects 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.validate` and
  `glexml/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:

```sh
./scripts/fetch-xmlconf.sh  # download the suite into ./xmlconf/ (not committed)
gleam dev                   # run and print the conformance report
```

## Development

```sh
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
```