massive layout fixes and improvements, rework asides, add template functionality

This commit is contained in:
Sky Johnson 2025-08-12 13:50:48 -05:00
parent 6968e8822c
commit ae7b4a3066
20 changed files with 571 additions and 431 deletions

View File

@ -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;

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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(),

View File

@ -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?"
// 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)
}
}
data["_towns"] = townMap.ToSlice()
}
cardinalX := "E"
if user.X < 0 {
cardinalX = "W"
return data
}
cardinalY := "S"
if user.Y < 0 {
cardinalY = "N"
}
// 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{}
townname := ""
if user.Currently == "In Town" {
town, err := towns.ByCoords(user.X, user.Y)
if err != nil {
townname = "error finding town"
}
townname = "<div class=\"mb-05\"><b>In " + town.Name + "</b></div>"
}
townlist := ""
townIDs := user.GetTownIDs()
if len(townIDs) > 0 {
townlist = "<i>Teleport to:</i><br>"
for _, id := range townIDs {
town, err := towns.Find(id)
if err != nil {
continue
}
townlist += `<a href="#">` + town.Name + "</a><br>"
}
} else {
townlist = "<i>No town maps</i>"
}
return tmpl.RenderNamed(map[string]any{
"user": user.ToMap(),
"cardinalX": cardinalX,
"cardinalY": cardinalY,
"townname": townname,
"townlist": townlist,
})
}
// RightAside generates the right sidebar content
func RightAside(ctx router.Ctx) string {
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 := "<i>No weapon</i>"
if user.WeaponName != "" {
weaponName = user.WeaponName
// 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)
}
}
data["_spells"] = spellMap.ToSlice()
}
armorName := "<i>No armor</i>"
if user.ArmorName != "" {
armorName = user.ArmorName
}
shieldName := "<i>No shield</i>"
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 += `<a href="#">` + spell.Name + "</a>"
}
}
} else {
magicList = "<i>No healing spells known</i>"
}
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
}

View File

@ -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
}

View File

@ -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"}
// <h1>Default content</h1>
// {/block}
//
// Yield - template inheritance points:
//
// <main>{yield content}</main>
// <footer>{yield footer}</footer>
//
// # Template Inheritance Example
//
// layout.html:
//
// <!DOCTYPE html>
// <html>
// <head><title>{title}</title></head>
// <body>{yield content}</body>
// </html>
//
// page.html:
//
// {include "layout.html"}
// {block "content"}
// <h1>Welcome {user.name}!</h1>
// {/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

View File

