massive layout fixes and improvements, rework asides, add template functionality
This commit is contained in:
parent
6968e8822c
commit
ae7b4a3066
@ -108,8 +108,10 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.title {
|
div.title {
|
||||||
border: solid 1px black;
|
|
||||||
background-color: #eeeeee;
|
background-color: #eeeeee;
|
||||||
|
background-image: url("/assets/images/overlay.png");
|
||||||
|
background-repeat: repeat;
|
||||||
|
border: 1px solid #aaa;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
margin-bottom: 0.1rem;
|
margin-bottom: 0.1rem;
|
||||||
|
39
internal/helpers/ordered_map.go
Normal file
39
internal/helpers/ordered_map.go
Normal 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
|
||||||
|
}
|
@ -48,9 +48,7 @@ func showLogin(ctx router.Ctx, _ []string) {
|
|||||||
id = formData["id"]
|
id = formData["id"]
|
||||||
}
|
}
|
||||||
|
|
||||||
components.RenderPageTemplate(ctx, "Log In", "auth/login.html", map[string]any{
|
components.RenderPage(ctx, "Log In", "auth/login.html", map[string]any{
|
||||||
"csrf_token": csrf.GetToken(ctx, auth.Manager),
|
|
||||||
"csrf_field": csrf.HiddenField(ctx, auth.Manager),
|
|
||||||
"error_message": errorHTML,
|
"error_message": errorHTML,
|
||||||
"id": id,
|
"id": id,
|
||||||
})
|
})
|
||||||
@ -111,9 +109,7 @@ func showRegister(ctx router.Ctx, _ []string) {
|
|||||||
email = formData["email"]
|
email = formData["email"]
|
||||||
}
|
}
|
||||||
|
|
||||||
components.RenderPageTemplate(ctx, "Register", "auth/register.html", map[string]any{
|
components.RenderPage(ctx, "Register", "auth/register.html", map[string]any{
|
||||||
"csrf_token": csrf.GetToken(ctx, auth.Manager),
|
|
||||||
"csrf_field": csrf.HiddenField(ctx, auth.Manager),
|
|
||||||
"error_message": errorHTML,
|
"error_message": errorHTML,
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
|
@ -4,34 +4,15 @@ import (
|
|||||||
"dk/internal/middleware"
|
"dk/internal/middleware"
|
||||||
"dk/internal/router"
|
"dk/internal/router"
|
||||||
"dk/internal/template/components"
|
"dk/internal/template/components"
|
||||||
"dk/internal/users"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Index(ctx router.Ctx, _ []string) {
|
func Index(ctx router.Ctx, _ []string) {
|
||||||
currentUser := middleware.GetCurrentUser(ctx)
|
user := middleware.GetCurrentUser(ctx)
|
||||||
var username string
|
if user != nil {
|
||||||
if currentUser != nil {
|
|
||||||
username = currentUser.Username
|
|
||||||
user, _ := users.Find(currentUser.ID)
|
|
||||||
|
|
||||||
if user.Currently == "In Town" {
|
if user.Currently == "In Town" {
|
||||||
ctx.Redirect("/town", 303)
|
ctx.Redirect("/town", 303)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
username = "Guest"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := components.NewPageData(
|
components.RenderPage(ctx, "", "intro.html", nil)
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@ func RegisterTownRoutes(r *router.Router) {
|
|||||||
|
|
||||||
func showTown(ctx router.Ctx, _ []string) {
|
func showTown(ctx router.Ctx, _ []string) {
|
||||||
town := ctx.UserValue("town").(*towns.Town)
|
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,
|
"town": town,
|
||||||
"newscontent": components.GenerateTownNews(),
|
"newscontent": components.GenerateTownNews(),
|
||||||
"whosonline": components.GenerateTownWhosOnline(),
|
"whosonline": components.GenerateTownWhosOnline(),
|
||||||
|
@ -5,148 +5,65 @@ import (
|
|||||||
"dk/internal/middleware"
|
"dk/internal/middleware"
|
||||||
"dk/internal/router"
|
"dk/internal/router"
|
||||||
"dk/internal/spells"
|
"dk/internal/spells"
|
||||||
"dk/internal/template"
|
|
||||||
"dk/internal/towns"
|
"dk/internal/towns"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LeftAside generates the left sidebar content
|
// LeftAside generates the data map for the left sidebar.
|
||||||
func LeftAside(ctx router.Ctx) string {
|
// Returns an empty map when not auth'd.
|
||||||
|
func LeftAside(ctx router.Ctx) map[string]any {
|
||||||
|
data := map[string]any{}
|
||||||
|
|
||||||
user := middleware.GetCurrentUser(ctx)
|
user := middleware.GetCurrentUser(ctx)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ""
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.Cache.Load("leftside.html")
|
// Build owned town maps list
|
||||||
if err != nil {
|
if user.Towns != "" {
|
||||||
return "leftside failed to load?"
|
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"
|
return data
|
||||||
if user.X < 0 {
|
|
||||||
cardinalX = "W"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cardinalY := "S"
|
// RightAside generates the data map for the right sidebar.
|
||||||
if user.Y < 0 {
|
// Returns an empty map when not auth'd.
|
||||||
cardinalY = "N"
|
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)
|
user := middleware.GetCurrentUser(ctx)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ""
|
return data
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := template.Cache.Load("rightside.html")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hpPct := helpers.ClampPct(float64(user.HP), float64(user.MaxHP), 0, 100)
|
hpPct := helpers.ClampPct(float64(user.HP), float64(user.MaxHP), 0, 100)
|
||||||
mpPct := helpers.ClampPct(float64(user.MP), float64(user.MaxMP), 0, 100)
|
data["hpPct"] = hpPct
|
||||||
tpPct := helpers.ClampPct(float64(user.TP), float64(user.MaxTP), 0, 100)
|
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 {
|
if hpPct < 35 {
|
||||||
hpColor = "danger"
|
data["hpColor"] = "danger"
|
||||||
} else if hpPct < 75 {
|
} else if hpPct < 75 {
|
||||||
hpColor = "warning"
|
data["hpColor"] = "warning"
|
||||||
}
|
}
|
||||||
|
|
||||||
weaponName := "<i>No weapon</i>"
|
// Build known healing spells list
|
||||||
if user.WeaponName != "" {
|
if user.Spells != "" {
|
||||||
weaponName = user.WeaponName
|
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>"
|
return data
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -6,87 +6,42 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dk/internal/auth"
|
"dk/internal/auth"
|
||||||
|
"dk/internal/csrf"
|
||||||
"dk/internal/middleware"
|
"dk/internal/middleware"
|
||||||
"dk/internal/router"
|
"dk/internal/router"
|
||||||
"dk/internal/template"
|
"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
|
// 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 {
|
if template.Cache == nil || auth.Manager == nil {
|
||||||
return fmt.Errorf("template.Cache or auth.Manager not initialized")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load layout template: %w", err)
|
return fmt.Errorf("failed to load layout template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the base template data with common fields
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"title": pageData.Title,
|
"_title": PageTitle(title),
|
||||||
"content": pageData.Content,
|
"authenticated": middleware.IsAuthenticated(ctx),
|
||||||
"topnav": GenerateTopNav(ctx),
|
"csrf": csrf.HiddenField(ctx, auth.Manager),
|
||||||
"leftside": pageData.LeftSide,
|
"_totaltime": middleware.GetRequestTime(ctx),
|
||||||
"rightside": pageData.RightSide,
|
"_numqueries": 0,
|
||||||
"totaltime": middleware.GetRequestTime(ctx),
|
"_version": "1.0.0",
|
||||||
"numqueries": pageData.NumQueries,
|
"_build": "dev",
|
||||||
"version": pageData.Version,
|
"user": middleware.GetCurrentUser(ctx),
|
||||||
"build": pageData.Build,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge in additional data (overwrites common data if keys conflict)
|
maps.Copy(data, LeftAside(ctx))
|
||||||
|
maps.Copy(data, RightAside(ctx))
|
||||||
maps.Copy(data, additionalData)
|
maps.Copy(data, additionalData)
|
||||||
|
|
||||||
// Set defaults for empty fields
|
tmpl.WriteTo(ctx, data)
|
||||||
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)
|
|
||||||
return nil
|
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
|
// 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"
|
// 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.
|
// 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"
|
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
|
|
||||||
}
|
|
||||||
|
@ -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
|
|
@ -2,9 +2,11 @@ package template
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -21,11 +23,6 @@ type TemplateCache struct {
|
|||||||
basePath string
|
basePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderOptions struct {
|
|
||||||
ResolveIncludes bool
|
|
||||||
Blocks map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Template struct {
|
type Template struct {
|
||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
@ -120,37 +117,33 @@ func (c *TemplateCache) checkAndReload(tmpl *Template) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *Template) RenderPositional(args ...any) string {
|
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
|
result := t.content
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
placeholder := fmt.Sprintf("{%d}", i)
|
placeholder := fmt.Sprintf("{%d}", i)
|
||||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", arg))
|
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", arg))
|
||||||
}
|
}
|
||||||
if opts.ResolveIncludes {
|
result = t.processIncludes(result, nil)
|
||||||
result = t.processIncludes(result, nil, opts)
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Template) RenderNamed(data map[string]any) string {
|
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
|
result := t.content
|
||||||
|
|
||||||
// Process blocks first to extract them
|
// 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
|
// Process includes
|
||||||
if opts.ResolveIncludes {
|
result = t.processIncludes(result, data)
|
||||||
result = t.processIncludes(result, data, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
for key, value := range data {
|
||||||
placeholder := fmt.Sprintf("{%s}", key)
|
placeholder := fmt.Sprintf("{%s}", key)
|
||||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
|
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.replaceDotNotation(result, data)
|
||||||
|
|
||||||
result = t.processYield(result, opts)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,17 +274,13 @@ func (t *Template) getStructField(obj any, fieldName string) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data 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
|
var result string
|
||||||
|
|
||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
result = t.RenderNamedWithOptions(opts, v)
|
result = t.RenderNamed(v)
|
||||||
case []any:
|
case []any:
|
||||||
result = t.RenderPositionalWithOptions(opts, v...)
|
result = t.RenderPositional(v...)
|
||||||
default:
|
default:
|
||||||
rv := reflect.ValueOf(data)
|
rv := reflect.ValueOf(data)
|
||||||
if rv.Kind() == reflect.Slice {
|
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++ {
|
for i := 0; i < rv.Len(); i++ {
|
||||||
args[i] = rv.Index(i).Interface()
|
args[i] = rv.Index(i).Interface()
|
||||||
}
|
}
|
||||||
result = t.RenderPositionalWithOptions(opts, args...)
|
result = t.RenderPositional(args...)
|
||||||
} else {
|
} 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
|
// 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
|
result := content
|
||||||
|
|
||||||
for {
|
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 {
|
if includedTemplate, err := t.cache.Load(templateName); err == nil {
|
||||||
var includedContent string
|
var includedContent string
|
||||||
if data != nil {
|
if data != nil {
|
||||||
// Create new options to pass blocks to included template
|
includedContent = includedTemplate.RenderNamed(data)
|
||||||
includeOpts := RenderOptions{
|
|
||||||
ResolveIncludes: opts.ResolveIncludes,
|
|
||||||
Blocks: opts.Blocks,
|
|
||||||
}
|
|
||||||
includedContent = includedTemplate.RenderNamedWithOptions(includeOpts, data)
|
|
||||||
} else {
|
} else {
|
||||||
includedContent = includedTemplate.content
|
includedContent = includedTemplate.content
|
||||||
}
|
}
|
||||||
@ -352,30 +334,8 @@ func (t *Template) processIncludes(content string, data map[string]any, opts Ren
|
|||||||
return result
|
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
|
// processBlocks extracts {block "name"}...{/block} sections
|
||||||
func (t *Template) processBlocks(content string, opts *RenderOptions) string {
|
func (t *Template) processBlocks(content string, blocks map[string]string) string {
|
||||||
if opts.Blocks == nil {
|
|
||||||
opts.Blocks = make(map[string]string)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := content
|
result := content
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@ -401,7 +361,7 @@ func (t *Template) processBlocks(content string, opts *RenderOptions) string {
|
|||||||
contentEnd += contentStart
|
contentEnd += contentStart
|
||||||
|
|
||||||
blockContent := result[contentStart:contentEnd]
|
blockContent := result[contentStart:contentEnd]
|
||||||
opts.Blocks[blockName] = blockContent
|
blocks[blockName] = blockContent
|
||||||
|
|
||||||
// Remove the block definition from the template
|
// Remove the block definition from the template
|
||||||
result = result[:start] + result[contentEnd+len(endTag):]
|
result = result[:start] + result[contentEnd+len(endTag):]
|
||||||
@ -410,6 +370,364 @@ func (t *Template) processBlocks(content string, opts *RenderOptions) string {
|
|||||||
return result
|
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
|
// 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).
|
// 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 {
|
func RenderToContext(ctx *fasthttp.RequestCtx, templateName string, data map[string]any) bool {
|
||||||
|
@ -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) {
|
func TestBlocksAndYield(t *testing.T) {
|
||||||
tmpl := &Template{
|
tmpl := &Template{
|
||||||
name: "test",
|
name: "test",
|
||||||
|
@ -68,7 +68,6 @@ type User struct {
|
|||||||
Towns string `db:"towns" json:"towns"`
|
Towns string `db:"towns" json:"towns"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement Model interface
|
|
||||||
func (u *User) GetTableName() string {
|
func (u *User) GetTableName() string {
|
||||||
return "users"
|
return "users"
|
||||||
}
|
}
|
||||||
@ -81,7 +80,6 @@ func (u *User) SetID(id int) {
|
|||||||
u.ID = id
|
u.ID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience methods wrapping generic functions
|
|
||||||
func (u *User) Set(field string, value any) error {
|
func (u *User) Set(field string, value any) error {
|
||||||
return database.Set(u, field, value)
|
return database.Set(u, field, value)
|
||||||
}
|
}
|
||||||
@ -94,7 +92,6 @@ func (u *User) Delete() error {
|
|||||||
return database.Delete(u)
|
return database.Delete(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new User with sensible defaults
|
|
||||||
func New() *User {
|
func New() *User {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
return &User{
|
return &User{
|
||||||
@ -138,7 +135,6 @@ func scanUser(stmt *sqlite.Stmt) *User {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query functions
|
|
||||||
func Find(id int) (*User, error) {
|
func Find(id int) (*User, error) {
|
||||||
var user *User
|
var user *User
|
||||||
query := `SELECT ` + userColumns() + ` FROM users WHERE id = ?`
|
query := `SELECT ` + userColumns() + ` FROM users WHERE id = ?`
|
||||||
@ -250,7 +246,6 @@ func (u *User) Insert() error {
|
|||||||
return database.Insert(u, columns, values...)
|
return database.Insert(u, columns, values...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
func (u *User) RegisteredTime() time.Time {
|
func (u *User) RegisteredTime() time.Time {
|
||||||
return time.Unix(u.Registered, 0)
|
return time.Unix(u.Registered, 0)
|
||||||
}
|
}
|
||||||
@ -345,7 +340,6 @@ func (u *User) SetPosition(x, y int) {
|
|||||||
u.Set("Y", y)
|
u.Set("Y", y)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMap converts the user to a map for efficient template rendering
|
|
||||||
func (u *User) ToMap() map[string]any {
|
func (u *User) ToMap() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"ID": u.ID,
|
"ID": u.ID,
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
|
{include "layout.html"}
|
||||||
|
|
||||||
|
{block "content"}
|
||||||
<h1>Log In</h1>
|
<h1>Log In</h1>
|
||||||
|
|
||||||
{error_message}
|
{error_message}
|
||||||
|
|
||||||
<form class="standard mb-1" action="/login" method="post">
|
<form class="standard mb-1" action="/login" method="post">
|
||||||
{csrf_field}
|
{csrf}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="id">Email/Username</label>
|
<label for="id">Email/Username</label>
|
||||||
@ -28,3 +31,4 @@
|
|||||||
You may also <a href="/change-password">change your password</a>, or
|
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.
|
<a href="/lost-password">request a new one</a> if you've lost yours.
|
||||||
</p>
|
</p>
|
||||||
|
{/block}
|
@ -1,9 +1,12 @@
|
|||||||
|
{include "layout.html"}
|
||||||
|
|
||||||
|
{block "content"}
|
||||||
<h1>Register</h1>
|
<h1>Register</h1>
|
||||||
|
|
||||||
{error_message}
|
{error_message}
|
||||||
|
|
||||||
<form class="standard" action="/register" method="post">
|
<form class="standard" action="/register" method="post">
|
||||||
{csrf_field}
|
{csrf}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
@ -43,4 +46,4 @@
|
|||||||
<button class="btn" type="reset" name="reset">Reset</button>
|
<button class="btn" type="reset" name="reset">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{/block}
|
||||||
|
5
templates/intro.html
Normal file
5
templates/intro.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{include "layout.html"}
|
||||||
|
|
||||||
|
{block "content"}
|
||||||
|
Hey there, Guest!
|
||||||
|
{/block}
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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/reset.css">
|
||||||
<link rel="stylesheet" href="/assets/dk.css">
|
<link rel="stylesheet" href="/assets/dk.css">
|
||||||
@ -24,20 +24,28 @@
|
|||||||
<div id="container">
|
<div id="container">
|
||||||
<header>
|
<header>
|
||||||
<div><img src="/assets/images/logo.gif" alt="Dragon Knight" title="Dragon Knight"></div>
|
<div><img src="/assets/images/logo.gif" alt="Dragon Knight" title="Dragon Knight"></div>
|
||||||
<nav>{topnav}</nav>
|
<nav>{include "topnav.html"}</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="game">
|
<section id="game">
|
||||||
<aside id="left">{leftside}</aside>
|
<aside id="left">
|
||||||
<main>{content}</main>
|
{if authenticated}
|
||||||
<aside id="right">{rightside}</aside>
|
{include "leftside.html"}
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
<main>{yield "content"}</main>
|
||||||
|
<aside id="right">
|
||||||
|
{if authenticated}
|
||||||
|
{include "rightside.html"}
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div>Powered by <a href="/">Dragon Knight</a></div>
|
<div>Powered by <a href="/">Dragon Knight</a></div>
|
||||||
<div>© 2025 Sharkk</div>
|
<div>© 2025 Sharkk</div>
|
||||||
<div>{totaltime} Seconds, {numqueries} Queries</div>
|
<div>{_totaltime} Seconds, {_numqueries} Queries</div>
|
||||||
<div>Version {version} {build}</div>
|
<div>Version {_version} {_build}</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
<section>
|
<section>
|
||||||
<div class="title"><img src="/assets/images/button_location.gif" alt="Location" title="Location"></div>
|
<div class="title"><img src="/assets/images/button_location.gif" alt="Location" title="Location"></div>
|
||||||
|
|
||||||
<div><b>{user.Currently}</b></div>
|
<div><b>
|
||||||
<div>{user.X}{cardinalX}, {user.Y}{cardinalY}</div>
|
{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" >
|
<form id="move-compass" action="/move" method="post" >
|
||||||
<button id="north" name="direction" value="north">North</button>
|
<button id="north" name="direction" value="north">North</button>
|
||||||
@ -18,8 +24,14 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></div>
|
<div class="title"><img src="/assets/images/button_towns.gif" alt="Towns" title="Towns"></div>
|
||||||
<div>{townname}</div>
|
{if #_towns > 0}
|
||||||
<div>{townlist}</div>
|
<i>Teleport to:</i>
|
||||||
|
{for map in _towns}
|
||||||
|
<a href="#">{map.name}</a>
|
||||||
|
{/for}
|
||||||
|
{else}
|
||||||
|
<i>No town maps</i>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="functions">
|
<section id="functions">
|
||||||
|
@ -13,17 +13,17 @@
|
|||||||
<div id="statbars">
|
<div id="statbars">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div id="hp" class="container {hpcolor}"><div class="bar" style="height: {hppct}%;"></div></div>
|
<div id="hp" class="container {hpcolor}"><div class="bar" style="height: {hppct}%;"></div></div>
|
||||||
<label>HP</label>
|
<span>HP</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div id="mp" class="container"><div class="bar" style="height: {mppct}%;"></div></div>
|
<div id="mp" class="container"><div class="bar" style="height: {mppct}%;"></div></div>
|
||||||
<label>MP</label>
|
<span>MP</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div id="tp" class="container"><div class="bar" style="height: {tppct}%;"></div></div>
|
<div id="tp" class="container"><div class="bar" style="height: {tppct}%;"></div></div>
|
||||||
<label>TP</label>
|
<span>TP</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -35,22 +35,40 @@
|
|||||||
<div class="title"><img src="/assets/images/button_inventory.gif" alt="Inventory" title="Inventory"></div>
|
<div class="title"><img src="/assets/images/button_inventory.gif" alt="Inventory" title="Inventory"></div>
|
||||||
<div>
|
<div>
|
||||||
<img src="/assets/images/icon_weapon.gif" alt="Weapon" title="Weapon">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<img src="/assets/images/icon_armor.gif" alt="Armor" title="Armor">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<img src="/assets/images/icon_shield.gif" alt="Shield" title="Shield">
|
<img src="/assets/images/icon_shield.gif" alt="Shield" title="Shield">
|
||||||
{shieldname}
|
{if user.ShieldName != ""}
|
||||||
|
{user.ShieldName}
|
||||||
|
{else}
|
||||||
|
<i>No shield</i>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{slot1name}
|
{if user.Slot1Name != ""}{slot1name}{/if}
|
||||||
{slot2name}
|
{if user.Slot2Name != ""}{slot2name}{/if}
|
||||||
{slot3name}
|
{if user.Slot3Name != ""}{slot3name}{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="title"><img src="/assets/images/button_fastspells.gif" alt="Fast Spells" title="Fast Spells"></div>
|
<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>
|
</section>
|
11
templates/topnav.html
Normal file
11
templates/topnav.html
Normal 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
3
templates/town/inn.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div class="town inn">
|
||||||
|
|
||||||
|
</div>
|
@ -1,3 +1,6 @@
|
|||||||
|
{include layout.html}
|
||||||
|
|
||||||
|
{block "content"}
|
||||||
<div class="town">
|
<div class="town">
|
||||||
<div class="options">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/block}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user