Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

template.gno

14.30 Kb · 697 lines
  1package template
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"unicode"
  7
  8	"gno.land/p/moul/md/v0"
  9	"gno.land/p/moul/typeutil/v0"
 10	"gno.land/p/nt/ufmt/v0"
 11)
 12
 13// Renderer provides a simple template engine with a clean API
 14type Renderer struct {
 15	funcs map[string]Func  // Template functions
 16	data  map[string]interface{} // Global data
 17	ctx   *context       // Current evaluation context
 18}
 19
 20// Func is the signature for template functions
 21type Func func(args ...string) string
 22
 23// context tracks variable scope during evaluation
 24type context struct {
 25	parent *context
 26	vars   map[string]interface{} // Local variables
 27	loop   *loopContext          // Range loop state
 28}
 29
 30// loopContext holds state for range loops
 31type loopContext struct {
 32	items []interface{} // Items being iterated
 33	index int          // Current index
 34}
 35
 36// NewRenderer creates a new template renderer with default functions
 37func NewRenderer() *Renderer {
 38	r := &Renderer{
 39		funcs: make(map[string]Func),
 40		data:  make(map[string]interface{}),
 41	}
 42	r.registerDefaults()
 43	return r
 44}
 45
 46// Render processes a template with the given data
 47func (r *Renderer) Render(template string, data map[string]interface{}) string {
 48	if data != nil {
 49		r.data = data
 50	}
 51	r.ctx = nil
 52	return r.render(template)
 53}
 54
 55// render is the core rendering engine
 56func (r *Renderer) render(tmpl string) string {
 57	var out strings.Builder
 58	
 59	for len(tmpl) > 0 {
 60		// Find next placeholder
 61		start := strings.Index(tmpl, "{{")
 62		if start < 0 {
 63			out.WriteString(tmpl)
 64			break
 65		}
 66		
 67		// Write content before placeholder
 68		out.WriteString(tmpl[:start])
 69		
 70		// Parse placeholder
 71		placeholder, end := r.parsePlaceholder(tmpl[start:])
 72		if placeholder == nil {
 73			out.WriteString("{{")
 74			tmpl = tmpl[start+2:]
 75			continue
 76		}
 77		
 78		// Apply left trim
 79		if placeholder.trimLeft {
 80			s := out.String()
 81			out.Reset()
 82			out.WriteString(strings.TrimRightFunc(s, unicode.IsSpace))
 83		}
 84		
 85		// Process placeholder
 86		result, consumed := r.process(placeholder, tmpl[start+end:])
 87		out.WriteString(result)
 88		
 89		// Advance position
 90		tmpl = tmpl[start+end+consumed:]
 91		
 92		// Apply right trim
 93		if placeholder.trimRight {
 94			tmpl = strings.TrimLeftFunc(tmpl, unicode.IsSpace)
 95		}
 96	}
 97	
 98	return out.String()
 99}
