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 6024b97..0000000 Binary files a/assets/images/backgrounds/background.gif and /dev/null differ diff --git a/assets/images/backgrounds/classic.jpg b/assets/images/backgrounds/classic.jpg deleted file mode 100644 index b4bd6cd..0000000 Binary files a/assets/images/backgrounds/classic.jpg and /dev/null differ diff --git a/assets/images/bars_green.gif b/assets/images/bars_green.gif deleted file mode 100644 index 32349da..0000000 Binary files a/assets/images/bars_green.gif and /dev/null differ diff --git a/assets/images/bars_red.gif b/assets/images/bars_red.gif deleted file mode 100644 index c92630d..0000000 Binary files a/assets/images/bars_red.gif and /dev/null differ diff --git a/assets/images/bars_yellow.gif b/assets/images/bars_yellow.gif deleted file mode 100644 index 628ecae..0000000 Binary files a/assets/images/bars_yellow.gif and /dev/null differ 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 := `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 := `This is the content block.
` - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -}