waveterm/pkg/vdom/vdom_html.go

254 lines
5.2 KiB
Go

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdom
import (
"errors"
"fmt"
"io"
"strings"
"github.com/wavetermdev/htmltoken"
)
// can tokenize and bind HTML to Elems
func appendChildToStack(stack []*Elem, child *Elem) {
if child == nil {
return
}
if len(stack) == 0 {
return
}
parent := stack[len(stack)-1]
parent.Children = append(parent.Children, *child)
}
func pushElemStack(stack []*Elem, elem *Elem) []*Elem {
if elem == nil {
return stack
}
return append(stack, elem)
}
func popElemStack(stack []*Elem) []*Elem {
if len(stack) <= 1 {
return stack
}
curElem := stack[len(stack)-1]
appendChildToStack(stack[:len(stack)-1], curElem)
return stack[:len(stack)-1]
}
func curElemTag(stack []*Elem) string {
if len(stack) == 0 {
return ""
}
return stack[len(stack)-1].Tag
}
func finalizeStack(stack []*Elem) *Elem {
if len(stack) == 0 {
return nil
}
for len(stack) > 1 {
stack = popElemStack(stack)
}
rtnElem := stack[0]
if len(rtnElem.Children) == 0 {
return nil
}
if len(rtnElem.Children) == 1 {
return &rtnElem.Children[0]
}
return rtnElem
}
func getAttr(token htmltoken.Token, key string) string {
for _, attr := range token.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
func tokenToElem(token htmltoken.Token, data map[string]any) *Elem {
elem := &Elem{Tag: token.Data}
if len(token.Attr) > 0 {
elem.Props = make(map[string]any)
}
for _, attr := range token.Attr {
if attr.Key == "" || attr.Val == "" {
continue
}
if strings.HasPrefix(attr.Val, "#bind:") {
bindKey := attr.Val[6:]
bindVal, ok := data[bindKey]
if !ok {
continue
}
elem.Props[attr.Key] = bindVal
continue
}
elem.Props[attr.Key] = attr.Val
}
return elem
}
func isWsChar(char rune) bool {
return char == ' ' || char == '\t' || char == '\n' || char == '\r'
}
func isWsByte(char byte) bool {
return char == ' ' || char == '\t' || char == '\n' || char == '\r'
}
func isFirstCharLt(s string) bool {
for _, char := range s {
if isWsChar(char) {
continue
}
return char == '<'
}
return false
}
func isLastCharGt(s string) bool {
for i := len(s) - 1; i >= 0; i-- {
char := s[i]
if isWsByte(char) {
continue
}
return char == '>'
}
return false
}
func isAllWhitespace(s string) bool {
for _, char := range s {
if !isWsChar(char) {
return false
}
}
return true
}
func trimWhitespaceConditionally(s string) string {
// Trim leading whitespace if the first non-whitespace character is '<'
if isAllWhitespace(s) {
return ""
}
if isFirstCharLt(s) {
s = strings.TrimLeftFunc(s, func(r rune) bool {
return isWsChar(r)
})
}
// Trim trailing whitespace if the last non-whitespace character is '>'
if isLastCharGt(s) {
s = strings.TrimRightFunc(s, func(r rune) bool {
return isWsChar(r)
})
}
return s
}
func processWhitespace(htmlStr string) string {
lines := strings.Split(htmlStr, "\n")
var newLines []string
for _, line := range lines {
trimmedLine := trimWhitespaceConditionally(line + "\n")
if trimmedLine == "" {
continue
}
newLines = append(newLines, trimmedLine)
}
return strings.Join(newLines, "")
}
func processTextStr(s string) string {
if s == "" {
return ""
}
if isAllWhitespace(s) {
return " "
}
return strings.TrimSpace(s)
}
func Bind(htmlStr string, data map[string]any) *Elem {
htmlStr = processWhitespace(htmlStr)
r := strings.NewReader(htmlStr)
iter := htmltoken.NewTokenizer(r)
var elemStack []*Elem
elemStack = append(elemStack, &Elem{Tag: FragmentTag})
var tokenErr error
outer:
for {
tokenType := iter.Next()
token := iter.Token()
switch tokenType {
case htmltoken.StartTagToken:
if token.Data == "bind" {
tokenErr = errors.New("bind tag must be self closing")
break outer
}
elem := tokenToElem(token, data)
elemStack = pushElemStack(elemStack, elem)
case htmltoken.EndTagToken:
if token.Data == "bind" {
tokenErr = errors.New("bind tag must be self closing")
break outer
}
if len(elemStack) <= 1 {
tokenErr = fmt.Errorf("end tag %q without start tag", token.Data)
break outer
}
if curElemTag(elemStack) != token.Data {
tokenErr = fmt.Errorf("end tag %q does not match start tag %q", token.Data, curElemTag(elemStack))
break outer
}
elemStack = popElemStack(elemStack)
case htmltoken.SelfClosingTagToken:
if token.Data == "bind" {
keyAttr := getAttr(token, "key")
dataVal := data[keyAttr]
elemList := partToElems(dataVal)
for _, elem := range elemList {
appendChildToStack(elemStack, &elem)
}
continue
}
elem := tokenToElem(token, data)
appendChildToStack(elemStack, elem)
case htmltoken.TextToken:
if token.Data == "" {
continue
}
textStr := processTextStr(token.Data)
if textStr == "" {
continue
}
elem := TextElem(textStr)
appendChildToStack(elemStack, &elem)
case htmltoken.CommentToken:
continue
case htmltoken.DoctypeToken:
tokenErr = errors.New("doctype not supported")
break outer
case htmltoken.ErrorToken:
if iter.Err() == io.EOF {
break outer
}
tokenErr = iter.Err()
break outer
}
}
if tokenErr != nil {
errTextElem := TextElem(tokenErr.Error())
appendChildToStack(elemStack, &errTextElem)
}
return finalizeStack(elemStack)
}