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}