sourceafFandoc::HtmlNode.fan


@Js
abstract class HtmlNode {
    private HtmlNode?   _parent
    private HtmlNode[]  _nodes  := HtmlNode[,]
//          Str:Obj?    meta    := Str:Obj?[:]

    HtmlNode?   parent()    { _parent }
    HtmlNode[]  nodes()     { _nodes }
    HtmlElem?   elem()      { this as HtmlElem }
    
    @Operator
    This add(HtmlNode node) {
        if (node._parent != null)
            throw Err("HtmlNode already parented: ${node}")
        this._nodes.add(node)
        node._parent = this
        return this
    }
    
    This addAll(HtmlNode[] nodes) {
        nodes.each { this.add(it) }
        return this
    }
    
    HtmlNode replaceWith(HtmlNode node) {
        i := this._parent?._nodes?.indexSame(this)
             this._parent?._nodes?.set(i, node)
        return node
    }
    
    This removeMe() {
        this._parent?._nodes?.removeSame(this)
        this._parent = null
        return this
    }
    
    This removeAllChildren() {
        _nodes.each { it._parent = null }
        _nodes.clear
        return this
    }
    
    abstract Void print(OutStream out)
    
    @NoDoc
    override Str toStr() { str := StrBuf(); print(str.out); return str.toStr }
}


@Js
class HtmlElem : HtmlNode {
    static const Str[] voidTags := "area base br col embed hr img input keygen link menuitem meta param source track wbr".split
    static const Str[] rawTags  := "script style textarea title".split

    private Str         _name
    private Str:Str?    attrs   := Str:Str?[:] { ordered = true}
    
    Str? id {
        get { this["id"] }
        set { this["id"] = it }
    }   

    Str? klass {
        get { this["class"] }
        set { this["class"] = it }
    }

    Str? title {
        get { this["title"] }
        set { this["title"] = it }
    }
    
    Str text {
        get {
            if (nodes.size == 1 && nodes.first is HtmlText)
                return ((HtmlText) nodes.first).getPlainText

            text := ""
            nodes.each |node| {
                if (node is HtmlElem)
                    text += ((HtmlElem) node).text
                if (node is HtmlText) {
                    text += ((HtmlText) node).getPlainText
                }
            }
            return text
        }
        set {
            nodes.clear
            nodes.add(HtmlText(it))
        }
    }
    
    new make(Str name, Str? cssClass := null) {
        this._name = name.lower.trim
        if (cssClass != null)
            addClass(cssClass)
    }
    
    Str name() { _name }
    
    This rename(Str newName) {
        _name = newName
        return this
    }

    ** Gets an attribue value
    @Operator
    Str? get(Str attr) {
        attrs[attr]
    }

    ** Sets an attribute value. Empty strings for name only attrs.
    @Operator
    HtmlElem set(Str attr, Obj? val) {
        if (val is Uri)
            val = ((Uri) val).encode
        attrs[attr] = val

        if (val == "")
            attrs[attr] = null
        // I know null should really indicate a name-only attr,
        // but that messes with my nice getters / setters for id / class
        // and *could* also be un-expected behaviour
        if (val == null)
            attrs.remove(attr)
        return this
    }
    
    Uri? getUri(Str attr) {
        val := attrs[attr]
        return val == null ? null : Uri.decode(val, false)
    }
    
    This addClass(Str cssClass) {
        if (cssClass.isEmpty) return this
        klass = (klass?.plus(" ") ?: "") + cssClass
        return this
    }
    
    This removeClass(Str cssClass) {
        if (klass == null || cssClass.isEmpty) return this
        klass = klass.split.removeAll(cssClass.split).join(" ")
        return this
    }
    
    This addText(Str text) {
        add(HtmlText(text))
    }
    
    This addHtml(Str text) {
        add(HtmlText(text, true))
    }
    
    ** Returns 'true' if this is a 'Void' element.
    ** See [Void elements]`https://html.spec.whatwg.org/multipage/syntax.html#void-elements`.
    Bool isVoid() {
        voidTags.contains(name)
    }
    
    ** Returns 'true' if this is a 'Raw Text' element.
    ** See [Raw text elements]`https://html.spec.whatwg.org/multipage/syntax.html#raw-text-elements`.
    Bool isRawText() {
        rawTags.contains(name)
    }
    
    @NoDoc
    override Void print(OutStream out) {
        if (isVoid && nodes.size > 0)
            typeof.pod.log.warn("Void tag '${name}' *MUST NOT* have content!") 

        mod := OutStream.xmlEscNewlines + OutStream.xmlEscQuotes
        out.writeChar('<').writeXml(name, mod)
        if (attrs.size > 0) {
            attrKeys := attrs.keys
            for (i := 0; i < attrKeys.size; ++i) {
                key := attrKeys[i]
                val := attrs[key]
                out.writeChar(' ').writeXml(key, mod)
                if (val != null)
                    out.writeChar('=').writeChar('"').writeXml(val.toStr, mod).writeChar('"')
            }
        }
        out.writeChar('>')
        
        for (i := 0; i < nodes.size; ++i) {
            node := nodes[i]
            node.print(out)
        }

        // interestingly, HTML does NOT allow self-closing tags (that's just XML)
        // instead it just lets Void tags omit their end tag
        if (isVoid == false || nodes.size > 0)
            out.writeChar('<').writeChar('/').writeXml(name, mod).writeChar('>')
    }
}


@Js
class HtmlText : HtmlNode {
    Str     text
    Bool    isHtml
    
    new make(Str text, Bool isHtml := false) {
        this.text   = text
        this.isHtml = isHtml
    }
    
    Str getPlainText() {
        isHtml ? "" : text
    }

    @NoDoc
    override Void print(OutStream out) {
        if (text.isEmpty) return
        if (isHtml || parent?.elem?.isRawText == true) out.writeChars(text); else out.writeXml(text)
    }
}

//@NoDoc
//class HtmlConditional : HtmlNode {
//  Str? condition
//  private HtmlNode[] nodes := [,]
//  
//  new make(|This| f) { f(this) }
//
//  new makeWithCondition(Str? condition, |This|? in := null) {
//      this.condition = condition.trim
//      in?.call(this)
//  }
//
//  @Operator
//  This add(HtmlNode node) {
//      nodes.add(node)
//      return this
//  }
//
//  internal HtmlElem? content() {
//      return (nodes.size == 1 && nodes.first is HtmlElem) ? nodes.first : null
//  }
//  
//  @NoDoc
//  override internal Void print(OutStream out) {
//      str := Str.defVal
//      
//      if (condition != null)
//          str += "<!--[${condition.toXml}]>"
//      
//      str += nodes.join(Str.defVal) { it.print().trim }
//      
//      if (condition != null)
//          str += "<![endif]-->"
//
//      return str
//  }
//}
//
//@NoDoc
//class HtmlComment : HtmlNode {
//  private Str comment
//  
//  new make(Str comment) {
//      this.comment = comment
//  }
//
//  @NoDoc
//  override internal Void print(OutStream out) {
//      return "<!-- ${comment.toXml} -->"
//  }
//}