Files
kjol/go/bundler/compile_solid_gen.go

1039 lines
28 KiB
Go

package bundler
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
//
// Strategy (behavior-parity with babel-preset-solid, not byte-parity):
// - Static element structure is baked into an HTML template string cloned at
// runtime via _$template(); we always quote attrs and emit closing tags
// (valid HTML the browser parses to the same DOM babel's terser template does).
// - Dynamic children -> _$insert(parent, () => expr, marker). Every dynamic
// expression is wrapped in a thunk: correct and (for static exprs) merely an
// extra no-op effect. babel unwraps `f()` -> `f` as an optimization; we skip
// that for now (behavior-identical).
// - Dynamic attrs -> _$effect(() => _$setAttribute(el, name, expr)).
// - Components -> _$createComponent(Tag, props) with reactive prop getters.
import (
"fmt"
"regexp"
"sort"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
type solidGen struct {
templates []string // template HTML strings in first-seen order
tmplVars []string // parallel _tmpl$N names
tmplByStr map[string]string // dedupe identical templates
helpers map[string]bool // solid-js/web helpers used
events map[string]bool // delegated event names
source string // the module source, for ref binding-kind lookup
tmplN int
}
func newSolidGen() *solidGen {
return &solidGen{tmplByStr: map[string]string{}, helpers: map[string]bool{}, events: map[string]bool{}}
}
func (g *solidGen) helper(name string) string {
g.helpers[name] = true
return "_$" + name
}
func (g *solidGen) template(html string) string {
if v, ok := g.tmplByStr[html]; ok {
return v
}
g.tmplN++
v := "_tmpl$"
if g.tmplN > 1 {
v = fmt.Sprintf("_tmpl$%d", g.tmplN)
}
g.tmplByStr[html] = v
g.templates = append(g.templates, html)
g.tmplVars = append(g.tmplVars, v)
g.helper("template")
return v
}
// compileSolidGo compiles a TSX source to Solid dom-expressions output. In dev
// mode each component is wrapped with solid-refresh so the module is an HMR
// boundary; prod (dev=false) leaves components bare (that's what SSR renders).
func compileSolidGo(src, _ string, dev bool) (string, error) {
stripped, err := stripTypesPreserveJSX(src)
if err != nil {
return "", err
}
refresh := false
if dev {
stripped, refresh = wrapComponents(stripped)
}
g := newSolidGen()
g.source = stripped
body := g.transformJSX(stripped)
var b strings.Builder
for _, h := range sortedKeys(g.helpers) {
fmt.Fprintf(&b, "import { %s as _$%s } from \"solid-js/web\";\n", h, h)
}
if refresh {
b.WriteString(`import { $$component as _$$component } from "solid-refresh";` + "\n")
b.WriteString(`import { $$registry as _$$registry } from "solid-refresh";` + "\n")
b.WriteString(`import { $$refresh as _$$refresh } from "solid-refresh";` + "\n")
b.WriteString("const _REGISTRY = _$$registry();\n")
}
for i, t := range g.templates {
fmt.Fprintf(&b, "var %s = /*#__PURE__*/_$template(`%s`);\n", g.tmplVars[i], t)
}
b.WriteString(body)
if refresh {
b.WriteString("if (import.meta.hot) { _$$refresh(\"esm\", import.meta.hot, _REGISTRY); }\n")
}
if len(g.events) > 0 {
evs := sortedKeys(g.events)
q := make([]string, len(evs))
for i, e := range evs {
q[i] = `"` + e + `"`
}
fmt.Fprintf(&b, "_$delegateEvents([%s]);\n", strings.Join(q, ", "))
}
return b.String(), nil
}
// wrapComponents rewrites each top-level component declaration into a
// solid-refresh registry entry: `function A(){…}` → `const A =
// _$$component(_REGISTRY, "A", function A(){…});`. A declaration is a component
// if it's a PascalCase function (declaration or arrow/function const) whose body
// contains JSX. Returns the rewritten source and whether any component was found.
func wrapComponents(src string) (string, bool) {
var out strings.Builder
found := false
for _, ch := range segmentTopLevel(src) {
if rw, ok := rewrapComponent(ch); ok {
found = true
out.WriteString(rw)
} else {
out.WriteString(ch)
}
}
return out.String(), found
}
var (
reCompFunc = regexp.MustCompile(`^(export\s+)?(default\s+)?function\s+([A-Z][\w$]*)`)
reCompConst = regexp.MustCompile(`^(export\s+)?(?:const|let|var)\s+([A-Z][\w$]*)\s*=\s*`)
)
func rewrapComponent(chunk string) (string, bool) {
trimmed := strings.TrimSpace(chunk)
// A top-level `function Foo` is a component when its body contains JSX.
if m := reCompFunc.FindStringSubmatch(trimmed); m != nil {
if !containsJSX(trimmed) {
return chunk, false
}
name := m[3]
fn := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, m[1]), m[2]))
wrap := fmt.Sprintf(`_$$component(_REGISTRY, %q, %s)`, name, fn)
switch {
case m[2] != "": // export default function
return fmt.Sprintf("const %s = %s;\nexport default %s;\n", name, wrap, name), true
case m[1] != "": // export function
return fmt.Sprintf("export const %s = %s;\n", name, wrap), true
default:
return fmt.Sprintf("const %s = %s;\n", name, wrap), true
}
}
// A PascalCase const is a component when its initializer is a function
// (inline JSX) OR a factory call, e.g. `const AlertGreen = makeAlert("green")`.
if m := reCompConst.FindStringSubmatch(trimmed); m != nil {
name := m[2]
expr := strings.TrimSuffix(strings.TrimSpace(trimmed[len(m[0]):]), ";")
if !containsJSX(trimmed) && !isComponentInitializer(expr) {
return chunk, false
}
wrap := fmt.Sprintf(`_$$component(_REGISTRY, %q, %s)`, name, expr)
prefix := ""
if m[1] != "" {
prefix = "export "
}
return fmt.Sprintf("%sconst %s = %s;\n", prefix, name, wrap), true
}
return chunk, false
}
var reCompInit = regexp.MustCompile(`^[A-Za-z_$][\w$.]*\s*(\(|=>)`)
// isComponentInitializer reports whether a const initializer is a function or a
// factory call producing a component (arrow, function expr, `id(...)`, `id => …`).
// Plain values ({…}, "…", [ … ], numbers) are excluded.
func isComponentInitializer(e string) bool {
e = strings.TrimSpace(e)
return strings.HasPrefix(e, "(") || strings.HasPrefix(e, "function ") ||
strings.HasPrefix(e, "async ") || reCompInit.MatchString(e)
}
// containsJSX reports whether src has a JSX element in expression position,
// skipping strings/comments so a `<` inside them isn't counted.
func containsJSX(src string) bool {
n := len(src)
state := stNormal
var prevSig byte
for i := 0; i < n; i++ {
c := src[i]
switch state {
case stNormal:
switch c {
case '/':
if i+1 < n && src[i+1] == '/' {
state = stLineComment
continue
}
if i+1 < n && src[i+1] == '*' {
state = stBlockComment
continue
}
prevSig = c
case '\'':
state = stSingle
case '"':
state = stDouble
case '`':
state = stTemplate
case '<':
if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') {
return true
}
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
}
}
}
return false
}
func stripTypesPreserveJSX(src string) (string, error) {
res := esbuild.Transform(src, esbuild.TransformOptions{
Loader: esbuild.LoaderTSX,
JSX: esbuild.JSXPreserve,
Format: esbuild.FormatESModule,
LogLevel: esbuild.LogLevelSilent,
})
if len(res.Errors) > 0 {
return "", fmt.Errorf("%s", res.Errors[0].Text)
}
return string(res.Code), nil
}
// transformJSX scans JS, finds JSX in expression position, and replaces each with
// its compiled expression. Non-JSX text passes through. Same lexer as
// captureBraces so `<` inside strings/comments/regex isn't mistaken for JSX.
func (g *solidGen) transformJSX(src string) string {
var out strings.Builder
n := len(src)
state := stNormal
var tmplStack []int
var prevSig byte
depth := 0
for i := 0; i < n; i++ {
c := src[i]
switch state {
case stNormal:
switch c {
case '/':
if i+1 < n && src[i+1] == '/' {
state = stLineComment
out.WriteByte(c)
continue
}
if i+1 < n && src[i+1] == '*' {
state = stBlockComment
out.WriteByte(c)
continue
}
if regexAllowed(src, i, prevSig) {
state = stRegex
}
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
}
case ')', ']':
if depth > 0 {
depth--
}
prevSig = c
case '<':
if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') {
if node, ni, err := parseJSX(src, i); err == nil {
out.WriteString(g.genNode(&node))
i = ni - 1
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
out.WriteByte(c)
i++
out.WriteByte(src[i])
continue
}
case stSingle:
if c == '\\' {
out.WriteByte(c)
i++
if i < n {
out.WriteByte(src[i])
}
continue
} else if c == '\'' {
state = stNormal
prevSig = c
}
case stDouble:
if c == '\\' {
out.WriteByte(c)
i++
if i < n {
out.WriteByte(src[i])
}
continue
} else if c == '"' {
state = stNormal
prevSig = c
}
case stTemplate:
if c == '\\' {
out.WriteByte(c)
i++
if i < n {
out.WriteByte(src[i])
}
continue
} else if c == '`' {
state = stNormal
prevSig = c
} else if c == '$' && i+1 < n && src[i+1] == '{' {
depth++
tmplStack = append(tmplStack, depth)
state = stNormal
out.WriteByte(c)
i++
out.WriteByte(src[i])
continue
}
case stRegex:
if c == '\\' {
out.WriteByte(c)
i++
if i < n {
out.WriteByte(src[i])
}
continue
} else if c == '/' {
state = stNormal
prevSig = c
}
}
out.WriteByte(c)
}
return out.String()
}
// compileExpr recompiles any JSX nested inside an opaque expression string.
func (g *solidGen) compileExpr(expr string) string {
if !strings.Contains(expr, "<") {
return expr
}
return g.transformJSX(expr)
}
// genNode compiles a top-level JSX node into a JS expression.
func (g *solidGen) genNode(node *jsxNode) string {
switch node.kind {
case jsxElement:
return g.genElement(node)
case jsxComponent:
return g.genComponent(node)
case jsxFragment:
return g.genFragment(node)
case jsxExpr:
return g.compileExpr(node.expr)
case jsxText:
return jsStringLit(collapseText(node.text))
}
return "null"
}
// iife accumulates the navigation var decls and operation statements for one
// element's cloned template.
type iife struct {
g *solidGen
elN int
decls []string
ops []string
}
func (c *iife) nextEl() string {
c.elN++
if c.elN == 1 {
return "_el$"
}
return fmt.Sprintf("_el$%d", c.elN)
}
func (g *solidGen) genElement(node *jsxNode) string {
annotateMarkers(node)
tv := g.template(buildTemplate(node))
c := &iife{g: g}
root := c.nextEl()
c.decls = append(c.decls, root+" = "+tv+"()")
c.walk(node, root)
if len(c.ops) == 0 {
return tv + "()"
}
var b strings.Builder
b.WriteString("(() => { var " + strings.Join(c.decls, ", ") + "; ")
for _, op := range c.ops {
b.WriteString(op + "; ")
}
b.WriteString("return " + root + "; })()")
return b.String()
}
// walk emits attribute/event ops for node (referenced by ref) and recurses into
// children, navigating to those that need a DOM reference.
func (c *iife) walk(node *jsxNode, ref string) {
if hasSpread(node) {
c.spread(ref, node) // spread subsumes every attr; none are baked
} else {
for _, a := range node.attrs {
c.attr(ref, a)
}
}
// A 2-arg _$insert(parent, value) REPLACES all of parent's content, so it's
// only valid when the dynamic child is parent's sole content. With static or
// multiple children, every dynamic child needs a 3-arg insert whose marker is
// the next DOM node (or null to append without clearing).
contentCount := contentChildCount(node)
childRefs := c.assignChildRefs(node, ref)
for i := range node.children {
ch := &node.children[i]
switch ch.kind {
case jsxElement:
if r := childRefs[i]; r != "" {
c.walk(ch, r)
}
case jsxExpr, jsxComponent:
if ch.kind == jsxExpr && exprIsEmpty(ch.expr) {
continue // {/* comment */} / empty expression: nothing to insert
}
val := c.g.genNode(ch)
if ch.kind == jsxExpr {
val = wrapReactive(val)
}
if contentCount == 1 {
c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s)", c.g.helper("insert"), ref, val))
} else {
marker := "null"
if ch.marker {
marker = childRefs[i]
}
c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s, %s)", c.g.helper("insert"), ref, val, marker))
}
}
}
}
func contentChildCount(node *jsxNode) int {
count := 0
for i := range node.children {
if renderedChild(&node.children[i]) {
count++
}
}
return count
}
// renderedChild reports whether a child produces content — false for
// whitespace-only text and empty/comment-only JSX expressions ({/* ... */}).
func renderedChild(ch *jsxNode) bool {
switch ch.kind {
case jsxText:
return collapseText(ch.text) != ""
case jsxExpr:
return !exprIsEmpty(ch.expr)
case jsxElement, jsxComponent:
return true
}
return false
}
// exprIsEmpty reports whether a JSX expression is empty or only comments — a
// no-op like {} or {/* note */}, which Solid drops.
func exprIsEmpty(expr string) bool {
e := strings.TrimSpace(expr)
for e != "" {
if strings.HasPrefix(e, "/*") {
if i := strings.Index(e, "*/"); i >= 0 {
e = strings.TrimSpace(e[i+2:])
continue
}
}
if strings.HasPrefix(e, "//") {
if i := strings.IndexByte(e, '\n'); i >= 0 {
e = strings.TrimSpace(e[i+1:])
continue
}
e = ""
}
break
}
return e == ""
}
// assignChildRefs walks the DOM child sequence (static children + markers),
// assigning a nav var to each one that needs a reference, and returns a map from
// child index to its var ("" if none). Vars chain via firstChild/nextSibling.
func (c *iife) assignChildRefs(node *jsxNode, parentRef string) map[int]string {
refs := map[int]string{}
// Which children need a ref?
need := func(i int, ch *jsxNode) bool {
switch ch.kind {
case jsxElement:
return elementNeedsRef(ch)
case jsxExpr, jsxComponent:
return ch.marker // marker node is referenced as the insert anchor
}
return false
}
// Find the last DOM-producing child that needs a ref (waypoint boundary).
last := -1
for i := range node.children {
ch := &node.children[i]
if isDOMChild(ch) && need(i, ch) {
last = i
}
}
if last < 0 {
return refs
}
var prev string
for i := 0; i <= last; i++ {
ch := &node.children[i]
if !isDOMChild(ch) {
continue // dynamic child without a marker contributes no DOM node
}
var nav string
if prev == "" {
nav = parentRef + ".firstChild"
} else {
nav = prev + ".nextSibling"
}
v := c.nextEl()
c.decls = append(c.decls, v+" = "+nav)
refs[i] = v
prev = v
}
return refs
}
func (c *iife) attr(ref string, a jsxAttr) {
switch a.kind {
case attrStatic:
// baked into the template (nothing at runtime)
case attrExpr:
expr := c.g.compileExpr(a.expr)
switch {
case isEventAttr(a.name):
ev := strings.ToLower(a.name[2:])
c.ops = append(c.ops, fmt.Sprintf("%s.addEventListener(%q, %s)", ref, ev, expr))
case a.name == "ref":
// A ref that's a function/const (callback) or an inline function must
// only call _$use — emitting the `expr = el` assignment branch would be
// a static "assign to const" error even though it's dead at runtime.
// A mutable let/var (or member) gets the runtime typeof dispatch so the
// element can be assigned to it.
if isLValueExpr(expr) && !c.g.isConstBinding(expr) {
c.ops = append(c.ops, fmt.Sprintf("typeof %s === \"function\" ? %s(%s, %s) : %s = %s",
expr, c.g.helper("use"), expr, ref, expr, ref))
} else {
c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s)", c.g.helper("use"), expr, ref))
}
case a.name == "style":
// style accepts a string OR an object ({top: …}); _$style applies
// both (setProperty per key for objects), diffing against the previous
// value. Generic setAttribute would stringify an object to
// "[object Object]" and break positioning.
c.ops = append(c.ops, fmt.Sprintf("%s((_p$) => %s(%s, %s, _p$))",
c.g.helper("effect"), c.g.helper("style"), ref, expr))
case a.name == "classList":
c.ops = append(c.ops, fmt.Sprintf("%s((_p$) => %s(%s, %s, _p$))",
c.g.helper("effect"), c.g.helper("classList"), ref, expr))
case contentProps[a.name]:
// innerHTML/textContent/innerText are DOM properties, not reflected
// attributes — setAttribute would silently no-op (e.g. an <svg>'s
// innerHTML icon content would never appear). Assign the property.
c.ops = append(c.ops, fmt.Sprintf("%s(() => %s.%s = %s)",
c.g.helper("effect"), ref, a.name, expr))
default:
c.ops = append(c.ops, fmt.Sprintf("%s(() => %s(%s, %q, %s))",
c.g.helper("effect"), c.g.helper("setAttribute"), ref, a.name, expr))
}
}
}
// spread routes every attribute of an element with a {...} through
// _$spread(el, _$mergeProps(...), isSVG=false, skipChildren=true), preserving
// source order so later props override earlier ones (matching babel).
func (c *iife) spread(ref string, node *jsxNode) {
var args []string
var obj []string
flush := func() {
if len(obj) > 0 {
args = append(args, "{ "+strings.Join(obj, ", ")+" }")
obj = nil
}
}
for _, a := range node.attrs {
switch a.kind {
case attrSpread:
flush()
args = append(args, c.g.compileExpr(a.expr))
case attrStatic:
if a.boolt {
obj = append(obj, fmt.Sprintf("%s: true", propKey(a.name)))
} else {
obj = append(obj, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value)))
}
case attrExpr:
obj = append(obj, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), c.g.compileExpr(a.expr)))
}
}
flush()
props := args[0]
if len(args) > 1 {
props = fmt.Sprintf("%s(%s)", c.g.helper("mergeProps"), strings.Join(args, ", "))
}
c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s, false, true)", c.g.helper("spread"), ref, props))
}
func hasSpread(node *jsxNode) bool {
for _, a := range node.attrs {
if a.kind == attrSpread {
return true
}
}
return false
}
var reLValue = regexp.MustCompile(`^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$`)
func isLValueExpr(e string) bool { return reLValue.MatchString(strings.TrimSpace(e)) }
var reSimpleIdent = regexp.MustCompile(`^[A-Za-z_$][\w$]*$`)
// isConstBinding reports whether a simple-identifier ref target is a
// const/function (a ref callback that must never be assigned to). Only an
// explicit `let`/`var` declaration in the source makes it assignable; everything
// else (const, function, param, unknown) is treated as a callback — the safe
// default, since emitting an assignment to a const is a hard build error while
// use-only is always valid. Member expressions are assignable (return false).
func (g *solidGen) isConstBinding(name string) bool {
name = strings.TrimSpace(name)
if !reSimpleIdent.MatchString(name) {
return false
}
if regexp.MustCompile(`\b(?:let|var)\s+`+regexp.QuoteMeta(name)+`\b`).MatchString(g.source) {
return false
}
return true
}
func (g *solidGen) genComponent(node *jsxNode) string {
tag := node.tag
props := g.componentProps(node)
return fmt.Sprintf("%s(%s, %s)", g.helper("createComponent"), tag, props)
}
// componentProps builds the props object: static attrs as plain values, dynamic
// attrs as reactive getters, and children as a `children` prop.
func (g *solidGen) componentProps(node *jsxNode) string {
var parts []string
for _, a := range node.attrs {
switch a.kind {
case attrStatic:
if a.boolt {
parts = append(parts, fmt.Sprintf("%s: true", propKey(a.name)))
} else {
parts = append(parts, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value)))
}
case attrExpr:
parts = append(parts, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), g.compileExpr(a.expr)))
case attrSpread:
// milestone 3 (mergeProps)
}
}
if ch := g.childrenProp(node); ch != "" {
parts = append(parts, ch)
}
if len(parts) == 0 {
return "{}"
}
return "{ " + strings.Join(parts, ", ") + " }"
}
// childrenProp builds a component's `children` prop entry. JSX children are
// wrapped in a `get children()` accessor so they evaluate lazily inside the
// parent component's execution — essential for context providers (children must
// run after the parent sets context) and for correct reactivity. Static text has
// no such need and is passed as a plain value (matching babel).
func (g *solidGen) childrenProp(node *jsxNode) string {
var kids []*jsxNode
for i := range node.children {
ch := &node.children[i]
if !renderedChild(ch) {
continue // drop whitespace-only text and empty expressions
}
kids = append(kids, ch)
}
switch len(kids) {
case 0:
return ""
case 1:
if kids[0].kind == jsxText {
return "children: " + jsStringLit(collapseText(kids[0].text))
}
return "get children() { return " + g.genNode(kids[0]) + "; }"
default:
parts := make([]string, len(kids))
for i, k := range kids {
parts[i] = g.genNode(k)
}
return "get children() { return [" + strings.Join(parts, ", ") + "]; }"
}
}
func (g *solidGen) genFragment(node *jsxNode) string {
var kids []*jsxNode
for i := range node.children {
ch := &node.children[i]
if ch.kind == jsxText && strings.TrimSpace(ch.text) == "" {
continue
}
kids = append(kids, ch)
}
if len(kids) == 1 {
return g.genNode(kids[0])
}
parts := make([]string, len(kids))
for i, k := range kids {
if k.kind == jsxExpr {
parts[i] = fmt.Sprintf("%s(() => %s)", g.helper("memo"), g.compileExpr(k.expr))
} else {
parts[i] = g.genNode(k)
}
}
return "[" + strings.Join(parts, ", ") + "]"
}
// ---- template building -----------------------------------------------------
func buildTemplate(node *jsxNode) string {
var sb strings.Builder
writeTemplate(node, &sb)
return sb.String()
}
func writeTemplate(node *jsxNode, sb *strings.Builder) {
sb.WriteString("<" + node.tag)
if !hasSpread(node) { // with a spread, all attrs are applied at runtime
for _, a := range node.attrs {
if a.kind == attrStatic {
if a.boolt {
sb.WriteString(" " + a.name)
} else {
sb.WriteString(" " + a.name + `="` + a.value + `"`)
}
}
}
}
sb.WriteString(">")
for i := range node.children {
ch := &node.children[i]
switch ch.kind {
case jsxText:
sb.WriteString(escapeTemplateText(collapseText(ch.text)))
case jsxElement:
writeTemplate(ch, sb)
case jsxExpr, jsxComponent:
if ch.marker {
sb.WriteString("<!>")
}
}
}
sb.WriteString("</" + node.tag + ">")
}
// annotateMarkers sets node.marker on dynamic children that need a `<!>` anchor:
// those with a DOM-producing sibling after them. Recurses into element children.
func annotateMarkers(node *jsxNode) {
after := false
for i := len(node.children) - 1; i >= 0; i-- {
ch := &node.children[i]
switch ch.kind {
case jsxText:
if collapseText(ch.text) != "" {
after = true
}
case jsxElement:
after = true
annotateMarkers(ch)
case jsxExpr, jsxComponent:
if ch.kind == jsxExpr && exprIsEmpty(ch.expr) {
continue // empty/comment expression produces nothing
}
if after {
ch.marker = true // the <!> it emits is itself a DOM node
}
}
}
}
// ---- reactivity + helpers --------------------------------------------------
var reSimpleCall = regexp.MustCompile(`^[A-Za-z_$][\w$]*\(\)$`)
// wrapReactive wraps a child expression so _$insert treats it reactively. Bare
// accessor calls stay as-is via a thunk; correctness over babel's unwrap opt.
func wrapReactive(expr string) string {
e := strings.TrimSpace(expr)
if isStaticLiteral(e) {
return e
}
return "() => " + e
}
func isStaticLiteral(e string) bool {
if e == "true" || e == "false" || e == "null" || e == "undefined" {
return true
}
if len(e) >= 2 && (e[0] == '"' || e[0] == '\'' || e[0] == '`') {
return true
}
// number
allNum := true
for i := 0; i < len(e); i++ {
if !(e[i] >= '0' && e[i] <= '9' || e[i] == '.') {
allNum = false
break
}
}
return allNum && e != ""
}
// contentProps are DOM properties (not reflected attributes) that must be set by
// direct property assignment rather than setAttribute.
var contentProps = map[string]bool{"innerHTML": true, "textContent": true, "innerText": true}
func isEventAttr(name string) bool {
// An event handler is `on` followed by the event name; casing after `on` is
// irrelevant (onClick and onclick both mean click) — the name is lowercased
// before addEventListener. This matches solid-js/html's runtime, which keys
// off the `on` prefix alone, so .tsx and html`` templates agree. Require a
// letter so namespaced forms (on:click / oncapture:) fall through untouched.
return len(name) > 2 && name[0] == 'o' && name[1] == 'n' &&
((name[2] >= 'A' && name[2] <= 'Z') || (name[2] >= 'a' && name[2] <= 'z'))
}
// elementNeedsRef reports whether a static element needs a nav var: it has
// runtime ops (dynamic attrs/events/ref) or dynamic children (inserts).
func elementNeedsRef(node *jsxNode) bool {
for _, a := range node.attrs {
if a.kind != attrStatic {
return true
}
}
for i := range node.children {
ch := &node.children[i]
switch ch.kind {
case jsxExpr, jsxComponent:
return true
case jsxElement:
if elementNeedsRef(ch) {
return true
}
}
}
return false
}
func isDOMChild(ch *jsxNode) bool {
switch ch.kind {
case jsxText:
return collapseText(ch.text) != ""
case jsxElement:
return true
case jsxExpr, jsxComponent:
return ch.marker
}
return false
}
// collapseText applies JSX whitespace normalization: lines are trimmed and
// joined by a single space; text that is only whitespace-with-newline vanishes.
func collapseText(s string) string {
if strings.TrimSpace(s) == "" {
if strings.ContainsAny(s, "\n") {
return ""
}
return s // significant single-line whitespace (e.g. "a {x} b")
}
if !strings.ContainsAny(s, "\n") {
return s
}
lines := strings.Split(s, "\n")
var kept []string
for _, l := range lines {
l = strings.Trim(l, " \t\r")
if l != "" {
kept = append(kept, l)
}
}
return strings.Join(kept, " ")
}
func escapeTemplateText(s string) string {
// Templates are raw HTML; escape only the backtick that would end the JS
// template literal and `${` interpolation.
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "`", "\\`")
s = strings.ReplaceAll(s, "${", "\\${")
return s
}
func propKey(name string) string {
if name == "class" {
return `"class"`
}
for i := 0; i < len(name); i++ {
if !(isASCIILetter(name[i]) || name[i] == '_' || name[i] == '$' || (i > 0 && name[i] >= '0' && name[i] <= '9')) {
return jsStringLit(name)
}
}
return name
}
func jsStringLit(s string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
default:
b.WriteRune(r)
}
}
b.WriteByte('"')
return b.String()
}
// validateJS checks code with esbuild and returns the first error. It uses Build
// (Bundle:false, so imports are left unresolved) rather than Transform because
// Build runs semantic checks Transform skips — notably assign-to-const, which a
// mis-generated ref would produce and which only surfaces in the real bundle.
func validateJS(code string) error {
res := esbuild.Build(esbuild.BuildOptions{
Stdin: &esbuild.StdinOptions{Contents: code, Loader: esbuild.LoaderJS},
Bundle: false,
Write: false,
LogLevel: esbuild.LogLevelSilent,
})
if len(res.Errors) > 0 {
return fmt.Errorf("%s", res.Errors[0].Text)
}
return nil
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}