400 lines
9.8 KiB
Go
400 lines
9.8 KiB
Go
package bundler
|
|
|
|
// 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'
|
|
}
|