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.
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()
⁂
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.
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.
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.
| Group | Verbs |
|---|---|
| Structure | append prepend insert insert_before insert_after replace_with remove unwrap wrap clear smooth |
| Attributes | set_attr remove_attr add_class remove_class attr has_attr has_class |
| Content | set_text text html dumps dumps_html |
| Navigation | children parent descendants closest first last eq filter |
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
A caller who only wants to count tags writes one Sink and never allocates a node.
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.
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.
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 }
❦