100
101// placeholder represents a parsed {{...}} tag
102type placeholder struct {
103	expr      string
104	trimLeft  bool
105	trimRight bool
106}
107
108// parsePlaceholder extracts a {{...}} placeholder
109func (r *Renderer) parsePlaceholder(tmpl string) (*placeholder, int) {
110	if !strings.HasPrefix(tmpl, "{{") {
111		return nil, 0
112	}
113	
114	// Find matching }}
115	end := r.findClosing(tmpl, 2)
116	if end < 0 {
117		return nil, 0
118	}
119	
120	// Extract content
121	content := tmpl[2:end]
122	
123	// Check trim markers
124	p := &placeholder{}
125	if strings.HasPrefix(content, "-") {
126		p.trimLeft = true
127		content = content[1:]
128	}
129	if strings.HasSuffix(content, "-") {
130		p.trimRight = true
131		content = content[:len(content)-1]
132	}
133	
134	p.expr = strings.TrimSpace(content)
135	return p, end + 2
136}
137
138// findClosing finds the closing }} for a {{
139func (r *Renderer) findClosing(tmpl string, start int) int {
140	depth := 1
141	i := start
142	
143	for i < len(tmpl)-1 {
144		if tmpl[i] == '{' && tmpl[i+1] == '{' {
145			depth++
146			i += 2
147		} else if tmpl[i] == '}' && tmpl[i+1] == '}' {
148			depth--
149			if depth == 0 {
150				return i
151			}
152			i += 2
153		} else {
154			i++
155		}
156	}
157	
158	return -1
159}
160
161// 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 "", 0
166	}
167	
168	cmd := parts[0]
169	args := parts[1:]
170	
171	// Handle variables
172	if strings.HasPrefix(cmd, ".") {
173		if val := r.resolve(cmd); val != nil {
174			return typeutil.ToString(val), 0
175		}
176		return "", 0
177	}
178	
179	// Handle commands
180	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 parent
187	default:
188		// Special handling for index function
189		if cmd == "index" && len(args) >= 2 {
190			return r.indexFunc(args...), 0
191		}
192		// Try function
193		if fn, ok := r.funcs[cmd]; ok {
194			resolved := r.resolveArgs(args)
195			return fn(resolved...), 0
196		}
197		return p.expr, 0
198	}
199}
200
201// doRange implements {{range .items}}...{{end}}
202func (r *Renderer) doRange(args []string, tmpl string) (string, int) {
203	if len(args) == 0 {
204		return "", 0
205	}
206	
207	// Get collection
208	val := r.resolve(args[0])
209	if val == nil {
210		return "", 0
211	}
212	
213	// Convert to items
214	items := toSlice(val)
215	if items == nil {
216		return "", 0
217	}
218	
219	// Find block
220	block, consumed := r.findBlock(tmpl, "end")
221	if consumed == 0 {
222		return "", 0
223	}
224	
225	// Execute loop
226	var out strings.Builder
227	oldCtx := r.ctx
228	
229	for i, item := range items {
230		// Create loop context
231		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 map
241		if m, ok := item.(map[string]interface{}); ok {
242			for k, v := range m {
243				r.ctx.vars[k] = v
244			}
245		}
246		
247		out.WriteString(r.render(block))
248	}
249	
250	r.ctx = oldCtx
251	return out.String(), consumed
252}
253
254// doIf implements {{if .cond}}...{{else}}...{{end}}
255func (r *Renderer) doIf(args []string, tmpl string) (string, int) {
256	if len(args) == 0 {
257		return "", 0
258	}
259	
260	// Evaluate condition
261	val := r.resolve(args[0])
262	cond := typeutil.ToBool(val)
263	
264	// Find block
265	block, consumed := r.findBlock(tmpl, "end")
266	if consumed == 0 {
267		return "", 0
268	}
269	
270	// Split on else
271	ifBlock, elseBlock := r.splitElse(block)
272	
273	// Execute branch
274	oldCtx := r.ctx
275	r.ctx = &context{
276		parent: oldCtx,
277		vars:   make(map[string]interface{}),
278	}
279	
280	var result string
281	if cond {
282		result = r.render(ifBlock)
283	} else {
284		result = r.render(elseBlock)
285	}
286	
287	r.ctx = oldCtx
288	return result, consumed
289}
290
291// findBlock finds content up to {{end}}
292func (r *Renderer) findBlock(tmpl string, endMarker string) (string, int) {
293	var out strings.Builder
294	depth := 1
295	i := 0
296	
297	for i < len(tmpl) {
298		// Find next {{
299		next := strings.Index(tmpl[i:], "{{")
300		if next < 0 {
301			break
302		}
303		
304		// Add content before {{
305		out.WriteString(tmpl[i : i+next])
306		
307		// Parse placeholder
308		p, consumed := r.parsePlaceholder(tmpl[i+next:])
309		if p == nil {
310			out.WriteString("{{")
311			i += next + 2
312			continue
313		}
314		
315		// Check command
316		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 + consumed
325				}
326			}
327		}
328		
329		// Add to output
330		out.WriteString(tmpl[i+next : i+next+consumed])
331		i += next + consumed
332	}
333	
334	return "", 0
335}
336
337// splitElse splits block on {{else}}
338func (r *Renderer) splitElse(block string) (string, string) {
339	depth := 0
340	i := 0
341	
342	for i < len(block) {
343		next := strings.Index(block[i:], "{{")
344		if next < 0 {
345			break
346		}
347		
348		p, consumed := r.parsePlaceholder(block[i+next:])
349		if p == nil {
350			i += next + 2
351			continue
352		}
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 + consumed
369	}
370	
371	return block, ""
372}
373
374// parseExpr splits expression respecting quotes and parentheses
375func (r *Renderer) parseExpr(expr string) []string {
376	var parts []string
377	var current strings.Builder
378	var inQuote bool
379	var parenDepth int
380	var prevChar rune
381	
382	for _, ch := range expr {
383		switch ch {
384		case '"':
385			if prevChar != '\\' {
386				inQuote = !inQuote
387			}
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 = ch
412	}
413	
414	if current.Len() > 0 {
415		parts = append(parts, current.String())
416	}
417	
418	return parts
419}
420
421// resolve looks up a variable
422func (r *Renderer) resolve(name string) interface{} {
423	if !strings.HasPrefix(name, ".") {
424		return nil
425	}
426	name = strings.TrimPrefix(name, ".")
427	
428	// Check context stack
429	ctx := r.ctx
430	for ctx != nil {
431		// Handle . in range
432		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 vars
439		if val, ok := ctx.vars[name]; ok {
440			return val
441		}
442		
443		ctx = ctx.parent
444	}
445	
446	// Check global data
447	return lookup(r.data, name)
448}
449
450// resolveArgs resolves all arguments
451func (r *Renderer) resolveArgs(args []string) []string {
452	result := make([]string, len(args))
453	
454	for i, arg := range args {
455		// Handle nested calls
456		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 index
461				if parts[0] == "index" && len(parts) >= 3 {
462					result[i] = r.indexFunc(parts[1:]...)
463					continue
464				}
465				if fn, ok := r.funcs[parts[0]]; ok {
466					resolved := r.resolveArgs(parts[1:])
467					result[i] = fn(resolved...)
468					continue
469				}
470			}
471		}
472		
473		// Handle variables
474		if strings.HasPrefix(arg, ".") {
475			if val := r.resolve(arg); val != nil {
476				result[i] = typeutil.ToString(val)
477				continue
478			}
479		}
480		
481		// Handle nested templates
482		if strings.Contains(arg, "{{") {
483			// For quoted strings with nested templates, handle specially
484			if len(arg) >= 2 && arg[0] == '"' && arg[len(arg)-1] == '"' {
485				// Remove outer quotes, process template, then result is the value
486				inner := arg[1 : len(arg)-1]
487				result[i] = r.render(inner)
488			} else {
489				result[i] = r.render(arg)
490			}
491			continue
492		}
493		
494		// Plain string
495		result[i] = unquote(arg)
496	}
497	
498	return result
499}
500
501// registerDefaults registers built-in functions
502func (r *Renderer) registerDefaults() {
503	// Markdown helpers
504	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	// Utilities
540	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] = v
551		}
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.indexFunc
564}
565
566// indexFunc provides array/map indexing
567func (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 literal
573	collection := strings.TrimSpace(args[0])
574	indexStr := strings.TrimSpace(args[1])
575	
576	// Remove quotes from index if present
577	indexStr = unquote(indexStr)
578	
579	// For unit tests - literal values
580	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 variable
588	val := r.resolve(collection)
589	if val == nil {
590		return "[error: collection not found]"
591	}
592	
593	// Parse index
594	idx, err := strconv.Atoi(indexStr)
595	if err != nil {
596		return "[error: index must be a number]"
597	}
598	
599	// Do the indexing
600	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}
619
620// Helper functions
621
622// lookup navigates nested maps
623func lookup(data map[string]interface{}, path string) interface{} {
624	parts := strings.Split(path, ".")
625	var current interface{} = data
626	
627	for _, part := range parts {
628		if part == "" {
629			continue
630		}
631		if m, ok := current.(map[string]interface{}); ok {
632			current = m[part]
633		} else {
634			return nil
635		}
636	}
637	
638	return current
639}
640
641// toSlice converts various types to []interface{}
642func toSlice(val interface{}) []interface{} {
643	switch v := val.(type) {
644	case []interface{}:
645		return v
646	case []map[string]interface{}:
647		result := make([]interface{}, len(v))
648		for i, m := range v {
649			result[i] = m
650		}
651		return result
652	default:
653		return typeutil.ToInterfaceSlice(val)
654	}
655}
656
657// unquote removes quotes from strings
658func unquote(s string) string {
659	// Don't trim space - preserve formatting
660	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
661		return s[1 : len(s)-1]
662	}
663	return s
664}
665
666// Function wrappers for cleaner registration
667
668func 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}
676
677func 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}
685
686func 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}
694
695// Helper is kept for backward compatibility
696type Helper interface {
697	Execute(args []string, data map[string]interface{}) string
698}