<moonxml/>

MoonBit · a document library

Read the web.
Query it like a tree.

moonxml parses HTML the way a browser does — repairing as it goes — and XML the way a validator does, then hands you one mutable tree to walk, query and rewrite.

HTML· XML· SVG· htmx· XPath

listing i — tour.mbt

let page = @html.loads("<ul id=list><li>one<li>two</ul>")

// two list items, though the document closed none
page.css("li").length()          // 2

// the chain goes on: css, xpath, your own selectors
page.css("#list").xpath(".//li[2]")
    .set_text("two, edited")

// and back out, repaired, as HTML
page.dumps()

⁂

Ithe tree

One tree, two syntaxes

HTML is not well-formed and never has been; XML is and must be. Both become the same node kinds, so the query you wrote for a page works on a feed — the node does not remember which parser made it.

node kinds

pub(all) enum Node {
  ElementNode(Element)
  TextNode(String)
  CDataNode(String)
  CommentNode(String)
  ProcessingInstructionNode(…)
  DoctypeNode(String)
}

element

pub struct Element {
  name      : String            // {uri}local
  attrs     : Array[(String, String)]
  children : Array[Node]
  ...
}

Names are James Clark spelled — {urn:a}entry, not a:entry — so a node means the same thing whichever prefix the document chose.

IIthe chain

The chain never breaks

Every query narrows the nodes you have and answers the same shape, so a chain can mix engines and still land on a node you can edit.

listing ii — one selection, narrowed

1page.xpath("//table")          1 · xpath
2    .css("tr:nth-child(2)")    2 · css
3    .selector("mine", "")        3 · a selector you registered
4    .custom(@moonxml.SelectorFn::new(by_price))
5    .items[0]                  5 · a node, not a selection
6    .children()[1]             6 · walk down, one node
7    .css("span")               7 · and query again
8    .add_class("picked")        8 · edit in place

Every selector this engine implements, run against one document — the query, the nodes it answered, and the text or attribute taken out of them: the selector board.

CSS

Type, class, id, attributes with every operator and the case flag, four combinators, :not/:is/:where/:has, the structural pseudo-classes, an+b, :lang(), and namespace prefixes resolved through the map you pass.

XPath 1.0

All thirteen axes, node tests, predicates with expressions, the operator table, the core functions — and typed answers: nodes, strings, numbers, booleans. Attributes and namespaces are nodes with identities, so @href and namespace::* keep theirs.

Yours

Implement Selector, register it by name, call it in the chain like a built-in. SelectorFn covers the ones that are just a closure.

IIIthe verbs

Editing is not a second library

The verbs a scraping job needs, on the same selection the query returned. Parents stay true, so a node you moved knows where it is.

GroupVerbs
Structureappend prepend insert insert_before insert_after replace_with remove unwrap wrap clear smooth
Attributesset_attr remove_attr add_class remove_class attr has_attr has_class
Contentset_text text html dumps dumps_html
Navigationchildren parent descendants closest first last eq filter
IVthe reading

Two ways to read a document

Build the tree, or do not build it at all. The XML scanner emits events to any sink, which is how the tree builder itself works — one parser, and no drift between it and a second one written for streaming.

Plate I — the reading of a document, from text to tree

source html | xml scanner tokenizer tree construction Event StartTag, Text… Sink your counter, or Document::push Document css · xpath · edit dumps optional

A caller who only wants to count tags writes one Sink and never allocates a node.

Vthe hypermedia

A page that declares its requests

htmx puts the wiring in attributes: when to ask, what to ask, where the answer goes. moonxml/htmx reads those attributes the way htmx reads them — inherited down the tree, resolved from the element that wrote them — and hands back the request a click would have made, then puts the answer where it said.

listing v — one submit, described and answered

let page = @html.loads(
  "<form hx-post='/todos' hx-target='#list' hx-swap='beforeend'>" +
  "<input name='title' value='write it down'></form><ul id='list'></ul>")

let request = @htmx.Hx::of(page.css("form").items[0])
  .request("submit").unwrap()

request.address()     "/todos" — a post keeps its values out of the URL
request.query()       "title=write+it+down"
request.headers()     HX-Request, HX-Target, HX-Trigger

ignore(request.answer("<li>a second one</li>"))

Triggers

hx-trigger with once, changed, delay:, throttle:, from:, queue:, and every 2s.

Targets

this, closest, find, next, previous, or a selector — resolved from the element that wrote it, inherited until something nearer says otherwise.

Swaps

Seven positions, hx-select for a part of the answer, and out-of-band elements sent to the ids they name.

VIthe limits

Small, and stated plainly

Two dependencies

moonbitlang/core and nothing else. No transitive surprises in your lockfile.

Fails loudly, once

One error family, Refused: a broken XML document names the byte, a bad selector names the fault. HTML never refuses — it repairs, and says what it repaired.

Four backends

wasm, wasm-gc, js and native, tested on all of them, because a parser moves bytes and bytes are where backends differ.

VIIthe copy

Install

shell

moon add moonbitstack/moonxml

moon.pkg

import {
  "moonbitstack/moonxml"       // tree, chain, engines
  "moonbitstack/moonxml/html"  // @html.loads
  "moonbitstack/moonxml/xml"   // @xml.loads
  "moonbitstack/moonxml/htmx"  // @htmx.Hx
}

Start The Guided Tour  ·  The Complete API  ·  How It Works

❦