// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// Serialization. The format-library verbs: `dumps` writes a tree to text,
/// `dump` answers bytes, `loads` and `load` read one back (those live with
/// their parsers, in `moonxml/xml` and `moonxml/html`).
///
/// Two rule sets, because there are two languages. The XML writer is exact:
/// attribute order, CDATA, comments, processing instructions and the doctype
/// survive, and a well-formed document that is not edited comes back
/// byte-identical — the one deviation is that character references are
/// decoded, as a DOM means them to be. The HTML writer follows WHATWG's
/// serialization rules, and its criterion is the fixpoint: write, read, write
/// again gives the same bytes.

///|
/// The XML serialization of a node.
///
/// ```mbt check
/// test {
///   let element = @moonxml.Element::new("a", children=[TextNode("hi")])
///   assert_eq(@moonxml.dumps(ElementNode(element)), "hi")
/// }
/// ```
pub fn dumps(node : Node) -> String {
  let out = StringBuilder()
  write_xml(node, out)
  out.to_string()
}

///|
/// The XML serialization of a node, as bytes.
pub fn dump(node : Node) -> Bytes {
  @utf8.encode(dumps(node))
}

///|
/// The HTML serialization of a node, WHATWG's rules.
///
/// ```mbt check
/// test {
///   let element = @moonxml.Element::new("div", children=[TextNode("hi")])
///   assert_eq(@moonxml.dumps_html(ElementNode(element)), "
hi
") /// } /// ``` pub fn dumps_html(node : Node) -> String { let out = StringBuilder() write_html(node, out, raw=false, scripting=false) out.to_string() } ///| /// The HTML serialization of a node, as bytes. pub fn dump_html(node : Node) -> Bytes { @utf8.encode(dumps_html(node)) } ///| /// A whole document, in the flavour it was read as. pub fn Document::dumps(self : Document) -> String { match self.flavour { Xml => { let out = StringBuilder() for child in self.root.children { write_xml(child, out) } out.to_string() } Html => self.dumps_html() } } ///| /// A whole document as bytes, in the flavour it was read as. pub fn Document::dump(self : Document) -> Bytes { @utf8.encode(self.dumps()) } ///| /// A whole document, HTML rules whatever it was read as. pub fn Document::dumps_html(self : Document) -> String { let out = StringBuilder() let scripting = self.scripting() for child in self.root.children { write_html(child, out, raw=false, scripting~) } out.to_string() } ///| /// A whole document as bytes, by HTML's rules. pub fn Document::dump_html(self : Document) -> Bytes { @utf8.encode(self.dumps_html()) } // The XML writer. ///| fn write_xml(node : Node, out : StringBuilder) -> Unit { // `xml` is bound before any document says anything and never needs a // declaration of its own, so an `xml:lang` keeps its prefix instead of // being given an invented one that nothing declares. write_xml_in(node, out, [("xml", "http://www.w3.org/XML/1998/namespace")]) } ///| /// Write one node given the space declarations in force: `{uri}local` /// becomes a prefixed name again, using the prefix the document declared when /// there is one and inventing `ns1` when there is not. fn write_xml_in( node : Node, out : StringBuilder, scope : Array[(String, String)], ) -> Unit { match node { ElementNode(data) => { let local_scope = scope.copy() for pair in data.attrs { if pair.0 == "xmlns" { local_scope.push(("", pair.1)) } else if pair.0.has_prefix("xmlns:") { local_scope.push((pair.0[6:pair.0.length()].to_owned(), pair.1)) } } let declared : Array[(String, String)] = [] let tag = qualify(data.name, local_scope, declared, false) // Every name is resolved before anything is written: an attribute whose // space nothing declares earns a declaration, and a declaration written // after the attribute it covers would not cover it. let written : Array[(String, String)] = [] for pair in data.attrs { written.push((qualify(pair.0, local_scope, declared, true), pair.1)) } out.write_string("<" + tag) for pair in declared { out.write_string( " xmlns:" + pair.0 + "=\"" + escape_xml_attr(pair.1) + "\"", ) } for pair in written { out.write_string(" " + pair.0 + "=\"" + escape_xml_attr(pair.1) + "\"") } if data.children.is_empty() && data.closed { out.write_string("/>") return } out.write_string(">") for child in data.children { write_xml_in(child, out, local_scope) } out.write_string( "", ) } TextNode(text) => out.write_string(escape_xml_text(text)) CDataNode(text) => out.write_string("") CommentNode(text) => out.write_string("") ProcessingInstructionNode(pi) => out.write_string("") DoctypeNode(text) => out.write_string("") // Attributes and namespaces are not children of anything, so there is no // position in a document for them to be written at. AttributeNode(_) | NamespaceNode(_) => () } } ///| /// A name as it is written: unchanged when it carries no space, prefixed /// when it does. A name whose space nothing declares gets a declaration on /// the element being written, collected in `declared`. fn qualify( name : String, scope : Array[(String, String)], declared : Array[(String, String)], is_attr : Bool, ) -> String { let uri = match namespace_of(name) { Some(found) => found None => return name } let tail = local_of(name) for pair in scope { if pair.1 == uri && (!is_attr || !pair.0.is_empty()) { if pair.0.is_empty() { return tail } return pair.0 + ":" + tail } } let mut k = 1 let mut prefix = "" let mut chosen = false while !chosen { prefix = "ns\{k}" let mut taken = false for pair in scope { if pair.0 == prefix { taken = true } } chosen = !taken if !chosen { k = k + 1 } } scope.push((prefix, uri)) declared.push((prefix, uri)) prefix + ":" + tail } // The HTML writer, WHATWG's serialization rules. ///| fn write_html( node : Node, out : StringBuilder, raw~ : Bool, scripting~ : Bool, ) -> Unit { match node { ElementNode(data) => { let foreign = data.name.has_prefix("{") let tag = if foreign { local_of(data.name) } else { data.name } out.write_string("<" + tag) for pair in data.attrs { let value = pair.1 let name = html_attribute_name(pair.0) if value.is_empty() { out.write_string(" " + name) } else { out.write_string(" " + name + "=\"" + escape_html_attr(value) + "\"") } } out.write_string(">") // A void element never opens; a raw text element's children are written // exactly as they are. Foreign elements answer neither rule. if !foreign && is_void_element(tag) { return } // The reader drops one newline right after these tags' start tag, so one // has to be written for it to drop. if !foreign && keeps_leading_newline(tag) { match data.children { [TextNode(text), ..] => if text.has_prefix("\n") { out.write_string("\n") } _ => () } } // With scripting on, a `noscript`'s content is raw text, because that is // how it was read: escaping it would put the escapes in the text. let raw_here = !foreign && (is_raw_text_element(tag) || (tag == "noscript" && scripting)) for child in data.children { write_html(child, out, raw=raw_here || raw, scripting~) } // A template's children are its contents, and they belong inside the // element when it is written. match data.content() { Some(fragment) => for child in fragment.children { write_html(child, out, raw=raw_here || raw, scripting~) } None => () } out.write_string("") } TextNode(text) => if raw { out.write_string(text) } else { out.write_string(escape_html_text(text)) } CDataNode(text) => if raw { out.write_string(text) } else { out.write_string(escape_html_text(text)) } CommentNode(text) => out.write_string("") ProcessingInstructionNode(pi) => out.write_string("") DoctypeNode(text) => write_doctype(text, out) // Attributes and namespaces are not children of anything, so there is no // position in a document for them to be written at. AttributeNode(_) | NamespaceNode(_) => () } } ///| /// The tags whose content the reader starts one newline later: a newline right /// after the start tag is dropped, the way an author's line break is. fn keeps_leading_newline(tag : String) -> Bool { tag == "pre" || tag == "textarea" || tag == "listing" } ///| /// An attribute name as HTML spells it. A foreign attribute is a Clark name in /// the tree; HTML has prefixes for exactly three namespaces, so those get /// theirs back and anything else is written by its local name. fn html_attribute_name(name : String) -> String { match namespace_of(name) { Some(uri) => match uri { "http://www.w3.org/1999/xlink" => "xlink:" + local_of(name) "http://www.w3.org/XML/1998/namespace" => "xml:" + local_of(name) "http://www.w3.org/2000/xmlns/" => "xmlns:" + local_of(name) _ => local_of(name) } None => name } } ///| /// A document type declaration as HTML spells it: the name, then `PUBLIC` with /// both identifiers or `SYSTEM` with the one. The tree keeps the declaration /// the way the test corpus writes it — `name "public" "system"` — so the /// keywords have to be put back. fn write_doctype(text : String, out : StringBuilder) -> Unit { let space = match text.find(" ") { Some(at) => at None => -1 } if space < 0 { out.write_string("") return } let name = text[0:space].to_owned() let rest = text[space + 1:text.length()].trim().to_owned() match quoted_parts(rest) { Some(pair) => { let (public_id, system_id) = pair if public_id.is_empty() { out.write_string("") } else { out.write_string( "", ) } } None => out.write_string("") } } ///| /// The two quoted identifiers of a doctype, or nothing when they are not two /// quoted strings — an identifier that carries a quote of its own cannot be /// told from the closing one. fn quoted_parts(text : String) -> (String, String)? { let n = text.length() if n < 2 || text.at(0) != 0x22 || text.at(n - 1) != 0x22 { return None } // The public identifier ends at the first quote followed by a space, the // system one at the last quote. let mut i = 1 while i < n - 1 { if text.at(i) == 0x22 && text.at(i + 1) == 0x20 { // Past the closing quote, the space and the next opening quote. return Some((text[1:i].to_owned(), text[i + 3:n - 1].to_owned())) } i = i + 1 } None } ///| /// The elements HTML leaves open on purpose, the list WHATWG's serialisation /// gives (§13.3): the end tag is never written for them. SVG's handful rides /// along as foreign content, which the writer knows by the `{` on the name. fn is_void_element(tag : String) -> Bool { match tag { "area" | "base" | "basefont" | "bgsound" | "br" | "col" | "embed" | "frame" | "hr" | "img" | "input" | "keygen" | "link" | "meta" | "param" | "source" | "track" | "wbr" => true _ => false } } ///| /// The elements whose text is written exactly as it stands. fn is_raw_text_element(tag : String) -> Bool { match tag { "script" | "style" | "xmp" | "iframe" | "noembed" | "noframes" | "plaintext" => true _ => false } } ///| /// The local part of a `{uri}local` name. /// /// ```mbt check /// test { /// assert_eq(@moonxml.local_of("{urn:a}entry"), "entry") /// assert_eq(@moonxml.local_of("entry"), "entry") /// } /// ``` pub fn local_of(name : String) -> String { match rfind(name, '}') { Some(at) => name[at + 1:name.length()].to_owned() None => name } } ///| /// The URI of a `{uri}local` name, or `None` when the name carries none. /// /// ```mbt check /// test { /// assert_eq(@moonxml.namespace_of("{urn:a}entry"), Some("urn:a")) /// assert_eq(@moonxml.namespace_of("entry"), None) /// } /// ``` pub fn namespace_of(name : String) -> String? { if name.has_prefix("{") { match rfind(name, '}') { Some(at) => Some(name[1:at].to_owned()) None => None } } else { None } } ///| /// The last place a character occurs, if it does. fn rfind(text : String, unit : Char) -> Int32? { let mut i = text.length() - 1 while i >= 0 { if char_at(text, i) == unit { return Some(i) } i = i - 1 } None } ///| fn char_at(text : String, at : Int32) -> Char { match text.at(at).to_char() { Some(c) => c None => '?' } } ///| /// Text escapes `&` and `<` always; a `>` only when it would close a CDATA /// section that is not one — the minimal escape that keeps the text legal. fn escape_xml_text(text : String) -> String { if !(text.contains("&") || text.contains("<") || text.contains(">") || text.contains("\r")) { return text } let out = StringBuilder() let mut run = 0 for ch in text { if ch == '&' { out.write_string("&") run = 0 } else if ch == '<' { out.write_string("<") run = 0 } else if ch == '\r' { // A reader turns a raw carriage return into a line feed; one that came // in as a reference is still a carriage return here, and only a // reference keeps it one. out.write_string(" ") run = 0 } else if ch == '>' && run >= 2 { out.write_string(">") run = 0 } else { out.write_char(ch) run = if ch == ']' { run + 1 } else { 0 } } } out.to_string() } ///| /// Attribute values escape the quote they are written in, plus `&` and `<`, /// and the three characters a reader normalises to spaces inside an attribute /// value (XML 1.0 §3.3.3), which survive only as references. fn escape_xml_attr(text : String) -> String { if !(text.contains("&") || text.contains("<") || text.contains("\"") || text.contains("\t") || text.contains("\n") || text.contains("\r")) { return text } let out = StringBuilder() for ch in text { match ch { '&' => out.write_string("&") '<' => out.write_string("<") '"' => out.write_string(""") '\t' => out.write_string(" ") '\n' => out.write_string(" ") '\r' => out.write_string(" ") _ => out.write_char(ch) } } out.to_string() } ///| /// WHATWG's text escaping: `&`, `<`, `>` and a no-break space, which has no /// business sitting raw in a document that may be re-read as Latin-1. fn escape_html_text(text : String) -> String { if !(text.contains("&") || text.contains("<") || text.contains(">") || text.contains("\r") || text.contains("\u{A0}")) { return text } let out = StringBuilder() for ch in text { match ch { '&' => out.write_string("&") '<' => out.write_string("<") '>' => out.write_string(">") // The reader folds a carriage return into a line feed, so a text that // carries one has to write it as a reference. '\r' => out.write_string(" ") _ => if ch == '\u{A0}' { out.write_string(" ") } else { out.write_char(ch) } } } out.to_string() } ///| /// WHATWG's attribute-value escaping: `&`, the no-break space and the quote. fn escape_html_attr(text : String) -> String { if !(text.contains("&") || text.contains("\"") || text.contains("\r") || text.contains("\u{A0}")) { return text } let out = StringBuilder() for ch in text { match ch { '&' => out.write_string("&") '"' => out.write_string(""") '\r' => out.write_string(" ") _ => if ch == '\u{A0}' { out.write_string(" ") } else { out.write_char(ch) } } } out.to_string() }