package borogove;
import haxe.DynamicAccess;
import haxe.ds.ReadOnlyArray;
using StringTools;
using Lambda;
import borogove.Stanza;
#if cpp
import HaxeCBridge;
#end
@:expose
@:nullSafety(StrictThreaded)
#if cpp
@:build(HaxeCBridge.expose())
@:build(HaxeSwiftBridge.expose())
#end
/**
Rich text
WARNING: this is possibly untrusted HTML. You must render or sanitize appropriately!
**/
class Html {
private static final HTML_EMPTY = [
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr"
];
@:allow(borogove)
private final xml: ReadOnlyArray<Node>;
private final sender: Null<Participant>;
@:allow(borogove)
private function new(xml: Array<Node>, sender: Null<Participant>) {
this.xml = xml;
this.sender = sender;
}
#if js
/**
HTML builder, make an element
**/
public static function element(tag: String, attrs: DynamicAccess<String>, children: Array<Html>) {
final s = new Stanza(tag, attrs);
for (c in children) {
for (n in c.xml) {
s.addDirectChild(n);
}
}
return new Html([Element(s)], null);
}
#else
/**
HTML builder, make an element
**/
public static function element(tag: String, attr: Array<String>, attrValues: Array<String>, children: Array<Html>) {
final attrs: DynamicAccess<String> = {};
for (i => a in attr) {
attrs[a] = attrValues[i];
}
final s = new Stanza(tag, attrs);
for (c in children) {
for (n in c.xml) {
s.addDirectChild(n);
}
}
return new Html([Element(s)], null);
}
#end
/**
HTML builder, make some text
**/
public static function text(text: String) {
return new Html([CData(new TextNode(text))], null);
}
/**
HTML builder, make a fragment
**/
public static function fragment(nodes: Array<Html>) {
return new Html(nodes.map(n -> n.xml).flatten(), null);
}
/**
Build HTML payload from source
**/
public static function fromString(html: String): Html {
final nodes = [];
for (node in htmlparser.HtmlParser.run(html, true)) {
final el = Util.downcast(node, htmlparser.HtmlNodeElement);
if (el != null && (el.name == "html" || el.name == "body")) {
for (inner in el.nodes) {
nodes.push(htmlToNode(inner));
}
} else {
nodes.push(htmlToNode(node));
}
}
return new Html(nodes, null);
}
private static function htmlToNode(node: htmlparser.HtmlNode) {
final txt = Util.downcast(node, htmlparser.HtmlNodeText);
if (txt != null) {
return CData(new TextNode(txt.toText()));
}
final el = Util.downcast(node, htmlparser.HtmlNodeElement);
if (el != null) {
final s = new Stanza(el.name, {});
for (attr in el.attributes) {
s.attr.set(attr.name, attr.value);
}
for (child in el.nodes) {
s.addDirectChild(htmlToNode(child));
}
return Element(s);
}
throw "node was neither text nor element?";
}
@:allow(borogove)
private function isPlainText() {
// Don't use our own reduce because we want to check the raw nodes
return !xml.map(item -> switch (item) {
case Element(el):
el.reduce(
(st, kids) -> {
final attrs = st.attr.keys();
if (["div", "span", "p", "br"].contains(st.name)) {
return attrs.length < 1 && !kids.exists(plain -> !plain);
}
return false;
},
txt -> true
);
case CData(txt): true;
}).exists(plain -> !plain);
}
/**
Walk the HTML tree to produce a new value
**/
public function reduce<T>(f: (String, Null<Array<String>>, Null<Array<String>>, Null<Array<T>>)->T):Array<T> {
var isAction = false;
function mkTxt(txt: String) {
final senderP = sender;
return if (!isAction && txt.startsWith("/me ") && senderP != null) {
isAction = true;
f(senderP.displayName + txt.substr(3), null, null, null);
} else {
f(txt, null, null, null);
};
}
final fragment = xml.map(item -> switch (item) {
case Element(el):
el.reduce(
(st, kids) -> {
// We don't deeply sanitize but we can remove some obvious dumb stuff
if (st.name == "style" || st.name == "script") return mkTxt("");
final keys = st.attr.keys().filter(k -> !k.startsWith("on"));
return f(
st.name,
keys,
keys.map(k -> {
final v = st.attr.get(k) ?? "";
if (st.name == "img" && k == "src" && v != "") {
final hash = Hash.fromUri(v);
hash == null ? v : hash.toUri();
} else {
v;
}
}),
kids
);
},
txt -> mkTxt(txt)
);
case CData(txt):
mkTxt(txt.content);
});
return isAction ? [f("div", ["class"], ["action"], fragment)] : fragment;
}
/**
Get HTML source as a string
**/
public function toString(): String {
return reduce((tag, attr, attrValue, kids) -> {
if (attr == null && kids == null) {
return StringTools.htmlEscape(tag);
} else if (attr != null && attrValue != null) {
final el = Xml.createElement(tag);
for (i => attr_k in attr) {
el.set(attr_k, attrValue[i]);
}
final start = el.toString();
final buffer = new StringBuf();
buffer.addSub(start, 0, start.length-2);
if (HTML_EMPTY.contains(tag)) {
buffer.add(" />");
return buffer.toString();
}
buffer.add(">");
if (kids != null) {
for (kid in kids) {
buffer.add(kid);
}
}
buffer.add("</");
buffer.add(tag);
buffer.add(">");
return buffer.toString();
}
throw "Invalid arguments";
}).join("");
}
/**
Get plain text suitable for showing to a user
**/
public function toPlainText(): String {
// Could use reduce, but we already have XEP0393.render around
final body = new Stanza("body");
body.addChildNodes(xml);
return ~/\n(\n)?$/.replace(XEP0393.render(body), "");
}
#if js
/**
Get HTML as a DocumentFragment
**/
public function asDOM(): js.html.DocumentFragment {
final nodes = reduce((tag, attr, attrValue, kids) -> {
if (attr == null && kids == null) {
return (js.Browser.document.createTextNode(tag) : js.html.Node);
} else if (attr != null && attrValue != null) {
final el = js.Browser.document.createElement(tag);
for (i => attr_k in attr) {
el.setAttribute(attr_k, attrValue[i]);
}
if (kids != null) el.append(...kids);
return el;
}
throw "Invalid arguments";
});
final frag = new js.html.DocumentFragment();
frag.append(...nodes);
return frag;
}
#end
}