package jsbundler // 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, // except for void elements (br, img, input, ...) which never get one — // see voidElements. // - 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 ") b.WriteString(strings.Join(c.decls, ", ")) b.WriteString("; ") for _, op := range c.ops { b.WriteString(op) b.WriteString("; ") } b.WriteString("return ") b.WriteString(root) b.WriteString("; })()") 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(_ 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 '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)) case booleanAttrs[a.name]: // HTML boolean attributes are presence-based: setAttribute(name, // String(value)) would emit `checked="false"`, which the browser // parses as present (any string value) -> checked stays true. Use // _$setBoolAttribute, which adds the attribute (empty) when truthy // and removes it when falsy. c.ops = append(c.ops, fmt.Sprintf("%s(() => %s(%s, %q, %s))", c.g.helper("effect"), c.g.helper("setBoolAttribute"), 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, children as a `children` prop, and any {...spread} // threaded through the mergeProps helper (mirroring spread(), preserving source // order so later props override earlier ones, matching babel). func (g *solidGen) componentProps(node *jsxNode) string { 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 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), g.compileExpr(a.expr))) case attrSpread: flush() args = append(args, g.compileExpr(a.expr)) } } if ch := g.childrenProp(node); ch != "" { obj = append(obj, ch) } flush() if len(args) == 0 { return "{}" } if len(args) == 1 { return args[0] } return fmt.Sprintf("%s(%s)", g.helper("mergeProps"), strings.Join(args, ", ")) } // 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 !renderedChild(ch) { continue // drop whitespace-only text and comment-only expressions ({/* ... */}) } 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 ----------------------------------------------------- // voidElements are HTML elements that can never have a closing tag or // children. Writing one out as `` isn't just redundant — for `br` // specifically it's actively wrong: the HTML parser doesn't ignore a stray // `
` like it does for other unmatched end tags, it's spec'd to act as // *another* `

` start tag. That silently inserts an extra DOM node, // shifting every subsequent .firstChild/.nextSibling in the compiled // navigation chain by one and crashing template hydration. Matches the list // solid-js/html's own VOID_ELEMENTS uses (frontend/vendor/solid-js/html). var voidElements = map[string]bool{ "area": true, "base": true, "br": true, "col": true, "embed": true, "hr": true, "img": true, "input": true, "keygen": true, "link": true, "menuitem": true, "meta": true, "param": true, "source": true, "track": true, "wbr": true, } func buildTemplate(node *jsxNode) string { var sb strings.Builder writeTemplate(node, &sb) return sb.String() } func writeTemplate(node *jsxNode, sb *strings.Builder) { sb.WriteString("<") 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(" ") sb.WriteString(a.name) } else { sb.WriteString(" ") sb.WriteString(a.name) sb.WriteString(`="`) sb.WriteString(a.value) sb.WriteString(`"`) } } } } sb.WriteString(">") if voidElements[strings.ToLower(node.tag)] { return // no children, no closing tag } 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("") } // 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} // booleanAttrs are HTML boolean attributes: presence means true regardless of // the attribute's string value, so a naive setAttribute(name, String(false)) // (which emits e.g. checked="false") leaves the attribute present and the // browser reads it as true. These must go through setBoolAttribute instead, // which adds/removes the attribute based on the JS value's truthiness. var booleanAttrs = map[string]bool{ "allowfullscreen": true, "async": true, "autofocus": true, "autoplay": true, "checked": true, "controls": true, "default": true, "defer": true, "disabled": true, "formnovalidate": true, "hidden": true, "indeterminate": true, "ismap": true, "loop": true, "multiple": true, "muted": true, "nomodule": true, "novalidate": true, "open": true, "playsinline": true, "readonly": true, "required": true, "reversed": true, "seamless": true, "selected": 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. // collapseText applies JSX's whitespace rules to one text child. It is a port of // Babel's cleanJSXElementLiteralChild, and it has to be, because the rules are not // what you would guess: // // leading whitespace is stripped from every line EXCEPT the first, // trailing whitespace is stripped from every line EXCEPT the last. // // That asymmetry is the whole point. In // // // // the text before the expression is "\n Clicked " and the text after it is // " times\n". The indentation must go; the single space before `{` and after `}` // must NOT — they are the spaces between the words. Trimming both ends of every // line (as this did) renders "Clicked0times", and it does it silently, in every // multi-line component in the codebase. // // Blank lines vanish, and the surviving lines are joined with a single space, so a // paragraph broken across source lines still reads as a sentence. func collapseText(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") lines := strings.Split(s, "\n") // The last line with any non-whitespace on it. Every kept line before this one // gets a separating space; this one does not, or every text child would end in a // trailing space. Starts at 0 (not -1) so an all-whitespace chunk is left exactly // as it is — " " between two elements is a real, significant space. lastNonEmpty := 0 for i, l := range lines { if strings.Trim(l, " \t") != "" { lastNonEmpty = i } } var b strings.Builder for i, l := range lines { line := strings.ReplaceAll(l, "\t", " ") if i != 0 { line = strings.TrimLeft(line, " ") } if i != len(lines)-1 { line = strings.TrimRight(line, " ") } if line == "" { continue } if i != lastNonEmpty { line += " " } b.WriteString(line) } return b.String() } 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 }