create template package
This commit is contained in:
parent
c7d08d8004
commit
2efa1e0d07
@ -41,7 +41,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
||||
(?, 'David', 'Server lag is really bad right now...'),
|
||||
(?, 'Eve', 'Quick question about spell mechanics')`
|
||||
|
||||
timestamps := []interface{}{
|
||||
timestamps := []any{
|
||||
now - 3600*6, // 6 hours ago
|
||||
now - 3600*4, // 4 hours ago
|
||||
now - 3600*2, // 2 hours ago
|
||||
|
@ -45,7 +45,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
||||
(?, ?, 2, 4, 0, 'Re: Bug Reports', 'Found a small issue with spell casting.'),
|
||||
(?, ?, 3, 0, 0, 'Strategy Discussion', 'Let us discuss optimal character builds and strategies.')`
|
||||
|
||||
timestamps := []interface{}{
|
||||
timestamps := []any{
|
||||
now - 86400*7, now - 86400*1, // Thread 1, last activity 1 day ago
|
||||
now - 86400*6, now - 86400*6, // Reply 1
|
||||
now - 86400*1, now - 86400*1, // Reply 2 (most recent activity on thread 1)
|
||||
|
@ -40,7 +40,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
||||
(3, ?, 'Fourth post from admin'),
|
||||
(2, ?, 'Fifth post - maintenance notice')`
|
||||
|
||||
timestamps := []interface{}{
|
||||
timestamps := []any{
|
||||
now - 86400*7, // 1 week ago
|
||||
now - 86400*5, // 5 days ago
|
||||
now - 86400*2, // 2 days ago
|
||||
|
5
internal/template/doc.go
Normal file
5
internal/template/doc.go
Normal file
@ -0,0 +1,5 @@
|
||||
// Package template provides in-memory template caching with automatic reloading
|
||||
// and placeholder replacement functionality. Templates are loaded from files
|
||||
// adjacent to the binary and support both positional and named placeholder
|
||||
// replacement with dot notation for accessing nested map values.
|
||||
package template
|
226
internal/template/template.go
Normal file
226
internal/template/template.go
Normal file
@ -0,0 +1,226 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
templates map[string]*Template
|
||||
basePath string
|
||||
}
|
||||
|
||||
type Template struct {
|
||||
name string
|
||||
content string
|
||||
modTime time.Time
|
||||
filePath string
|
||||
}
|
||||
|
||||
func NewCache(basePath string) *Cache {
|
||||
if basePath == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
basePath = "."
|
||||
} else {
|
||||
basePath = filepath.Dir(exe)
|
||||
}
|
||||
}
|
||||
|
||||
return &Cache{
|
||||
templates: make(map[string]*Template),
|
||||
basePath: basePath,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Load(name string) (*Template, error) {
|
||||
c.mu.RLock()
|
||||
tmpl, exists := c.templates[name]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
if err := c.checkAndReload(tmpl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
return c.loadFromFile(name)
|
||||
}
|
||||
|
||||
func (c *Cache) loadFromFile(name string) (*Template, error) {
|
||||
filePath := filepath.Join(c.basePath, "templates", name)
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template file not found: %s", name)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read template: %w", err)
|
||||
}
|
||||
|
||||
tmpl := &Template{
|
||||
name: name,
|
||||
content: string(content),
|
||||
modTime: info.ModTime(),
|
||||
filePath: filePath,
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.templates[name] = tmpl
|
||||
c.mu.Unlock()
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func (c *Cache) checkAndReload(tmpl *Template) error {
|
||||
info, err := os.Stat(tmpl.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.ModTime().After(tmpl.modTime) {
|
||||
content, err := os.ReadFile(tmpl.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
tmpl.content = string(content)
|
||||
tmpl.modTime = info.ModTime()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Template) RenderPositional(args ...any) string {
|
||||
result := t.content
|
||||
for i, arg := range args {
|
||||
placeholder := fmt.Sprintf("{%d}", i)
|
||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", arg))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *Template) RenderNamed(data map[string]any) string {
|
||||
result := t.content
|
||||
|
||||
for key, value := range data {
|
||||
placeholder := fmt.Sprintf("{%s}", key)
|
||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
|
||||
}
|
||||
|
||||
result = t.replaceDotNotation(result, data)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *Template) replaceDotNotation(content string, data map[string]any) string {
|
||||
result := content
|
||||
|
||||
start := 0
|
||||
for {
|
||||
startIdx := strings.Index(result[start:], "{")
|
||||
if startIdx == -1 {
|
||||
break
|
||||
}
|
||||
startIdx += start
|
||||
|
||||
endIdx := strings.Index(result[startIdx:], "}")
|
||||
if endIdx == -1 {
|
||||
break
|
||||
}
|
||||
endIdx += startIdx
|
||||
|
||||
placeholder := result[startIdx+1 : endIdx]
|
||||
|
||||
if strings.Contains(placeholder, ".") {
|
||||
value := t.getNestedValue(data, placeholder)
|
||||
if value != nil {
|
||||
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
||||
start = startIdx + len(fmt.Sprintf("%v", value))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
start = endIdx + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *Template) getNestedValue(data map[string]any, path string) any {
|
||||
keys := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
for i, key := range keys {
|
||||
if i == len(keys)-1 {
|
||||
return current[key]
|
||||
}
|
||||
|
||||
next, ok := current[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := next.(type) {
|
||||
case map[string]any:
|
||||
current = v
|
||||
case map[any]any:
|
||||
newMap := make(map[string]any)
|
||||
for k, val := range v {
|
||||
newMap[fmt.Sprintf("%v", k)] = val
|
||||
}
|
||||
current = newMap
|
||||
default:
|
||||
rv := reflect.ValueOf(next)
|
||||
if rv.Kind() == reflect.Map {
|
||||
newMap := make(map[string]any)
|
||||
for _, k := range rv.MapKeys() {
|
||||
newMap[fmt.Sprintf("%v", k.Interface())] = rv.MapIndex(k).Interface()
|
||||
}
|
||||
current = newMap
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data any) {
|
||||
var result string
|
||||
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
result = t.RenderNamed(v)
|
||||
case []any:
|
||||
result = t.RenderPositional(v...)
|
||||
default:
|
||||
rv := reflect.ValueOf(data)
|
||||
if rv.Kind() == reflect.Slice {
|
||||
args := make([]any, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
args[i] = rv.Index(i).Interface()
|
||||
}
|
||||
result = t.RenderPositional(args...)
|
||||
} else {
|
||||
result = t.RenderPositional(data)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
ctx.WriteString(result)
|
||||
}
|
208
internal/template/template_test.go
Normal file
208
internal/template/template_test.go
Normal file
@ -0,0 +1,208 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewCache(t *testing.T) {
|
||||
cache := NewCache("")
|
||||
if cache == nil {
|
||||
t.Fatal("NewCache returned nil")
|
||||
}
|
||||
if cache.templates == nil {
|
||||
t.Fatal("templates map not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionalReplacement(t *testing.T) {
|
||||
tmpl := &Template{
|
||||
name: "test",
|
||||
content: "Hello {0}, you are {1} years old!",
|
||||
}
|
||||
|
||||
result := tmpl.RenderPositional("Alice", 25)
|
||||
expected := "Hello Alice, you are 25 years old!"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedReplacement(t *testing.T) {
|
||||
tmpl := &Template{
|
||||
name: "test",
|
||||
content: "Hello {name}, you are {age} years old!",
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"name": "Bob",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
result := tmpl.RenderNamed(data)
|
||||
expected := "Hello Bob, you are 30 years old!"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDotNotationReplacement(t *testing.T) {
|
||||
tmpl := &Template{
|
||||
name: "test",
|
||||
content: "User: {user.name}, Email: {user.contact.email}",
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"user": map[string]any{
|
||||
"name": "Charlie",
|
||||
"contact": map[string]any{
|
||||
"email": "charlie@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := tmpl.RenderNamed(data)
|
||||
expected := "User: Charlie, Email: charlie@example.com"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateLoadingAndCaching(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "template_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
err = os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
templateFile := filepath.Join(templatesDir, "test.html")
|
||||
content := "Hello {name}!"
|
||||
err = os.WriteFile(templateFile, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewCache(tmpDir)
|
||||
|
||||
tmpl, err := cache.Load("test.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tmpl.content != content {
|
||||
t.Errorf("Expected content %q, got %q", content, tmpl.content)
|
||||
}
|
||||
|
||||
tmpl2, err := cache.Load("test.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tmpl != tmpl2 {
|
||||
t.Error("Template should be cached and return same instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateReloading(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "template_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
err = os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
templateFile := filepath.Join(templatesDir, "test.html")
|
||||
content1 := "Hello {name}!"
|
||||
err = os.WriteFile(templateFile, []byte(content1), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewCache(tmpDir)
|
||||
|
||||
tmpl, err := cache.Load("test.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tmpl.content != content1 {
|
||||
t.Errorf("Expected content %q, got %q", content1, tmpl.content)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
content2 := "Hi {name}, welcome!"
|
||||
err = os.WriteFile(templateFile, []byte(content2), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tmpl2, err := cache.Load("test.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tmpl2.content != content2 {
|
||||
t.Errorf("Expected reloaded content %q, got %q", content2, tmpl2.content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNestedValue(t *testing.T) {
|
||||
tmpl := &Template{}
|
||||
|
||||
data := map[string]any{
|
||||
"level1": map[string]any{
|
||||
"level2": map[string]any{
|
||||
"value": "found",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := tmpl.getNestedValue(data, "level1.level2.value")
|
||||
if result != "found" {
|
||||
t.Errorf("Expected 'found', got %v", result)
|
||||
}
|
||||
|
||||
result = tmpl.getNestedValue(data, "level1.nonexistent")
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for nonexistent path, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixedReplacementTypes(t *testing.T) {
|
||||
tmpl := &Template{
|
||||
name: "test",
|
||||
content: "Hello {name}, you have {count} {items.type}s!",
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"name": "Dave",
|
||||
"count": 5,
|
||||
"items": map[string]any{
|
||||
"type": "apple",
|
||||
},
|
||||
}
|
||||
|
||||
result := tmpl.RenderNamed(data)
|
||||
expected := "Hello Dave, you have 5 apples!"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
@ -223,8 +223,8 @@ The builder automatically sets sensible defaults for all fields if not specified
|
||||
|
||||
// Get all equipment
|
||||
equipment := user.GetEquipment()
|
||||
weapon := equipment["weapon"].(map[string]interface{})
|
||||
armor := equipment["armor"].(map[string]interface{})
|
||||
weapon := equipment["weapon"].(map[string]any)
|
||||
armor := equipment["armor"].(map[string]any)
|
||||
|
||||
fmt.Printf("Weapon: %s (ID: %d)\n", weapon["name"], weapon["id"])
|
||||
fmt.Printf("Armor: %s (ID: %d)\n", armor["name"], armor["id"])
|
||||
|
@ -382,14 +382,14 @@ func (u *User) HasVisitedTown(townID string) bool {
|
||||
}
|
||||
|
||||
// GetEquipment returns all equipped item information
|
||||
func (u *User) GetEquipment() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"weapon": map[string]interface{}{"id": u.WeaponID, "name": u.WeaponName},
|
||||
"armor": map[string]interface{}{"id": u.ArmorID, "name": u.ArmorName},
|
||||
"shield": map[string]interface{}{"id": u.ShieldID, "name": u.ShieldName},
|
||||
"slot1": map[string]interface{}{"id": u.Slot1ID, "name": u.Slot1Name},
|
||||
"slot2": map[string]interface{}{"id": u.Slot2ID, "name": u.Slot2Name},
|
||||
"slot3": map[string]interface{}{"id": u.Slot3ID, "name": u.Slot3Name},
|
||||
func (u *User) GetEquipment() map[string]any {
|
||||
return map[string]any{
|
||||
"weapon": map[string]any{"id": u.WeaponID, "name": u.WeaponName},
|
||||
"armor": map[string]any{"id": u.ArmorID, "name": u.ArmorName},
|
||||
"shield": map[string]any{"id": u.ShieldID, "name": u.ShieldName},
|
||||
"slot1": map[string]any{"id": u.Slot1ID, "name": u.Slot1Name},
|
||||
"slot2": map[string]any{"id": u.Slot2ID, "name": u.Slot2Name},
|
||||
"slot3": map[string]any{"id": u.Slot3ID, "name": u.Slot3Name},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,7 +87,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
||||
('charlie', 'hashed_pass_3', 'charlie@example.com', 0, 'verify_token_123', ?, ?, 4, 0, 0, 3, 1, 100, 0, 15, 0, 10, 15, 0, 10, 5, 5, 5, 5, '', ''),
|
||||
('diana', 'hashed_pass_4', 'diana@example.com', 1, '', ?, ?, 0, 25, -10, 1, 8, 1200, 3500, 35, 25, 15, 35, 25, 15, 12, 10, 15, 12, '1,2,3,6,7', '1,2,3,4')`
|
||||
|
||||
timestamps := []interface{}{
|
||||
timestamps := []any{
|
||||
now - 86400*7, now - 3600*2, // alice: registered 1 week ago, last online 2 hours ago
|
||||
now - 86400*5, now - 86400*1, // bob: registered 5 days ago, last online 1 day ago
|
||||
now - 86400*1, now - 86400*1, // charlie: registered 1 day ago, last online 1 day ago
|
||||
@ -644,7 +644,7 @@ func TestGetEquipmentAndStats(t *testing.T) {
|
||||
t.Error("Expected non-nil equipment map")
|
||||
}
|
||||
|
||||
weapon, ok := equipment["weapon"].(map[string]interface{})
|
||||
weapon, ok := equipment["weapon"].(map[string]any)
|
||||
if !ok {
|
||||
t.Error("Expected weapon to be a map")
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user