1package template23import (4 "strconv"5 "strings"6 "unicode"78 "gno.land/p/moul/md/v0"9 "gno.land/p/moul/typeutil/v0"10 "gno.land/p/nt/ufmt/v0"11)1213// Renderer provides a simple template engine with a clean API14type Renderer struct {15 funcs map[string]Func // Template functions16 data map[string]interface{} // Global data17 ctx *context // Current evaluation context18}1920// Func is the signature for template functions21type Func func(args ...string) string2223// context tracks variable scope during evaluation24type context struct {25 parent *context26 vars map[string]interface{} // Local variables27 loop *loopContext // Range loop state28}2930// loopContext holds state for range loops31type loopContext struct {32 items []interface{} // Items being iterated33 index int // Current index34}3536// NewRenderer creates a new template renderer with default functions37func NewRenderer() *Renderer {38 r := &Renderer{39 funcs: make(map[string]Func),40 data: make(map[string]interface{}),41 }42 r.registerDefaults()43 return r44}4546// Render processes a template with the given data47func (r *Renderer) Render(template string, data map[string]interface{}) string {48 if data != nil {49 r.data = data50 }51 r.ctx = nil52 return r.render(template)53}5455// render is the core rendering engine56func (r *Renderer) render(tmpl string) string {57 var out strings.Builder58 59 for len(tmpl) > 0 {60 // Find next placeholder61 start := strings.Index(tmpl, "{{")62 if start < 0 {63 out.WriteString(tmpl)64 break65 }66 67 // Write content before placeholder68 out.WriteString(tmpl[:start])69 70 // Parse placeholder71 placeholder, end := r.parsePlaceholder(tmpl[start:])72 if placeholder == nil {73 out.WriteString("{{")74 tmpl = tmpl[start+2:]75 continue76 }77 78 // Apply left trim79 if placeholder.trimLeft {80 s := out.String()81 out.Reset()82 out.WriteString(strings.TrimRightFunc(s, unicode.IsSpace))83 }84 85 // Process placeholder86 result, consumed := r.process(placeholder, tmpl[start+end:])87 out.WriteString(result)88 89 // Advance position90 tmpl = tmpl[start+end+consumed:]91 92 // Apply right trim93 if placeholder.trimRight {94 tmpl = strings.TrimLeftFunc(tmpl, unicode.IsSpace)95 }96 }97 98 return out.String()99}100101// placeholder represents a parsed {{...}} tag102type placeholder struct {103 expr string104 trimLeft bool105 trimRight bool106}107108// parsePlaceholder extracts a {{...}} placeholder109func (r *Renderer) parsePlaceholder(tmpl string) (*placeholder, int) {110 if !strings.HasPrefix(tmpl, "{{") {111 return nil, 0112 }113 114 // Find matching }}115 end := r.findClosing(tmpl, 2)116 if end < 0 {117 return nil, 0118 }119 120 // Extract content121 content := tmpl[2:end]122 123 // Check trim markers124 p := &placeholder{}125 if strings.HasPrefix(content, "-") {126 p.trimLeft = true127 content = content[1:]128 }129 if strings.HasSuffix(content, "-") {130 p.trimRight = true131 content = content[:len(content)-1]132 }133 134 p.expr = strings.TrimSpace(content)135 return p, end + 2136}137138// findClosing finds the closing }} for a {{139func (r *Renderer) findClosing(tmpl string, start int) int {140 depth := 1141 i := start142 143 for i < len(tmpl)-1 {144 if tmpl[i] == '{' && tmpl[i+1] == '{' {145 depth++146 i += 2147 } else if tmpl[i] == '}' && tmpl[i+1] == '}' {148 depth--149 if depth == 0 {150 return i151 }152 i += 2153 } else {154 i++155 }156 }157 158 return -1159}160161// process evaluates a placeholder and returns (result, extraBytesConsumed)162func (r *Renderer) process(p *placeholder, remaining string) (string, int) {163 parts := r.parseExpr(p.expr)164 if len(parts) == 0 {165 return "", 0166 }167 168 cmd := parts[0]169 args := parts[1:]170 171 // Handle variables172 if strings.HasPrefix(cmd, ".") {173 if val := r.resolve(cmd); val != nil {174 return typeutil.ToString(val), 0175 }176 return "", 0177 }178 179 // Handle commands180 switch cmd {181 case "range":182 return r.doRange(args, remaining)183 case "if":184 return r.doIf(args, remaining)185 case "end", "else":186 return "", 0 // Handled by parent187 default:188 // Special handling for index function189 if cmd == "index" && len(args) >= 2 {190 return r.indexFunc(args...), 0191 }192 // Try function193 if fn, ok := r.funcs[cmd]; ok {194 resolved := r.resolveArgs(args)195 return fn(resolved...), 0196 }197 return p.expr, 0198 }199}200201// doRange implements {{range .items}}...{{end}}202func (r *Renderer) doRange(args []string, tmpl string) (string, int) {203 if len(args) == 0 {204 return "", 0205 }206 207 // Get collection208 val := r.resolve(args[0])209 if val == nil {210 return "", 0211 }212 213 // Convert to items214 items := toSlice(val)215 if items == nil {216 return "", 0217 }218 219 // Find block220 block, consumed := r.findBlock(tmpl, "end")221 if consumed == 0 {222 return "", 0223 }224 225 // Execute loop226 var out strings.Builder227 oldCtx := r.ctx228 229 for i, item := range items {230 // Create loop context231 r.ctx = &context{232 parent: oldCtx,233 vars: make(map[string]interface{}),234 loop: &loopContext{235 items: items,236 index: i,237 },238 }239 240 // Add item properties if map241 if m, ok := item.(map[string]interface{}); ok {242 for k, v := range m {243 r.ctx.vars[k] = v244 }245 }246 247 out.WriteString(r.render(block))248 }249 250 r.ctx = oldCtx251 return out.String(), consumed252}253254// doIf implements {{if .cond}}...{{else}}...{{end}}255func (r *Renderer) doIf(args []string, tmpl string) (string, int) {256 if len(args) == 0 {257 return "", 0258 }259 260 // Evaluate condition261 val := r.resolve(args[0])262 cond := typeutil.ToBool(val)263 264 // Find block265 block, consumed := r.findBlock(tmpl, "end")266 if consumed == 0 {267 return "", 0268 }269 270 // Split on else271 ifBlock, elseBlock := r.splitElse(block)272 273 // Execute branch274 oldCtx := r.ctx275 r.ctx = &context{276 parent: oldCtx,277 vars: make(map[string]interface{}),278 }279 280 var result string281 if cond {282 result = r.render(ifBlock)283 } else {284 result = r.render(elseBlock)285 }286 287 r.ctx = oldCtx288 return result, consumed289}290291// findBlock finds content up to {{end}}292func (r *Renderer) findBlock(tmpl string, endMarker string) (string, int) {293 var out strings.Builder294 depth := 1295 i := 0296 297 for i < len(tmpl) {298 // Find next {{299 next := strings.Index(tmpl[i:], "{{")300 if next < 0 {301 break302 }303 304 // Add content before {{305 out.WriteString(tmpl[i : i+next])306 307 // Parse placeholder308 p, consumed := r.parsePlaceholder(tmpl[i+next:])309 if p == nil {310 out.WriteString("{{")311 i += next + 2312 continue313 }314 315 // Check command316 parts := r.parseExpr(p.expr)317 if len(parts) > 0 {318 switch parts[0] {319 case "range", "if":320 depth++321 case endMarker:322 depth--323 if depth == 0 {324 return out.String(), i + next + consumed325 }326 }327 }328 329 // Add to output330 out.WriteString(tmpl[i+next : i+next+consumed])331 i += next + consumed332 }333 334 return "", 0335}336337// splitElse splits block on {{else}}338func (r *Renderer) splitElse(block string) (string, string) {339 depth := 0340 i := 0341 342 for i < len(block) {343 next := strings.Index(block[i:], "{{")344 if next < 0 {345 break346 }347 348 p, consumed := r.parsePlaceholder(block[i+next:])349 if p == nil {350 i += next + 2351 continue352 }353 354 parts := r.parseExpr(p.expr)355 if len(parts) > 0 {356 switch parts[0] {357 case "if":358 depth++359 case "end":360 depth--361 case "else":362 if depth == 0 {363 return block[:i+next], block[i+next+consumed:]364 }365 }366 }367 368 i += next + consumed369 }370 371 return block, ""372}373374// parseExpr splits expression respecting quotes and parentheses375func (r *Renderer) parseExpr(expr string) []string {376 var parts []string377 var current strings.Builder378 var inQuote bool379 var parenDepth int380 var prevChar rune381 382 for _, ch := range expr {383 switch ch {384 case '"':385 if prevChar != '\\' {386 inQuote = !inQuote387 }388 current.WriteRune(ch)389 case '(':390 if !inQuote {391 parenDepth++392 }393 current.WriteRune(ch)394 case ')':395 if !inQuote {396 parenDepth--397 }398 current.WriteRune(ch)399 case ' ':400 if !inQuote && parenDepth == 0 {401 if current.Len() > 0 {402 parts = append(parts, current.String())403 current.Reset()404 }405 } else {406 current.WriteRune(ch)407 }408 default:409 current.WriteRune(ch)410 }411 prevChar = ch412 }413 414 if current.Len() > 0 {415 parts = append(parts, current.String())416 }417 418 return parts419}420421// resolve looks up a variable422func (r *Renderer) resolve(name string) interface{} {423 if !strings.HasPrefix(name, ".") {424 return nil425 }426 name = strings.TrimPrefix(name, ".")427 428 // Check context stack429 ctx := r.ctx430 for ctx != nil {431 // Handle . in range432 if name == "" && ctx.loop != nil {433 if ctx.loop.index < len(ctx.loop.items) {434 return ctx.loop.items[ctx.loop.index]435 }436 }437 438 // Check local vars439 if val, ok := ctx.vars[name]; ok {440 return val441 }442 443 ctx = ctx.parent444 }445 446 // Check global data447 return lookup(r.data, name)448}449450// resolveArgs resolves all arguments451func (r *Renderer) resolveArgs(args []string) []string {452 result := make([]string, len(args))453 454 for i, arg := range args {455 // Handle nested calls456 if strings.HasPrefix(arg, "(") && strings.HasSuffix(arg, ")") {457 inner := arg[1 : len(arg)-1]458 parts := r.parseExpr(inner)459 if len(parts) > 0 {460 // Special handling for index461 if parts[0] == "index" && len(parts) >= 3 {462 result[i] = r.indexFunc(parts[1:]...)463 continue464 }465 if fn, ok := r.funcs[parts[0]]; ok {466 resolved := r.resolveArgs(parts[1:])467 result[i] = fn(resolved...)468 continue469 }470 }471 }472 473 // Handle variables474 if strings.HasPrefix(arg, ".") {475 if val := r.resolve(arg); val != nil {476 result[i] = typeutil.ToString(val)477 continue478 }479 }480 481 // Handle nested templates482 if strings.Contains(arg, "{{") {483 // For quoted strings with nested templates, handle specially484 if len(arg) >= 2 && arg[0] == '"' && arg[len(arg)-1] == '"' {485 // Remove outer quotes, process template, then result is the value486 inner := arg[1 : len(arg)-1]487 result[i] = r.render(inner)488 } else {489 result[i] = r.render(arg)490 }491 continue492 }493 494 // Plain string495 result[i] = unquote(arg)496 }497 498 return result499}500501// registerDefaults registers built-in functions502func (r *Renderer) registerDefaults() {503 // Markdown helpers504 r.funcs["H1"] = wrap1(md.H1)505 r.funcs["H2"] = wrap1(md.H2)506 r.funcs["H3"] = wrap1(md.H3)507 r.funcs["H4"] = wrap1(md.H4)508 r.funcs["H5"] = wrap1(md.H5)509 r.funcs["H6"] = wrap1(md.H6)510 r.funcs["Bold"] = wrap1(md.Bold)511 r.funcs["Italic"] = wrap1(md.Italic)512 r.funcs["Strikethrough"] = wrap1(md.Strikethrough)513 r.funcs["InlineCode"] = wrap1(md.InlineCode)514 r.funcs["BulletItem"] = wrap1(md.BulletItem)515 r.funcs["CodeBlock"] = wrap1(md.CodeBlock)516 r.funcs["Blockquote"] = wrap1(md.Blockquote)517 r.funcs["Paragraph"] = wrap1(md.Paragraph)518 r.funcs["EscapeText"] = wrap1(md.EscapeText)519 520 r.funcs["Link"] = wrap2(md.Link)521 r.funcs["Image"] = wrap2(md.Image)522 r.funcs["LanguageCodeBlock"] = wrap2(md.LanguageCodeBlock)523 r.funcs["Footnote"] = wrap2(md.FootnoteDefinition)524 r.funcs["CollapsibleSection"] = wrap2(md.CollapsibleSection)525 526 r.funcs["InlineImageWithLink"] = wrap3(md.InlineImageWithLink)527 528 r.funcs["TodoItem"] = func(args ...string) string {529 if len(args) >= 2 {530 return md.TodoItem(args[0], args[1] == "true")531 }532 return "[error: TodoItem requires 2 arguments]"533 }534 535 r.funcs["HorizontalRule"] = func(args ...string) string {536 return md.HorizontalRule()537 }538 539 // Utilities540 r.funcs["concat"] = func(args ...string) string {541 return strings.Join(args, "")542 }543 544 r.funcs["printf"] = func(args ...string) string {545 if len(args) == 0 {546 return ""547 }548 values := make([]interface{}, len(args)-1)549 for i, v := range args[1:] {550 values[i] = v551 }552 return ufmt.Sprintf(args[0], values...)553 }554 555 r.funcs["trim"] = wrap1(strings.TrimSpace)556 r.funcs["string"] = func(args ...string) string {557 if len(args) > 0 {558 return typeutil.ToString(args[0])559 }560 return ""561 }562 563 r.funcs["index"] = r.indexFunc564}565566// indexFunc provides array/map indexing567func (r *Renderer) indexFunc(args ...string) string {568 if len(args) < 2 {569 return "[error: index requires 2 arguments]"570 }571 572 // Handle the first argument - could be variable or literal573 collection := strings.TrimSpace(args[0])574 indexStr := strings.TrimSpace(args[1])575 576 // Remove quotes from index if present577 indexStr = unquote(indexStr)578 579 // For unit tests - literal values580 if !strings.HasPrefix(collection, ".") {581 if idx, err := strconv.Atoi(indexStr); err == nil {582 return ufmt.Sprintf("[%d]", idx)583 }584 return ufmt.Sprintf("[%s]", indexStr)585 }586 587 // Resolve the collection variable588 val := r.resolve(collection)589 if val == nil {590 return "[error: collection not found]"591 }592 593 // Parse index594 idx, err := strconv.Atoi(indexStr)595 if err != nil {596 return "[error: index must be a number]"597 }598 599 // Do the indexing600 switch v := val.(type) {601 case []interface{}:602 if idx >= 0 && idx < len(v) {603 return typeutil.ToString(v[idx])604 }605 case []string:606 if idx >= 0 && idx < len(v) {607 return v[idx]608 }609 default:610 if items := toSlice(val); items != nil {611 if idx >= 0 && idx < len(items) {612 return typeutil.ToString(items[idx])613 }614 }615 }616 617 return "[error: index out of range]"618}619620// Helper functions621622// lookup navigates nested maps623func lookup(data map[string]interface{}, path string) interface{} {624 parts := strings.Split(path, ".")625 var current interface{} = data626 627 for _, part := range parts {628 if part == "" {629 continue630 }631 if m, ok := current.(map[string]interface{}); ok {632 current = m[part]633 } else {634 return nil635 }636 }637 638 return current639}640641// toSlice converts various types to []interface{}642func toSlice(val interface{}) []interface{} {643 switch v := val.(type) {644 case []interface{}:645 return v646 case []map[string]interface{}:647 result := make([]interface{}, len(v))648 for i, m := range v {649 result[i] = m650 }651 return result652 default:653 return typeutil.ToInterfaceSlice(val)654 }655}656657// unquote removes quotes from strings658func unquote(s string) string {659 // Don't trim space - preserve formatting660 if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {661 return s[1 : len(s)-1]662 }663 return s664}665666// Function wrappers for cleaner registration667668func wrap1(fn func(string) string) Func {669 return func(args ...string) string {670 if len(args) > 0 {671 return fn(args[0])672 }673 return "[error: missing argument]"674 }675}676677func wrap2(fn func(string, string) string) Func {678 return func(args ...string) string {679 if len(args) >= 2 {680 return fn(args[0], args[1])681 }682 return "[error: requires 2 arguments]"683 }684}685686func wrap3(fn func(string, string, string) string) Func {687 return func(args ...string) string {688 if len(args) >= 3 {689 return fn(args[0], args[1], args[2])690 }691 return "[error: requires 3 arguments]"692 }693}694695// Helper is kept for backward compatibility696type Helper interface {697 Execute(args []string, data map[string]interface{}) string698}Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.