guided tour
Seven steps to a
document you can edit.
Each step is a program you can paste into a file and run. Nothing here is an aside: by step seven you have used every part of the library.
The same tour ships as a runnable example: moon run src/examples/tour.
-
Read a page
HTML is read the way a browser reads it — the document you hand over need not be closed, quoted, or correct.
step1.mbt
let page = @html.loads("<ul id=list><li>coffee<li>tea</ul>") println(page.css("li").length()) // 2 println(page.dumps())
output
2 <html><head></head><body><ul id="list"><li>coffee</li><li>tea</li></ul></body></html>The parser told you what it fixed.page.repairs()counts the elements it implied, the tags it dropped, the content it moved, and what it closed at the end of the file. -
Query by CSS
Selectors 4, minus the parts that need a live browser to answer.
step2.mbt
page.css("#list li") // descendant page.css("li + li") // next sibling page.css("li:first-child") page.css("ul :not(.done)") page.css("a[href^='https']") // attribute prefix page.css("ul:has(li.done)") // a parent, by its child page.css("li:nth-child(2n+1)") page.css("li:lang(en-GB)") // a language, by prefix page.css("input:enabled")
A pseudo-class that only a browser can answer —
:hover,:visited— is aRefusedparse error, not a silent miss.:lang()reads the nearest ancestor that declares a language, and:enabledand:disabledreach only what a document can actually disable, so a paragraph is neither.step2b.mbt
// a namespace prefix is resolved by the map you pass, the way a // stylesheet's @namespace rules would let ns : @moonxml.NamespaceMap = { default: "", prefixes: [("svg", "http://www.w3.org/2000/svg")], } page.css("svg|rect", namespaces=ns) page.css("*|rect", namespaces=ns) // any namespace page.css("|rect", namespaces=ns) // no namespace
-
Query by XPath
The whole of XPath 1.0, and a typed answer when you want one.
step3.mbt
// a node-set, as a selection you can chain on page.xpath("//li[2]").text() // anything else, as the value it is let count = page.xpath_value("count(//li)") // Num(2.0) let yes = page.xpath_value("//li = 'tea'") // Flag(true) // an attribute is a node with an identity of its own page.xpath("//li/@id").text() page.xpath("//li/@id/ancestor::*").length() // the namespace axis answers what is in scope on an element page.xpath_value("count(//li/namespace::*)")
-
Chain the two
Every query answers the same shape, so nothing stops you mixing them — or stepping down into a node and carrying on.
step4.mbt
page.css("#list").xpath(".//li[1]").text() // "coffee" page.xpath("//ul").children().length() // 2 page.by_tag("li").items[0].css("b") // a node, queried again
Results come back in document order, deduplicated by identity, and an empty selection stays empty — it does not become “the whole document”.
-
Edit the tree
The verbs work on whatever the query returned. Parents stay true: a node you move knows where it went.
step5.mbt
page.css("li").first().set_text("espresso") page.css("li").last().add_class("done") let cocoa = @moonxml.Element::new("li", children = [@moonxml.TextNode("cocoa")]) page.css("ul").append(@moonxml.ElementNode(cocoa)) page.css("li").first().wrap("strong") // one node, one wrapper
output
<ul id="list"><strong><li>espresso</li></strong><li class="done">tea</li><li>cocoa</li></ul>
A node that moved is in one place only, and the queries that read the tree's shape answer from the tree as it stands:
index_in_parent(),previous_element_sibling(),child_elements().set_childrenreplaces the lot in one edit, andclone_deepanswers a copy that belongs to no tree. -
Your own selector
Selection logic that is yours belongs in the chain, not in a loop beside it.
step6.mbt
let by_length = @moonxml.SelectorFn::new((ctx, _q) => { ctx.css("a[href]").filter(node => match node { @moonxml.ElementNode(data) => match data.attr("href") { Some(href) => href.has_prefix("https://") None => false } _ => false }) }) page.register("external", by_length) page.selector("external", "").text()
registeris per document, so two documents can disagree about what a name means.customruns a selector you never registered, when a name would be noise. -
Or read XML, or build nothing at all
XML is strict: a document either reads whole or is refused with the offset of the fault. What it keeps, it keeps exactly.
step7.mbt
let src = "<feed xmlns:a='urn:a'><a:entry id='1'/></feed>" let feed = @xml.loads(src) feed.css("entry").items[0].name() // {urn:a}entry feed.css("{urn:a}entry").length() // 1 feed.dumps() == src // true, byte for byte
And when you only need to look at the document rather than keep it:
step7b.mbt
struct Tagger { mut tags : Int } impl @event.Sink for Tagger with push(self, event) { match event { @event.StartTag(_) => self.tags = self.tags + 1 _ => () } } let tagger = { tags: 0 } @xml.scan("<a><b/><c/></a>", tagger) println(tagger.tags) // 3, no tree built // HTML answers its tokens the same way, before any tree is built @html.scan("<p class=a class=b>x</div>", tagger)
The HTML stream is the tokenizer's own reading: a repeated attribute keeps the first, and an end tag that matches nothing is still an end tag, because the tree builder is what would have dealt with it and it has not run.
-
Entities, defaults and the internal subset
XML's doctype declares things, and a reader that ignores it cannot read the document. Entity references expand, attribute defaults fill in, and a document that expands past a million characters is refused rather than read.
step8.mbt
let doc = @xml.loads( "<!DOCTYPE r [<!ENTITY who 'the reader'>" + "<!ATTLIST r kind CDATA 'plain'>]><r>hello &who;</r>") doc.css("r").text() // "hello the reader" doc.css("r").attr("kind") // Some("plain") doc.unresolved() // [], nothing was out of reach
An entity that lives in a file beside the document is not fetched — this library does no I/O — and
unresolved()names what was skipped, which is what XML 1.0 asks a non-validating processor to do. -
SVG, MathML and templates
HTML's foreign content is parsed as foreign, so an SVG subtree keeps its own spelling and its own namespace, and a template keeps its contents to itself.
step9.mbt
let page = @html.loads("<svg viewBox='0 0 1 1'><clipPath/></svg>") page.css("svg").items[0].name() // {http://www.w3.org/2000/svg}svg page.css("clipPath").length() // 1 — the name keeps its capital page.css("svg").attr("viewBox") // Some("0 0 1 1") let with_template = @html.loads("<template><p>inert</p></template>") with_template.css("p").length() // 0 — nothing in a template is in the document with_template.css("template").items[0].content() // the fragment it holds, children and all
-
Read a fragment the way innerHTML does
A fragment is markup without a document around it, read inside an element named as the context.
step10.mbt
let cells = @html.loads_fragment( "<tr><td>a</td></tr>", context="tbody") cells.css("td").text() // "a" cells.css("tr").length() // 1 @html.loads_fragment("<desc><b>x</b></desc>", context="svg svg") .css("b").length() // 1, an HTML tag inside SVG's integration point
-
htmx, without a browser
A hypermedia page does not script its requests; it declares them.
@htmxreads those declarations off the tree, describes the request they add up to, and puts the answer where they say it goes.step11.mbt
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" request.query() // "title=write+it+down" request.headers() // HX-Request, HX-Target, and what the page declared ignore(request.answer("<li>a second one</li>")) // the list grows
Triggers, targets, swaps, values and out-of-band content are all read the way htmx reads them, inheritance included. Sending the request and waiting for the answer are the two things left to the caller, because they are the two things a library cannot do.
-
Then the API
Next: the complete API, or how the parsers and the chain work.
❦