1039 lines
28 KiB
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
|
|
}
|