<moonxml/>

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.

seven steps· each one a program· no aside

The same tour ships as a runnable example: moon run src/examples/tour.

  1. 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.
  2. 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 a Refused parse error, not a silent miss. :lang() reads the nearest ancestor that declares a language, and :enabled and :disabled reach 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
  3. 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::*)")
  4. 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”.

  5. 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_children replaces the lot in one edit, and clone_deep answers a copy that belongs to no tree.

  6. 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()

    register is per document, so two documents can disagree about what a name means. custom runs a selector you never registered, when a name would be noise.

  7. 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.

  8. 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.

  9. 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
  10. 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
  11. htmx, without a browser

    A hypermedia page does not script its requests; it declares them. @htmx reads 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.

  12. Then the API

❦