HTML, XML and SVG for MoonBit: parse, query and edit.
let page = @html.loads("<ul id=list><li>coffee<li>tea</ul>")
// the document closed neither item; the reader closed both
assert_eq(page.css("li").length(), 2)
assert_eq(page.xpath("//li[2]").text(), "tea")
assert_eq(
page.dumps(),
"<html><head></head><body><ul id=\"list\"><li>coffee</li><li>tea</li></ul></body></html>",
)| One tree, two syntaxes | Node is element, text, CDATA, comment, processing instruction, doctype, attribute or namespace. Nothing about a node says which parser made it. |
| One chain | Every query answers a Selection, so css → xpath → selector → custom → items[i] → children()[j] → css again never changes type. |
| Two engines, and yours | CSS Selectors 4's statically decidable subset — namespace prefixes resolved through a map you pass — all of XPath 1.0, and a Selector trait you implement and register by name. |
| Editing, not re-parsing | A full set of editing verbs on the same selection the query returned, with parent pointers kept true. |
| Two ways to read | Build the tree, or stream Events into your own Sink and build nothing — @xml.scan for XML, @html.scan for HTML's tokens. The tree builder is itself a sink: one parser, no drift. |
| One error family | Refused: malformed input names its offset, a bad selector names its fault. HTML never refuses — it repairs, and tells you what it repaired. |
moon add moonbitstack/moonxmlimport {
"moonbitstack/moonxml", // tree, chain, engines, writer
"moonbitstack/moonxml/html", // @html.loads
"moonbitstack/moonxml/xml", // @xml.loads
"moonbitstack/moonxml/event", // Event, Sink
"moonbitstack/moonxml/htmx", // @htmx.Hx — htmx's attributes, read and carried out
}let page = @html.loads(
"<table><tr><td>one</td></tr><tr><td><span>two</span></td></tr></table>")
// your own selector is one more link in the chain: register a name for it
page.register("mine", @moonxml.SelectorFn::new(fn(ctx, _query) {
ctx.by_tag("td")
}))
page
.xpath("//table") // any engine
.css("tr:nth-child(2)") // any other
.selector("mine", "") // one you registered
.items[0] // a Node, not a selection
.css("span") // and query again
.set_text("done")
assert_eq(page.xpath("//td/span").text(), "done")
// one you never registered goes straight to custom
assert_eq(
page.css("tr").custom(@moonxml.SelectorFn::new(fn(ctx, _query) {
ctx.filter(fn(node) { node.text() == "one" })
})).text(),
"one",
)let page = @html.loads("<svg viewBox='0 0 1 1'><rect/><rect/></svg><rect/>")
let ns : @moonxml.NamespaceMap = {
default: "",
prefixes: [("svg", "http://www.w3.org/2000/svg")],
}
assert_eq(page.css("svg|rect", namespaces=ns).length(), 2) // one prefix
assert_eq(page.css("*|rect", namespaces=ns).length(), 3) // any namespace
assert_eq(page.css("|rect", namespaces=ns).length(), 1) // no namespacelet page = @html.loads("<ul id=list><li>coffee 4.5<li>tea<li>beans 12</ul>")
// the items whose text carries an amount
let priced = page.css("li").filter(fn(li) {
li.text().contains_any(chars="0123456789")
})
assert_eq(priced.length(), 2)
assert_eq(priced.last().text(), "beans 12")| read | root document_element() flavour() repairs() |
| search | css(q) xpath(q) xpath_value(q) selector(name, q) custom(sel) by_id(v) by_class(v) by_tag(v) by_name(v) |
| content | text() html() children() descendants() dumps() dump() dumps_html() dump_html() events() |
| change | append(node) prepend(node) clear() smooth() register(name, sel) push(event) |
| search | css xpath xpath_value selector custom by_id by_class by_tag by_name filter(f) closest(q) |
| narrow | first() last() eq(i) children() parent() descendants() items[i] |
| read | length() text() html() attr(name) |
| change | append prepend remove replace_with unwrap wrap clear set_text set_attr remove_attr add_class remove_class smooth |
| fields | name attrs children parent closed |
| structure | append prepend insert(at, node) insert_before insert_after replace_with remove unwrap wrap clear set_children(nodes) smooth |
| position | child_elements() index_in_parent() previous_element_sibling() next_element_sibling() |
| attributes | attr has_attr set_attr remove_attr classes has_class add_class remove_class |
| content | text() set_text(s) html() walk(f) document() |
| language | lang() is_lang(tag) |
let page = @html.loads(
"<form hx-post='/todos' hx-target='#list' hx-swap='beforeend'>" +
"<input name='title' value='write it down'><button>Add</button></form>" +
"<ul id='list'></ul>")
let form = match page.css("form").items[0] {
ElementNode(element) => element
_ => abort("no form")
}
let request = @htmx.Hx::of(form).request("submit").unwrap()
assert_eq(request.address(), "/todos")
assert_eq(request.query(), "title=write+it+down")
assert_eq(request.headers().length(), 2)
ignore(request.answer("<li>a second one</li>"))
assert_eq(page.css("#list").html(), "<li>a second one</li>")| Suite | Cases | Result |
|---|---|---|
| html5lib tree construction | 1792 | all, scripting off and on as each case asks |
| html5lib tokenizer | 7017 | all |
| W3C XML conformance suite | 1154 | all: every document it calls well-formed reads, every other is refused |
| libxml2's XPath 1.0 corpus | 326 | 324 agree; the two below answer as XPath 1.0 asks |
| W3C CSS3 Selectors test suite | 99 files | all |
| src/examples/tour | the whole surface in one run: moon run src/examples/tour |
| src/examples/hypermedia | a page that declares its requests: read it, make one, swap the answer in |
| src/examples/selectors | every built-in selector against one document — the run behind the selector board |
| src/examples/scrape | a page in, a table out: query, filter, reshape, serialize |
| src/examples/rewrite | read a feed, edit every entry, write it back byte-faithfully |
struct DeepLinks {}
impl Selector for DeepLinks with select(_self, ctx, _query) {
ctx.css("a[href]").filter(fn(node) {
node is ElementNode(e) && e.attr("href").has_prefix("https://")
})
}fn Document::css(self : Document, query : String, namespaces? : NamespaceMap) -> Selection raise Refusedfn Element::css(self : Element, query : String, namespaces? : NamespaceMap) -> Selection raise Refused doc.css("a|ref", namespaces={ default: "", prefixes: [("a", "urn:x")] })pub(all) enum Node {
ElementNode(Element)
TextNode(String)
CDataNode(String)
CommentNode(String)
ProcessingInstructionNode(ProcessingInstruction)
DoctypeNode(String)
AttributeNode(Attribute)
NamespaceNode(Namespace)
}pub(all) struct ProcessingInstruction {
target : String
data : String
}pub(all) struct Repairs {
implied : Int
dropped : Int
reparented : Int
closed_at_eof : Int
} doc.xpath("//ul").css("li.external").xpath("@href")
.selector("tag", "a").custom(DeepLinks::new())
.items[2].children[0].css("span").text()fn Selection::closest(self : Selection, query : String, namespaces? : NamespaceMap) -> Selection raise Refusedfn Selection::css(self : Selection, query : String, namespaces? : NamespaceMap) -> Selection raise Refusedimpl Selector for SelectorFnfn SelectorFn::select(self : SelectorFn, ctx : Selection, query : String) -> Selection raise Refusedfn namespace_of(name : String) -> String?Install
Download zipHTML, XML and SVG for MoonBit: parse, query and edit.