rtmlast
packageAPI reference for the rtmlast
package.
Imports
(7)TestRenderNodesEscapesValues
RenderNodes follows the same escape-by-default policy as the production
renderer in core: {{var}}, @prop, @store and @signal output is text.
Parameters
func TestRenderNodesEscapesValues(t *testing.T)
{
mgr := state.NewStoreManager()
st := mgr.NewStore("s", state.WithModule("app"))
st.Set("v", "<i>store</i>")
nodes, err := Parse("{{name}}\n@prop:name\n@store:app.s.v\n")
if err != nil {
t.Fatal(err)
}
ctx := &RenderContext{
Props: map[string]any{"name": "<b>x</b>"},
StoreMgr: mgr,
}
out := RenderNodes(nodes, ctx)
if strings.Contains(out, "<b>x</b>") || strings.Contains(out, "<i>store</i>") {
t.Fatalf("markup injected: %s", out)
}
if !strings.Contains(out, "<b>x</b>") {
t.Fatalf("var/prop not escaped: %s", out)
}
if !strings.Contains(out, "<i>store</i>") {
t.Fatalf("store not escaped: %s", out)
}
}
TestRenderNodesEscapesSignals
Parameters
func TestRenderNodesEscapesSignals(t *testing.T)
{
sig := state.NewSignal("<img src=x>")
nodes, err := Parse("@signal:v")
if err != nil {
t.Fatal(err)
}
out := RenderNodes(nodes, &RenderContext{Props: map[string]any{"v": sig}})
if !strings.Contains(out, "<img src=x>") {
t.Fatalf("signal not escaped: %s", out)
}
}
Node
Node is the root of the AST.
type Node interface
Methods
func node(...)
ElementNode
ElementNode is a rendered HTML element. It holds the tag name,
attributes (plain + bound), children, and optional [ref] / [key].
type ElementNode struct
Methods
func (ElementNode) node()
{}
Fields
Uses
Attr
Attr is a static HTML attribute.
type Attr struct
Fields
| Name | Type | Description |
|---|---|---|
| Name | string | |
| Value | string |
BoundAttr
BoundAttr is an attribute whose value is computed from a reactive Expr.
type BoundAttr struct
Fields
| Name | Type | Description |
|---|---|---|
| Name | string | |
| Expr | Expr | |
| Bool | bool |
Uses
IfNode
IfNode is a conditional block.
type IfNode struct
Methods
func (IfNode) node()
{}
Fields
| Name | Type | Description |
|---|---|---|
| Cond | Expr | |
| Then | []Node | |
| ElseIf | []ElseIfBranch | |
| Else | []Node |
Uses
ElseIfBranch
ElseIfBranch is an @else-if branch.
type ElseIfBranch struct
Uses
ForNode
ForNode is a list loop.
type ForNode struct
Methods
func (ForNode) node()
{}
Uses
Expr
Expr represents a reactive expression.
It is later evaluated against component scope.
type Expr interface
Methods
func expr(...)
BinaryExpr
BinaryExpr is lhs op rhs.
type BinaryExpr struct
Methods
func (BinaryExpr) expr()
{}
BinOp
BinOp is a binary operator.
type BinOp int
UnaryExpr
UnaryExpr is !expr or -expr.
type UnaryExpr struct
Methods
func (UnaryExpr) expr()
{}
UnaryOp
UnaryOp is a unary operator.
type UnaryOp int
TernaryExpr
TernaryExpr is cond ? then : else (rare, but supported).
type TernaryExpr struct
Methods
func (TernaryExpr) expr()
{}
TokenType
TokenType identifies a lexer token.
type TokenType int
Token
Token is one lexer result.
type Token struct
Fields
| Name | Type | Description |
|---|---|---|
| Type | TokenType | |
| Value | string |
Uses
Lexer
Lexer tokenizes an RTML template.
type Lexer struct
Methods
Lex returns all tokens in the input.
Returns
func (*Lexer) Lex() []Token
{
var tokens []Token
var text strings.Builder
for l.pos < len(l.input) {
ch := l.input[l.pos]
if ch == '{' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '{' {
if text.Len() > 0 {
tokens = append(tokens, Token{Type: TokenText, Value: text.String()})
text.Reset()
}
l.pos += 2
var expr strings.Builder
for l.pos < len(l.input) {
if l.pos+1 < len(l.input) && l.input[l.pos] == '}' && l.input[l.pos+1] == '}' {
l.pos += 2
break
}
expr.WriteByte(l.input[l.pos])
l.pos++
}
tokens = append(tokens, Token{Type: TokenVarOpen, Value: strings.TrimSpace(expr.String())})
tokens = append(tokens, Token{Type: TokenVarClose, Value: "}}"})
} else if ch == '@' && isCommandPrefix(l.input[l.pos+1:]) {
if text.Len() > 0 {
tokens = append(tokens, Token{Type: TokenText, Value: text.String()})
text.Reset()
}
l.pos++
start := l.pos
for l.pos < len(l.input) && !isNewline(l.input[l.pos]) && l.input[l.pos] != '<' {
l.pos++
}
cmdText := strings.TrimSpace(l.input[start:l.pos])
tokens = append(tokens, Token{Type: TokenCommand, Value: cmdText})
} else {
text.WriteByte(ch)
l.pos++
}
}
if text.Len() > 0 {
tokens = append(tokens, Token{Type: TokenText, Value: text.String()})
}
tokens = append(tokens, Token{Type: TokenEOF})
return tokens
}
Fields
| Name | Type | Description |
|---|---|---|
| input | string | |
| pos | int |
NewLexer
NewLexer creates a lexer for input.
Parameters
Returns
func NewLexer(input string) *Lexer
{
return &Lexer{input: input}
}
isCommandPrefix
Parameters
Returns
func isCommandPrefix(s string) bool
{
prefixes := []string{"if:", "else-if:", "else", "endif", "for:", "endfor", "include:", "slot:", "endslot", "on:", "store:", "signal:", "prop:", "h:", "plugin:", "expr:"}
for _, p := range prefixes {
if len(s) > len(p) && s[:len(p)] == p {
return true
}
if s == p {
return true
}
}
return false
}
isNewline
Parameters
Returns
func isNewline(ch byte) bool
{
return ch == '\n' || ch == '\r'
}
Parse
Parse builds an RTML syntax tree.
Parameters
Returns
func Parse(template string) ([]Node, error)
{
lex := NewLexer(template)
tokens := lex.Lex()
p := &parser{tokens: tokens, pos: 0}
return p.parseNodes()
}
parser
type parser struct
Methods
Returns
func (*parser) peek() Token
{
if p.pos >= len(p.tokens) {
return Token{Type: TokenEOF}
}
return p.tokens[p.pos]
}
Returns
func (*parser) next() Token
{
if p.pos >= len(p.tokens) {
return Token{Type: TokenEOF}
}
t := p.tokens[p.pos]
p.pos++
return t
}
Returns
func (*parser) parseNodes() ([]Node, error)
{
var nodes []Node
for {
t := p.peek()
if t.Type == TokenEOF {
break
}
switch t.Type {
case TokenText:
p.next()
if t.Value != "" {
nodes = append(nodes, TextNode{Text: t.Value})
}
case TokenVarOpen:
p.next()
expr := ParseExpr(t.Value)
nodes = append(nodes, VarNode{Expr: expr})
if p.peek().Type == TokenVarClose {
p.next()
}
case TokenCommand:
p.next()
cmdText := t.Value
node, consumed, err := p.parseCommand(cmdText)
if err != nil {
return nodes, err
}
if node != nil {
nodes = append(nodes, node)
}
_ = consumed
default:
p.next()
}
}
return nodes, nil
}
Parameters
Returns
func (*parser) parseCommand(cmdText string) (Node, bool, error)
{
colonIdx := strings.Index(cmdText, ":")
if colonIdx < 0 {
switch cmdText {
case "else":
return nil, false, nil
case "endif":
return nil, false, nil
case "endfor":
return nil, false, nil
case "endslot":
return nil, false, nil
}
return CommandNode{Kind: cmdText, Value: ""}, true, nil
}
kind := cmdText[:colonIdx]
rest := cmdText[colonIdx+1:]
switch kind {
case "if":
node, err := p.parseIf(rest)
return node, true, err
case "for":
node, err := p.parseFor(rest)
return node, true, err
case "include":
return IncludeNode{Name: rest, Props: nil}, true, nil
case "slot":
return p.parseSlot(rest)
case "on":
return CommandNode{Kind: "on", Value: rest}, true, nil
case "store", "signal", "prop":
return CommandNode{Kind: kind, Value: rest}, true, nil
case "h":
return CommandNode{Kind: "h", Value: rest}, true, nil
case "plugin":
return CommandNode{Kind: "plugin", Value: rest}, true, nil
case "expr":
return ExprNode{Expr: ParseExpr(rest)}, true, nil
default:
return CommandNode{Kind: kind, Value: rest}, true, nil
}
}
Parameters
Returns
func (*parser) parseIf(condStr string) (Node, error)
{
thenNodes, _ := p.parseUntilCommands("else-if", "else", "endif")
node := IfNode{Cond: ParseExpr(condStr), Then: thenNodes}
for p.peek().Type == TokenCommand && strings.HasPrefix(p.peek().Value, "else-if:") {
cmdText := p.next().Value
elseCond := strings.TrimPrefix(cmdText, "else-if:")
body, _ := p.parseUntilCommands("else-if", "else", "endif")
node.ElseIf = append(node.ElseIf, ElseIfBranch{Cond: ParseExpr(elseCond), Body: body})
}
if p.peek().Type == TokenCommand && p.peek().Value == "else" {
p.next()
elseBody, _ := p.parseUntilCommands("endif")
node.Else = elseBody
}
if p.peek().Type == TokenCommand && p.peek().Value == "endif" {
p.next()
}
return node, nil
}
Parameters
Returns
func (*parser) parseFor(detail string) (Node, error)
{
body, _ := p.parseUntilCommands("endfor")
if p.peek().Type == TokenCommand && p.peek().Value == "endfor" {
p.next()
}
parts := strings.SplitN(detail, " in ", 2)
alias := strings.TrimSpace(parts[0])
exprStr := ""
if len(parts) > 1 {
exprStr = strings.TrimSpace(parts[1])
}
keyAlias := ""
if commaIdx := strings.Index(alias, ","); commaIdx != -1 {
keyAlias = strings.TrimSpace(alias[commaIdx+1:])
alias = strings.TrimSpace(alias[:commaIdx])
}
return ForNode{Alias: alias, KeyAlias: keyAlias, Expr: ParseExpr(exprStr), Body: body}, nil
}
Parameters
Returns
func (*parser) parseSlot(name string) (Node, bool, error)
{
body, _ := p.parseUntilCommands("endslot")
if p.peek().Type == TokenCommand && p.peek().Value == "endslot" {
p.next()
}
return SlotNode{Name: name, Fallback: body}, true, nil
}
Parameters
Returns
func (*parser) parseUntilCommands(commands ...string) ([]Node, error)
{
var nodes []Node
for {
t := p.peek()
if t.Type == TokenEOF {
break
}
if t.Type == TokenCommand {
for _, cmd := range commands {
if t.Value == cmd || strings.HasPrefix(t.Value, cmd+":") || strings.HasPrefix(t.Value, cmd+" ") {
return nodes, nil
}
}
}
p.next()
switch t.Type {
case TokenText:
if t.Value != "" {
nodes = append(nodes, TextNode{Text: t.Value})
}
case TokenVarOpen:
nodes = append(nodes, VarNode{Expr: ParseExpr(t.Value)})
if p.peek().Type == TokenVarClose {
p.next()
}
case TokenCommand:
node, _, err := p.parseCommand(t.Value)
if err != nil {
return nodes, err
}
if node != nil {
nodes = append(nodes, node)
}
}
}
return nodes, nil
}
Fields
| Name | Type | Description |
|---|---|---|
| tokens | []Token | |
| pos | int |
ParseExpr
ParseExpr parses a reactive RTML expression.
Parameters
Returns
func ParseExpr(s string) Expr
{
s = strings.TrimSpace(s)
if s == "" {
return LiteralExpr{Value: ""}
}
// Ternary: cond then X else Y (preferred) or cond ? X : Y (legacy)
if idx, ok := findTernaryThen(s); ok {
condStr := strings.TrimSpace(s[:idx])
rest := s[idx+5:] // len(" then") = 5
elseIdx, ok2 := findElse(rest)
if ok2 {
thenStr := strings.TrimSpace(rest[:elseIdx])
elseStr := strings.TrimSpace(rest[elseIdx+5:]) // len(" else") = 5
return TernaryExpr{Cond: ParseExpr(condStr), Then: ParseExpr(thenStr), Else: ParseExpr(elseStr)}
}
}
if idx, ok := findTernarySymbol(s); ok {
condStr := strings.TrimSpace(s[:idx])
rest := s[idx+1:]
colonIdx := findTernaryColon(rest)
if colonIdx >= 0 {
thenStr := strings.TrimSpace(rest[:colonIdx])
elseStr := strings.TrimSpace(rest[colonIdx+1:])
return TernaryExpr{Cond: ParseExpr(condStr), Then: ParseExpr(thenStr), Else: ParseExpr(elseStr)}
}
}
if result, ok := tryParseBinary(s); ok {
return result
}
if strings.HasPrefix(s, "!") || strings.HasPrefix(s, "not ") {
var inner string
if strings.HasPrefix(s, "not ") {
inner = strings.TrimSpace(s[4:])
} else {
inner = strings.TrimSpace(s[1:])
}
return UnaryExpr{Op: UnaryNot, Expr: ParseExpr(inner)}
}
if strings.HasPrefix(s, "-") && len(s) > 1 {
return UnaryExpr{Op: UnaryNeg, Expr: ParseExpr(s[1:])}
}
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
return LiteralExpr{Value: s[1 : len(s)-1]}
}
if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") && len(s) >= 2 {
return LiteralExpr{Value: s[1 : len(s)-1]}
}
if n, ok := tryParseNumber(s); ok {
return LiteralExpr{Value: n}
}
if strings.HasPrefix(s, "store:") || strings.HasPrefix(s, "signal:") || strings.HasPrefix(s, "prop:") {
return IdentExpr{Name: s}
}
if strings.Contains(s, ".") && !strings.Contains(s, "(") {
parts := strings.SplitN(s, ".", 2)
if isIdent(parts[0]) && isIdent(parts[1]) {
return FieldExpr{Obj: ParseExpr(parts[0]), Field: parts[1]}
}
}
if strings.Contains(s, "(") {
parenIdx := strings.Index(s, "(")
fnName := s[:parenIdx]
argsStr := s[parenIdx+1 : len(s)-1]
var args []Expr
if argsStr != "" {
for _, a := range strings.Split(argsStr, ",") {
args = append(args, ParseExpr(strings.TrimSpace(a)))
}
}
return CallExpr{Fn: fnName, Args: args}
}
return IdentExpr{Name: s}
}
Uses
findTernaryThen
findTernaryThen finds “ then ” outside strings and parens.
Parameters
Returns
func findTernaryThen(s string) (int, bool)
{
depth := 0
inStr := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' || ch == '\'' {
inStr = !inStr
}
if inStr {
continue
}
if ch == '(' {
depth++
}
if ch == ')' {
depth--
}
if depth > 0 {
continue
}
if i+5 <= len(s) && s[i:i+5] == " then" && (i+5 >= len(s) || s[i+5] == ' ') {
return i, true
}
}
return -1, false
}
findElse
findElse finds “ else ” outside strings and parens.
Parameters
Returns
func findElse(s string) (int, bool)
{
depth := 0
inStr := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' || ch == '\'' {
inStr = !inStr
}
if inStr {
continue
}
if ch == '(' {
depth++
}
if ch == ')' {
depth--
}
if depth > 0 {
continue
}
if i+5 <= len(s) && s[i:i+5] == " else" && (i+5 >= len(s) || s[i+5] == ' ') {
return i, true
}
}
return -1, false
}
findTernarySymbol
findTernarySymbol finds “?” outside strings and parens.
Parameters
Returns
func findTernarySymbol(s string) (int, bool)
{
depth := 0
inStr := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' || ch == '\'' {
inStr = !inStr
}
if inStr {
continue
}
if ch == '(' {
depth++
}
if ch == ')' {
depth--
}
if depth > 0 {
continue
}
if ch == '?' {
return i, true
}
}
return -1, false
}
findTernaryColon
findTernaryColon finds “:” outside strings and parens, used for legacy ? : ternary.
Parameters
Returns
func findTernaryColon(s string) int
{
depth := 0
inStr := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' || ch == '\'' {
inStr = !inStr
}
if inStr {
continue
}
if ch == '(' {
depth++
}
if ch == ')' {
depth--
}
if depth > 0 {
continue
}
if ch == ':' {
return i
}
}
return -1
}
isIdent
Parameters
Returns
func isIdent(s string) bool
{
if s == "" {
return false
}
for _, ch := range s {
if !unicode.IsLetter(ch) && ch != '_' && !unicode.IsDigit(ch) {
return false
}
}
return true
}
tryParseNumber
Parameters
Returns
func tryParseNumber(s string) (any, bool)
{
var f float64
if _, err := fmt.Sscanf(s, "%f", &f); err == nil {
if f == float64(int(f)) {
return int(f), true
}
return f, true
}
return nil, false
}
tryParseBinary
Parameters
Returns
func tryParseBinary(s string) (Expr, bool)
{
type opInfo struct {
op BinOp
syms []string
}
ops := []opInfo{
{OpOr, []string{" or ", "||"}},
{OpAnd, []string{" and ", "&&"}},
{OpEq, []string{" is ", "=="}},
{OpNeq, []string{" is not ", "!="}},
{OpLte, []string{"<="}},
{OpGte, []string{">="}},
{OpLt, []string{"<"}},
{OpGt, []string{">"}},
{OpAdd, []string{"+"}},
{OpSub, []string{"-"}},
{OpMul, []string{"*"}},
{OpDiv, []string{"/"}},
}
for _, o := range ops {
for _, sym := range o.syms {
idx := findBinaryOp(s, sym)
if idx >= 0 {
lhs := strings.TrimSpace(s[:idx])
rhs := strings.TrimSpace(s[idx+len(sym):])
if lhs != "" && rhs != "" {
return BinaryExpr{Op: o.op, LHS: ParseExpr(lhs), RHS: ParseExpr(rhs)}, true
}
}
}
}
return nil, false
}
Uses
findBinaryOp
Parameters
Returns
func findBinaryOp(s, op string) int
{
depth := 0
inStr := false
inVar := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' || ch == '\'' {
inStr = !inStr
}
if inStr {
continue
}
if ch == '{' {
inVar = true
}
if ch == '}' {
inVar = false
}
if inVar {
continue
}
if ch == '(' {
depth++
}
if ch == ')' {
depth--
}
if depth > 0 {
continue
}
if i+len(op) <= len(s) && s[i:i+len(op)] == op {
return i
}
}
return -1
}
TestParseText
Parameters
func TestParseText(t *testing.T)
{
nodes, err := Parse("hello world")
if err != nil {
t.Fatal(err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
tn, ok := nodes[0].(TextNode)
if !ok {
t.Fatalf("expected TextNode, got %T", nodes[0])
}
if tn.Text != "hello world" {
t.Fatalf("expected 'hello world', got '%s'", tn.Text)
}
}
TestParseVarInterpolation
Parameters
func TestParseVarInterpolation(t *testing.T)
{
input := "hello {{name}} world"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(nodes))
}
vn, ok := nodes[1].(VarNode)
if !ok {
t.Fatalf("expected VarNode, got %T", nodes[1])
}
ident, ok := vn.Expr.(IdentExpr)
if !ok {
t.Fatalf("expected IdentExpr, got %T", vn.Expr)
}
if ident.Name != "name" {
t.Fatalf("expected 'name', got '%s'", ident.Name)
}
}
TestParseIfConditional
Parameters
func TestParseIfConditional(t *testing.T)
{
input := "@if:active\nhello\n@else\nworld\n@endif"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
ifn, ok := nodes[0].(IfNode)
if !ok {
t.Fatalf("expected IfNode, got %T", nodes[0])
}
ident, ok := ifn.Cond.(IdentExpr)
if !ok {
t.Fatalf("expected IdentExpr condition, got %T", ifn.Cond)
}
if ident.Name != "active" {
t.Fatalf("expected condition 'active', got '%s'", ident.Name)
}
if len(ifn.Then) == 0 {
t.Fatal("expected Then branch")
}
if len(ifn.Else) == 0 {
t.Fatal("expected Else branch")
}
}
TestParseForLoop
Parameters
func TestParseForLoop(t *testing.T)
{
input := "@for:item in items\n<div>{{item}}</div>\n@endfor"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
fn, ok := nodes[0].(ForNode)
if !ok {
t.Fatalf("expected ForNode, got %T", nodes[0])
}
if fn.Alias != "item" {
t.Fatalf("expected alias 'item', got '%s'", fn.Alias)
}
}
TestParseInclude
Parameters
func TestParseInclude(t *testing.T)
{
input := "@include:MyComponent"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
inc, ok := nodes[0].(IncludeNode)
if !ok {
t.Fatalf("expected IncludeNode, got %T", nodes[0])
}
if inc.Name != "MyComponent" {
t.Fatalf("expected 'MyComponent', got '%s'", inc.Name)
}
}
TestParseBinaryExpr
Parameters
func TestParseBinaryExpr(t *testing.T)
{
expr := ParseExpr("count > 0")
bin, ok := expr.(BinaryExpr)
if !ok {
t.Fatalf("expected BinaryExpr, got %T", expr)
}
if bin.Op != OpGt {
t.Fatalf("expected OpGt, got %d", bin.Op)
}
}
TestParseBinaryExprEq
Parameters
func TestParseBinaryExprEq(t *testing.T)
{
expr := ParseExpr("status == \"active\"")
bin, ok := expr.(BinaryExpr)
if !ok {
t.Fatalf("expected BinaryExpr, got %T", expr)
}
if bin.Op != OpEq {
t.Fatalf("expected OpEq, got %d", bin.Op)
}
}
TestParseUnaryNot
Parameters
func TestParseUnaryNot(t *testing.T)
{
expr := ParseExpr("!visible")
un, ok := expr.(UnaryExpr)
if !ok {
t.Fatalf("expected UnaryExpr, got %T", expr)
}
if un.Op != UnaryNot {
t.Fatalf("expected UnaryNot, got %d", un.Op)
}
}
TestParseFieldExpr
Parameters
func TestParseFieldExpr(t *testing.T)
{
expr := ParseExpr("user.name")
field, ok := expr.(FieldExpr)
if !ok {
t.Fatalf("expected FieldExpr, got %T", expr)
}
if field.Field != "name" {
t.Fatalf("expected 'name', got '%s'", field.Field)
}
}
TestParseCallExpr
Parameters
func TestParseCallExpr(t *testing.T)
{
expr := ParseExpr("format(date)")
call, ok := expr.(CallExpr)
if !ok {
t.Fatalf("expected CallExpr, got %T", expr)
}
if call.Fn != "format" {
t.Fatalf("expected 'format', got '%s'", call.Fn)
}
}
TestParseElseIf
Parameters
func TestParseElseIf(t *testing.T)
{
input := "@if:a\nA\n@else-if:b\nB\n@else\nC\n@endif"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
ifn, ok := nodes[0].(IfNode)
if !ok {
t.Fatalf("expected IfNode, got %T", nodes[0])
}
if len(ifn.ElseIf) != 1 {
t.Fatalf("expected 1 else-if, got %d", len(ifn.ElseIf))
}
if len(ifn.Else) == 0 {
t.Fatal("expected else branch")
}
}
TestParseSlot
Parameters
func TestParseSlot(t *testing.T)
{
input := "@slot:header\n<h1>fallback</h1>\n@endslot"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
sn, ok := nodes[0].(SlotNode)
if !ok {
t.Fatalf("expected SlotNode, got %T", nodes[0])
}
if sn.Name != "header" {
t.Fatalf("expected 'header', got '%s'", sn.Name)
}
}
TestParseStoreIdent
Parameters
func TestParseStoreIdent(t *testing.T)
{
expr := ParseExpr("store:app.user.name")
ident, ok := expr.(IdentExpr)
if !ok {
t.Fatalf("expected IdentExpr, got %T", expr)
}
if ident.Name != "store:app.user.name" {
t.Fatalf("expected 'store:app.user.name', got '%s'", ident.Name)
}
}
TestParseSignalIdent
Parameters
func TestParseSignalIdent(t *testing.T)
{
expr := ParseExpr("signal:count")
ident, ok := expr.(IdentExpr)
if !ok {
t.Fatalf("expected IdentExpr, got %T", expr)
}
if ident.Name != "signal:count" {
t.Fatalf("expected 'signal:count', got '%s'", ident.Name)
}
}
TestParseComplexTemplate
Parameters
func TestParseComplexTemplate(t *testing.T)
{
input := "<div>{{user.name}}@if:admin\nadmin panel\n@endif</div>"
nodes, err := Parse(input)
if err != nil {
t.Fatal(err)
}
if len(nodes) == 0 {
t.Fatal("expected nodes")
}
}
escapeValue
escapeValue renders a substituted value HTML-escaped, matching the
escape-by-default policy of the production renderer in core.
Parameters
Returns
func escapeValue(v any) string
{
return html.EscapeString(fmt.Sprintf("%v", v))
}
HTMLComponent
HTMLComponent exposes component data required by the AST renderer.
type HTMLComponent interface
Methods
RenderContext
RenderContext contains component, property and store data for rendering.
type RenderContext struct
Fields
| Name | Type | Description |
|---|---|---|
| Component | HTMLComponent | |
| Props | map[string]any | |
| StoreMgr | *state.StoreManager |
RenderNodes
RenderNodes renders a sequence of AST nodes.
Parameters
Returns
func RenderNodes(nodes []Node, ctx *RenderContext) string
{
var sb strings.Builder
for _, n := range nodes {
sb.WriteString(renderNode(n, ctx))
}
return sb.String()
}
renderNode
Parameters
Returns
func renderNode(n Node, ctx *RenderContext) string
{
switch v := n.(type) {
case TextNode:
return v.Text
case ExprNode:
return renderExprNode(v, ctx)
case VarNode:
return renderVar(v, ctx)
case IfNode:
return renderIf(v, ctx)
case ForNode:
return renderFor(v, ctx)
case IncludeNode:
return renderInclude(v, ctx)
case SlotNode:
return renderSlot(v, ctx)
case CommandNode:
return renderCommand(v, ctx)
default:
return ""
}
}
Uses
renderVar
Parameters
Returns
func renderVar(v VarNode, ctx *RenderContext) string
{
val := evalExpr(v.Expr, ctx)
return fmt.Sprintf(`<span data-var>%s</span>`, escapeValue(val))
}
Uses
renderExprNode
Parameters
Returns
func renderExprNode(v ExprNode, ctx *RenderContext) string
{
val := evalExpr(v.Expr, ctx)
return fmt.Sprintf(`<span data-expr>%s</span>`, escapeValue(val))
}
Uses
renderIf
Parameters
Returns
func renderIf(v IfNode, ctx *RenderContext) string
{
condVal := evalBool(v.Cond, ctx)
if condVal {
return RenderNodes(v.Then, ctx)
}
for _, branch := range v.ElseIf {
if evalBool(branch.Cond, ctx) {
return RenderNodes(branch.Body, ctx)
}
}
if len(v.Else) > 0 {
return RenderNodes(v.Else, ctx)
}
return ""
}
Uses
renderFor
Parameters
Returns
func renderFor(v ForNode, ctx *RenderContext) string
{
collection := evalExpr(v.Expr, ctx)
var items []any
switch c := collection.(type) {
case []any:
items = c
case map[string]any:
for k, val := range c {
items = append(items, map[string]any{"key": k, "value": val})
}
default:
return ""
}
var sb strings.Builder
for i, item := range items {
childCtx := *ctx
if childCtx.Props == nil {
childCtx.Props = map[string]any{}
}
childCtx.Props[v.Alias] = item
if v.KeyAlias != "" {
switch k := item.(type) {
case map[string]any:
childCtx.Props[v.KeyAlias] = k["key"]
default:
childCtx.Props[v.KeyAlias] = i
}
}
sb.WriteString(RenderNodes(v.Body, &childCtx))
}
return sb.String()
}
Uses
renderInclude
Parameters
Returns
func renderInclude(v IncludeNode, _ *RenderContext) string
{
return fmt.Sprintf(`@include:%s`, v.Name)
}
renderSlot
Parameters
Returns
func renderSlot(v SlotNode, ctx *RenderContext) string
{
if content, ok := ctx.Props["slot:"+v.Name]; ok {
if s, ok := content.(string); ok {
return s
}
}
return RenderNodes(v.Fallback, ctx)
}
Uses
renderCommand
Parameters
Returns
func renderCommand(v CommandNode, ctx *RenderContext) string
{
switch v.Kind {
case "store":
return renderStoreCmd(v.Value, ctx)
case "signal":
return renderSignalCmd(v.Value, ctx)
case "prop":
return renderPropCmd(v.Value, ctx)
case "on":
return renderEventCmd(v.Value)
case "h":
return fmt.Sprintf(`<span data-host-var="%s" data-host-expected=""></span>`, v.Value)
default:
return fmt.Sprintf(`@%s:%s`, v.Kind, v.Value)
}
}
renderStoreCmd
Parameters
Returns
func renderStoreCmd(val string, ctx *RenderContext) string
{
parts := strings.Split(val, ".")
isW := strings.HasSuffix(val, ":w")
if isW && len(parts) >= 3 {
cleanVal := strings.TrimSuffix(val, ":w")
cparts := strings.Split(cleanVal, ".")
if len(cparts) == 3 {
placeholder := fmt.Sprintf("@store:%s:w", cleanVal)
return placeholder
}
}
if len(parts) == 3 && !isW {
store := ctx.StoreMgr.GetStore(parts[0], parts[1])
if store != nil {
v := store.Get(parts[2])
return fmt.Sprintf(`<span data-store="%s">%s</span>`, val, escapeValue(v))
}
}
return fmt.Sprintf(`@store:%s`, val)
}
renderSignalCmd
Parameters
Returns
func renderSignalCmd(val string, ctx *RenderContext) string
{
name := val
isW := strings.HasSuffix(val, ":w")
if isW {
name = strings.TrimSuffix(val, ":w")
return fmt.Sprintf("@signal:%s:w", name)
}
if prop, ok := ctx.Props[name]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
v := sig.Read()
return fmt.Sprintf(`<span data-signal="%s">%s</span>`, name, escapeValue(v))
}
}
return fmt.Sprintf(`@signal:%s`, name)
}
renderPropCmd
Parameters
Returns
func renderPropCmd(val string, ctx *RenderContext) string
{
if v, ok := ctx.Props[val]; ok {
return escapeValue(v)
}
return fmt.Sprintf(`@prop:%s`, val)
}
renderEventCmd
Parameters
Returns
func renderEventCmd(val string) string
{
parts := strings.SplitN(val, ":", 2)
if len(parts) == 2 {
event := parts[0]
handler := parts[1]
return fmt.Sprintf(`data-on-%s="%s"`, event, handler)
}
return fmt.Sprintf(`data-on-%s`, val)
}
evalExpr
Parameters
Returns
func evalExpr(e Expr, ctx *RenderContext) any
{
switch v := e.(type) {
case IdentExpr:
return lookupIdent(v.Name, ctx)
case LiteralExpr:
return v.Value
case BinaryExpr:
return evalBinary(v, ctx)
case UnaryExpr:
return evalUnary(v, ctx)
case FieldExpr:
obj := evalExpr(v.Obj, ctx)
if m, ok := obj.(map[string]any); ok {
return m[v.Field]
}
return nil
case CallExpr:
return nil
case TernaryExpr:
if evalBool(v.Cond, ctx) {
return evalExpr(v.Then, ctx)
}
return evalExpr(v.Else, ctx)
default:
return nil
}
}
Uses
evalBool
Parameters
Returns
func evalBool(e Expr, ctx *RenderContext) bool
{
val := evalExpr(e, ctx)
switch v := val.(type) {
case bool:
return v
case string:
return v != ""
case int:
return v != 0
case float64:
return v != 0
case nil:
return false
default:
return true
}
}
Uses
evalBinary
Parameters
Returns
func evalBinary(b BinaryExpr, ctx *RenderContext) any
{
lhs := evalExpr(b.LHS, ctx)
rhs := evalExpr(b.RHS, ctx)
switch b.Op {
case OpEq:
return fmt.Sprintf("%v", lhs) == fmt.Sprintf("%v", rhs)
case OpNeq:
return fmt.Sprintf("%v", lhs) != fmt.Sprintf("%v", rhs)
case OpAnd:
return toBool(lhs) && toBool(rhs)
case OpOr:
return toBool(lhs) || toBool(rhs)
case OpLt, OpGt, OpLte, OpGte:
return compareValues(lhs, rhs, b.Op)
case OpAdd:
return toFloat(lhs) + toFloat(rhs)
case OpSub:
return toFloat(lhs) - toFloat(rhs)
case OpMul:
return toFloat(lhs) * toFloat(rhs)
case OpDiv:
r := toFloat(rhs)
if r == 0 {
return 0.0
}
return toFloat(lhs) / r
default:
return nil
}
}
Uses
evalUnary
Parameters
Returns
func evalUnary(u UnaryExpr, ctx *RenderContext) any
{
val := evalExpr(u.Expr, ctx)
switch u.Op {
case UnaryNot:
return !toBool(val)
case UnaryNeg:
return -toFloat(val)
default:
return val
}
}
Uses
lookupIdent
Parameters
Returns
func lookupIdent(name string, ctx *RenderContext) any
{
if strings.HasPrefix(name, "store:") {
parts := strings.Split(strings.TrimPrefix(name, "store:"), ".")
if len(parts) == 3 && ctx.StoreMgr != nil {
store := ctx.StoreMgr.GetStore(parts[0], parts[1])
if store != nil {
return store.Get(parts[2])
}
}
return nil
}
if strings.HasPrefix(name, "signal:") {
sigName := strings.TrimPrefix(name, "signal:")
if prop, ok := ctx.Props[sigName]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
return sig.Read()
}
return prop
}
return nil
}
if v, ok := ctx.Props[name]; ok {
if sig, ok := v.(interface{ Read() any }); ok {
return sig.Read()
}
return v
}
return nil
}
toBool
Parameters
Returns
func toBool(v any) bool
{
switch val := v.(type) {
case bool:
return val
case string:
return val != ""
case int:
return val != 0
case float64:
return val != 0
default:
return v != nil
}
}
toFloat
Parameters
Returns
func toFloat(v any) float64
{
switch val := v.(type) {
case int:
return float64(val)
case float64:
return val
case string:
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return f
default:
return 0
}
}
compareValues
Parameters
Returns
func compareValues(lhs, rhs any, op BinOp) bool
{
l, r := toFloat(lhs), toFloat(rhs)
switch op {
case OpLt:
return l < r
case OpGt:
return l > r
case OpLte:
return l <= r
case OpGte:
return l >= r
default:
return false
}
}