package jsbundler // Go-native Solid JSX compiler (replaces babel-preset-solid running in goja). // // Pipeline: esbuild strips TS types (JSX preserved), then this package parses the // JSX trees out of the JS and rewrites each into Solid's dom-expressions runtime // output (template cloning + fine-grained _$insert/_$effect/_$createComponent). // JS expressions inside `{...}` are captured as opaque strings and, where they // may contain nested JSX, recompiled recursively — so we never need a full JS // parser, only a JSX-aware scanner. // // This file is the PARSER (JSX text -> tree). Codegen lives in compile_solid_gen.go. import ( "fmt" "strings" ) type jsxKind int const ( jsxElement jsxKind = iota // lowercase tag -> real DOM element (templated) jsxComponent // Capitalized/dotted tag -> _$createComponent jsxFragment // <>... jsxText // literal character data between tags jsxExpr // {expr} — raw JS, may itself contain JSX ) type attrKind int const ( attrStatic attrKind = iota // name="literal" or bare boolean attrExpr // name={expr} attrSpread // {...expr} ) type jsxAttr struct { kind attrKind name string value string // literal string value (attrStatic with a value) expr string // JS expression (attrExpr) or spread source (attrSpread) boolt bool // bare boolean attribute (attrStatic, no `=`) } type jsxNode struct { kind jsxKind tag string attrs []jsxAttr children []jsxNode text string // jsxText expr string // jsxExpr (raw, may contain nested JSX) marker bool // codegen: this dynamic child needs a `` insert anchor } // parseJSX parses a JSX element/fragment beginning at src[i] == '<'. It returns // the node and the index just past the element's closing `>`. func parseJSX(src string, i int) (jsxNode, int, error) { n := len(src) if i >= n || src[i] != '<' { return jsxNode{}, 0, fmt.Errorf("parseJSX: expected '<' at %d", i) } i++ // consume '<' // Fragment: <> ... if i < n && src[i] == '>' { i++ children, ci, err := parseChildren(src, i) if err != nil { return jsxNode{}, 0, err } i, err = consumeCloseTag(src, ci) if err != nil { return jsxNode{}, 0, err } return jsxNode{kind: jsxFragment, children: children}, i, nil } // Tag name. start := i for i < n && isTagChar(src[i]) { i++ } if i == start { return jsxNode{}, 0, fmt.Errorf("parseJSX: empty tag name at %d", start) } node := jsxNode{tag: src[start:i]} if isComponentTag(node.tag) { node.kind = jsxComponent } else { node.kind = jsxElement } attrs, ai, selfClose, err := parseAttrs(src, i) if err != nil { return jsxNode{}, 0, err } node.attrs = attrs i = ai if selfClose { return node, i, nil } children, ci, err := parseChildren(src, i) if err != nil { return jsxNode{}, 0, err } node.children = children i, err = consumeCloseTag(src, ci) if err != nil { return jsxNode{}, 0, err } return node, i, nil } // parseAttrs parses attributes after the tag name until `>` or `/>`. It returns // the attrs, the index past the terminator, and whether the tag self-closed. func parseAttrs(src string, i int) ([]jsxAttr, int, bool, error) { n := len(src) var attrs []jsxAttr for i < n { for i < n && isSpace(src[i]) { i++ } if i >= n { break } switch { case src[i] == '>': return attrs, i + 1, false, nil case src[i] == '/' && i+1 < n && src[i+1] == '>': return attrs, i + 2, true, nil case src[i] == '{': // {...spread} expr, ni := captureBraces(src, i) e := strings.TrimSpace(expr) e = strings.TrimSpace(strings.TrimPrefix(e, "...")) attrs = append(attrs, jsxAttr{kind: attrSpread, expr: e}) i = ni default: ns := i for i < n && isAttrNameChar(src[i]) { i++ } if i == ns { return nil, 0, false, fmt.Errorf("parseAttrs: unexpected %q at %d", src[i], i) } name := src[ns:i] for i < n && isSpace(src[i]) { i++ } if i < n && src[i] == '=' { i++ for i < n && isSpace(src[i]) { i++ } if i >= n { return nil, 0, false, fmt.Errorf("parseAttrs: attr value expected") } if src[i] == '{' { expr, ni := captureBraces(src, i) attrs = append(attrs, jsxAttr{kind: attrExpr, name: name, expr: strings.TrimSpace(expr)}) i = ni } else if src[i] == '"' || src[i] == '\'' { q := src[i] i++ vs := i for i < n && src[i] != q { i++ } attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, value: src[vs:i]}) if i < n { i++ // closing quote } } else { return nil, 0, false, fmt.Errorf("parseAttrs: bad attr value at %d", i) } } else { attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, boolt: true}) } } } return nil, 0, false, fmt.Errorf("parseAttrs: unterminated tag") } // parseChildren parses child nodes until the matching ` 0 { nodes = append(nodes, jsxNode{kind: jsxText, text: text.String()}) text.Reset() } } for i < n { c := src[i] switch { case c == '<' && i+1 < n && src[i+1] == '/': flush() return nodes, i, nil case c == '<': flush() child, ni, err := parseJSX(src, i) if err != nil { return nil, 0, err } nodes = append(nodes, child) i = ni case c == '{': flush() expr, ni := captureBraces(src, i) nodes = append(nodes, jsxNode{kind: jsxExpr, expr: strings.TrimSpace(expr)}) i = ni default: text.WriteByte(c) i++ } } return nil, 0, fmt.Errorf("parseChildren: unterminated (missing close tag)") } // consumeCloseTag consumes `` (or ``) starting at src[i] == '<'. func consumeCloseTag(src string, i int) (int, error) { n := len(src) if i+1 >= n || src[i] != '<' || src[i+1] != '/' { return 0, fmt.Errorf("consumeCloseTag: expected '' { i++ } if i >= n { return 0, fmt.Errorf("consumeCloseTag: unterminated close tag") } return i + 1, nil // past '>' } // captureBraces returns the text between a `{` at src[i] and its matching `}` // (exclusive), and the index just past that `}`. It tracks strings, template // literals (with `${}` interpolation), comments, and regex literals so braces // inside them don't miscount — the same lexer the segmenter uses. func captureBraces(src string, i int) (inner string, next int) { n := len(src) start := i + 1 i++ // skip opening '{' depth := 0 state := stNormal var tmplStack []int var prevSig byte for ; i < n; i++ { c := src[i] switch state { case stNormal: switch c { case '/': if i+1 < n && src[i+1] == '/' { state = stLineComment i++ continue } if i+1 < n && src[i+1] == '*' { state = stBlockComment i++ continue } if regexAllowed(src, i, prevSig) { state = stRegex prevSig = c continue } prevSig = c case '\'': state = stSingle prevSig = c case '"': state = stDouble prevSig = c case '`': state = stTemplate prevSig = c case '{', '(', '[': depth++ prevSig = c case '}': if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] { tmplStack = tmplStack[:len(tmplStack)-1] depth-- state = stTemplate } else if depth > 0 { depth-- prevSig = c } else { return src[start:i], i + 1 // matching close of the outer '{' } case ')', ']': if depth > 0 { depth-- } prevSig = c case '<': // A `<` in expression position (and followed by a tag/fragment // start) opens nested JSX, not a less-than: skip the whole element // via parseJSX so its `` slashes and `{}` don't desync the JS // lexer. Otherwise it's the comparison operator. if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') { if _, ni, err := parseJSX(src, i); err == nil { i = ni - 1 // loop's i++ lands just past the element prevSig = '>' continue } } prevSig = c default: if !isSpace(c) { prevSig = c } } case stLineComment: if c == '\n' { state = stNormal } case stBlockComment: if c == '*' && i+1 < n && src[i+1] == '/' { state = stNormal i++ } case stSingle: if c == '\\' { i++ } else if c == '\'' { state = stNormal prevSig = c } case stDouble: if c == '\\' { i++ } else if c == '"' { state = stNormal prevSig = c } case stTemplate: if c == '\\' { i++ } else if c == '`' { state = stNormal prevSig = c } else if c == '$' && i+1 < n && src[i+1] == '{' { depth++ tmplStack = append(tmplStack, depth) state = stNormal i++ } case stRegex: if c == '\\' { i++ } else if c == '[' { for i++; i < n; i++ { if src[i] == '\\' { i++ continue } if src[i] == ']' { break } } } else if c == '/' { state = stNormal prevSig = c } } } return src[start:], n // unterminated } func isComponentTag(tag string) bool { if tag == "" { return false } if strings.ContainsAny(tag, ".") { return true // member expression component, e.g. } c := tag[0] return c >= 'A' && c <= 'Z' } func isTagChar(b byte) bool { return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == '.' || b == ':' || b == '_' } func isAttrNameChar(b byte) bool { return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == ':' || b == '_' } func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } // isASCIILetter used to live in tailwind.go, which the Solid compiler happened to // share a package with. It is a lexer helper, not a Tailwind one, so it moved here // with the code that actually uses it when the Tailwind engine was factored out into // kjol/tw. func isASCIILetter(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') }