// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom import ( "encoding/json" "errors" "fmt" "io" "strings" "github.com/wavetermdev/htmltoken" "github.com/wavetermdev/waveterm/pkg/vdom/cssparser" ) // can tokenize and bind HTML to Elems const Html_BindPrefix = "#bind:" const Html_ParamPrefix = "#param:" const Html_GlobalEventPrefix = "#globalevent" const Html_BindParamTagName = "bindparam" const Html_BindTagName = "bind" func appendChildToStack(stack []*VDomElem, child *VDomElem) { if child == nil { return } if len(stack) == 0 { return } parent := stack[len(stack)-1] parent.Children = append(parent.Children, *child) } func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem { if elem == nil { return stack } return append(stack, elem) } func popElemStack(stack []*VDomElem) []*VDomElem { 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 []*VDomElem) string { if len(stack) == 0 { return "" } return stack[len(stack)-1].Tag } func finalizeStack(stack []*VDomElem) *VDomElem { 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 } // returns value, isjson func getAttrString(token htmltoken.Token, key string) string { for _, attr := range token.Attr { if attr.Key == key { return attr.Val } } return "" } func attrToProp(attrVal string, isJson bool, params map[string]any) any { if isJson { var val any err := json.Unmarshal([]byte(attrVal), &val) if err != nil { return nil } unmStrVal, ok := val.(string) if !ok { return val } attrVal = unmStrVal // fallthrough using the json str val } if strings.HasPrefix(attrVal, Html_ParamPrefix) { bindKey := attrVal[len(Html_ParamPrefix):] bindVal, ok := params[bindKey] if !ok { return nil } return bindVal } if strings.HasPrefix(attrVal, Html_BindPrefix) { bindKey := attrVal[len(Html_BindPrefix):] if bindKey == "" { return nil } return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} } if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) { splitArr := strings.Split(attrVal, ":") if len(splitArr) < 2 { return nil } eventName := splitArr[1] if eventName == "" { return nil } return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName} } return attrVal } func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { elem := &VDomElem{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 } propVal := attrToProp(attr.Val, attr.IsJson, params) elem.Props[attr.Key] = propVal } 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 makePathStr(elemPath []string) string { return strings.Join(elemPath, " ") } func capitalizeAscii(s string) string { if s == "" || s[0] < 'a' || s[0] > 'z' { return s } return strings.ToUpper(s[:1]) + s[1:] } func toReactName(input string) string { // Check for CSS custom properties (variables) which start with '--' if strings.HasPrefix(input, "--") { return input } parts := strings.Split(input, "-") result := "" index := 0 if parts[0] == "" && len(parts) > 1 { // handle vendor prefixes prefix := parts[1] if prefix == "ms" { result += "ms" } else { result += capitalizeAscii(prefix) } index = 2 // Skip the empty string and prefix } else { result += parts[0] index = 1 } // Convert remaining parts to CamelCase for ; index < len(parts); index++ { if parts[index] != "" { result += capitalizeAscii(parts[index]) } } return result } func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any { if len(styleMap) == 0 { return nil } rtn := make(map[string]any) for key, val := range styleMap { rtn[toReactName(key)] = attrToProp(val, false, params) } return rtn } func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error { styleText, ok := elem.Props["style"].(string) if !ok { return nil } parser := cssparser.MakeParser(styleText) m, err := parser.Parse() if err != nil { return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) } elem.Props["style"] = convertStyleToReactStyles(m, params) return nil } func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) { if elem == nil { return } // call fixStyleAttribute, and walk children elemCountMap := make(map[string]int) if len(elemPath) == 0 { elemPath = append(elemPath, elem.Tag) } fixStyleAttribute(elem, params, elemPath) for i := range elem.Children { child := &elem.Children[i] elemCountMap[child.Tag]++ subPath := child.Tag if elemCountMap[child.Tag] > 1 { subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag]) } elemPath = append(elemPath, subPath) fixupStyleAttributes(&elem.Children[i], params, elemPath) elemPath = elemPath[:len(elemPath)-1] } } func Bind(htmlStr string, params map[string]any) *VDomElem { htmlStr = processWhitespace(htmlStr) r := strings.NewReader(htmlStr) iter := htmltoken.NewTokenizer(r) var elemStack []*VDomElem elemStack = append(elemStack, &VDomElem{Tag: FragmentTag}) var tokenErr error outer: for { tokenType := iter.Next() token := iter.Token() switch tokenType { case htmltoken.StartTagToken: if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { tokenErr = errors.New("bind tags must be self closing") break outer } elem := tokenToElem(token, params) elemStack = pushElemStack(elemStack, elem) case htmltoken.EndTagToken: if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { tokenErr = errors.New("bind tags 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 == Html_BindParamTagName { keyAttr := getAttrString(token, "key") dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { appendChildToStack(elemStack, &elem) } continue } if token.Data == Html_BindTagName { keyAttr := getAttrString(token, "key") binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) continue } elem := tokenToElem(token, params) 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) } rtn := finalizeStack(elemStack) fixupStyleAttributes(rtn, params, nil) return rtn }