@ -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 {

View File

@ -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 := `<div>{include "partial.html"}</div>`
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 := `<div>{include "partial.html"}</div>`
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
func TestBlocksAndYield(t *testing.T) {
tmpl := &Template{
name: "test",

View File

@ -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,

View File

@ -1,9 +1,12 @@
{include "layout.html"}
{block "content"}
<h1>Log In</h1>
{error_message}
<form class="standard mb-1" action="/login" method="post">
{csrf_field}
{csrf}
<div class="row">
<label for="id">Email/Username</label>
@ -28,3 +31,4 @@
You may also <a href="/change-password">change your password</a>, or
<a href="/lost-password">request a new one</a> if you've lost yours.
</p>
{/block}

View File

@ -1,9 +1,12 @@
{include "layout.html"}
{block "content"}
<h1>Register</h1>
{error_message}
<form class="standard" action="/register" method="post">
{csrf_field}
{csrf}
<div class="row">
<div>
@ -43,4 +46,4 @@
<button class="btn" type="reset" name="reset">Reset</button>
</div>
</form>
{/block}

5
templates/intro.html Normal file
View File

@ -0,0 +1,5 @@
{include "layout.html"}
{block "content"}
Hey there, Guest!
{/block}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<title>{_title}</title>
<link rel="stylesheet" href="/assets/reset.css">
<link rel="stylesheet" href="/assets/dk.css">
@ -24,20 +24,28 @@
<div id="container">
<header>
<div><img src="/assets/images/logo.gif" alt="Dragon Knight" title="Dragon Knight"></div>
<nav>{topnav}</nav>
<nav>{include "topnav.html"}</nav>
</header>
<section id="game">
<aside id="left">{leftside}</aside>
<main>{content}</main>
<aside id="right">{rightside}</aside>
<aside id="left">
{if authenticated}
{include "leftside.html"}
{/if}
</aside>
<main>{yield "content"}</main>
<aside id="right">
{if authenticated}
{include "rightside.html"}
{/if}
</aside>
</section>
<footer>
<div>Powered by <a href="/">Dragon Knight</a></div>
<div>&copy; 2025 Sharkk</div>
<div>{totaltime} Seconds, {numqueries} Queries</div>
<div>Version {version} {build}</div>
<div>{_totaltime} Seconds, {_numqueries} Queries</div>
<div>Version {_version} {_build}</div>
</footer>
</div>
</body>

View File

@ -1,10 +1,16 @@
<section>
<div class="title"><img src="/assets/images/button_location.gif" alt="Location" title="Location"></div>
<div><b>{user.Currently}</b></div>
<div>{user.X}{cardinalX}, {user.Y}{cardinalY}</div>
<div><b>
{if user.Currently == "In Town" and town != nil}
In {town.Name}
{else}
{user.Currently}
{/if}
</b></div>
<div>{user.X}{if user.X < 0}W{else}E{/if}, {user.Y}{if user.Y < 0}N{else}S{/if}</div>
<a href="javascript:openmappopup()">View Map</a>
<a href="javascript:open_map_popup()">View Map</a>
<form id="move-compass" action="/move" method="post" >
<button id="north" name="direction" value="north">North</button>
@ -18,8 +24,14 @@
<section>
<div class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></div>
<div>{townname}</div>
<div>{townlist}</div>
{if #_towns > 0}
<i>Teleport to:</i>
{for map in _towns}
<a href="#">{map.name}</a>
{/for}
{else}
<i>No town maps</i>
{/if}
</section>
<section id="functions">

View File

@ -13,17 +13,17 @@
<div id="statbars">
<div class="stat">
<div id="hp" class="container {hpcolor}"><div class="bar" style="height: {hppct}%;"></div></div>
<label>HP</label>
<span>HP</span>
</div>
<div class="stat">
<div id="mp" class="container"><div class="bar" style="height: {mppct}%;"></div></div>
<label>MP</label>
<span>MP</span>
</div>
<div class="stat">
<div id="tp" class="container"><div class="bar" style="height: {tppct}%;"></div></div>
<label>TP</label>
<span>TP</span>
</div>
</div>
@ -35,22 +35,40 @@
<div class="title"><img src="/assets/images/button_inventory.gif" alt="Inventory" title="Inventory"></div>
<div>
<img src="/assets/images/icon_weapon.gif" alt="Weapon" title="Weapon">
{weaponname}
{if user.WeaponName != ""}
{user.WeaponName}
{else}
<i>No weapon</i>
{/if}
</div>
<div>
<img src="/assets/images/icon_armor.gif" alt="Armor" title="Armor">
{armorname}
{if user.ArmorName != ""}
{user.ArmorName}
{else}
<i>No armor</i>
{/if}
</div>
<div>
<img src="/assets/images/icon_shield.gif" alt="Shield" title="Shield">
{shieldname}
{if user.ShieldName != ""}
{user.ShieldName}
{else}
<i>No shield</i>
{/if}
</div>
{slot1name}
{slot2name}
{slot3name}
{if user.Slot1Name != ""}{slot1name}{/if}
{if user.Slot2Name != ""}{slot2name}{/if}
{if user.Slot3Name != ""}{slot3name}{/if}
</section>
<section>
<div class="title"><img src="/assets/images/button_fastspells.gif" alt="Fast Spells" title="Fast Spells"></div>
{magiclist}
{if #_spells > 0}
{for spell in _spells}
<a href="#">{spell.name}</a>
{/for}
{else}
<i>No known healing spells</i>
{/if}
</section>

11
templates/topnav.html Normal file
View File

@ -0,0 +1,11 @@
{if authenticated}
<form action="/logout" method="post" class="logout">
{csrf}
<button class="img-button" type="submit"><img src="/assets/images/button_logout.gif" alt="Log Out" title="Log Out"></button>
</form>
<a href="/help"><img src="/assets/images/button_help.gif" alt="Help" title="Help"></a>
{else}
<a href="/login"><img src="/assets/images/button_login.gif" alt="Log In" title="Log In"></a>
<a href="/register"><img src="/assets/images/button_register.gif" alt="Register" title="Register"></a>
<a href="/help"><img src="/assets/images/button_help.gif" alt="Help" title="Help"></a>
{/if}

3
templates/town/inn.html Normal file
View File

@ -0,0 +1,3 @@
<div class="town inn">
</div>

View File

@ -1,3 +1,6 @@
{include layout.html}
{block "content"}
<div class="town">
<div class="options">
<div class="title"><img src="/assets/images/town_{town.ID}.gif" alt="Welcome to {town.Name}" title="Welcome to {town.Name}"></div>
@ -24,3 +27,4 @@
</div>
</div>
</div>
{/block}