From 53f778c32395d77afd9d337b1705e277de4a588c Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Wed, 13 Aug 2025 10:17:52 -0500 Subject: [PATCH] work template engine, implement item purchase and equip --- assets/dk.css | 2 +- assets/images/backgrounds/background.gif | Bin 7994 -> 0 bytes assets/images/backgrounds/classic.jpg | Bin 6228 -> 0 bytes assets/images/bars_green.gif | Bin 94 -> 0 bytes assets/images/bars_red.gif | Bin 94 -> 0 bytes assets/images/bars_yellow.gif | Bin 94 -> 0 bytes internal/actions/user_item.go | 56 ++ internal/routes/town.go | 40 + internal/template/cache.go | 100 +++ internal/template/template.go | 991 ++++++++++------------- internal/template/template_test.go | 314 ------- 11 files changed, 611 insertions(+), 892 deletions(-) delete mode 100644 assets/images/backgrounds/background.gif delete mode 100644 assets/images/backgrounds/classic.jpg delete mode 100644 assets/images/bars_green.gif delete mode 100644 assets/images/bars_red.gif delete mode 100644 assets/images/bars_yellow.gif create mode 100644 internal/actions/user_item.go create mode 100644 internal/template/cache.go delete mode 100644 internal/template/template_test.go diff --git a/assets/dk.css b/assets/dk.css index dfdd942..a08761a 100644 --- a/assets/dk.css +++ b/assets/dk.css @@ -197,7 +197,7 @@ button.btn { } &.btn-primary { - color: rgba(0, 0, 0, 0.5); + color: rgba(0, 0, 0, 0.75); background-color: #F2994A; background-image: url("/assets/images/overlay.png"), linear-gradient(to bottom, #F2C94C, #F2994A); border-color: #F2994A; diff --git a/assets/images/backgrounds/background.gif b/assets/images/backgrounds/background.gif deleted file mode 100644 index 6024b970e58e33488fe451013d272a8930a904e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7994 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 I)VP@XFR+O%p#T5? diff --git a/assets/images/bars_green.gif b/assets/images/bars_green.gif deleted file mode 100644 index 32349dae785e474b018cb0e0d7a485788088c77f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94 zcmZ?wbhEHbb8qxp& diff --git a/assets/images/bars_yellow.gif b/assets/images/bars_yellow.gif deleted file mode 100644 index 628ecae217172a2d0395f4a27a39f5cef359dc38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94 zcmZ?wbhEHbWAUYHUo$Y2csk$WG2 diff --git a/internal/actions/user_item.go b/internal/actions/user_item.go new file mode 100644 index 0000000..ada1ad7 --- /dev/null +++ b/internal/actions/user_item.go @@ -0,0 +1,56 @@ +package actions + +import ( + "dk/internal/items" + "dk/internal/users" +) + +// UserEquipItem equips a given item onto a user. This overwrites any +// previously equipped item in the slot. Does not save. +func UserEquipItem(user *users.User, item *items.Item) { + slotInUse := false + if item.Type == items.TypeWeapon && user.WeaponID != 0 { + slotInUse = true + } + if item.Type == items.TypeArmor && user.ArmorID != 0 { + slotInUse = true + } + if item.Type == items.TypeShield && user.ShieldID != 0 { + slotInUse = true + } + + var oldItem *items.Item + if slotInUse && item.Type == items.TypeWeapon { + oldItem, _ = items.Find(user.WeaponID) + } else if slotInUse && item.Type == items.TypeArmor { + oldItem, _ = items.Find(user.ArmorID) + } else if slotInUse && item.Type == items.TypeShield { + oldItem, _ = items.Find(user.ShieldID) + } + + if oldItem != nil { + switch oldItem.Type { + case items.TypeWeapon: + user.Set("Attack", user.Attack-oldItem.Att) + case items.TypeArmor: + user.Set("Defense", user.Defense-oldItem.Att) + case items.TypeShield: + user.Set("Defense", user.Defense-oldItem.Att) + } + } + + switch item.Type { + case items.TypeWeapon: + user.Set("Attack", user.Attack+item.Att) + user.Set("WeaponID", item.ID) + user.Set("WeaponName", item.Name) + case items.TypeArmor: + user.Set("Defense", user.Defense+item.Att) + user.Set("ArmorID", item.ID) + user.Set("ArmorName", item.Name) + case items.TypeShield: + user.Set("Defense", user.Defense+item.Att) + user.Set("ShieldID", item.ID) + user.Set("ShieldName", item.Name) + } +} diff --git a/internal/routes/town.go b/internal/routes/town.go index f5f4c54..799bd10 100644 --- a/internal/routes/town.go +++ b/internal/routes/town.go @@ -1,6 +1,7 @@ package routes import ( + "dk/internal/actions" "dk/internal/auth" "dk/internal/helpers" "dk/internal/items" @@ -9,6 +10,8 @@ import ( "dk/internal/template/components" "dk/internal/towns" "dk/internal/users" + "slices" + "strconv" ) func RegisterTownRoutes(r *router.Router) { @@ -20,6 +23,7 @@ func RegisterTownRoutes(r *router.Router) { group.Get("/inn", showInn) group.Get("/shop", showShop) group.WithMiddleware(middleware.CSRF(auth.Manager)).Post("/inn", rest) + group.Get("/shop/buy/:id", buyItem) } func showTown(ctx router.Ctx, _ []string) { @@ -93,3 +97,39 @@ func showShop(ctx router.Ctx, _ []string) { "error_message": errorHTML, }) } + +func buyItem(ctx router.Ctx, params []string) { + id, err := strconv.Atoi(params[0]) + if err != nil { + auth.SetFlashMessage(ctx, "error", "Error purchasing item; "+err.Error()) + ctx.Redirect("/town/shop", 302) + return + } + + town := ctx.UserValue("town").(*towns.Town) + if !slices.Contains(town.GetShopItems(), id) { + auth.SetFlashMessage(ctx, "error", "The item doesn't exist in this shop.") + ctx.Redirect("/town/shop", 302) + return + } + + item, err := items.Find(id) + if err != nil { + auth.SetFlashMessage(ctx, "error", "Error purchasing item; "+err.Error()) + ctx.Redirect("/town/shop", 302) + return + } + + user := ctx.UserValue("user").(*users.User) + if user.Gold < item.Value { + auth.SetFlashMessage(ctx, "error", "You don't have enough gold to buy "+item.Name) + ctx.Redirect("/town/shop", 302) + return + } + + user.Set("Gold", user.Gold-item.Value) + actions.UserEquipItem(user, item) + user.Save() + + ctx.Redirect("/town/shop", 302) +} diff --git a/internal/template/cache.go b/internal/template/cache.go new file mode 100644 index 0000000..62d572c --- /dev/null +++ b/internal/template/cache.go @@ -0,0 +1,100 @@ +package template + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +var Cache *TemplateCache + +type TemplateCache struct { + mu sync.RWMutex + templates map[string]*Template + basePath string +} + +func NewCache(basePath string) *TemplateCache { + if basePath == "" { + exe, err := os.Executable() + if err != nil { + basePath = "." + } else { + basePath = filepath.Dir(exe) + } + } + + return &TemplateCache{ + templates: make(map[string]*Template), + basePath: basePath, + } +} + +func InitializeCache(basePath string) { + Cache = NewCache(basePath) +} + +func (c *TemplateCache) Load(name string) (*Template, error) { + c.mu.RLock() + tmpl, exists := c.templates[name] + c.mu.RUnlock() + + if exists { + if err := c.checkAndReload(tmpl); err != nil { + return nil, err + } + return tmpl, nil + } + + return c.loadFromFile(name) +} + +func (c *TemplateCache) loadFromFile(name string) (*Template, error) { + filePath := filepath.Join(c.basePath, "templates", name) + + info, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("template file not found: %s", name) + } + + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read template: %w", err) + } + + tmpl := &Template{ + name: name, + content: string(content), + modTime: info.ModTime(), + filePath: filePath, + cache: c, + } + + c.mu.Lock() + c.templates[name] = tmpl + c.mu.Unlock() + + return tmpl, nil +} + +func (c *TemplateCache) checkAndReload(tmpl *Template) error { + info, err := os.Stat(tmpl.filePath) + if err != nil { + return err + } + + if info.ModTime().After(tmpl.modTime) { + content, err := os.ReadFile(tmpl.filePath) + if err != nil { + return err + } + + c.mu.Lock() + tmpl.content = string(content) + tmpl.modTime = info.ModTime() + c.mu.Unlock() + } + + return nil +} diff --git a/internal/template/template.go b/internal/template/template.go index 0d36bcd..47a0120 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -3,26 +3,14 @@ package template import ( "fmt" "maps" - "os" - "path/filepath" "reflect" "strconv" "strings" - "sync" "time" "github.com/valyala/fasthttp" ) -// Cache is the global singleton instance -var Cache *TemplateCache - -type TemplateCache struct { - mu sync.RWMutex - templates map[string]*Template - basePath string -} - type Template struct { name string content string @@ -31,132 +19,334 @@ type Template struct { cache *TemplateCache } -func NewCache(basePath string) *TemplateCache { - if basePath == "" { - exe, err := os.Executable() - if err != nil { - basePath = "." - } else { - basePath = filepath.Dir(exe) - } - } - - return &TemplateCache{ - templates: make(map[string]*Template), - basePath: basePath, - } -} - -// InitializeCache initializes the global Cache singleton -func InitializeCache(basePath string) { - Cache = NewCache(basePath) -} - -func (c *TemplateCache) Load(name string) (*Template, error) { - c.mu.RLock() - tmpl, exists := c.templates[name] - c.mu.RUnlock() - - if exists { - if err := c.checkAndReload(tmpl); err != nil { - return nil, err - } - return tmpl, nil - } - - return c.loadFromFile(name) -} - -func (c *TemplateCache) loadFromFile(name string) (*Template, error) { - filePath := filepath.Join(c.basePath, "templates", name) - - info, err := os.Stat(filePath) - if err != nil { - return nil, fmt.Errorf("template file not found: %s", name) - } - - content, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read template: %w", err) - } - - tmpl := &Template{ - name: name, - content: string(content), - modTime: info.ModTime(), - filePath: filePath, - cache: c, - } - - c.mu.Lock() - c.templates[name] = tmpl - c.mu.Unlock() - - return tmpl, nil -} - -func (c *TemplateCache) checkAndReload(tmpl *Template) error { - info, err := os.Stat(tmpl.filePath) - if err != nil { - return err - } - - if info.ModTime().After(tmpl.modTime) { - content, err := os.ReadFile(tmpl.filePath) - if err != nil { - return err - } - - c.mu.Lock() - tmpl.content = string(content) - tmpl.modTime = info.ModTime() - c.mu.Unlock() - } - - return nil -} - func (t *Template) RenderPositional(args ...any) string { result := t.content for i, arg := range args { - placeholder := fmt.Sprintf("{%d}", i) - result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", arg)) + result = strings.ReplaceAll(result, fmt.Sprintf("{%d}", i), fmt.Sprintf("%v", arg)) } - result = t.processIncludes(result, nil) return result } func (t *Template) RenderNamed(data map[string]any) string { - result := t.content - - // Process blocks first to extract them - blocks := make(map[string]string) - result = t.processBlocks(result, blocks) - - // Process includes - result = t.processIncludes(result, data) - - // Process loops and conditionals - result = t.processLoops(result, data) - result = t.processConditionals(result, data) - - // Process yield with conditionals in blocks - result = t.processYield(result, blocks, data) - - // Apply data substitutions - for key, value := range data { - placeholder := fmt.Sprintf("{%s}", key) - result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value)) + if data == nil { + data = make(map[string]any) } - result = t.replaceDotNotation(result, data) + blocks := make(map[string]string) + result := t.processBlocks(t.content, blocks) + result = t.processIncludes(result, data, blocks) + result = t.processYields(result, blocks, data) + result = t.processLoops(result, data) + result = t.processConditionals(result, data) + result = t.processVariables(result, data) return result } -func (t *Template) replaceDotNotation(content string, data map[string]any) string { +func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data any) { + var result string + + switch v := data.(type) { + case map[string]any: + result = t.RenderNamed(v) + case []any: + result = t.RenderPositional(v...) + default: + rv := reflect.ValueOf(data) + if rv.Kind() == reflect.Slice { + args := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + args[i] = rv.Index(i).Interface() + } + result = t.RenderPositional(args...) + } else { + result = t.RenderPositional(data) + } + } + + ctx.SetContentType("text/html; charset=utf-8") + ctx.WriteString(result) +} + +func (t *Template) processBlocks(content string, blocks map[string]string) string { result := content + for { + start := strings.Index(result, "{block ") + if start == -1 { + break + } + + nameEnd := strings.Index(result[start:], "}") + if nameEnd == -1 { + break + } + nameEnd += start + + blockName := strings.Trim(result[start+7:nameEnd], "\" ") + contentStart := nameEnd + 1 + + contentEnd := t.findMatchingEnd(result[contentStart:], "{block", "{/block}") + if contentEnd == -1 { + break + } + contentEnd += contentStart + + blocks[blockName] = result[contentStart:contentEnd] + result = result[:start] + result[contentEnd+8:] // +8 for "{/block}" + } + + return result +} + +func (t *Template) processIncludes(content string, data map[string]any, blocks map[string]string) string { + result := content + + for { + start := strings.Index(result, "{include ") + if start == -1 { + break + } + + end := strings.Index(result[start:], "}") + if end == -1 { + break + } + end += start + + templateName := strings.Trim(result[start+9:end], "\" ") + + if tmpl, err := t.cache.Load(templateName); err == nil { + // Process included template with same blocks context + included := tmpl.processBlocks(tmpl.content, blocks) + included = tmpl.processIncludes(included, data, blocks) + included = tmpl.processYields(included, blocks, data) + included = tmpl.processLoops(included, data) + included = tmpl.processConditionals(included, data) + included = tmpl.processVariables(included, data) + + result = result[:start] + included + result[end+1:] + } else { + result = result[:start] + result[end+1:] + } + } + + return result +} + +func (t *Template) processYields(content string, blocks map[string]string, data map[string]any) string { + result := content + + // Process defined blocks + for blockName, blockContent := range blocks { + processed := t.processLoops(blockContent, data) + processed = t.processConditionals(processed, data) + processed = t.processVariables(processed, data) + + result = strings.ReplaceAll(result, fmt.Sprintf(`{yield "%s"}`, blockName), processed) + } + + // Remove unused named yields + start := 0 + for { + yieldStart := strings.Index(result[start:], "{yield ") + if yieldStart == -1 { + break + } + yieldStart += start + + yieldEnd := strings.Index(result[yieldStart:], "}") + if yieldEnd == -1 { + break + } + yieldEnd += yieldStart + + result = result[:yieldStart] + result[yieldEnd+1:] + start = yieldStart + } + + // Remove any remaining unnamed yields + result = strings.ReplaceAll(result, "{yield}", "") + + return result +} + +func (t *Template) processLoops(content string, data map[string]any) string { + result := content + + for { + start := strings.Index(result, "{for ") + if start == -1 { + break + } + + headerEnd := strings.Index(result[start:], "}") + if headerEnd == -1 { + break + } + headerEnd += start + + header := result[start+5 : headerEnd] + contentStart := headerEnd + 1 + + contentEnd := t.findMatchingEnd(result[contentStart:], "{for", "{/for}") + if contentEnd == -1 { + break + } + contentEnd += contentStart + + loopContent := result[contentStart:contentEnd] + expanded := t.expandLoop(header, loopContent, data) + + result = result[:start] + expanded + result[contentEnd+6:] // +6 for "{/for}" + } + + return result +} + +func (t *Template) expandLoop(header, content string, data map[string]any) string { + parts := strings.Split(strings.TrimSpace(header), " in ") + if len(parts) != 2 { + return "" + } + + varPart := strings.TrimSpace(parts[0]) + sourcePart := strings.TrimSpace(parts[1]) + + source := t.getNestedValue(data, sourcePart) + if source == nil { + return "" + } + + var result strings.Builder + rv := reflect.ValueOf(source) + + if strings.Contains(varPart, ",") { + // Key,value iteration + vars := strings.Split(varPart, ",") + keyVar := strings.TrimSpace(vars[0]) + valueVar := strings.TrimSpace(vars[1]) + + switch rv.Kind() { + case reflect.Map: + for _, key := range rv.MapKeys() { + iterData := make(map[string]any) + maps.Copy(iterData, data) + iterData[keyVar] = key.Interface() + iterData[valueVar] = rv.MapIndex(key).Interface() + + iterContent := t.processLoops(content, iterData) + iterContent = t.processConditionals(iterContent, iterData) + iterContent = t.processVariables(iterContent, iterData) + result.WriteString(iterContent) + } + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + iterData := make(map[string]any) + maps.Copy(iterData, data) + iterData[keyVar] = i + iterData[valueVar] = rv.Index(i).Interface() + + iterContent := t.processLoops(content, iterData) + iterContent = t.processConditionals(iterContent, iterData) + iterContent = t.processVariables(iterContent, iterData) + result.WriteString(iterContent) + } + } + } else { + // Single variable iteration + switch rv.Kind() { + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + iterData := make(map[string]any) + maps.Copy(iterData, data) + iterData[varPart] = rv.Index(i).Interface() + + iterContent := t.processLoops(content, iterData) + iterContent = t.processConditionals(iterContent, iterData) + iterContent = t.processVariables(iterContent, iterData) + result.WriteString(iterContent) + } + case reflect.Map: + for _, key := range rv.MapKeys() { + iterData := make(map[string]any) + maps.Copy(iterData, data) + iterData[varPart] = rv.MapIndex(key).Interface() + + iterContent := t.processLoops(content, iterData) + iterContent = t.processConditionals(iterContent, iterData) + iterContent = t.processVariables(iterContent, iterData) + result.WriteString(iterContent) + } + } + } + + return result.String() +} + +func (t *Template) processConditionals(content string, data map[string]any) string { + result := content + + for { + start := strings.Index(result, "{if ") + if start == -1 { + break + } + + headerEnd := strings.Index(result[start:], "}") + if headerEnd == -1 { + break + } + headerEnd += start + + condition := strings.TrimSpace(result[start+4 : headerEnd]) + contentStart := headerEnd + 1 + + contentEnd := t.findMatchingEnd(result[contentStart:], "{if", "{/if}") + if contentEnd == -1 { + break + } + contentEnd += contentStart + + ifContent := result[contentStart:contentEnd] + + // Find else at top level + elsePos := t.findElseAtLevel(ifContent) + var trueContent, falseContent string + + if elsePos != -1 { + trueContent = ifContent[:elsePos] + falseContent = ifContent[elsePos+6:] // Skip "{else}" + } else { + trueContent = ifContent + } + + var selectedContent string + if t.evaluateCondition(condition, data) { + selectedContent = trueContent + } else { + selectedContent = falseContent + } + + // Process selected content recursively + selectedContent = t.processLoops(selectedContent, data) + selectedContent = t.processConditionals(selectedContent, data) + + result = result[:start] + selectedContent + result[contentEnd+5:] // +5 for "{/if}" + } + + return result +} + +func (t *Template) processVariables(content string, data map[string]any) string { + result := content + + // Process simple variables + for key, value := range data { + result = strings.ReplaceAll(result, fmt.Sprintf("{%s}", key), fmt.Sprintf("%v", value)) + } + + // Process dot notation start := 0 for { startIdx := strings.Index(result[start:], "{") @@ -188,13 +378,84 @@ func (t *Template) replaceDotNotation(content string, data map[string]any) strin return result } +func (t *Template) findMatchingEnd(content, startTag, endTag string) int { + level := 1 + pos := 0 + + for pos < len(content) && level > 0 { + nextStart := strings.Index(content[pos:], startTag) + nextEnd := strings.Index(content[pos:], endTag) + + if nextStart != -1 && (nextEnd == -1 || nextStart < nextEnd) { + level++ + pos += nextStart + len(startTag) + } else if nextEnd != -1 { + level-- + if level == 0 { + return pos + nextEnd + } + pos += nextEnd + len(endTag) + } else { + break + } + } + + return -1 +} + +func (t *Template) findElseAtLevel(content string) int { + level := 0 + pos := 0 + + for pos < len(content) { + nextIf := strings.Index(content[pos:], "{if ") + nextElse := strings.Index(content[pos:], "{else}") + nextEnd := strings.Index(content[pos:], "{/if}") + + earliest := -1 + var tag string + + if nextIf != -1 && (earliest == -1 || nextIf < earliest-pos) { + earliest = pos + nextIf + tag = "if" + } + if nextElse != -1 && (earliest == -1 || nextElse < earliest-pos) { + earliest = pos + nextElse + tag = "else" + } + if nextEnd != -1 && (earliest == -1 || nextEnd < earliest-pos) { + earliest = pos + nextEnd + tag = "end" + } + + if earliest == -1 { + break + } + + switch tag { + case "if": + level++ + pos = earliest + 4 + case "else": + if level == 0 { + return earliest + } + pos = earliest + 6 + case "end": + level-- + pos = earliest + 5 + } + } + + return -1 +} + func (t *Template) getNestedValue(data map[string]any, path string) any { keys := strings.Split(path, ".") var current any = data for i, key := range keys { if i == len(keys)-1 { - // Final key - handle both maps and structs switch v := current.(type) { case map[string]any: return v[key] @@ -203,7 +464,6 @@ func (t *Template) getNestedValue(data map[string]any, path string) any { } } - // Intermediate key - get the next value var next any switch v := current.(type) { case map[string]any: @@ -219,35 +479,12 @@ func (t *Template) getNestedValue(data map[string]any, path string) any { } } - // Prepare for next iteration - switch v := next.(type) { - case map[string]any: - current = v - case map[any]any: - newMap := make(map[string]any) - for k, val := range v { - newMap[fmt.Sprintf("%v", k)] = val - } - current = newMap - default: - rv := reflect.ValueOf(next) - if rv.Kind() == reflect.Map { - newMap := make(map[string]any) - for _, k := range rv.MapKeys() { - newMap[fmt.Sprintf("%v", k.Interface())] = rv.MapIndex(k).Interface() - } - current = newMap - } else { - // For structs, keep the struct value for the next iteration - current = next - } - } + current = t.convertToStringMap(next) } return nil } -// getStructField gets a field value from a struct using reflection func (t *Template) getStructField(obj any, fieldName string) any { if obj == nil { return nil @@ -273,408 +510,34 @@ func (t *Template) getStructField(obj any, fieldName string) any { return field.Interface() } -func (t *Template) getLength(value any) int { - if value == nil { - return 0 - } - - rv := reflect.ValueOf(value) - switch rv.Kind() { - case reflect.Slice, reflect.Array, reflect.Map, reflect.String: - return rv.Len() - default: - return 0 - } -} - -func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data any) { - var result string - - switch v := data.(type) { +func (t *Template) convertToStringMap(value any) any { + switch v := value.(type) { case map[string]any: - result = t.RenderNamed(v) - case []any: - result = t.RenderPositional(v...) + return v + case map[any]any: + newMap := make(map[string]any) + for k, val := range v { + newMap[fmt.Sprintf("%v", k)] = val + } + return newMap default: - rv := reflect.ValueOf(data) - if rv.Kind() == reflect.Slice { - args := make([]any, rv.Len()) - for i := 0; i < rv.Len(); i++ { - args[i] = rv.Index(i).Interface() + rv := reflect.ValueOf(value) + if rv.Kind() == reflect.Map { + newMap := make(map[string]any) + for _, k := range rv.MapKeys() { + newMap[fmt.Sprintf("%v", k.Interface())] = rv.MapIndex(k).Interface() } - result = t.RenderPositional(args...) - } else { - result = t.RenderPositional(data) + return newMap } + return value } - - ctx.SetContentType("text/html; charset=utf-8") - ctx.WriteString(result) } -// processIncludes handles {include "template.html"} directives -func (t *Template) processIncludes(content string, data map[string]any) string { - result := content - - for { - start := strings.Index(result, "{include ") - if start == -1 { - break - } - - end := strings.Index(result[start:], "}") - if end == -1 { - break - } - end += start - - directive := result[start+9 : end] // Skip "{include " - templateName := strings.Trim(directive, "\" ") - - if includedTemplate, err := t.cache.Load(templateName); err == nil { - var includedContent string - if data != nil { - includedContent = includedTemplate.RenderNamed(data) - } else { - includedContent = includedTemplate.content - } - result = result[:start] + includedContent + result[end+1:] - } else { - // Remove the include directive if template not found - result = result[:start] + result[end+1:] - } - } - - return result -} - -// processBlocks extracts {block "name"}...{/block} sections -func (t *Template) processBlocks(content string, blocks map[string]string) string { - result := content - - for { - start := strings.Index(result, "{block ") - if start == -1 { - break - } - - nameEnd := strings.Index(result[start:], "}") - if nameEnd == -1 { - break - } - nameEnd += start - - blockName := strings.Trim(result[start+7:nameEnd], "\" ") - - contentStart := nameEnd + 1 - endTag := "{/block}" - contentEnd := strings.Index(result[contentStart:], endTag) - if contentEnd == -1 { - break - } - contentEnd += contentStart - - blockContent := result[contentStart:contentEnd] - blocks[blockName] = blockContent - - // Remove the block definition from the template - result = result[:start] + result[contentEnd+len(endTag):] - } - - return result -} - -// processYield handles {yield} directives for template inheritance -func (t *Template) processYield(content string, blocks map[string]string, data map[string]any) string { - result := content - - for blockName, blockContent := range blocks { - // Process conditionals and loops in block content before yielding - processedBlock := t.processLoops(blockContent, data) - processedBlock = t.processConditionals(processedBlock, data) - - yieldPlaceholder := fmt.Sprintf("{yield \"%s\"}", blockName) - result = strings.ReplaceAll(result, yieldPlaceholder, processedBlock) - } - - // Replace any remaining {yield} with empty string - result = strings.ReplaceAll(result, "{yield}", "") - - return result -} - -// processLoops handles {for item in items}...{/for} and {for key,value in map}...{/for} -func (t *Template) processLoops(content string, data map[string]any) string { - result := content - - for { - start := strings.Index(result, "{for ") - if start == -1 { - break - } - - headerEnd := strings.Index(result[start:], "}") - if headerEnd == -1 { - break - } - headerEnd += start - - header := result[start+5 : headerEnd] // Skip "{for " - - contentStart := headerEnd + 1 - endTag := "{/for}" - contentEnd := strings.Index(result[contentStart:], endTag) - if contentEnd == -1 { - break - } - contentEnd += contentStart - - loopContent := result[contentStart:contentEnd] - expanded := t.expandLoop(header, loopContent, data) - - result = result[:start] + expanded + result[contentEnd+len(endTag):] - } - - return result -} - -// expandLoop processes a single loop construct -func (t *Template) expandLoop(header, content string, data map[string]any) string { - parts := strings.Split(strings.TrimSpace(header), " in ") - if len(parts) != 2 { - return "" - } - - varPart := strings.TrimSpace(parts[0]) - sourcePart := strings.TrimSpace(parts[1]) - - source := t.getNestedValue(data, sourcePart) - if source == nil { - return "" - } - - var result strings.Builder - - // Handle key,value pairs - if strings.Contains(varPart, ",") { - keyVar, valueVar := strings.TrimSpace(varPart[:strings.Index(varPart, ",")]), strings.TrimSpace(varPart[strings.Index(varPart, ",")+1:]) - - rv := reflect.ValueOf(source) - switch rv.Kind() { - case reflect.Map: - for _, key := range rv.MapKeys() { - iterData := make(map[string]any) - maps.Copy(iterData, data) - iterData[keyVar] = key.Interface() - iterData[valueVar] = rv.MapIndex(key).Interface() - - iterResult := content - iterResult = t.processLoops(iterResult, iterData) - iterResult = t.processConditionals(iterResult, iterData) - for k, v := range iterData { - placeholder := fmt.Sprintf("{%s}", k) - iterResult = strings.ReplaceAll(iterResult, placeholder, fmt.Sprintf("%v", v)) - } - iterResult = t.replaceDotNotation(iterResult, iterData) - result.WriteString(iterResult) - } - case reflect.Slice, reflect.Array: - for i := 0; i < rv.Len(); i++ { - iterData := make(map[string]any) - maps.Copy(iterData, data) - iterData[keyVar] = i - iterData[valueVar] = rv.Index(i).Interface() - - iterResult := content - iterResult = t.processLoops(iterResult, iterData) - iterResult = t.processConditionals(iterResult, iterData) - for k, v := range iterData { - placeholder := fmt.Sprintf("{%s}", k) - iterResult = strings.ReplaceAll(iterResult, placeholder, fmt.Sprintf("%v", v)) - } - iterResult = t.replaceDotNotation(iterResult, iterData) - result.WriteString(iterResult) - } - } - } else { - // Single variable iteration - rv := reflect.ValueOf(source) - switch rv.Kind() { - case reflect.Slice, reflect.Array: - for i := 0; i < rv.Len(); i++ { - iterData := make(map[string]any) - maps.Copy(iterData, data) - iterData[varPart] = rv.Index(i).Interface() - - iterResult := content - iterResult = t.processLoops(iterResult, iterData) - iterResult = t.processConditionals(iterResult, iterData) - for k, v := range iterData { - placeholder := fmt.Sprintf("{%s}", k) - iterResult = strings.ReplaceAll(iterResult, placeholder, fmt.Sprintf("%v", v)) - } - iterResult = t.replaceDotNotation(iterResult, iterData) - result.WriteString(iterResult) - } - case reflect.Map: - for _, key := range rv.MapKeys() { - iterData := make(map[string]any) - maps.Copy(iterData, data) - iterData[varPart] = rv.MapIndex(key).Interface() - - iterResult := content - iterResult = t.processLoops(iterResult, iterData) - iterResult = t.processConditionals(iterResult, iterData) - for k, v := range iterData { - placeholder := fmt.Sprintf("{%s}", k) - iterResult = strings.ReplaceAll(iterResult, placeholder, fmt.Sprintf("%v", v)) - } - iterResult = t.replaceDotNotation(iterResult, iterData) - result.WriteString(iterResult) - } - } - } - - return result.String() -} - -// processConditionals handles {if condition}...{/if} and {if condition}...{else}...{/if} -func (t *Template) processConditionals(content string, data map[string]any) string { - result := content - - for { - start := strings.Index(result, "{if ") - if start == -1 { - break - } - - headerEnd := strings.Index(result[start:], "}") - if headerEnd == -1 { - break - } - headerEnd += start - - condition := strings.TrimSpace(result[start+4 : headerEnd]) // Skip "{if " - - contentStart := headerEnd + 1 - - // Find matching {/if} by tracking nesting level - nestLevel := 1 - pos := contentStart - contentEnd := -1 - - for pos < len(result) && nestLevel > 0 { - ifStart := strings.Index(result[pos:], "{if ") - endStart := strings.Index(result[pos:], "{/if}") - - if ifStart != -1 && (endStart == -1 || ifStart < endStart) { - // Found nested {if} - nestLevel++ - pos += ifStart + 4 - } else if endStart != -1 { - // Found {/if} - nestLevel-- - if nestLevel == 0 { - contentEnd = pos + endStart - break - } - pos += endStart + 5 - } else { - break - } - } - - if contentEnd == -1 { - break - } - - ifContent := result[contentStart:contentEnd] - - // Check for else clause at the same nesting level - elseStart := t.findElseAtLevel(ifContent) - var trueContent, falseContent string - if elseStart != -1 { - trueContent = ifContent[:elseStart] - falseContent = ifContent[elseStart+6:] // Skip "{else}" - } else { - trueContent = ifContent - falseContent = "" - } - - var selectedContent string - if t.evaluateCondition(condition, data) { - selectedContent = trueContent - } else { - selectedContent = falseContent - } - - // Recursively process the selected content - selectedContent = t.processLoops(selectedContent, data) - selectedContent = t.processConditionals(selectedContent, data) - - result = result[:start] + selectedContent + result[contentEnd+5:] // +5 for "{/if}" - } - - return result -} - -// findElseAtLevel finds {else} at the top level (not nested) -func (t *Template) findElseAtLevel(content string) int { - nestLevel := 0 - pos := 0 - - for pos < len(content) { - ifStart := strings.Index(content[pos:], "{if ") - elseStart := strings.Index(content[pos:], "{else}") - endStart := strings.Index(content[pos:], "{/if}") - - // Find the earliest occurrence - earliest := -1 - var tag string - - if ifStart != -1 && (earliest == -1 || ifStart < earliest-pos) { - earliest = pos + ifStart - tag = "if" - } - if elseStart != -1 && (earliest == -1 || elseStart < earliest-pos) { - earliest = pos + elseStart - tag = "else" - } - if endStart != -1 && (earliest == -1 || endStart < earliest-pos) { - earliest = pos + endStart - tag = "end" - } - - if earliest == -1 { - break - } - - switch tag { - case "if": - nestLevel++ - pos = earliest + 4 - case "else": - if nestLevel == 0 { - return earliest - } - pos = earliest + 6 - case "end": - nestLevel-- - pos = earliest + 5 - } - } - - return -1 -} - -// evaluateCondition evaluates simple conditions like "user.name", "count > 0", "items" func (t *Template) evaluateCondition(condition string, data map[string]any) bool { condition = strings.TrimSpace(condition) - // Handle 'or' operator (lower precedence) if strings.Contains(condition, " or ") { - parts := strings.SplitSeq(condition, " or ") - for part := range parts { + for _, part := range strings.Split(condition, " or ") { if t.evaluateCondition(strings.TrimSpace(part), data) { return true } @@ -682,10 +545,8 @@ func (t *Template) evaluateCondition(condition string, data map[string]any) bool return false } - // Handle 'and' operator (higher precedence) if strings.Contains(condition, " and ") { - parts := strings.SplitSeq(condition, " and ") - for part := range parts { + for _, part := range strings.Split(condition, " and ") { if !t.evaluateCondition(strings.TrimSpace(part), data) { return false } @@ -693,7 +554,6 @@ func (t *Template) evaluateCondition(condition string, data map[string]any) bool return true } - // Handle comparison operators for _, op := range []string{">=", "<=", "!=", "==", ">", "<"} { if strings.Contains(condition, op) { parts := strings.Split(condition, op) @@ -705,33 +565,24 @@ func (t *Template) evaluateCondition(condition string, data map[string]any) bool } } - // Simple existence check - value := t.getConditionValue(condition, data) - return t.isTruthy(value) + return t.isTruthy(t.getConditionValue(condition, data)) } -// getConditionValue gets a value for condition evaluation func (t *Template) getConditionValue(expr string, data map[string]any) any { expr = strings.TrimSpace(expr) - // Handle length operator if strings.HasPrefix(expr, "#") { - varName := expr[1:] // Remove the # - value := t.getNestedValue(data, varName) - return t.getLength(value) + return t.getLength(t.getNestedValue(data, expr[1:])) } - // Try to parse as number if num, err := strconv.ParseFloat(expr, 64); err == nil { return num } - // Try to parse as string literal if strings.HasPrefix(expr, "\"") && strings.HasSuffix(expr, "\"") { return expr[1 : len(expr)-1] } - // Try as variable reference if strings.Contains(expr, ".") { return t.getNestedValue(data, expr) } @@ -743,7 +594,6 @@ func (t *Template) getConditionValue(expr string, data map[string]any) any { return expr } -// compareValues compares two values with the given operator func (t *Template) compareValues(left, right any, op string) bool { switch op { case "==": @@ -770,7 +620,6 @@ func (t *Template) compareValues(left, right any, op string) bool { return false } -// toFloat converts a value to float64 if possible func (t *Template) toFloat(value any) (float64, bool) { switch v := value.(type) { case int: @@ -789,7 +638,6 @@ func (t *Template) toFloat(value any) (float64, bool) { return 0, false } -// isTruthy determines if a value is truthy func (t *Template) isTruthy(value any) bool { if value == nil { return false @@ -816,27 +664,16 @@ func (t *Template) isTruthy(value any) bool { } } -// RenderToContext is a simplified helper that renders a template and writes it to the request context -// with error handling. Returns true if successful, false if an error occurred (error is written to response). -func RenderToContext(ctx *fasthttp.RequestCtx, templateName string, data map[string]any) bool { - tmpl, err := Cache.Load(templateName) - if err != nil { - ctx.SetStatusCode(fasthttp.StatusInternalServerError) - fmt.Fprintf(ctx, "Template error: %v", err) - return false +func (t *Template) getLength(value any) int { + if value == nil { + return 0 } - tmpl.WriteTo(ctx, data) - return true -} - -// RenderNamed is a simplified helper that loads and renders a template with the given data, -// returning the rendered content or an error. -func RenderNamed(templateName string, data map[string]any) (string, error) { - tmpl, err := Cache.Load(templateName) - if err != nil { - return "", fmt.Errorf("failed to load template %s: %w", templateName, err) + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Slice, reflect.Array, reflect.Map, reflect.String: + return rv.Len() + default: + return 0 } - - return tmpl.RenderNamed(data), nil } diff --git a/internal/template/template_test.go b/internal/template/template_test.go deleted file mode 100644 index 8660171..0000000 --- a/internal/template/template_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package template - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestNewCache(t *testing.T) { - cache := NewCache("") - if cache == nil { - t.Fatal("NewCache returned nil") - } - if cache.templates == nil { - t.Fatal("templates map not initialized") - } -} - -func TestPositionalReplacement(t *testing.T) { - tmpl := &Template{ - name: "test", - content: "Hello {0}, you are {1} years old!", - } - - result := tmpl.RenderPositional("Alice", 25) - expected := "Hello Alice, you are 25 years old!" - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - -func TestNamedReplacement(t *testing.T) { - tmpl := &Template{ - name: "test", - content: "Hello {name}, you are {age} years old!", - } - - data := map[string]any{ - "name": "Bob", - "age": 30, - } - - result := tmpl.RenderNamed(data) - expected := "Hello Bob, you are 30 years old!" - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - -func TestDotNotationReplacement(t *testing.T) { - tmpl := &Template{ - name: "test", - content: "User: {user.name}, Email: {user.contact.email}", - } - - data := map[string]any{ - "user": map[string]any{ - "name": "Charlie", - "contact": map[string]any{ - "email": "charlie@example.com", - }, - }, - } - - result := tmpl.RenderNamed(data) - expected := "User: Charlie, Email: charlie@example.com" - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - -func TestTemplateLoadingAndCaching(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "template_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - templatesDir := filepath.Join(tmpDir, "templates") - err = os.MkdirAll(templatesDir, 0755) - if err != nil { - t.Fatal(err) - } - - templateFile := filepath.Join(templatesDir, "test.html") - content := "Hello {name}!" - err = os.WriteFile(templateFile, []byte(content), 0644) - if err != nil { - t.Fatal(err) - } - - cache := NewCache(tmpDir) - - tmpl, err := cache.Load("test.html") - if err != nil { - t.Fatal(err) - } - - if tmpl.content != content { - t.Errorf("Expected content %q, got %q", content, tmpl.content) - } - - tmpl2, err := cache.Load("test.html") - if err != nil { - t.Fatal(err) - } - - if tmpl != tmpl2 { - t.Error("Template should be cached and return same instance") - } -} - -func TestTemplateReloading(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "template_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - templatesDir := filepath.Join(tmpDir, "templates") - err = os.MkdirAll(templatesDir, 0755) - if err != nil { - t.Fatal(err) - } - - templateFile := filepath.Join(templatesDir, "test.html") - content1 := "Hello {name}!" - err = os.WriteFile(templateFile, []byte(content1), 0644) - if err != nil { - t.Fatal(err) - } - - cache := NewCache(tmpDir) - - tmpl, err := cache.Load("test.html") - if err != nil { - t.Fatal(err) - } - - if tmpl.content != content1 { - t.Errorf("Expected content %q, got %q", content1, tmpl.content) - } - - time.Sleep(10 * time.Millisecond) - - content2 := "Hi {name}, welcome!" - err = os.WriteFile(templateFile, []byte(content2), 0644) - if err != nil { - t.Fatal(err) - } - - tmpl2, err := cache.Load("test.html") - if err != nil { - t.Fatal(err) - } - - if tmpl2.content != content2 { - t.Errorf("Expected reloaded content %q, got %q", content2, tmpl2.content) - } -} - -func TestGetNestedValue(t *testing.T) { - tmpl := &Template{} - - data := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "value": "found", - }, - }, - } - - result := tmpl.getNestedValue(data, "level1.level2.value") - if result != "found" { - t.Errorf("Expected 'found', got %v", result) - } - - result = tmpl.getNestedValue(data, "level1.nonexistent") - if result != nil { - t.Errorf("Expected nil for nonexistent path, got %v", result) - } -} - -func TestMixedReplacementTypes(t *testing.T) { - tmpl := &Template{ - name: "test", - content: "Hello {name}, you have {count} {items.type}s!", - } - - data := map[string]any{ - "name": "Dave", - "count": 5, - "items": map[string]any{ - "type": "apple", - }, - } - - result := tmpl.RenderNamed(data) - expected := "Hello Dave, you have 5 apples!" - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - -func TestIncludeSupport(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "template_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - templatesDir := filepath.Join(tmpDir, "templates") - err = os.MkdirAll(templatesDir, 0755) - if err != nil { - t.Fatal(err) - } - - // Create main template with include - mainTemplate := `{include "header.html"}Hello {name}!` - err = os.WriteFile(filepath.Join(templatesDir, "main.html"), []byte(mainTemplate), 0644) - if err != nil { - t.Fatal(err) - } - - // Create included template - headerTemplate := `{title}` - err = os.WriteFile(filepath.Join(templatesDir, "header.html"), []byte(headerTemplate), 0644) - if err != nil { - t.Fatal(err) - } - - cache := NewCache(tmpDir) - tmpl, err := cache.Load("main.html") - if err != nil { - t.Fatal(err) - } - - data := map[string]any{ - "name": "Alice", - "title": "Welcome", - } - - result := tmpl.RenderNamed(data) - expected := "WelcomeHello Alice!" - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - -func TestBlocksAndYield(t *testing.T) { - tmpl := &Template{ - name: "test", - content: `{block "content"}Default content{/block}
{yield content}
`, - } - - result := tmpl.RenderNamed(map[string]any{}) - expected := "
Default content
" - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - -func TestTemplateInheritance(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "template_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - templatesDir := filepath.Join(tmpDir, "templates") - err = os.MkdirAll(templatesDir, 0755) - if err != nil { - t.Fatal(err) - } - - // Layout template - layoutTemplate := `{title}{yield content}` - err = os.WriteFile(filepath.Join(templatesDir, "layout.html"), []byte(layoutTemplate), 0644) - if err != nil { - t.Fatal(err) - } - - // Page template that extends layout - pageTemplate := `{include "layout.html"}{block "content"}

Welcome {name}!

This is the content block.

{/block}` - err = os.WriteFile(filepath.Join(templatesDir, "page.html"), []byte(pageTemplate), 0644) - if err != nil { - t.Fatal(err) - } - - cache := NewCache(tmpDir) - tmpl, err := cache.Load("page.html") - if err != nil { - t.Fatal(err) - } - - data := map[string]any{ - "title": "Test Page", - "name": "Bob", - } - - result := tmpl.RenderNamed(data) - expected := `Test Page

Welcome Bob!

This is the content block.

` - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -}