Files
kjol/go/webbundler/compile_solid.go

408 lines
10 KiB
Go

package webbundler
// 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 `</`. It returns the nodes
// and the index at the `<` of the closing tag. Adjacent character data becomes a
// single jsxText node (JSX whitespace normalization happens in codegen).
func parseChildren(src string, i int) ([]jsxNode, int, error) {
n := len(src)
var nodes []jsxNode
var text strings.Builder
flush := func() {
if text.Len() > 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 `</name>` (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 '</' at %d", i)
}
i += 2
for i < n && src[i] != '>' {
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 `</tag>` 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. <Foo.Bar>
}
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')
}