diff --git a/assets/dk.css b/assets/dk.css index 3060155..b1f4d60 100644 --- a/assets/dk.css +++ b/assets/dk.css @@ -108,8 +108,10 @@ a { } div.title { - border: solid 1px black; background-color: #eeeeee; + background-image: url("/assets/images/overlay.png"); + background-repeat: repeat; + border: 1px solid #aaa; font-weight: bold; padding: 5px; margin-bottom: 0.1rem; diff --git a/internal/helpers/ordered_map.go b/internal/helpers/ordered_map.go new file mode 100644 index 0000000..10b1b42 --- /dev/null +++ b/internal/helpers/ordered_map.go @@ -0,0 +1,39 @@ +package helpers + +type OrderedMap[K comparable, V any] struct { + keys []K + data map[K]V +} + +func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] { + return &OrderedMap[K, V]{ + keys: make([]K, 0), + data: make(map[K]V), + } +} + +func (om *OrderedMap[K, V]) Set(key K, value V) { + if _, exists := om.data[key]; !exists { + om.keys = append(om.keys, key) + } + om.data[key] = value +} + +func (om *OrderedMap[K, V]) Range(fn func(K, V) bool) { + for _, key := range om.keys { + if !fn(key, om.data[key]) { + break + } + } +} + +func (om *OrderedMap[K, V]) ToSlice() []map[string]any { + result := make([]map[string]any, 0, len(om.keys)) + for _, key := range om.keys { + result = append(result, map[string]any{ + "id": key, + "name": om.data[key], + }) + } + return result +} diff --git a/internal/routes/auth.go b/internal/routes/auth.go index 80a8384..3196296 100644 --- a/internal/routes/auth.go +++ b/internal/routes/auth.go @@ -48,9 +48,7 @@ func showLogin(ctx router.Ctx, _ []string) { id = formData["id"] } - components.RenderPageTemplate(ctx, "Log In", "auth/login.html", map[string]any{ - "csrf_token": csrf.GetToken(ctx, auth.Manager), - "csrf_field": csrf.HiddenField(ctx, auth.Manager), + components.RenderPage(ctx, "Log In", "auth/login.html", map[string]any{ "error_message": errorHTML, "id": id, }) @@ -111,9 +109,7 @@ func showRegister(ctx router.Ctx, _ []string) { email = formData["email"] } - components.RenderPageTemplate(ctx, "Register", "auth/register.html", map[string]any{ - "csrf_token": csrf.GetToken(ctx, auth.Manager), - "csrf_field": csrf.HiddenField(ctx, auth.Manager), + components.RenderPage(ctx, "Register", "auth/register.html", map[string]any{ "error_message": errorHTML, "username": username, "email": email, diff --git a/internal/routes/index.go b/internal/routes/index.go index c0a933d..dfd99e3 100644 --- a/internal/routes/index.go +++ b/internal/routes/index.go @@ -4,34 +4,15 @@ import ( "dk/internal/middleware" "dk/internal/router" "dk/internal/template/components" - "dk/internal/users" - "fmt" - - "github.com/valyala/fasthttp" ) func Index(ctx router.Ctx, _ []string) { - currentUser := middleware.GetCurrentUser(ctx) - var username string - if currentUser != nil { - username = currentUser.Username - user, _ := users.Find(currentUser.ID) - + user := middleware.GetCurrentUser(ctx) + if user != nil { if user.Currently == "In Town" { ctx.Redirect("/town", 303) } - } else { - username = "Guest" } - pageData := components.NewPageData( - "Dragon Knight", - fmt.Sprintf("Hello %s!", username), - ) - - if err := components.RenderPage(ctx, pageData, nil); err != nil { - ctx.SetStatusCode(fasthttp.StatusInternalServerError) - fmt.Fprintf(ctx, "Template error: %v", err) - return - } + components.RenderPage(ctx, "", "intro.html", nil) } diff --git a/internal/routes/town.go b/internal/routes/town.go index 47bcc57..513c9e8 100644 --- a/internal/routes/town.go +++ b/internal/routes/town.go @@ -17,7 +17,7 @@ func RegisterTownRoutes(r *router.Router) { func showTown(ctx router.Ctx, _ []string) { town := ctx.UserValue("town").(*towns.Town) - components.RenderPageTemplate(ctx, town.Name, "town/town.html", map[string]any{ + components.RenderPage(ctx, town.Name, "town/town.html", map[string]any{ "town": town, "newscontent": components.GenerateTownNews(), "whosonline": components.GenerateTownWhosOnline(), diff --git a/internal/template/components/asides.go b/internal/template/components/asides.go index 20cb225..cf7a422 100644 --- a/internal/template/components/asides.go +++ b/internal/template/components/asides.go @@ -5,148 +5,65 @@ import ( "dk/internal/middleware" "dk/internal/router" "dk/internal/spells" - "dk/internal/template" "dk/internal/towns" ) -// LeftAside generates the left sidebar content -func LeftAside(ctx router.Ctx) string { +// LeftAside generates the data map for the left sidebar. +// Returns an empty map when not auth'd. +func LeftAside(ctx router.Ctx) map[string]any { + data := map[string]any{} + user := middleware.GetCurrentUser(ctx) if user == nil { - return "" + return data } - tmpl, err := template.Cache.Load("leftside.html") - if err != nil { - return "leftside failed to load?" - } - - cardinalX := "E" - if user.X < 0 { - cardinalX = "W" - } - - cardinalY := "S" - if user.Y < 0 { - cardinalY = "N" - } - - townname := "" - if user.Currently == "In Town" { - town, err := towns.ByCoords(user.X, user.Y) - if err != nil { - townname = "error finding town" - } - townname = "
In " + town.Name + "
" - } - - townlist := "" - townIDs := user.GetTownIDs() - if len(townIDs) > 0 { - townlist = "Teleport to:
" - for _, id := range townIDs { - town, err := towns.Find(id) - if err != nil { - continue + // Build owned town maps list + if user.Towns != "" { + townMap := helpers.NewOrderedMap[int, string]() + for _, id := range user.GetTownIDs() { + if town, err := towns.Find(id); err == nil { + townMap.Set(id, town.Name) } - - townlist += `` + town.Name + "
" } - } else { - townlist = "No town maps" + data["_towns"] = townMap.ToSlice() } - return tmpl.RenderNamed(map[string]any{ - "user": user.ToMap(), - "cardinalX": cardinalX, - "cardinalY": cardinalY, - "townname": townname, - "townlist": townlist, - }) + return data } -// RightAside generates the right sidebar content -func RightAside(ctx router.Ctx) string { +// RightAside generates the data map for the right sidebar. +// Returns an empty map when not auth'd. +func RightAside(ctx router.Ctx) map[string]any { + data := map[string]any{} + user := middleware.GetCurrentUser(ctx) if user == nil { - return "" - } - - tmpl, err := template.Cache.Load("rightside.html") - if err != nil { - return "" + return data } hpPct := helpers.ClampPct(float64(user.HP), float64(user.MaxHP), 0, 100) - mpPct := helpers.ClampPct(float64(user.MP), float64(user.MaxMP), 0, 100) - tpPct := helpers.ClampPct(float64(user.TP), float64(user.MaxTP), 0, 100) + data["hpPct"] = hpPct + data["mpPct"] = helpers.ClampPct(float64(user.MP), float64(user.MaxMP), 0, 100) + data["tpPct"] = helpers.ClampPct(float64(user.TP), float64(user.MaxTP), 0, 100) - hpColor := "" + data["hpColor"] = "" if hpPct < 35 { - hpColor = "danger" + data["hpColor"] = "danger" } else if hpPct < 75 { - hpColor = "warning" + data["hpColor"] = "warning" } - weaponName := "No weapon" - if user.WeaponName != "" { - weaponName = user.WeaponName - } - - armorName := "No armor" - if user.ArmorName != "" { - armorName = user.ArmorName - } - - shieldName := "No shield" - if user.ShieldName != "" { - shieldName = user.ShieldName - } - - slot1Name := "" - if user.Slot1Name != "" { - slot1Name = user.Slot1Name - } - - slot2Name := "" - if user.Slot2Name != "" { - slot2Name = user.Slot2Name - } - - slot3Name := "" - if user.Slot3Name != "" { - slot3Name = user.Slot3Name - } - - magicList := "" - list := user.GetSpellIDs() - if len(list) > 0 { - for i := range list { - spell, err := spells.Find(i) - if err != nil { - continue - } - - if spell.IsHealing() { - magicList += `` + spell.Name + "" + // Build known healing spells list + if user.Spells != "" { + spellMap := helpers.NewOrderedMap[int, string]() + for _, id := range user.GetSpellIDs() { + if spell, err := spells.Find(id); err == nil { + spellMap.Set(id, spell.Name) } } - } else { - magicList = "No healing spells known" + data["_spells"] = spellMap.ToSlice() } - return tmpl.RenderNamed(map[string]any{ - "user": user.ToMap(), - "hppct": hpPct, - "hpcolor": hpColor, - "mppct": mpPct, - "tppct": tpPct, - "weaponname": weaponName, - "armorname": armorName, - "shieldname": shieldName, - "slot1name": slot1Name, - "slot2name": slot2Name, - "slot3name": slot3Name, - "magiclist": magicList, - }) + return data } diff --git a/internal/template/components/page.go b/internal/template/components/page.go index d295936..a017de5 100644 --- a/internal/template/components/page.go +++ b/internal/template/components/page.go @@ -6,87 +6,42 @@ import ( "strings" "dk/internal/auth" + "dk/internal/csrf" "dk/internal/middleware" "dk/internal/router" "dk/internal/template" - - "github.com/valyala/fasthttp" ) -// PageData holds common page template data -type PageData struct { - Title string - Content string - TopNav string - LeftSide string - RightSide string - TotalTime string - NumQueries string - Version string - Build string -} - // RenderPage renders a page using the layout template with common data and additional custom data -func RenderPage(ctx router.Ctx, pageData PageData, additionalData map[string]any) error { +func RenderPage(ctx router.Ctx, title, tmplPath string, additionalData map[string]any) error { if template.Cache == nil || auth.Manager == nil { return fmt.Errorf("template.Cache or auth.Manager not initialized") } - layoutTmpl, err := template.Cache.Load("layout.html") + tmpl, err := template.Cache.Load(tmplPath) if err != nil { return fmt.Errorf("failed to load layout template: %w", err) } - // Build the base template data with common fields data := map[string]any{ - "title": pageData.Title, - "content": pageData.Content, - "topnav": GenerateTopNav(ctx), - "leftside": pageData.LeftSide, - "rightside": pageData.RightSide, - "totaltime": middleware.GetRequestTime(ctx), - "numqueries": pageData.NumQueries, - "version": pageData.Version, - "build": pageData.Build, + "_title": PageTitle(title), + "authenticated": middleware.IsAuthenticated(ctx), + "csrf": csrf.HiddenField(ctx, auth.Manager), + "_totaltime": middleware.GetRequestTime(ctx), + "_numqueries": 0, + "_version": "1.0.0", + "_build": "dev", + "user": middleware.GetCurrentUser(ctx), } - // Merge in additional data (overwrites common data if keys conflict) + maps.Copy(data, LeftAside(ctx)) + maps.Copy(data, RightAside(ctx)) maps.Copy(data, additionalData) - // Set defaults for empty fields - if data["leftside"] == "" { - data["leftside"] = LeftAside(ctx) - } - if data["rightside"] == "" { - data["rightside"] = RightAside(ctx) - } - if data["numqueries"] == "" { - data["numqueries"] = "0" - } - if data["version"] == "" { - data["version"] = "1.0.0" - } - if data["build"] == "" { - data["build"] = "dev" - } - - layoutTmpl.WriteTo(ctx, data) + tmpl.WriteTo(ctx, data) return nil } -// NewPageData creates a new PageData with sensible defaults -func NewPageData(title, content string) PageData { - return PageData{ - Title: title, - Content: content, - LeftSide: "", - RightSide: "", - NumQueries: "0", - Version: "1.0.0", - Build: "dev", - } -} - // PageTitle returns a proper title for a rendered page. If an empty string // is given, returns "Dragon Knight". If the provided title already has " - Dragon Knight" // at the end, returns title as-is. Appends " - Dragon Knight" to title otherwise. @@ -101,24 +56,3 @@ func PageTitle(title string) string { return title + " - Dragon Knight" } - -// RenderPageTemplate is a simplified helper that renders a template within the page layout. -// It loads the template, renders it with the provided data, and then renders the full page. -// Returns true if successful, false if an error occurred (error is written to response). -func RenderPageTemplate(ctx router.Ctx, title, templateName string, data map[string]any) bool { - content, err := template.RenderNamed(templateName, data) - if err != nil { - ctx.SetStatusCode(fasthttp.StatusInternalServerError) - fmt.Fprintf(ctx, "Template error: %v", err) - return false - } - - pageData := NewPageData(PageTitle(title), content) - if err := RenderPage(ctx, pageData, nil); err != nil { - ctx.SetStatusCode(fasthttp.StatusInternalServerError) - fmt.Fprintf(ctx, "Template error: %v", err) - return false - } - - return true -} diff --git a/internal/template/doc.go b/internal/template/doc.go deleted file mode 100644 index 4624feb..0000000 --- a/internal/template/doc.go +++ /dev/null @@ -1,75 +0,0 @@ -// Package template provides a high-performance template engine with in-memory -// caching, automatic reloading, and advanced template composition features. -// -// # Basic Usage -// -// cache := template.NewCache("") // Auto-detects binary location -// tmpl, err := cache.Load("page.html") -// if err != nil { -// log.Fatal(err) -// } -// -// data := map[string]any{ -// "title": "Welcome", -// "user": map[string]any{ -// "name": "Alice", -// "email": "alice@example.com", -// }, -// } -// -// result := tmpl.RenderNamed(data) -// -// # Placeholder Types -// -// Named placeholders: {name}, {title} -// -// Dot notation: {user.name}, {user.contact.email} -// -// Positional: {0}, {1}, {2} -// -// # Template Composition -// -// Includes - embed other templates with data sharing: -// -// {include "header.html"} -// {include "nav.html"} -// -// Blocks - define reusable content sections: -// -// {block "content"} -//

Default content

-// {/block} -// -// Yield - template inheritance points: -// -//
{yield content}
-// -// -// # Template Inheritance Example -// -// layout.html: -// -// -// -// {title} -// {yield content} -// -// -// page.html: -// -// {include "layout.html"} -// {block "content"} -//

Welcome {user.name}!

-// {/block} -// -// # Advanced Features -// -// Disable includes for partial rendering: -// -// opts := RenderOptions{ResolveIncludes: false} -// chunk := tmpl.RenderNamedWithOptions(opts, data) -// -// FastHTTP integration: -// -// tmpl.WriteTo(ctx, data) // Sets content-type and writes response -package template diff --git a/internal/template/template.go b/internal/template/template.go index 4fc0560..22b0e5c 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -2,9 +2,11 @@ package template import ( "fmt" + "maps" "os" "path/filepath" "reflect" + "strconv" "strings" "sync" "time" @@ -21,11 +23,6 @@ type TemplateCache struct { basePath string } -type RenderOptions struct { - ResolveIncludes bool - Blocks map[string]string -} - type Template struct { name string content string @@ -120,37 +117,33 @@ func (c *TemplateCache) checkAndReload(tmpl *Template) error { } func (t *Template) RenderPositional(args ...any) string { - return t.RenderPositionalWithOptions(RenderOptions{ResolveIncludes: true}, args...) -} - -func (t *Template) RenderPositionalWithOptions(opts RenderOptions, 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)) } - if opts.ResolveIncludes { - result = t.processIncludes(result, nil, opts) - } + result = t.processIncludes(result, nil) return result } func (t *Template) RenderNamed(data map[string]any) string { - return t.RenderNamedWithOptions(RenderOptions{ResolveIncludes: true}, data) -} - -func (t *Template) RenderNamedWithOptions(opts RenderOptions, data map[string]any) string { result := t.content // Process blocks first to extract them - result = t.processBlocks(result, &opts) + blocks := make(map[string]string) + result = t.processBlocks(result, blocks) - // Process includes next so they get the data substitutions - if opts.ResolveIncludes { - result = t.processIncludes(result, data, opts) - } + // Process includes + result = t.processIncludes(result, data) - // Apply data substitutions after includes are processed + // Process loops and conditionals + result = t.processLoops(result, data) + result = t.processConditionals(result, data) + + // Process yield before variable substitution + result = t.processYield(result, blocks) + + // Apply data substitutions for key, value := range data { placeholder := fmt.Sprintf("{%s}", key) result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value)) @@ -158,8 +151,6 @@ func (t *Template) RenderNamedWithOptions(opts RenderOptions, data map[string]an result = t.replaceDotNotation(result, data) - result = t.processYield(result, opts) - return result } @@ -283,17 +274,13 @@ func (t *Template) getStructField(obj any, fieldName string) any { } func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data any) { - t.WriteToWithOptions(ctx, data, RenderOptions{ResolveIncludes: true}) -} - -func (t *Template) WriteToWithOptions(ctx *fasthttp.RequestCtx, data any, opts RenderOptions) { var result string switch v := data.(type) { case map[string]any: - result = t.RenderNamedWithOptions(opts, v) + result = t.RenderNamed(v) case []any: - result = t.RenderPositionalWithOptions(opts, v...) + result = t.RenderPositional(v...) default: rv := reflect.ValueOf(data) if rv.Kind() == reflect.Slice { @@ -301,9 +288,9 @@ func (t *Template) WriteToWithOptions(ctx *fasthttp.RequestCtx, data any, opts R for i := 0; i < rv.Len(); i++ { args[i] = rv.Index(i).Interface() } - result = t.RenderPositionalWithOptions(opts, args...) + result = t.RenderPositional(args...) } else { - result = t.RenderPositionalWithOptions(opts, data) + result = t.RenderPositional(data) } } @@ -312,7 +299,7 @@ func (t *Template) WriteToWithOptions(ctx *fasthttp.RequestCtx, data any, opts R } // processIncludes handles {include "template.html"} directives -func (t *Template) processIncludes(content string, data map[string]any, opts RenderOptions) string { +func (t *Template) processIncludes(content string, data map[string]any) string { result := content for { @@ -333,12 +320,7 @@ func (t *Template) processIncludes(content string, data map[string]any, opts Ren if includedTemplate, err := t.cache.Load(templateName); err == nil { var includedContent string if data != nil { - // Create new options to pass blocks to included template - includeOpts := RenderOptions{ - ResolveIncludes: opts.ResolveIncludes, - Blocks: opts.Blocks, - } - includedContent = includedTemplate.RenderNamedWithOptions(includeOpts, data) + includedContent = includedTemplate.RenderNamed(data) } else { includedContent = includedTemplate.content } @@ -352,30 +334,8 @@ func (t *Template) processIncludes(content string, data map[string]any, opts Ren return result } -// processYield handles {yield} directives for template inheritance -func (t *Template) processYield(content string, opts RenderOptions) string { - if opts.Blocks == nil { - return strings.ReplaceAll(content, "{yield}", "") - } - - result := content - for blockName, blockContent := range opts.Blocks { - yieldPlaceholder := fmt.Sprintf("{yield %s}", blockName) - result = strings.ReplaceAll(result, yieldPlaceholder, blockContent) - } - - // Replace any remaining {yield} with empty string - result = strings.ReplaceAll(result, "{yield}", "") - - return result -} - // processBlocks extracts {block "name"}...{/block} sections -func (t *Template) processBlocks(content string, opts *RenderOptions) string { - if opts.Blocks == nil { - opts.Blocks = make(map[string]string) - } - +func (t *Template) processBlocks(content string, blocks map[string]string) string { result := content for { @@ -401,7 +361,7 @@ func (t *Template) processBlocks(content string, opts *RenderOptions) string { contentEnd += contentStart blockContent := result[contentStart:contentEnd] - opts.Blocks[blockName] = blockContent + blocks[blockName] = blockContent // Remove the block definition from the template result = result[:start] + result[contentEnd+len(endTag):] @@ -410,6 +370,364 @@ func (t *Template) processBlocks(content string, opts *RenderOptions) string { return result } +// processYield handles {yield} directives for template inheritance +func (t *Template) processYield(content string, blocks map[string]string) string { + result := content + + for blockName, blockContent := range blocks { + yieldPlaceholder := fmt.Sprintf("{yield \"%s\"}", blockName) + result = strings.ReplaceAll(result, yieldPlaceholder, blockContent) + } + + // 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 + endTag := "{/if}" + contentEnd := strings.Index(result[contentStart:], endTag) + if contentEnd == -1 { + break + } + contentEnd += contentStart + + ifContent := result[contentStart:contentEnd] + + // Check for else clause + elseStart := strings.Index(ifContent, "{else}") + 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+len(endTag):] + } + + return result +} + +// 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 'and' operator + if strings.Contains(condition, " and ") { + parts := strings.SplitSeq(condition, " and ") + for part := range parts { + if !t.evaluateCondition(strings.TrimSpace(part), data) { + return false + } + } + return true + } + + // Handle comparison operators + for _, op := range []string{">=", "<=", "!=", "==", ">", "<"} { + if strings.Contains(condition, op) { + parts := strings.Split(condition, op) + if len(parts) == 2 { + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + return t.compareValues(t.getConditionValue(left, data), t.getConditionValue(right, data), op) + } + } + } + + // Simple existence check + value := t.getConditionValue(condition, data) + return t.isTruthy(value) +} + +// 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) + } + + // 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) + } + + if value, ok := data[expr]; ok { + return value + } + + return expr +} + +// compareValues compares two values with the given operator +func (t *Template) compareValues(left, right any, op string) bool { + switch op { + case "==": + return fmt.Sprintf("%v", left) == fmt.Sprintf("%v", right) + case "!=": + return fmt.Sprintf("%v", left) != fmt.Sprintf("%v", right) + case ">", ">=", "<", "<=": + leftNum, leftOk := t.toFloat(left) + rightNum, rightOk := t.toFloat(right) + if !leftOk || !rightOk { + return false + } + switch op { + case ">": + return leftNum > rightNum + case ">=": + return leftNum >= rightNum + case "<": + return leftNum < rightNum + case "<=": + return leftNum <= rightNum + } + } + return false +} + +// toFloat converts a value to float64 if possible +func (t *Template) toFloat(value any) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f, true + } + } + return 0, false +} + +// isTruthy determines if a value is truthy +func (t *Template) isTruthy(value any) bool { + if value == nil { + return false + } + + switch v := value.(type) { + case bool: + return v + case int: + return v != 0 + case float64: + return v != 0 + case string: + return v != "" + default: + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + return rv.Len() > 0 + case reflect.Ptr: + return !rv.IsNil() + } + return true + } +} + +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 + } +} + // 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 { diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 2e2c88f..8660171 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -253,40 +253,6 @@ func TestIncludeSupport(t *testing.T) { } } -func TestIncludeDisabled(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) - } - - mainTemplate := `
{include "partial.html"}
` - err = os.WriteFile(filepath.Join(templatesDir, "main.html"), []byte(mainTemplate), 0644) - if err != nil { - t.Fatal(err) - } - - cache := NewCache(tmpDir) - tmpl, err := cache.Load("main.html") - if err != nil { - t.Fatal(err) - } - - opts := RenderOptions{ResolveIncludes: false} - result := tmpl.RenderNamedWithOptions(opts, map[string]any{}) - expected := `
{include "partial.html"}
` - - if result != expected { - t.Errorf("Expected %q, got %q", expected, result) - } -} - func TestBlocksAndYield(t *testing.T) { tmpl := &Template{ name: "test", diff --git a/internal/users/users.go b/internal/users/users.go index f6a80a2..b79e5b6 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -68,7 +68,6 @@ type User struct { Towns string `db:"towns" json:"towns"` } -// Implement Model interface func (u *User) GetTableName() string { return "users" } @@ -81,7 +80,6 @@ func (u *User) SetID(id int) { u.ID = id } -// Convenience methods wrapping generic functions func (u *User) Set(field string, value any) error { return database.Set(u, field, value) } @@ -94,7 +92,6 @@ func (u *User) Delete() error { return database.Delete(u) } -// New creates a new User with sensible defaults func New() *User { now := time.Now().Unix() return &User{ @@ -138,7 +135,6 @@ func scanUser(stmt *sqlite.Stmt) *User { return user } -// Query functions func Find(id int) (*User, error) { var user *User query := `SELECT ` + userColumns() + ` FROM users WHERE id = ?` @@ -250,7 +246,6 @@ func (u *User) Insert() error { return database.Insert(u, columns, values...) } -// Helper methods func (u *User) RegisteredTime() time.Time { return time.Unix(u.Registered, 0) } @@ -345,7 +340,6 @@ func (u *User) SetPosition(x, y int) { u.Set("Y", y) } -// ToMap converts the user to a map for efficient template rendering func (u *User) ToMap() map[string]any { return map[string]any{ "ID": u.ID, diff --git a/templates/auth/login.html b/templates/auth/login.html index 445cfbd..1b6fd43 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -1,9 +1,12 @@ +{include "layout.html"} + +{block "content"}

Log In

{error_message}
- {csrf_field} + {csrf}
@@ -27,4 +30,5 @@

You may also change your password, or request a new one if you've lost yours. -

\ No newline at end of file +

+{/block} \ No newline at end of file diff --git a/templates/auth/register.html b/templates/auth/register.html index bca6f00..013efc9 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -1,9 +1,12 @@ +{include "layout.html"} + +{block "content"}

Register

{error_message} - {csrf_field} + {csrf}
@@ -43,4 +46,4 @@
- +{/block} diff --git a/templates/intro.html b/templates/intro.html new file mode 100644 index 0000000..47dee9a --- /dev/null +++ b/templates/intro.html @@ -0,0 +1,5 @@ +{include "layout.html"} + +{block "content"} +Hey there, Guest! +{/block} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index cf37f94..927cb9a 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -3,7 +3,7 @@ - {title} + {_title} @@ -24,20 +24,28 @@
Dragon Knight
- +
- -
{content}
- + +
{yield "content"}
+
Powered by Dragon Knight
© 2025 Sharkk
-
{totaltime} Seconds, {numqueries} Queries
-
Version {version} {build}
+
{_totaltime} Seconds, {_numqueries} Queries
+
Version {_version} {_build}
diff --git a/templates/leftside.html b/templates/leftside.html index 8deeae2..93185bc 100644 --- a/templates/leftside.html +++ b/templates/leftside.html @@ -1,10 +1,16 @@
Location
-
{user.Currently}
-
{user.X}{cardinalX}, {user.Y}{cardinalY}
+
+ {if user.Currently == "In Town" and town != nil} + In {town.Name} + {else} + {user.Currently} + {/if} +
+
{user.X}{if user.X < 0}W{else}E{/if}, {user.Y}{if user.Y < 0}N{else}S{/if}
- View Map + View Map
@@ -18,8 +24,14 @@
Towns
-
{townname}
-
{townlist}
+ {if #_towns > 0} + Teleport to: + {for map in _towns} + {map.name} + {/for} + {else} + No town maps + {/if}
diff --git a/templates/rightside.html b/templates/rightside.html index bf2bacc..1ca4347 100644 --- a/templates/rightside.html +++ b/templates/rightside.html @@ -13,17 +13,17 @@
- + HP
- + MP
- + TP
@@ -35,22 +35,40 @@
Inventory
Weapon - {weaponname} + {if user.WeaponName != ""} + {user.WeaponName} + {else} + No weapon + {/if}
Armor - {armorname} + {if user.ArmorName != ""} + {user.ArmorName} + {else} + No armor + {/if}
Shield - {shieldname} + {if user.ShieldName != ""} + {user.ShieldName} + {else} + No shield + {/if}
- {slot1name} - {slot2name} - {slot3name} + {if user.Slot1Name != ""}{slot1name}{/if} + {if user.Slot2Name != ""}{slot2name}{/if} + {if user.Slot3Name != ""}{slot3name}{/if}
Fast Spells
- {magiclist} + {if #_spells > 0} + {for spell in _spells} + {spell.name} + {/for} + {else} + No known healing spells + {/if}
\ No newline at end of file diff --git a/templates/topnav.html b/templates/topnav.html new file mode 100644 index 0000000..12b260b --- /dev/null +++ b/templates/topnav.html @@ -0,0 +1,11 @@ +{if authenticated} + + {csrf} + +
+Help +{else} +Log In +Register +Help +{/if} \ No newline at end of file diff --git a/templates/town/inn.html b/templates/town/inn.html new file mode 100644 index 0000000..8b2226d --- /dev/null +++ b/templates/town/inn.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/templates/town/town.html b/templates/town/town.html index e33774a..ad5173a 100644 --- a/templates/town/town.html +++ b/templates/town/town.html @@ -1,3 +1,6 @@ +{include layout.html} + +{block "content"}
Welcome to {town.Name}
@@ -24,3 +27,4 @@
+{/block}