// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Node index for O(1) parent/sibling access (using Int directly)
/// Sentinel value NO_NODE = -1 indicates "no node"
pub const NO_NODE : Int = -1
///|
/// DOM Element node
pub struct Element {
tag_name : String
ns : Namespace
attributes : Array[(String, String)]
children : Array[Int]
mut parent : Int
// HTML-specific flags for integration points
is_html_integration_point : Bool
is_mathml_text_integration_point : Bool
// Template content (node ID of DocumentFragment, or NO_NODE)
mut template_content : Int
} derive(Debug, Eq)
///|
/// Create a new Element
pub fn Element::new(
tag_name : String,
ns : Namespace,
attributes : Array[(String, String)],
) -> Element {
{
tag_name,
ns,
attributes,
children: [],
parent: NO_NODE,
is_html_integration_point: false,
is_mathml_text_integration_point: false,
template_content: NO_NODE,
}
}
///|
/// DOM Node - sum type for all node kinds
pub enum Node {
ElementNode(Element)
TextNode(String)
CommentNode(String)
ProcessingInstructionNode(target~ : String, data~ : String)
DocumentTypeNode(name~ : String, public_id~ : String, system_id~ : String)
DocumentNode(children~ : Array[Int])
DocumentFragmentNode(children~ : Array[Int])
} derive(Debug, Eq)
///|
/// The complete document owns all nodes in a flat array
pub struct Document {
nodes : Array[Node]
parents : Array[Int]
// Quick access indices
mut document_element : Int // The element
mut head_element : Int // The element
mut body_element : Int // The element
// Document mode (quirks, limited-quirks, no-quirks)
mut quirks_mode : QuirksMode
// Scripting mode used while parsing, needed for noscript serialization.
mut scripting_enabled : Bool
} derive(Debug, Eq)
///|
pub impl Show for Element with fn output(self, logger) {
logger.write_string("{tag_name: ")
Show::output(self.tag_name, logger)
logger.write_string(", ns: ")
Show::output(self.ns, logger)
logger.write_string(", attributes: ")
logger.write_string(@debug.to_string(self.attributes))
logger.write_string(", children: ")
logger.write_string(@debug.to_string(self.children))
logger.write_string(", parent: ")
Show::output(self.parent, logger)
logger.write_string(", is_html_integration_point: ")
Show::output(self.is_html_integration_point, logger)
logger.write_string(", is_mathml_text_integration_point: ")
Show::output(self.is_mathml_text_integration_point, logger)
logger.write_string(", template_content: ")
Show::output(self.template_content, logger)
logger.write_string("}")
}
///|
pub impl Show for Node with fn output(self, logger) {
match self {
ElementNode(elem) => {
logger.write_string("ElementNode(")
Show::output(elem, logger)
logger.write_string(")")
}
TextNode(text) => {
logger.write_string("TextNode(")
Show::output(text, logger)
logger.write_string(")")
}
CommentNode(text) => {
logger.write_string("CommentNode(")
Show::output(text, logger)
logger.write_string(")")
}
ProcessingInstructionNode(target~, data~) => {
logger.write_string("ProcessingInstructionNode(target=")
Show::output(target, logger)
logger.write_string(", data=")
Show::output(data, logger)
logger.write_string(")")
}
DocumentTypeNode(name~, public_id~, system_id~) => {
logger.write_string("DocumentTypeNode(name=")
Show::output(name, logger)
logger.write_string(", public_id=")
Show::output(public_id, logger)
logger.write_string(", system_id=")
Show::output(system_id, logger)
logger.write_string(")")
}
DocumentNode(children~) => {
logger.write_string("DocumentNode(children=")
logger.write_string(@debug.to_string(children))
logger.write_string(")")
}
DocumentFragmentNode(children~) => {
logger.write_string("DocumentFragmentNode(children=")
logger.write_string(@debug.to_string(children))
logger.write_string(")")
}
}
}
///|
pub impl Show for Document with fn output(self, logger) {
logger.write_string("{nodes: ")
logger.write_string(@debug.to_string(self.nodes))
logger.write_string(", parents: ")
logger.write_string(@debug.to_string(self.parents))
logger.write_string(", document_element: ")
Show::output(self.document_element, logger)
logger.write_string(", head_element: ")
Show::output(self.head_element, logger)
logger.write_string(", body_element: ")
Show::output(self.body_element, logger)
logger.write_string(", quirks_mode: ")
Show::output(self.quirks_mode, logger)
logger.write_string(", scripting_enabled: ")
Show::output(self.scripting_enabled, logger)
logger.write_string("}")
}
///|
/// Create a new empty document
pub fn Document::new() -> Document {
let doc = {
nodes: [],
parents: [],
document_element: NO_NODE,
head_element: NO_NODE,
body_element: NO_NODE,
quirks_mode: NoQuirks,
scripting_enabled: false,
}
// Add the document node as node 0
doc.nodes.push(DocumentNode(children=[]))
doc.parents.push(NO_NODE)
doc
}
///|
/// Get a node by ID
pub fn Document::get_node(self : Document, id : Int) -> Node? {
if id >= 0 && id < self.nodes.length() {
Some(self.nodes[id])
} else {
None
}
}
///|
/// Get an element by ID (returns None if not an element)
pub fn Document::get_element(self : Document, id : Int) -> Element? {
match self.get_node(id) {
Some(ElementNode(elem)) => Some(elem)
_ => None
}
}
///|
/// Add a new node and return its ID
pub fn Document::add_node(self : Document, node : Node) -> Int {
let id = self.nodes.length()
self.nodes.push(node)
self.parents.push(NO_NODE)
id
}
///|
fn Document::can_have_children(self : Document, id : Int) -> Bool {
match self.get_node(id) {
Some(ElementNode(_))
| Some(DocumentNode(_))
| Some(DocumentFragmentNode(_)) => true
_ => false
}
}
///|
fn Document::set_parent(
self : Document,
child_id : Int,
parent_id : Int,
) -> Unit {
self.parents[child_id] = parent_id
match self.nodes[child_id] {
ElementNode(elem) => elem.parent = parent_id
_ => ()
}
}
///|
fn Document::remove_child_reference(
self : Document,
parent_id : Int,
child_id : Int,
) -> Bool {
let children = match self.get_node(parent_id) {
Some(ElementNode(elem)) => elem.children
Some(DocumentNode(children~)) | Some(DocumentFragmentNode(children~)) =>
children
_ => return false
}
for i, id in children {
if id == child_id {
let _ = children.remove(i)
return true
}
}
false
}
///|
/// Return whether inserting child_id below parent_id would create a cycle.
fn Document::would_create_cycle(
self : Document,
parent_id : Int,
child_id : Int,
) -> Bool {
if parent_id < 0 ||
parent_id >= self.nodes.length() ||
child_id < 0 ||
child_id >= self.nodes.length() {
return true
}
let visited = Array::make(self.nodes.length(), false)
let stack = [child_id]
while stack.length() > 0 {
let current = stack.pop().unwrap()
if current == parent_id {
return true
}
if !visited[current] {
visited[current] = true
for descendant in self.get_children(current) {
if descendant < 0 || descendant >= self.nodes.length() {
return true
}
stack.push(descendant)
}
}
}
false
}
///|
/// Append a child node to a parent
pub fn Document::append_child(
self : Document,
parent_id : Int,
child_id : Int,
) -> Unit {
if self.would_create_cycle(parent_id, child_id) ||
!self.can_have_children(parent_id) {
return
}
let old_parent = self.get_parent(child_id)
if old_parent != NO_NODE {
let _ = self.remove_child_reference(old_parent, child_id)
}
// Add to parent's children
match self.nodes[parent_id] {
ElementNode(elem) => elem.children.push(child_id)
DocumentNode(children~) => children.push(child_id)
DocumentFragmentNode(children~) => children.push(child_id)
_ => ()
}
self.set_parent(child_id, parent_id)
}
///|
/// Insert a child node before another child
pub fn Document::insert_before(
self : Document,
parent_id : Int,
child_id : Int,
reference_id : Int,
) -> Unit {
if child_id == reference_id ||
self.would_create_cycle(parent_id, child_id) ||
!self.can_have_children(parent_id) {
return
}
let old_parent = self.get_parent(child_id)
if old_parent != NO_NODE {
let _ = self.remove_child_reference(old_parent, child_id)
}
// Find reference position and insert
match self.nodes[parent_id] {
ElementNode(elem) => {
let mut idx = elem.children.length()
for i, id in elem.children {
if id == reference_id {
idx = i
break
}
}
elem.children.insert(idx, child_id)
}
DocumentNode(children~) => {
let mut idx = children.length()
for i, id in children {
if id == reference_id {
idx = i
break
}
}
children.insert(idx, child_id)
}
DocumentFragmentNode(children~) => {
let mut idx = children.length()
for i, id in children {
if id == reference_id {
idx = i
break
}
}
children.insert(idx, child_id)
}
_ => ()
}
self.set_parent(child_id, parent_id)
}
///|
/// Remove a child from its parent
pub fn Document::remove_child(
self : Document,
parent_id : Int,
child_id : Int,
) -> Unit {
if parent_id < 0 ||
parent_id >= self.nodes.length() ||
child_id < 0 ||
child_id >= self.nodes.length() {
return
}
if self.remove_child_reference(parent_id, child_id) &&
self.get_parent(child_id) == parent_id {
self.set_parent(child_id, NO_NODE)
}
}
///|
/// Get the tag name of an element node
pub fn Document::get_tag_name(self : Document, id : Int) -> String? {
match self.get_node(id) {
Some(ElementNode(elem)) => Some(elem.tag_name)
_ => None
}
}
///|
/// Get the namespace of an element node
pub fn Document::get_ns(self : Document, id : Int) -> Namespace? {
match self.get_node(id) {
Some(ElementNode(elem)) => Some(elem.ns)
_ => None
}
}
///|
/// Check if a node is an element with the given tag name and namespace
pub fn Document::is_element(
self : Document,
id : Int,
tag_name : String,
ns : Namespace,
) -> Bool {
match self.get_node(id) {
Some(ElementNode(elem)) => elem.tag_name == tag_name && elem.ns == ns
_ => false
}
}
///|
/// Check if a node is an HTML element with the given tag name
pub fn Document::is_html_element(
self : Document,
id : Int,
tag_name : String,
) -> Bool {
self.is_element(id, tag_name, HTML)
}
///|
/// Get an attribute value from an element
pub fn Document::get_attribute(
self : Document,
id : Int,
attr_name : String,
) -> String? {
match self.get_node(id) {
Some(ElementNode(elem)) => {
for pair in elem.attributes {
if pair.0 == attr_name {
return Some(pair.1)
}
}
None
}
_ => None
}
}
///|
/// Set an attribute on an element
pub fn Document::set_attribute(
self : Document,
id : Int,
attr_name : String,
attr_value : String,
) -> Unit {
match self.nodes[id] {
ElementNode(elem) => {
// Check if attribute exists
for i, pair in elem.attributes {
if pair.0 == attr_name {
elem.attributes[i] = (attr_name, attr_value)
return
}
}
// Add new attribute
elem.attributes.push((attr_name, attr_value))
}
_ => ()
}
}
///|
/// Get the parent of a node
pub fn Document::get_parent(self : Document, id : Int) -> Int {
if id >= 0 && id < self.parents.length() {
self.parents[id]
} else {
NO_NODE
}
}
///|
/// Get the template content of a template element
pub fn Document::get_template_content(self : Document, id : Int) -> Int {
match self.get_node(id) {
Some(ElementNode(elem)) => elem.template_content
_ => NO_NODE
}
}
///|
/// Get children of a node
pub fn Document::get_children(self : Document, id : Int) -> Array[Int] {
match self.get_node(id) {
Some(ElementNode(elem)) => elem.children.copy()
Some(DocumentNode(children~)) => children.copy()
Some(DocumentFragmentNode(children~)) => children.copy()
_ => []
}
}
///|
/// Check if an element has a specific attribute
pub fn Document::has_attribute(
self : Document,
id : Int,
attr_name : String,
) -> Bool {
self.get_attribute(id, attr_name) is Some(_)
}
///|
/// Get text content of a node (concatenation of all text descendants)
pub fn Document::get_text_content(self : Document, id : Int) -> String {
let buf = StringBuilder()
self.collect_text_content(id, buf)
buf.to_string()
}
///|
fn Document::collect_text_content(
self : Document,
id : Int,
buf : StringBuilder,
) -> Unit {
match self.get_node(id) {
Some(TextNode(text)) => buf.write_string(text)
Some(ElementNode(elem)) =>
for child_id in elem.children {
self.collect_text_content(child_id, buf)
}
Some(DocumentNode(children~)) | Some(DocumentFragmentNode(children~)) =>
for child_id in children {
self.collect_text_content(child_id, buf)
}
_ => ()
}
}
///|
/// Serialize document to HTML string
pub fn Document::to_html(self : Document) -> String {
let buf = StringBuilder()
// Start from document children
match self.nodes[0] {
DocumentNode(children~) =>
for child_id in children {
self.serialize_node(child_id, buf)
}
_ => ()
}
buf.to_string()
}
///|
fn Document::serialize_node(
self : Document,
id : Int,
buf : StringBuilder,
) -> Unit {
match self.get_node(id) {
Some(DocumentTypeNode(name~, ..)) => {
buf.write_string("")
}
Some(TextNode(text)) => buf.write_string(escape_html_text(text))
Some(CommentNode(text)) => {
buf.write_string("")
}
Some(ProcessingInstructionNode(target~, data~)) => {
buf.write_string("")
buf.write_string(target)
buf.write_string(" ")
buf.write_string(data)
buf.write_string("?>")
}
Some(ElementNode(elem)) => {
buf.write_string("<")
buf.write_string(elem.tag_name)
for pair in elem.attributes {
buf.write_string(" ")
buf.write_string(pair.0)
buf.write_string("=\"")
buf.write_string(escape_html_attr(pair.1))
buf.write_string("\"")
}
if is_void_element(elem.tag_name) && elem.children.length() == 0 {
buf.write_string(">")
} else {
buf.write_string(">")
if elem.ns == HTML &&
elem.tag_name == "template" &&
elem.template_content != NO_NODE {
match self.get_node(elem.template_content) {
Some(DocumentFragmentNode(children~)) =>
for child_id in children {
self.serialize_node(child_id, buf)
}
_ => ()
}
} else if elem.ns == HTML &&
(
elem.tag_name == "style" ||
elem.tag_name == "script" ||
elem.tag_name == "xmp" ||
elem.tag_name == "iframe" ||
elem.tag_name == "noembed" ||
elem.tag_name == "noframes" ||
elem.tag_name == "plaintext" ||
(elem.tag_name == "noscript" && self.scripting_enabled)
) {
// In raw text elements, keep text nodes unescaped so that
// `parse(to_html(doc))` can roundtrip for parser-produced DOMs.
for child_id in elem.children {
match self.get_node(child_id) {
Some(TextNode(text)) => buf.write_string(text)
_ => self.serialize_node(child_id, buf)
}
}
} else {
for child_id in elem.children {
self.serialize_node(child_id, buf)
}
}
buf.write_string("")
buf.write_string(elem.tag_name)
buf.write_string(">")
}
}
_ => ()
}
}
///|
/// Check if tag is a void element (no closing tag)
fn is_void_element(tag : String) -> Bool {
match tag {
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr" => true
_ => false
}
}
///|
/// Escape HTML text content
fn escape_html_text(s : String) -> String {
let buf = StringBuilder()
for c in s {
match c {
'&' => buf.write_string("&")
'<' => buf.write_string("<")
'>' => buf.write_string(">")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Escape HTML attribute value
fn escape_html_attr(s : String) -> String {
let buf = StringBuilder()
for c in s {
match c {
'&' => buf.write_string("&")
'"' => buf.write_string(""")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Dump document in html5lib-tests format for conformance testing
pub fn Document::dump(self : Document) -> String {
let buf = StringBuilder()
// Start from document children (node 0 is the document)
match self.nodes[0] {
DocumentNode(children~) =>
for child_id in children {
self.dump_node(child_id, 0, buf)
}
_ => ()
}
// Remove trailing newline to match html5lib-tests format
let s = buf.to_string()
let len = s.length()
if len > 0 && s[len - 1] == 10 { // '\n' == 10
// Build string without trailing newline
let buf2 = StringBuilder()
for i = 0; i < len - 1; i = i + 1 {
buf2.write_char(Int::unsafe_to_char(s[i].to_int()))
}
buf2.to_string()
} else {
s
}
}
///|
fn Document::dump_node(
self : Document,
id : Int,
depth : Int,
buf : StringBuilder,
) -> Unit {
// Prevent infinite recursion from malformed trees
if depth > 100 {
return
}
let indent = String::make(depth * 2, ' ')
match self.get_node(id) {
Some(DocumentTypeNode(name~, public_id~, system_id~)) => {
buf.write_string(indent)
buf.write_string("\n")
}
Some(CommentNode(text)) => {
buf.write_string(indent)
buf.write_string("\n")
}
Some(ProcessingInstructionNode(target~, data~)) => {
buf.write_string(indent)
buf.write_string("")
buf.write_string(target)
buf.write_string(" ")
buf.write_string(data)
buf.write_string("?>\n")
}
Some(TextNode(text)) => {
buf.write_string(indent)
buf.write_string("\"")
buf.write_string(escape_dump_text(text))
buf.write_string("\"\n")
}
Some(ElementNode(elem)) => {
buf.write_string(indent)
buf.write_string("<")
// Add namespace prefix for non-HTML elements (inside angle brackets)
match elem.ns {
SVG => buf.write_string("svg ")
MathML => buf.write_string("math ")
HTML => ()
}
buf.write_string(elem.tag_name)
buf.write_string(">\n")
// Dump attributes in sorted order (html5lib-tests requirement)
// Use lexicographic comparison (MoonBit's String.compare sorts by length first)
let attrs = elem.attributes.copy()
attrs.sort_by(fn(a, b) { lex_compare(a.0, b.0) })
let attr_indent = String::make((depth + 1) * 2, ' ')
for pair in attrs {
buf.write_string(attr_indent)
buf.write_string(pair.0)
buf.write_string("=\"")
buf.write_string(pair.1)
buf.write_string("\"\n")
}
// For template elements, dump "content" and its children
if elem.tag_name == "template" && elem.template_content != NO_NODE {
let content_indent = String::make((depth + 1) * 2, ' ')
buf.write_string(content_indent)
buf.write_string("content\n")
// Dump the template content's children
match self.get_node(elem.template_content) {
Some(DocumentFragmentNode(children~)) =>
for child_id in children {
self.dump_node(child_id, depth + 2, buf)
}
_ => ()
}
} else {
// Dump regular children
for child_id in elem.children {
self.dump_node(child_id, depth + 1, buf)
}
}
}
_ => ()
}
}
///|
/// Escape text for dump output (show control chars)
fn escape_dump_text(s : String) -> String {
let buf = StringBuilder()
for c in s {
let code = c.to_int()
if code == 0 {
buf.write_string("\\u0000")
} else if code >= 0x80 && code <= 0x9F {
// C1 controls - escape as \u{XX} so they're visible
buf.write_string("\\u{")
buf.write_string(code.to_string(radix=16))
buf.write_string("}")
} else {
buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Lexicographic string comparison (MoonBit's String.compare uses length-first)
fn lex_compare(a : String, b : String) -> Int {
let a_len = a.length()
let b_len = b.length()
let min_len = if a_len < b_len { a_len } else { b_len }
for i = 0; i < min_len; i = i + 1 {
let a_char = a[i].to_int()
let b_char = b[i].to_int()
if a_char < b_char {
return -1
}
if a_char > b_char {
return 1
}
}
// All common characters are equal, compare by length
if a_len < b_len {
-1
} else if a_len > b_len {
1
} else {
0
}
}