///|
/// Node in the intermediate HTML tree.
pub(all) enum HtmlNode {
Element(
tag~ : String,
attributes~ : Map[String, String],
children~ : Array[HtmlNode],
fresh~ : Bool,
separator~ : String?
)
TextNode(String)
ForceWrite
} derive(Debug, Eq)
///|
/// Builds an HTML text node.
pub fn html_text(value : String) -> HtmlNode {
TextNode(value)
}
///|
/// Builds an HTML element node.
pub fn html_element(
tag : String,
attributes? : Map[String, String] = Map([]),
children? : Array[HtmlNode] = [],
fresh? : Bool = false,
separator? : String = "",
) -> HtmlNode {
Element(
tag~,
attributes~,
children~,
fresh~,
separator=separator_option(separator),
)
}
///|
/// Builds an HTML element with a fresh generated id.
pub fn fresh_html_element(
tag : String,
attributes? : Map[String, String] = Map([]),
children? : Array[HtmlNode] = [],
separator? : String = "",
) -> HtmlNode {
Element(
tag~,
attributes~,
children~,
fresh=true,
separator=separator_option(separator),
)
}
///|
fn separator_option(value : String) -> String? {
if value == "" {
None
} else {
Some(value)
}
}
///|
/// Serializes HTML nodes to a string.
pub fn write_html(
nodes : Array[HtmlNode],
pretty_print? : Bool = false,
) -> String {
let writer = HtmlWriter::{
pretty_print,
buffer: StringBuilder(),
indent: 0,
stack: [],
start: true,
in_text: false,
}
writer.write_nodes(nodes)
let result = writer.buffer.to_string()
if pretty_print && result.has_suffix("\n") {
result[:result.length() - 1].to_owned()
} else {
result
}
}
///|
priv struct HtmlWriter {
pretty_print : Bool
buffer : StringBuilder
mut indent : Int
stack : Array[String]
mut start : Bool
mut in_text : Bool
}
///|
fn HtmlWriter::write_nodes(self : HtmlWriter, nodes : Array[HtmlNode]) -> Unit {
for node in nodes {
self.write_node(node)
}
}
///|
fn HtmlWriter::write_node(self : HtmlWriter, node : HtmlNode) -> Unit {
match node {
TextNode(value) =>
if self.pretty_print {
self.write_pretty_text(value)
} else {
self.buffer.write_string(escape_html(value))
}
ForceWrite => ()
Element(tag~, attributes~, children~, ..) => {
let tag_name = html_primary_tag(tag)
if children.is_empty() && is_void_html_tag(tag_name) {
self.write_self_closing_tag(tag_name, attributes)
} else {
self.write_open_tag(tag_name, attributes)
self.write_nodes(children)
self.write_close_tag(tag_name)
}
}
}
}
///|
fn HtmlWriter::write_open_tag(
self : HtmlWriter,
tag_name : String,
attributes : Map[String, String],
) -> Unit {
if self.pretty_print && is_pretty_indented_tag(tag_name) {
self.write_pretty_indent()
}
self.stack.push(tag_name)
self.buffer.write_string("<")
self.buffer.write_string(tag_name)
self.write_attributes(attributes)
self.buffer.write_string(">")
if self.pretty_print && is_pretty_indented_tag(tag_name) {
self.indent = self.indent + 1
}
self.start = false
}
///|
fn HtmlWriter::write_close_tag(self : HtmlWriter, tag_name : String) -> Unit {
if self.pretty_print && is_pretty_indented_tag(tag_name) {
self.indent = self.indent - 1
self.write_pretty_indent()
}
ignore(self.stack.pop())
self.buffer.write_string("")
self.buffer.write_string(tag_name)
self.buffer.write_string(">")
}
///|
fn HtmlWriter::write_self_closing_tag(
self : HtmlWriter,
tag_name : String,
attributes : Map[String, String],
) -> Unit {
if self.pretty_print {
self.write_pretty_indent()
}
self.buffer.write_string("<")
self.buffer.write_string(tag_name)
self.write_attributes(attributes)
self.buffer.write_string(" />")
self.start = false
}
///|
fn HtmlWriter::write_pretty_text(self : HtmlWriter, value : String) -> Unit {
self.start_pretty_text()
if self.is_inside_pre() {
self.buffer.write_string(escape_html(value))
} else {
self.buffer.write_string(escape_html(indent_first_text_newline(value)))
}
}
///|
fn HtmlWriter::start_pretty_text(self : HtmlWriter) -> Unit {
if !self.in_text {
self.write_pretty_indent()
self.in_text = true
}
}
///|
fn HtmlWriter::write_pretty_indent(self : HtmlWriter) -> Unit {
self.in_text = false
if !self.start && self.is_inside_indented_element() && !self.is_inside_pre() {
self.buffer.write_string("\n")
for _ in 0.. Bool {
match self.stack.last() {
Some(tag_name) => is_pretty_indented_tag(tag_name)
None => true
}
}
///|
fn HtmlWriter::is_inside_pre(self : HtmlWriter) -> Bool {
self.stack.contains("pre")
}
///|
fn is_pretty_indented_tag(tag_name : String) -> Bool {
tag_name == "div" || tag_name == "p" || tag_name == "ul" || tag_name == "li"
}
///|
fn indent_first_text_newline(value : String) -> String {
match value.find("\n") {
Some(index) =>
value[:index + 1].to_owned() + " " + value[index + 1:].to_owned()
None => value
}
}
///|
fn html_primary_tag(tag : String) -> String {
match tag.find("|") {
Some(index) => tag[:index].trim().to_owned()
None => tag.trim().to_owned()
}
}
///|
fn html_tag_matches(candidate : String, actual : String) -> Bool {
let actual = html_primary_tag(actual)
for choice in candidate.split("|") {
if choice.trim().to_owned() == actual {
return true
}
}
false
}
///|
/// Returns whether a tag is serialized without an end tag.
pub fn is_void_html_tag(tag : String) -> Bool {
let tag = html_primary_tag(tag)
tag == "area" ||
tag == "base" ||
tag == "br" ||
tag == "col" ||
tag == "embed" ||
tag == "hr" ||
tag == "img" ||
tag == "input" ||
tag == "link" ||
tag == "meta" ||
tag == "param" ||
tag == "source" ||
tag == "track" ||
tag == "wbr"
}
///|
fn HtmlWriter::write_attributes(
self : HtmlWriter,
attributes : Map[String, String],
) -> Unit {
let items = attributes.to_array()
items.sort_by(fn(a, b) {
let (key_a, _) = a
let (key_b, _) = b
let rank_diff = html_attribute_rank(key_a) - html_attribute_rank(key_b)
if rank_diff != 0 {
rank_diff
} else {
key_a.compare(key_b)
}
})
for item in items {
let (key, value) = item
self.buffer.write_string(" ")
self.buffer.write_string(key)
self.buffer.write_string("=\"")
self.buffer.write_string(escape_html_attribute(value))
self.buffer.write_string("\"")
}
}
///|
fn html_attribute_rank(key : String) -> Int {
match key {
"class" => 0
"href" => 1
"alt" => 1
"type" => 1
"id" => 2
"src" => 2
"checked" => 2
"target" => 3
"disabled" => 3
_ => 10
}
}
///|
/// Escapes text for HTML content.
pub fn escape_html(value : String) -> String {
let builder = StringBuilder()
for char in value {
match char {
'&' => builder.write_string("&")
'<' => builder.write_string("<")
'>' => builder.write_string(">")
_ => builder.write_char(char)
}
}
builder.to_string()
}
///|
/// Escapes text for an HTML attribute value.
pub fn escape_html_attribute(value : String) -> String {
let builder = StringBuilder()
for char in value {
match char {
'&' => builder.write_string("&")
'<' => builder.write_string("<")
'>' => builder.write_string(">")
'"' => builder.write_string(""")
_ => builder.write_char(char)
}
}
builder.to_string()
}